原始数据入口
在浙江工商大学泰隆金融学院内网访问高频数据目录,下载自己有权限使用的文件。MAT 原始文件较大,适合研究或进一步扩展;本节不要求一次下载整个数据目录。
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 / time | 5 分钟 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))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()“下一 bar 执行”尤其重要。若你在 10:00 才根据 10:00 的收盘价计算出信号,就不能假装在同一个 10:00 收盘价成交。下面统一把信号 shift(1) 一格,表示信号只影响下一根 bar 的持仓。
turnover 不是“成交额”。在这里,它只记录仓位从 0→1 或 1→0 的变化幅度。仓位由 0 变为 1,turnover=1;连续持有时为 0。
为了让名称、代码和参考资料对应起来,本节不把它们只叫“策略 A、策略 B”。你可以在 Notebook、报告和 Git 提交中使用下面的完整名称。
英文:600519.SH 5-minute 6/24 moving-average trend-following strategy。
一句话:短均线站上长均线,就认为最近的价格方向偏强,下一根 bar 持有;短均线跌回长均线下方,就暂时持有现金。
类别:这是“时间序列动量 / 趋势跟随”的教学化实现,只观察 600519 自己的过去,不是在几只股票中挑赢家。
英文: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%}")样本期间的价格总体下行,因此“什么也不做”的结果并不乐观。这个事实不能成为修改规则、直到找到正收益的理由;它是之后解释策略结果的背景。
时间序列动量只问一只资产自己的过去表现,而不比较它和其他股票谁更强。相关研究发现,资产过去一段时期的收益对其后续收益可能具有预测性;在这里将它实现为最容易检查的短、长均线交叉规则:6 根 5 分钟 bar 的均线高于 24 根 bar 的均线时,下一根 bar 持有;否则持有现金。
# 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))均值回归策略的直觉与趋势策略相反:当价格在当日成交均价附近短暂跌得较多时,可能存在反弹机会。VWAP 是成交量加权平均价格,不是简单平均价;成交更多的 bar 对它的影响更大。Nasdaq 将 VWAP 定义为 volume-weighted average price;本页使用的计算形式也与“成交额 ÷ 成交股数”一致。这里仅用于构造一个容易理解的日内规则,而不宣称它在任何市场都有效。
# 每个交易日从头累计,不能把昨天的成交额带到今天
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))两条策略的收益计算完全相同,区别只是仓位来源。为避免“看起来交易很多却完全没有代价”的假象,以下假设每次买入或卖出扣除单边 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))高频规则最容易被成本击穿。这里已经能看到两条策略频繁改变仓位;如果忽略成本,结果会明显更好看,却不再是同一个研究问题。
比较策略不能只看期末净值。下面先把 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%}"))可下载本页计算出的 指标 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()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()94 个交易日和单一标的不足以推翻一个策略类别。这里只能说:这组参数在这个样本、成本与交易约束下没有胜过基准。
负结果依然是结果。它让你看到趋势规则可能遭遇震荡市场,均值回归可能被持续下跌或成本吞噬。
改变股票、时间段与成本假设;划分训练期和测试期;在所有设定完成前,不要挑选“最漂亮”的一组参数。
一项很重要的研究习惯:先固定规则、样本区间和指标,再运行。若看到结果后才反复改 6/24、−1、5bp,最终容易把偶然噪声误认为规律,这就是回测过拟合。
ONE_WAY_COST 分别改为 0、2bp、10bp,比较三条净值曲线。成本为零的结果为什么可能不可信?fast in [3, 6, 12] 与 slow in [12, 24, 48] 形成参数表,但先留出一段未参与选择的测试期。时间序列动量与横截面动量不同:前者以单个资产自身的过去收益判断方向;后者比较不同资产的相对强弱。可先阅读 Moskowitz、Ooi 与 Pedersen 的 Time Series Momentum 页面;动量研究的经典起点之一是 Jegadeesh 与 Titman(1993)。这些文献提供研究背景,不替代对中国股票市场、数据处理和交易制度的独立检验。
课程数据仅限教学和已获授权的研究用途。不要公开上传校内原始高频数据、账号或访问凭据。发布 Notebook、GitHub 仓库或作业时,使用本页提供的小样本或明确许可的公开数据。