跳转至


课程  因子投资  机器学习  Python  Poetry  ppw  tools  programming  Numpy  Pandas  pandas  算法  hdbscan  聚类  选股  Algo  minimum  numpy  algo  FFT  模式识别  配对交易  GBDT  LightGBM  XGBoost  statistics  CDF  KS-Test  monte-carlo  VaR  回测  过拟合  algorithms  machine learning  strategy  python  sklearn  pdf  概率  数学  面试题  量化交易  策略分类  风险管理  Info  interview  career  xgboost  PCA  wavelet  时序事件归因  SHAP  Figures  Behavioral Economics  graduate  arma  garch  人物  职场  Quantopian  figure  Banz  金融行业  买方  卖方  story  量化传奇  rsi  zigzag  穹顶压力  因子  ESG  因子策略  投资  策略  pe  ORB  Xgboost  Alligator  Indicator  factor  alpha101  alpha  技术指标  wave  quant  algorithm  pearson  spearman  tushare  因子分析  Alphalens  涨停板  herd-behaviour  momentum  因子评估  review  SMC  聪明钱  trade  history  indicators  zscore  波动率  强化学习  顶背离  freshman  resources  others  AI  DeepSeek  network  量子计算  金融交易  IBM  weekly  LLT  backtest  backtrader  研报  papers  UBL  quantlib  jupyter-notebook  scikit-learn  pypinyin  qmt  xtquant  blog  static-site  duckdb  工具  colors  free resources  barra  world quant  Alpha  openbb  数据  risk-management  llm  prompt  CANSLIM  Augment  arsenal  copilot  vscode  code  量化数据存储  hdf5  h5py  cursor  augment  trae  Jupyter  jupysql  pyarrow  parquet  数据源  quantstats  实盘  clickhouse  notebook  redis  remote-agent  AI-tools  Moonshot  回测,研报,tushare 

tools »

『Moonshot is all you need』 01 - 5分钟上手极简量化回测框架


做基本面策略回测,不想用 backtrader 这样的框架,但 Alphalens 也不适合日线;另外,作为初学者,希望能从零开始实现一个策略回测,有助于了解策略回测的原理。这个想法也没错,关键是,要如何实现?

这一系列文章,将以中金2023年的一期研报为例,介绍如何实现一个较复杂的策略回测。我们将学习到如何获取各种数据,如何进行数据预处理,如何设计回测框架等关键技术。

图1 中金研报

这个策略从股息收益、资本利得和风险规避三大维度入手,综合了事件选股和因子选股的手段,最终回测结果表明,近5年年化收益率达到29%。

图2 基本面策略构建思路

我们将实现一个可用于月度调仓策略的普适性框架,它有这些特点:

  • 按月调仓 适用于基本面策略
  • 模块职责划分清晰 易于叠加组合
  • 简单易上手

核心驱动框架

往简单里说,回测就是在指定的时间段(过去)里:

  1. 根据当时能得到的数据,发出交易信号
  2. 根据交易信号进行调仓
  3. 计算策略的每个交易期的收益
  4. 策略评估与可视化

一般来说,第一步是策略的核心。策略不同,要使用的数据也不同,构建的因子,以及决策逻辑也不同。但其它部分可以做到重复使用。特别是第4步,策略的评估与可视化,我们将使用 Quantstats 来实现。

Tip

使用知名的第三方框架来进行策略评估的好处不仅仅是省事,更重要的是便于策略之间进行比较。尽管算法都是公开的,但不同的策略评估框架在对缺失值的处理方式、默认参数的选择上还是存在差异。

我们先介绍如何实现第2步和第3步,即调仓和收益计算。

假设我们已经获取了股票的日线行情数据。由于我们的策略是按月调仓,所以,我们将行情数据重采样成了月线数据。

现在,这份数据看起来如下图所示:

图3 月行情数据

对月度调仓策略,先将数据重采样到月是一个重要的技巧。如果不这样,你就要先确定每个月的调仓日(每个月还不固定),再根据这个调仓日,去查找个股当天的因子数据和收盘价。一旦调仓日有个股停牌,就会出现数据缺失。

另一个好处是,现在我们可以在上月发出调仓信号后,按下月的开盘价买入,下月的收盘价卖出,这样严格地避开了未来数据。一些策略在计算收益时,仅使用收盘价,这样不可避免地要么引入未来数据,或者信号响应不及时。

现在,计算收益就变得很简单。比如,计算基准收益就是:

```{code-block} python df.groupby("month").apply(lambda x: (x.close / x.open - 1).mean())

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<div style='width:50%;text-align:center;margin: 0 auto 1rem'>
<img src='https://cdn.jsdelivr.net/gh/zillionare/imgbed2@main/images/2025/08/20250806133629.png'>
<span style='font-size:0.8em;display:inline-block;width:100%;text-align:center;color:grey'>图4 基准收益计算</span>
</div>

如果要计算组合的收益率呢?我们需要增加一列,先标记出哪些股票在当月的股票池中:

<div style='width:50%;text-align:center;margin: 0 auto 1rem'>
<img src='https://cdn.jsdelivr.net/gh/zillionare/imgbed2@main/images/2025/08/20250806135848.png'>
<span style='font-size:0.8em;display:inline-block;width:100%;text-align:center;color:grey'>图5 组合收益计算</span>
</div>

在图5中,我们增加了3列。其中 filter_1 是用来筛选股票的基础数据,比如,研报要求按每月筛选出股息率前 500 的股票,这一列就可以是股息率数据。

flag 列是根据 filter_1 列的数据,按照规则生成的『个股是否在股票池』的标记。在图中,假定规则是,如果 filter_1 大于零,则该个股在下个月的股票池中。这样我们就得到了2023年2月的股票池。

现在,我们计算策略收益时,只需要按月执行:

$$
\frac{\sum(returns \times flag)}{\sum flag}
$$

就能得到每月的策略收益。这一步相当于执行代码:

```{code-block} python
df.groupby("month").apply(lambda group: group[group["flag"] == 1]["returns"].mean())

到这一步为止,我们已经明确了要实现一个极简的月度回测框架,大致上要做的事情如下:

  1. 获取行情数据,重采样成为月度数据
  2. 根据策略要求,获取相关数据,构建因子
  3. 将第2步中构建的因子 (factor) 也重采样成为月度数据,附加到1中生成的 DataFrame 中
  4. 根据策略逻辑,将 factor 列转换成为 flag 列。
  5. 按月计算基准收益和策略收益
  6. 调用 quantstats 生成回测报告。

Moonshot 的实现

我们把这个极简框架命名为 Moonshot,因为它更适合固定按月调仓换股的策略。它的核心是一个名为 Moonshot 的类:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
class Moonshot:
    def __init__(self, daily_bars: pd.DataFrame):
        self.data: pd.DataFrame = resample_to_month(
            daily_bars, open="first", close="last"
        )
        self.data["flag"] = 1

        self.strategy_returns: pd.Series | None = None
        self.benchmark_returns: pd.Series | None = None
        self.analyzer: StrategyAnalyzer | None = None

    def append_factor(
        self, data: pd.DataFrame, factor_col: str, resample_method: str | None = None
    ) -> None:
        """将因子数据添加到回测数据(即self.data)中。

        如果resample_method参数不为None, 则需要重采样为月频,并且使用resample_method指定的方法。
        否则,认为因子已经是月频的,将直接添加到回测数据中。

        使用本方法,一次只能添加一个因子。

        Args:
            data: 因子数据,需包含'date'和'asset'列
            factor_col: 因子列名
            resample_method: 如果需要对因子重采样,此列为重采样方法。
        """
        if resample_method is not None:
            factor_data = resample_to_month(data, **{factor_col: resample_method})
        else:
            data_copy = data.copy()

            # 确保date列是datetime类型
            if not pd.api.types.is_datetime64_any_dtype(data_copy["date"]):
                data_copy["date"] = pd.to_datetime(data_copy["date"])

            data_copy["month"] = data_copy["date"].dt.to_period("M")

            # 检查是否有重复的(month, asset)组合
            duplicates = data_copy.duplicated(subset=["month", "asset"])
            if duplicates.any():
                duplicate_count = duplicates.sum()
                raise ValueError(
                    f"发现 {duplicate_count} 个重复的(month, asset)组合。"
                    "当resample_method=None时,传入的数据必须是无重复的月度数据。"
                    "如果您的数据是日频或有重复记录,请指定resample_method参数,"
                    "如:resample_method='last'、'mean'、'first'等"
                )

            factor_data = data_copy.set_index(["month", "asset"])[[factor_col]]

        self.data = self.data.join(factor_data, how="left")

    def screen(self, screen_method, **kwargs) -> "Moonshot":
        """应用股票筛选器

        Args:
            screen_method: 筛选方法(可调用对象)
            **kwargs: 筛选器参数

        Returns:
            Moonshot: 返回自身以支持链式调用
        """
        if callable(screen_method):
            flags = screen_method(**kwargs)

            # 当月选股,下月开仓
            flags = flags.groupby(level="asset").shift(1).fillna(0).astype(int)

            # 与现有flag进行逻辑与运算
            self.data["flag"] = self.data["flag"] & flags

        return self


def calculate_returns(self) -> "Moonshot":
    """计算策略收益率和基准收益率(向量化实现)

    使用向量化操作计算:
    1. 策略收益:每月flag=1的股票的等权平均收益
    2. 基准收益:每月所有股票的等权平均收益
    """
    # 计算所有股票的月收益率 (close - open) / open
    self.data["monthly_return"] = (self.data["close"] - self.data["open"]) / self.data[
        "open"
    ]

    # 按月分组计算策略收益(flag=1的股票等权平均)
    def calculate_strategy_return(group):
        selected = group[group.get("flag", 0) == 1]
        if len(selected) > 0:
            return selected["monthly_return"].mean()
        else:
            return 0.0

    strategy_returns = self.data.groupby("month").apply(calculate_strategy_return)
    strategy_returns.name = "strategy_returns"

    # 向量化计算基准收益(所有股票等权平均)
    benchmark_returns = self.data.groupby("month")["monthly_return"].mean()
    benchmark_returns.name = "benchmark_returns"

    # 存储结果
    self.strategy_returns = strategy_returns
    self.benchmark_returns = benchmark_returns

    self.analyzer = StrategyAnalyzer(
        strategy_returns=self.strategy_returns, benchmark_returns=self.benchmark_returns
    )

    return self

Moonshot 的使用方法如下:

```{code-block}python daily_bars = ... ms = Moonshot(daily_bars)

构建因子并加入到模型中

ms.append_factor(...)

回测

ms.screen(screen_func, **kwargs).calculate_returns().report()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
Moonshot 在初始化时,就要求我们传入日线行情数据,以便它可以构建最基础的数据结构(图3)。然后,我们可以通过 append_factor将因子加入模型中。

接下来,我们需要定义转换函数 screen_func,用来实现通过因子按月筛选股票,即实现图5中所示的转换。

我们把 screen 方法定义为链式调用,这样如果一个策略存在多个筛选条件,使用者只需要定义好各种筛选条件(screen_func),再依次调用 screen 方法即可。

最后,screen 方法返回 Moonshot 实例,我们可以在此基础上调用计算收益和输出报告等方法。

在 Moonshot 中,我们还调用了一个名为 resample_to_month 的方法,这个方法将时间序列数据重新采样到月度级别。

在 pandas 中,已经提供了 resample 方法:

```{code-block} python
df.groupby("asset").resample("ME").agg({"open": "first", "close": "last"})

但是,在数据量较大时(比如50万条记录左右),这个方法就比较慢,在一次运行中,我大约等待了10多秒。原因是 pandas 的聚合操作一直是它的性能短板,这也是像 polars 或者 duckdb 的优势所在。

在这里,我们给出一个 polars 的实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
def resample_to_month(data: pd.DataFrame, **kwargs) -> pd.DataFrame:
    """按月重采样,支持多列同时重采样

    Example:
        >>> resample_to_month(data, close='last', high='max', low='min', open='first', volume='sum')

    参数:
        data: DataFrame,需包含'date'和'asset'列。数据不要求有序。
        **kwargs: 关键字参数,格式为"列名=聚合方式"
                支持的聚合方式:'first'(首个值)、'last'(最后一个值)、
                                'mean'(平均值)、'max'(最大值)、'min'(最小值)

    返回:
        重采样后的DataFrame
    """
    df = pl.from_pandas(data)
    df = df.with_columns(pl.col("date").cast(pl.Datetime))

    df = df.with_columns(
        pl.concat_str(
            [
                pl.col("date").dt.year().cast(pl.Utf8),
                pl.lit("-"),
                pl.col("date").dt.month().cast(pl.Utf8).str.pad_start(2, fill_char="0"),
            ]
        ).alias("month")
    )

    # 定义支持的聚合方式映射(列名 -> 聚合表达式)
    agg_methods = {
        "first": lambda col: col.sort_by(pl.col("date")).first(),
        "last": lambda col: col.sort_by(pl.col("date")).last(),
        "mean": lambda col: col.mean(),
        "max": lambda col: col.max(),
        "min": lambda col: col.min(),
        "sum": lambda col: col.sum(),
    }

    # 构建聚合表达式列表
    agg_exprs = []
    for col_name, method in kwargs.items():
        if col_name not in df.columns:
            raise ValueError(f"数据中不存在列: {col_name}")

        # 检查聚合方式是否支持
        if method not in agg_methods:
            raise ValueError(
                f"不支持的聚合方式: {method},支持的方式为: {list(agg_methods.keys())}"
            )

        # 添加聚合表达式
        agg_exprs.append(agg_methods[method](pl.col(col_name)).alias(col_name))

    if not agg_exprs:
        raise ValueError("至少需要指定一个列的聚合方式(如open='first')")

    result = (
        df.group_by(pl.col("asset"), pl.col("month"))
        .agg(agg_exprs)
        .sort(pl.col("month"), pl.col("asset"))
    )

    result = result.to_pandas()
    result["month"] = pd.PeriodIndex(result["month"], freq="M")

    return result.set_index(["month", "asset"])

这个函数接受 dataframe 作为输入,最后也返回一个 dataframe,只在中间过程中使用polars。额外的数据格式转换会有可以忽略的性能损失,但是,坚持使用 dataframe 作为各个模块、各个方法之间的数据传递格式,会大大降低 coding 的难度。

现在,我们使用真实的数据,构造一个 Moonshot 对象,看看 resample_to_month() 函数的运行结果如何。

1
2
3
4
5
6
7
start = datetime.date(2018, 1, 1)
end = datetime.date(2023, 12, 31)

barss = load_bars(start, end, 100)
ms = Moonshot(barss.reset_index())

ms.data

现在我们看到,数据确实被重采样成了月度数据,并且索引是已经被设置为月度时间戳。

这一期内容就到这里。下一期我们将实现研报中的第一个筛选器 -- 股息率。我们将完整地实现获取数据、定义筛选器方法,并且进行一个完整的回测。