Skip to content
Go back

量化交易探索-工具篇(二):vnpy回测框架接入海龟策略

Edit page

工具篇(一)搞定了 AKShare 数据源,这一篇把整个量化研究闭环拉通:数据 → 入库 → 策略 → 回测 → 分析。选型上继续沿用之前提过的 vnpy——中国量化开源生态里最活跃的框架之一,覆盖了从行情接入、策略回测到实盘交易的整条链路。

策略上选一个经典的:海龟交易法(Turtle Trading)。1983 年理查德·丹尼斯在芝加哥拿这个策略训练了 13 个学员,四年赚了 1.75 亿美元,是趋势跟踪策略的鼻祖。规则简单到能写在一张 A4 纸上,但内含了系统化交易最重要的几个原则:信号明确、仓位管理、止损纪律。

内容顺序:装环境 → 建数据管道 → 写策略 → 用 Notebook 探索 → 发现一个反直觉的现象。全程实录,包括踩过的坑。

Table of contents

Open Table of contents

一、环境准备:合并到一个 venv

工具篇(一)里 AKShare 装在 ~/venvs/akshare。这一篇要用 vnpy,最合理的做法是两个包装在同一个 venv 里——因为核心用例就是”用 AKShare 拉数据 → 存进 vnpy 数据库 → 跑回测”,一个脚本里要同时 import 两边,必须共用环境。

分开 venv 的替代方案是”AKShare 导 CSV → vnpy 环境读 CSV → 入库”,多一次磁盘中转,没必要。

依赖冲突风险评估过:AKShare 和 vnpy 4.x 的核心依赖(pandas、numpy、requests)版本区间都比较宽松,pip 会自动选一个兼容版本,实际装起来不打架。vnpy 会额外拉 PySide6(Qt 图形库)和 ta-lib,跟 AKShare 没交集。

我选择把原来的 venv 目录改名为更中性的 ~/venvs/quant,反映它现在服务于整个量化工作流:

# venv 的 activate 脚本硬编码了绝对路径,直接 mv 会坏,用 pip freeze 重建
source ~/venvs/akshare/bin/activate
pip freeze > /tmp/pkgs.txt
deactivate

python3.13 -m venv ~/venvs/quant
source ~/venvs/quant/bin/activate
pip install -r /tmp/pkgs.txt

# 确认新环境没问题后再清理老的
rm -rf ~/venvs/akshare

顺手把 ~/.zshrc 里的别名也改掉:

sed -i '' 's|~/venvs/akshare/bin/activate|~/venvs/quant/bin/activate|g' ~/.zshrc
sed -i '' 's|alias akenv=|alias qenv=|g' ~/.zshrc
source ~/.zshrc

踩过的坑:venv 目录不能直接 mv 重命名,因为里面 bin/pipbin/python3 这些脚本的 shebang(#!/Users/52coder/venvs/akshare/bin/python3)是硬编码的绝对路径。移动后 pip install 会立刻死。用 pip freeze 导出包列表、重建 venv、批量装回来是最干净的方案,30 秒搞定。

二、装 vnpy

vnpy 依赖 ta-lib 的 C 库,先用 Homebrew 装底层:

brew install ta-lib

vnpy 官方在 macOS 上不提供一键发行版(VeighNa Studio 只有 Windows),所以从源码装。clone 主仓库拿到 install_osx.sh

cd ~/github
git clone https://github.com/vnpy/vnpy.git
cd vnpy
bash install_osx.sh

install_osx.sh 内部会升级 pip、装 numpy、装 ta-lib Python 绑定,最后 pip install . 装 vnpy 本体。默认从 vnpy 官方 PyPI 镜像源(pypi.vnpy.com)拉包,境外网络可能不通,可以换回官方源:

bash install_osx.sh python3 https://pypi.org/simple

vnpy 主仓库只是内核,具体的策略引擎、数据库适配器、交易接口都是独立子包,按需装:

pip install vnpy_ctastrategy vnpy_ctabacktester vnpy_sqlite

macOS 上跳过 CTP 接口vnpy_ctp 是国内期货接口,新版 CTP 的 Mac API 改成了 framework 目录结构,PyPI 没预编译 wheel,需要本地 clone + 编译。研究 A 股 ETF 用不到 CTP,直接跳过省事。

验证装好:

python3 -c "
import akshare as ak
import vnpy
from vnpy_ctastrategy import CtaTemplate
from vnpy_ctastrategy.backtesting import BacktestingEngine
print(f'akshare {ak.__version__}')
print(f'vnpy {vnpy.__version__}')
print('backtesting engine imported OK')
"

输出:

akshare 1.18.64
vnpy 4.4.0
backtesting engine imported OK

三、目录结构约定

从这一篇开始,量化研究的代码放到一个固定目录,别再散在各处:

~/quant-research/
├── scripts/
│   ├── turtle_etf_strategy.py       ← 策略类定义
│   └── fetch_and_backtest.py         ← 命令行版本
├── notebooks/
│   └── turtle_backtest.ipynb         ← 交互式实验
└── data/                             ← 数据/结果导出

.py.ipynb 分工不同,理解这一点很重要:

策略代码写在 .py 里,Notebook 只做”实验主线”:import 策略、拉数据、跑回测、分析结果。这是量化研究行业里的通用模式,vnpy 官方的 examples/ 目录也是这样组织的。

四、海龟策略实现

先给策略骨架,参数含义放到后面详解。文件放到 ~/quant-research/scripts/turtle_etf_strategy.py

"""海龟交易法 ETF 只做多版本"""

from vnpy_ctastrategy import (
    CtaTemplate, StopOrder,
    TickData, BarData, TradeData, OrderData,
    BarGenerator, ArrayManager,
)


class TurtleEtfStrategy(CtaTemplate):
    author = "52coder"

    entry_window: int = 20        # 突破 20 日高点入场
    exit_window: int = 10         # 跌破 10 日低点出场
    atr_window: int = 20          # ATR 波动率计算周期
    risk_per_trade: float = 0.02  # 每笔交易愿承担 2% 资金风险
    fixed_capital: float = 100_000.0

    entry_up: float = 0.0
    exit_down: float = 0.0
    atr_value: float = 0.0
    long_stop: float = 0.0

    parameters = ["entry_window", "exit_window", "atr_window",
                  "risk_per_trade", "fixed_capital"]
    variables = ["entry_up", "exit_down", "atr_value", "long_stop"]

    def on_init(self):
        self.bg = BarGenerator(self.on_bar)
        self.am = ArrayManager(size=max(self.entry_window, self.atr_window) + 10)
        self.load_bar(self.entry_window + 20)

    def on_start(self):
        self.put_event()

    def on_stop(self):
        self.put_event()

    def on_tick(self, tick):
        self.bg.update_tick(tick)

    def on_bar(self, bar):
        self.cancel_all()

        am = self.am
        am.update_bar(bar)
        if not am.inited:
            return

        # 唐奇安通道(不含当日)
        self.entry_up = am.high_array[-self.entry_window:-1].max()
        self.exit_down = am.low_array[-self.exit_window:-1].min()
        self.atr_value = am.atr(self.atr_window)

        if self.pos == 0:
            # 空仓:突破入场
            if bar.close_price > self.entry_up and self.atr_value > 0:
                risk_cash = self.fixed_capital * self.risk_per_trade
                unit_risk = 2 * self.atr_value
                shares = int(risk_cash / unit_risk / 100) * 100
                shares = max(shares, 100)
                self.buy(bar.close_price, shares)
                self.long_stop = bar.close_price - 2 * self.atr_value

        elif self.pos > 0:
            # 持仓:两个出场条件
            if bar.close_price < self.exit_down or bar.close_price < self.long_stop:
                self.sell(bar.close_price, abs(self.pos))

        self.put_event()

    def on_order(self, order): pass
    def on_trade(self, trade): self.put_event()
    def on_stop_order(self, stop_order): pass

对齐海龟策略的四个核心原则:

  1. 突破入场(20 日新高信号)
  2. 反向突破出场(10 日新低信号)
  3. ATR 波动率仓位管理shares = risk_cash / unit_risk
  4. 初始止损long_stop = 入场价 - 2 × ATR

原版海龟同时做多做空,这里改成只做多——适配 A 股 ETF 现券账户规则。

五、五个参数的含义

entry_window = 20:入场信号窗口。策略每天看”过去 20 天的最高价”,当前收盘突破这条线就买入。这是海龟原版 System 1 的经典值。改小(比如 10)会更敏感、交易更频繁;改大(比如 55)只捕捉大级别趋势,交易极少。

exit_window = 10:出场信号窗口。持仓时每天看”过去 10 天的最低价”,跌破就卖出。出场窗口 < 入场窗口 是关键设计——“进得慢、退得快”,保住利润。

atr_window = 20:ATR 计算周期。ATR(Average True Range)是量化里最重要的波动率指标,衡量”最近 N 天平均每天波动多少”。用来两处:一处决定买多少、一处决定止损位。

risk_per_trade = 0.02这是整个策略最关键的风控参数。规定”这笔交易如果亏,最多允许亏总资金的 2%“。交易界的经验值:1% 极保守、2% 稳健、5% 激进、10%+ 是赌博。

fixed_capital = 100_000.0:仓位计算的参考资金。策略每次买多少都基于这个数字算,故意不跟随账户浮盈浮亏变化——保持仓位稳定可预期,研究阶段这样更容易理解策略行为。

头寸计算的完整推演(假设 ETF 价格 4.00,ATR = 0.06):

risk_cash = 100_000 × 0.02 = 2000 元      ← 单笔允许亏损上限
unit_risk = 2 × 0.06     = 0.12 元/份     ← 每份 ETF 承担的风险
shares    = 2000 / 0.12 / 100 × 100 = 1600 份
long_stop = 4.00 - 2 × 0.06 = 3.88 元     ← 初始止损位

如果 ETF 真的跌到 3.88 触发止损,亏损正好是 1600 × 0.12 = 192 元… 等等,为什么不是 2000?因为 int() 向下取整加上”100 份整数倍”约束,实际买的份数比理论值小,亏损也相应变小。这是保守设计——永远不会超过 2% 风险,只会更少

六、数据 → 入库 → 回测:完整管道

策略写好后,用 AKShare 拉数据、写入 vnpy 数据库、跑回测。在 Notebook 里分 cell 组织:

Cell 1 — 路径配置(让 Notebook 找到 .py 文件)

import sys
from pathlib import Path
sys.path.insert(0, str(Path.home() / "quant-research" / "scripts"))

Cell 2 — 拉数据(用工具篇(一)里验证过的新浪源,境外可用)

import akshare as ak
import pandas as pd

from vnpy.trader.constant import Exchange, Interval
from vnpy.trader.database import get_database
from vnpy.trader.object import BarData

ETF_SYMBOL = "510300"
EXCHANGE = Exchange.SSE

df = ak.fund_etf_hist_sina(symbol=f"sh{ETF_SYMBOL}")
df["date"] = pd.to_datetime(df["date"])
df = df[(df["date"] >= "2020-01-01") & (df["date"] <= "2024-12-31")].reset_index(drop=True)

print(f"拉到 {len(df)} 根日线")
df.head()

输出 拉到 1212 根日线。注意新浪返回的字段里有 postVolpostAmt 两列全是 NaN——那是盘后成交量/成交额,A 股不支持盘后交易,这两列天然为空,接口里保留只是为了兼容有盘后交易的市场(美股 ETF 之类)。回测用不到,无视即可。

Cell 3 — 转 BarData 入库

bars = [
    BarData(
        gateway_name="AKSHARE_SINA",
        symbol=ETF_SYMBOL, exchange=EXCHANGE,
        datetime=row["date"].to_pydatetime(),
        interval=Interval.DAILY,
        volume=float(row["volume"]),
        open_price=float(row["open"]),
        high_price=float(row["high"]),
        low_price=float(row["low"]),
        close_price=float(row["close"]),
    )
    for _, row in df.iterrows()
]
get_database().save_bar_data(bars)
print(f"入库完成:{len(bars)} 根")

几个细节值得注意:

Cell 4 — 跑回测

from datetime import datetime
from vnpy_ctastrategy.backtesting import BacktestingEngine
from turtle_etf_strategy import TurtleEtfStrategy

engine = BacktestingEngine()
engine.set_parameters(
    vt_symbol="510300.SSE",
    interval=Interval.DAILY,
    start=datetime(2021, 1, 1),
    end=datetime(2024, 12, 31),
    rate=2.5e-5,      # ETF 手续费率
    slippage=0.001,   # 滑点
    size=1,           # ETF size=1
    pricetick=0.001,  # 最小报价单位
    capital=100_000,
)
engine.add_strategy(TurtleEtfStrategy, {
    "entry_window": 20, "exit_window": 10, "atr_window": 20,
    "risk_per_trade": 0.02, "fixed_capital": 100_000.0,
})
engine.load_data()
engine.run_backtesting()
result_df = engine.calculate_result()
stats = engine.calculate_statistics()

回测引擎的调用是六步固定序列new engine → set_parameters → add_strategy → load_data → run_backtesting → calculate_result/statistics。这个骨架在 Backtrader、Zipline、Backtesting.py 等其他框架里都一样,学一个就能触类旁通。

关键点:每次跑回测都要新建 engine,engine 内部有累积状态(成交记录、日报表、盈亏数据),复用会污染上一次的结果。这也是后面参数扫描时每次循环都要 engine = BacktestingEngine() 的原因。

跑完终端会打印统计指标:

起始资金:    100,000.00
结束资金:    106,891.35
总收益率:    6.89%
年化收益:    1.72%
最大回撤:    -10,111
百分比最大回撤: -8.44%
总成交笔数:  14
Sharpe Ratio: 0.20

七、Jupyter 里的探索式研究

Cell 4 跑完只是起点,Notebook 真正的价值在于探索

Cell 5 — 查看每日盈亏报表

result_df.tail(10)

这张表 vnpy 叫”逐日盯市盈亏报表”,是回测框架的核心输出。每一列对应一天的:

Cell 6 — 画资金曲线

import matplotlib.pyplot as plt

# 关键:matplotlib 默认字体不含中文,会显示成方块
plt.rcParams["font.sans-serif"] = ["PingFang SC", "Heiti SC", "Arial Unicode MS"]
plt.rcParams["axes.unicode_minus"] = False

fig, ax = plt.subplots(figsize=(12, 5))
result_df["balance"].plot(ax=ax, color="steelblue", linewidth=1.5)
ax.set_title("海龟策略 - 沪深300ETF - 资金曲线")
ax.set_ylabel("账户余额 (元)")
ax.grid(alpha=0.3)
plt.tight_layout()
plt.savefig("balance_curve.png", dpi=150, bbox_inches="tight")
plt.show()

第一次画出的图上中文会全是方块,并伴随警告 Glyph xxx (\N{CJK UNIFIED IDEOGRAPH-xxxx}) missing——原因是 matplotlib 默认字体(DejaVu Sans)不含 CJK 字符。macOS 上加两行 rcParams 指定用系统自带的苹方(PingFang SC)就好。

八、一个反直觉的观察:资金曲线为什么长期平躺

画出来的资金曲线有一个很明显的特征:大部分时间是水平线,只在少数几个时段出现波动。特别是 2021-03 到 2022-06 这一整年多、2023-08 到 2024-03 跨年那半年多,balance 数值几乎完全不动。

刚开始容易怀疑”是不是回测有 bug”,但这是趋势跟踪策略的正常行为,不是 bug。

原因:海龟策略只在突破 20 日新高时买入、只在跌破 10 日新低时卖出。如果 ETF 一直在中间区域震荡(既不创新高也不创新低),策略啥也不干——没有交易 + 没有持仓 = 账户余额不变 = 资金曲线是水平线

三种验证方法(由粗到细)

验证一:看交易日分布

trading_days = result_df[result_df["trade_count"] > 0]
print(f"总交易日:{len(result_df)}")
print(f"有交易的日子:{len(trading_days)} 天 ({len(trading_days)/len(result_df)*100:.1f}%)")

输出:

总交易日:969
有交易的日子:14 天 (1.4%)

14 笔交易分布在 969 个交易日里,98.6% 的时间在无所事事。这跟直觉相反——大部分新手看到这数字会想”策略是不是太懒了”,但这恰恰是趋势跟踪策略的核心哲学。

验证二:看持仓状态分布

in_position = result_df[result_df["end_pos"] != 0]
flat = result_df[result_df["end_pos"] == 0]
print(f"持仓状态:{len(in_position)} 天 ({len(in_position)/len(result_df)*100:.1f}%)")
print(f"空仓状态:{len(flat)} 天 ({len(flat)/len(result_df)*100:.1f}%)")

输出显示空仓状态大约占 95% 以上。海龟策略”少数时间在市场里、多数时间在观望”的特征就在这里量化出来。

验证三:可视化验证——把持仓期高亮到资金曲线上

fig, ax = plt.subplots(figsize=(14, 6))

# 资金曲线
ax.plot(result_df.index, result_df["balance"],
        color="steelblue", linewidth=1.5, label="账户余额")

# 持仓时段涂成橙色背景
in_position = result_df["end_pos"] != 0
ax.fill_between(
    result_df.index,
    result_df["balance"].min() * 0.98,
    result_df["balance"].max() * 1.02,
    where=in_position, alpha=0.15, color="orange",
    label="持仓期",
)

# 交易日标红点
trade_days = result_df[result_df["trade_count"] > 0]
ax.scatter(trade_days.index, trade_days["balance"],
           color="red", s=30, zorder=5, label="交易日")

ax.set_title("海龟策略资金曲线(橙色=持仓期,红点=交易日)")
ax.set_ylabel("账户余额(元)")
ax.grid(alpha=0.3)
ax.legend(loc="upper left")
plt.tight_layout()
plt.savefig("position_highlight.png", dpi=150, bbox_inches="tight")
plt.show()

持仓期高亮验证图

这张图一目了然:橙色(持仓期)恰好对应资金曲线有波动的部分白色(空仓期)全部对应水平线。红点标出的每笔交易都在橙色区域的边界(买入是开始、卖出是结束)。

这个”不作为”其实是策略的核心价值

对比一下”活跃型”策略(比如高频/日内策略每天都在开平仓),趋势策略的交易日占比通常都不到 5%。这个**“不作为的绝大多数时间”,才是趋势策略能在大行情里赚到大钱的原因**——如果每天都在场内小打小闹,一次真趋势启动时可能已经在错误方向上被套住了。

用一句话概括趋势跟踪的哲学:

市场大多数时候在震荡,策略应该按兵不动等真趋势出现;一旦真趋势启动,就重仓吃到底。耐心 > 频繁。

理解了这个心智模型,就不会因为”策略最近没交易”而怀疑策略失效——空仓也是策略的重要组成部分,“不亏钱”本身就有价值。

九、参数扫描:找最合适的窗口组合

海龟经典参数是 20/10,但那是丹尼斯在 1980 年代美国期货市场验证的结果。A 股 ETF 是不是也是这套?扫一遍看看:

import pandas as pd

results = []
for entry_win in [10, 20, 55]:
    for exit_win in [5, 10, 20]:
        if exit_win >= entry_win:
            continue
        engine = BacktestingEngine()
        engine.set_parameters(
            vt_symbol="510300.SSE", interval=Interval.DAILY,
            start=datetime(2021, 1, 1), end=datetime(2024, 12, 31),
            rate=2.5e-5, slippage=0.001, size=1, pricetick=0.001,
            capital=100_000,
        )
        engine.add_strategy(TurtleEtfStrategy, {
            "entry_window": entry_win, "exit_window": exit_win,
            "atr_window": 20, "risk_per_trade": 0.02,
            "fixed_capital": 100_000.0,
        })
        engine.load_data()
        engine.run_backtesting()
        engine.calculate_result()
        stats = engine.calculate_statistics(output=False)
        results.append({
            "entry": entry_win, "exit": exit_win,
            "total_return": stats["total_return"],
            "sharpe": stats["sharpe_ratio"],
            "max_dd": stats["max_ddpercent"],
        })

pd.DataFrame(results).sort_values("sharpe", ascending=False)

跑出来的结果:

entryexit总收益 (%)Sharpe最大回撤 (%)
2010+6.890.20-10.11
205+4.970.16-9.70
105-0.070.00-11.54
555-7.82-0.54-8.59
5510-8.60-0.59-9.36
5520-11.45-0.73-12.21

结果解读

1. 短窗口效果显著好于长窗口

entry=20 的两组都盈利,entry=55 的三组全部亏损。原因是 A 股 ETF 趋势的持续性远不如美国期货市场,55 日突破意味着”要等更强的趋势才入场”,等到时趋势往往已经走了大半。

2. 唯一像样的组合正好是海龟经典参数 20/10

这也是研究里最有意思的观察之一:一个 1980 年代在美国期货市场设计的策略,40 年后在 A 股 ETF 上依然能找到对应的甜蜜点。趋势跟踪策略的结构性智慧跨越了市场和时代。

3. 即使最好的组合 Sharpe 也只有 0.2

这 4 年 A 股大部分时间在震荡(除了 2024 年 9 月那波),对趋势策略非常不友好。反复的假突破让策略一直在小亏,全靠 2024 年 9 月那一波抓到肉才勉强翻正。这不是策略的问题,是市场结构和策略适配度的问题。

参数扫描踩过的坑

扫完之后我想看看最佳组合具体每笔交易怎么走的:

trades = engine.get_all_trades()
for t in trades:
    print(f"{t.datetime.date()}  {t.direction.value}  @{t.price:.3f}  x{t.volume}")

结果打印出 14 笔交易全部亏损,跟前面 6.89% 收益完全对不上。愣了几秒才反应过来——engine 是循环里的变量,循环结束后指向最后一次迭代的实例,也就是表现最差的 entry=55, exit=20 那组。

想看最优组合的交易记录,得单独重新跑一次,不能从参数扫描的循环里拿。或者更工程化的做法是扫描时把 engine 存起来:

scan_results = {}
for entry_win in [10, 20, 55]:
    for exit_win in [5, 10, 20]:
        if exit_win >= entry_win:
            continue
        engine = BacktestingEngine()
        # ... 参数设置 ...
        engine.run_backtesting()
        engine.calculate_result()
        stats = engine.calculate_statistics(output=False)
        scan_results[(entry_win, exit_win)] = {
            "engine": engine,   # 关键:保存 engine 实例
            "stats": stats,
        }

# 之后查任意组合的交易记录
best_engine = scan_results[(20, 10)]["engine"]
trades = best_engine.get_all_trades()

这个坑挺典型,量化研究里经常犯——循环里的变量作用域,跟你以为的可能不一样。

十、几个额外的排查经验

venv 隔离~/venvs/xxx 目录之间的包互相看不见。不同 Notebook 用的 kernel 对应哪个 venv 要看清楚,选错的话会出现”明明装过却 ModuleNotFoundError”。Jupyter 左上角能看到当前 kernel 名,跟你想用的 venv 对上就行。

matplotlib 中文乱码:macOS 上加 plt.rcParams["font.sans-serif"] = ["PingFang SC"] 一行搞定。全局配置可以写在 ~/.matplotlib/matplotlibrc 里,一次配好所有 Notebook。

Notebook 里 import 找不到 .py 文件:在第一个 cell 里 sys.path.insert(0, str(...)) 把策略目录加进搜索路径。这是 Notebook 和脚本组织分离的标准做法。

engine 复用:绝对不要。每次跑回测都新建一个,前一次的成交记录、日报表都会污染下一次。

十一、下一步:从”跑通”到”跑好”

到这里量化研究的核心闭环彻底跑通了:AKShare 拉数据 → vnpy 数据库 → 策略 → 回测 → 参数扫描 → 结果分析。这个骨架能承载后续所有的策略研究。

接下来这个系列的方向:

  1. 策略层扩展:从最简单的双均线、海龟往上,叠加 MACD 背离、缠论笔段结构等信号做过滤
  2. 多标的组合:不只跑一个 ETF,同时跑几个不相关的标的(沪深300 + 中证500 + 创业板 + 港股)做资产配置
  3. 考虑真实约束:T+1、涨跌停、停牌等 A 股特有规则的建模
  4. 实盘信号发送:策略跑出信号后,用 vnpy 的 gateway 层对接实盘券商,或者简化成”每天生成交易信号推送到微信”

这一篇的两个附加素材:

工具篇(三)大概率会走多因子叠加这个方向。

参考资料


Edit page
Share this post on:

Next Post
AstroPaper 博客本地构建与 Vercel 部署实录