Python在金融中的应用 · 第三部分

第九节:量化投资应用:模拟场景、策略与回测

从一份真实的分钟级股票数据出发,先把投资问题说清楚,再把交易规则写成代码,最后用统一的成本、滞后和风险指标检验结果。重点不在于“找一条一定赚钱的规则”,而在于建立一个不偷看未来、能被别人复算和质疑的研究过程。

先看完整任务:不是先写指标

假设你负责一个小型研究账户,只能做多一只股票或持有现金,资金初始净值为 1。你希望比较两类常见的日内规则:顺着短期价格趋势交易,或者在价格暂时偏离成交均价时等待反弹。代码的每一行都应能回答一个具体问题。

问题账户能做什么
数据何时可见
信号何时进出
回测成本与滞后
解释收益与风险
本页是研究与学习示例,不构成投资建议。样本只含一只股票、一个有限时段,不能据此推断未来表现;真实交易还会受到涨跌停、最小交易单位、滑点、冲击成本、税费、停牌和流动性约束。

数据场景与可复现样本

本页使用“资源”中的高频股票数据。原始文件是校内服务器提供的 600519.mat,包含分钟记录。为了让练习在普通电脑上快速运行,已抽取 2024-01-02 至 2024-05-27 的 5 分钟 bar,共 4,512 行,保存为课程数据文件。

原始数据入口

在浙江工商大学泰隆金融学院内网访问高频数据目录,下载自己有权限使用的文件。MAT 原始文件较大,适合研究或进一步扩展;本节不要求一次下载整个数据目录。

打开校内高频股票数据目录 ↗

本节轻量数据

下载 hf_600519_5min_2024.csv。它来自上述真实分钟数据的 5 分钟聚合,不是随机合成数据。下载后将文件放进 Notebook 同级的 data 文件夹。

字段含义本节怎样使用
datetime / date / time5 分钟 bar 的结束时点、交易日和时刻排序、绘图、按交易日重置 VWAP。
open / high / low / close该 5 分钟内的开高低收价格,单位为元以 close 计算可交易的下一 bar 收益。
volume / amount成交量与成交额计算成交量加权平均价格 VWAP。
vwap_5m单个 5 分钟 bar 的成交额 ÷ 成交量用于检查聚合是否合理;策略使用当日累计 VWAP。

读取、检查与画出样本

不要把 CSV 读进来就直接交易。先确认行数、日期范围、价格是否为正、每个交易日有多少根 bar。这里的检查也是最早发现缺失日期、午间休市错误填充、价格异常或重复记录的机会。

from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

data_path = Path("data/hf_600519_5min_2024.csv")
bars = pd.read_csv(data_path, parse_dates=["datetime"])
bars = bars.sort_values("datetime").reset_index(drop=True)

print("数据形状:", bars.shape)
print("日期范围:", bars["date"].min(), "至", bars["date"].max())
print("股票:", bars["ticker"].unique())
print("收盘价是否全部为正:", (bars["close"] > 0).all())
print(bars[["datetime", "open", "high", "low", "close", "volume"]].head(3))
数据形状: (4512, 11) 日期范围: 2024-01-02 至 2024-05-27 股票: ['600519.SH'] 收盘价是否全部为正: True datetime open high low close volume 0 2024-01-02 09:30:00 1712.00 1714.00 1691.80 1713.99 300031.0 1 2024-01-02 09:35:00 1713.99 1714.38 1705.00 1707.00 117750.0 2 2024-01-02 09:40:00 1707.00 1711.00 1698.20 1700.50 155600.0
fig, ax = plt.subplots(figsize=(11, 4.8))
ax.plot(bars["datetime"], bars["close"], color="#1f6b65", linewidth=1)
ax.set(title="600519.SH:课程样本的 5 分钟收盘价", xlabel="日期", ylabel="价格(元)")
ax.grid(alpha=0.25)
fig.tight_layout()
plt.show()
运行结果:出现一张价格随时间变化的折线图。这个图描述样本,不是策略表现。
600519 五分钟高频样本收盘价图
真实高频样本经 5 分钟聚合后的价格轨迹。原始分钟文件保留在校内数据服务器,本节提供轻量 CSV 供复现。

回测前必须写下的四个约束

单一标的只交易 600519.SH;不做横截面选股。
多头 / 现金仓位只能是 1 或 0;不卖空、不杠杆。
下一 bar 执行当前 bar 收盘后才看得到信号。
单边 5bp每次仓位变化都扣 0.05% 的示例成本。

“下一 bar 执行”尤其重要。若你在 10:00 才根据 10:00 的收盘价计算出信号,就不能假装在同一个 10:00 收盘价成交。下面统一把信号 shift(1) 一格,表示信号只影响下一根 bar 的持仓。

bar_returnt = closet / closet−1 − 1
net_returnt = positiont × bar_returnt − turnovert × cost

turnover 不是“成交额”。在这里,它只记录仓位从 0→1 或 1→0 的变化幅度。仓位由 0 变为 1,turnover=1;连续持有时为 0。

这两个策略到底叫什么

为了让名称、代码和参考资料对应起来,本节不把它们只叫“策略 A、策略 B”。你可以在 Notebook、报告和 Git 提交中使用下面的完整名称。

600519.SH 5 分钟 6/24 均线趋势跟随策略

英文:600519.SH 5-minute 6/24 moving-average trend-following strategy。

一句话:短均线站上长均线,就认为最近的价格方向偏强,下一根 bar 持有;短均线跌回长均线下方,就暂时持有现金。

类别:这是“时间序列动量 / 趋势跟随”的教学化实现,只观察 600519 自己的过去,不是在几只股票中挑赢家。

600519.SH 日内 VWAP 偏离均值回归策略

英文:600519.SH intraday VWAP-deviation mean-reversion strategy。

一句话:把当天成交量加权的平均价格当作“市场平均成交线”;当收盘价相对这条线向下偏离超过一个滚动标准差时,下一根 bar 试着持有,等待偏离缩小。

类别:这是“日内均值回归”的简化规则,不是 VWAP 执行算法,也不是说低于 VWAP 就一定会上涨。

为什么不是直接照搬论文参数?经典时间序列动量研究使用较长的资产和时间尺度;本节把思想缩小到学生可以看懂的 5 分钟数据。6、24 和 −1 是明确写下的教学参数,不是经过反复试验后挑出来的“最佳答案”。

基准:先知道“不做策略”会怎样

任何策略都需要基准。这里的基准是样本第一根 bar 买入并持有到最后一根 bar。它不一定是最好的基准,但能回答一个基本问题:复杂规则是否至少比简单持有更有价值?

bars["bar_return"] = bars["close"].pct_change().fillna(0.0)
bars["buyhold_nav"] = (1 + bars["bar_return"]).cumprod()

print("买入并持有期末净值:", round(bars["buyhold_nav"].iloc[-1], 4))
print("买入并持有样本区间收益:",
      f"{(bars['buyhold_nav'].iloc[-1] - 1):.2%}")
买入并持有期末净值: 0.9744 买入并持有样本区间收益: -2.56%

样本期间的价格总体下行,因此“什么也不做”的结果并不乐观。这个事实不能成为修改规则、直到找到正收益的理由;它是之后解释策略结果的背景。

600519.SH 5 分钟 6/24 均线趋势跟随策略

时间序列动量只问一只资产自己的过去表现,而不比较它和其他股票谁更强。相关研究发现,资产过去一段时期的收益对其后续收益可能具有预测性;在这里将它实现为最容易检查的短、长均线交叉规则:6 根 5 分钟 bar 的均线高于 24 根 bar 的均线时,下一根 bar 持有;否则持有现金。

SMAfast,t = mean(closet−5, …, closet)
signalt = 1[SMA6,t > SMA24,t]
# 6 个 5 分钟 bar = 30 分钟;24 个 bar = 120 分钟
bars["sma_fast"] = bars["close"].rolling(6, min_periods=6).mean()
bars["sma_slow"] = bars["close"].rolling(24, min_periods=24).mean()
bars["trend_signal"] = (bars["sma_fast"] > bars["sma_slow"]).astype(int)

# shift(1) 是避免“用收盘价在同一收盘价交易”的关键
bars["trend_position"] = bars["trend_signal"].shift(1).fillna(0)
bars["trend_turnover"] = bars["trend_position"].diff().abs().fillna(bars["trend_position"])

print(bars[["datetime", "close", "sma_fast", "sma_slow",
            "trend_signal", "trend_position"]].iloc[22:28].round(3))
datetime close sma_fast sma_slow trend_signal trend_position 22 2024-01-02 11:20:00 1682.00 1682.613 1693.512 0 0.0 23 2024-01-02 11:25:00 1679.00 1681.497 1692.908 0 0.0 24 2024-01-02 11:30:00 1681.40 1680.650 1691.550 0 0.0 25 2024-01-02 13:00:00 1677.20 1679.860 1690.110 0 0.0
均线趋势信号示例
一段单日数据上的均线趋势信号。图上的均线只利用当时及之前已经出现的价格;真正的持仓从下一根 bar 才开始。

600519.SH 日内 VWAP 偏离均值回归策略

均值回归策略的直觉与趋势策略相反:当价格在当日成交均价附近短暂跌得较多时,可能存在反弹机会。VWAP 是成交量加权平均价格,不是简单平均价;成交更多的 bar 对它的影响更大。Nasdaq 将 VWAP 定义为 volume-weighted average price;本页使用的计算形式也与“成交额 ÷ 成交股数”一致。这里仅用于构造一个容易理解的日内规则,而不宣称它在任何市场都有效。

VWAPt = Σi≤t amounti / Σi≤t volumei
zt = (closet − VWAPt) / sd12(close − VWAP)
signalt = 1[zt < −1]
# 每个交易日从头累计,不能把昨天的成交额带到今天
bars["cum_amount"] = bars.groupby("date")["amount"].cumsum()
bars["cum_volume"] = bars.groupby("date")["volume"].cumsum()
bars["vwap"] = bars["cum_amount"] / bars["cum_volume"].replace(0, np.nan)

bars["vwap_gap"] = bars["close"] - bars["vwap"]
bars["gap_std"] = bars.groupby("date")["vwap_gap"].transform(
    lambda x: x.rolling(12, min_periods=12).std()
)
bars["zscore"] = bars["vwap_gap"] / bars["gap_std"]
bars["meanrev_signal"] = (bars["zscore"] < -1.0).astype(int)
bars["meanrev_position"] = bars["meanrev_signal"].shift(1).fillna(0)
bars["meanrev_turnover"] = bars["meanrev_position"].diff().abs().fillna(bars["meanrev_position"])

print(bars[["datetime", "close", "vwap", "zscore",
            "meanrev_signal", "meanrev_position"]].dropna().head(4).round(3))
datetime close vwap zscore meanrev_signal meanrev_position 11 2024-01-02 10:25:00 1685.80 1695.139 -1.234 1 0.0 12 2024-01-02 10:30:00 1686.20 1694.312 -1.087 1 1.0 13 2024-01-02 10:35:00 1684.10 1693.467 -1.356 1 1.0 14 2024-01-02 10:40:00 1686.00 1692.908 -0.844 0 1.0
VWAP均值回归信号示例
当价格相对于当日 VWAP 的偏离低于最近 12 根 bar 偏离标准差的 −1 倍时,产生买入信号;仓位仍然在下一根 bar 执行。

把信号变成净收益:成本不能省略

两条策略的收益计算完全相同,区别只是仓位来源。为避免“看起来交易很多却完全没有代价”的假象,以下假设每次买入或卖出扣除单边 5bp,即 0.0005。这不是对真实佣金的报价;真实成本应根据市场、账户、成交量和交易方式重新设定。

ONE_WAY_COST = 0.0005  # 单边 5bp,只用于本节演示

for strategy in ["trend", "meanrev"]:
    bars[f"{strategy}_net_return"] = (
        bars[f"{strategy}_position"] * bars["bar_return"]
        - bars[f"{strategy}_turnover"] * ONE_WAY_COST
    )
    bars[f"{strategy}_nav"] = (1 + bars[f"{strategy}_net_return"]).cumprod()

print("趋势策略交易次数:", int(bars["trend_turnover"].sum()))
print("VWAP 策略交易次数:", int(bars["meanrev_turnover"].sum()))
print("趋势策略期末净值:", round(bars["trend_nav"].iloc[-1], 4))
print("VWAP 策略期末净值:", round(bars["meanrev_nav"].iloc[-1], 4))
趋势策略交易次数: 278 VWAP 策略交易次数: 353 趋势策略期末净值: 0.8467 VWAP 策略期末净值: 0.9283

高频规则最容易被成本击穿。这里已经能看到两条策略频繁改变仓位;如果忽略成本,结果会明显更好看,却不再是同一个研究问题。

统一计算收益、波动率、夏普与最大回撤

比较策略不能只看期末净值。下面先把 5 分钟收益复利合成为日收益,再计算年化收益、日收益年化波动率、无风险利率暂设为 0 的夏普比率,以及从历史峰值回落的最大回撤。

def daily_returns(bar_returns, dates):
    """把同一交易日的 5 分钟收益复利为一个日收益。"""
    return pd.DataFrame({"date": dates, "return": bar_returns}).groupby("date")["return"].apply(
        lambda x: (1 + x).prod() - 1
    )

def performance_report(nav, bar_returns, dates):
    daily = daily_returns(bar_returns, dates)
    annual_return = (1 + daily).prod() ** (252 / len(daily)) - 1
    annual_vol = daily.std(ddof=1) * np.sqrt(252)
    max_drawdown = (nav / nav.cummax() - 1).min()
    return {
        "样本区间收益": nav.iloc[-1] - 1,
        "年化收益": annual_return,
        "年化波动率": annual_vol,
        "夏普比率(rf=0)": annual_return / annual_vol,
        "最大回撤": max_drawdown,
    }

reports = {
    "买入并持有": performance_report(bars["buyhold_nav"], bars["bar_return"], bars["date"]),
    "趋势跟随(6/24)": performance_report(bars["trend_nav"], bars["trend_net_return"], bars["date"]),
    "VWAP 均值回归": performance_report(bars["meanrev_nav"], bars["meanrev_net_return"], bars["date"]),
}
report = pd.DataFrame(reports).T
print(report.applymap(lambda x: f"{x:.2%}"))
样本区间收益 年化收益 年化波动率 夏普比率(rf=0) 最大回撤 买入并持有 -2.56% -6.71% 18.58% -0.36 -9.24% 趋势跟随(6/24) -15.33% -36.00% 14.56% -2.47 -15.80% VWAP 均值回归 -7.17% -18.09% 8.46% -2.14 -7.76%

可下载本页计算出的 指标 CSV。注意:负夏普不是“代码错了”,而是该有限样本、该交易频率、该成本假设下,策略没有补偿其承担的风险。

结果图:净值与回撤要一起读

fig, ax = plt.subplots(figsize=(11, 5.5))
for column, label, color in [
    ("buyhold_nav", "买入并持有", "#71808a"),
    ("trend_nav", "趋势跟随(6/24)", "#1f6b65"),
    ("meanrev_nav", "VWAP 均值回归", "#a66a3d"),
]:
    ax.plot(bars["datetime"], bars[column], label=label, color=color, linewidth=2)
ax.set(title="含单边 5bp 成本后的策略净值", xlabel="日期", ylabel="初始净值 = 1")
ax.legend(); ax.grid(alpha=0.25)
fig.tight_layout(); plt.show()
运行结果:三条净值线从 1 开始。该样本中买入并持有期末最高;两条规则均因信号质量与频繁成本而未超过它。
三种策略净值结果
净值曲线展示同一真实样本与同一成本假设下的比较。它不是样本外验证,也不是未来收益预测。
fig, ax = plt.subplots(figsize=(11, 5.5))
for column, label, color in [
    ("buyhold_nav", "买入并持有", "#71808a"),
    ("trend_nav", "趋势跟随(6/24)", "#1f6b65"),
    ("meanrev_nav", "VWAP 均值回归", "#a66a3d"),
]:
    drawdown = bars[column] / bars[column].cummax() - 1
    ax.plot(bars["datetime"], drawdown, label=label, color=color, linewidth=1.8)
ax.axhline(0, color="#71808a", linewidth=0.8)
ax.set(title="从各自历史峰值计算的回撤", xlabel="日期", ylabel="回撤")
ax.legend(); ax.grid(alpha=0.25)
fig.tight_layout(); plt.show()
运行结果:回撤图最高为 0,向下越深表示相对历史峰值亏损越大。VWAP 规则的最大回撤较小,但这不自动意味着它更优,因为它的收益也较低。
三种策略回撤结果
风险不是只有波动率。最大回撤提示:如果在此前高点投入资金,最坏阶段会经历多大的账面回落。

怎样解释这一次“不成功”的回测

不能说策略无效

94 个交易日和单一标的不足以推翻一个策略类别。这里只能说:这组参数在这个样本、成本与交易约束下没有胜过基准。

也不能说代码无用

负结果依然是结果。它让你看到趋势规则可能遭遇震荡市场,均值回归可能被持续下跌或成本吞噬。

下一步应先扩展验证

改变股票、时间段与成本假设;划分训练期和测试期;在所有设定完成前,不要挑选“最漂亮”的一组参数。

一项很重要的研究习惯:先固定规则、样本区间和指标,再运行。若看到结果后才反复改 6/24、−1、5bp,最终容易把偶然噪声误认为规律,这就是回测过拟合。

从这里继续:可检查的扩展练习

  1. 成本敏感性:把 ONE_WAY_COST 分别改为 0、2bp、10bp,比较三条净值曲线。成本为零的结果为什么可能不可信?
  2. 参数不只选一组:用 fast in [3, 6, 12] 与 slow in [12, 24, 48] 形成参数表,但先留出一段未参与选择的测试期。
  3. 样本外检验:用 2024 年前半段确定规则,用后半段单独报告结果;不要把测试期结果再拿来调参。
  4. 多资产情形:从校内目录选择多只股票,分别计算各自信号,再讨论等权、波动率缩放和行业集中度。此时才真正进入组合层面的量化投资问题。
  5. 日志与复现:把数据来源、下载日期、字段解释、成本、参数、运行环境写进 README,并用 Git 保存每次修改。

参考资料、文件与数据边界

时间序列动量与横截面动量不同:前者以单个资产自身的过去收益判断方向;后者比较不同资产的相对强弱。可先阅读 Moskowitz、Ooi 与 Pedersen 的 Time Series Momentum 页面;动量研究的经典起点之一是 Jegadeesh 与 Titman(1993)。这些文献提供研究背景,不替代对中国股票市场、数据处理和交易制度的独立检验。

课程数据仅限教学和已获授权的研究用途。不要公开上传校内原始高频数据、账号或访问凭据。发布 Notebook、GitHub 仓库或作业时,使用本页提供的小样本或明确许可的公开数据。