Skip to content

给所有回测曲线好看但不敢上仓位的人

作 者:老余捞鱼

原创不易,转载请标明出处及原作者。

文中示例仅用于技术讨论,不构成任何操作建议。
量化策略开发应以学习和技术交流为目的。
本号不荐股、不卖课、不承诺收益。
市场有风险,请合法合规投资。

本文约 3000 字 | 预计阅读 5-8 分钟,内有实例代码,建议先收藏。

· · ·

先看一组数字。

策略总回报 2779%,同期长期持有这只股票是 1235%。10 万本金变成 287 万,147 笔交易,夏普比率 0.675,胜率 52.1%。

这是 AXON 股票从 2005 年到 2026 年的回测结果,数值看起来不错,但真正值钱的不是这条曲线,我们往下看。

① 策略到底在干什么

这套策略只有两个条件,一个管进场,一个管离场。

进场很反直觉,是在股票走弱的时候买。怎么判断走弱?用一个叫 CCI 的指标。你可以把它理解成一把尺子,量的是价格现在离它最近的平均位置有多远。当今天的 CCI 比 5 天前还低,说明价格正往偏离均值的方向往下走,动能越来越弱。就在这个”看起来要跌”的节骨眼,策略反而进场。

离场看”启明星”,一个由三根 K 线组成的小反转信号。第一根是阴线,第二根实体缩到第一根的一半以下,说明下跌的劲儿快用完了,第三根收阳,多头开始反攻。看到这个形态就离场。

在弱市进场、在反弹离场,不追高。这套逻辑上世纪 80 年代的技术分析书里就有,不新鲜,但简单意味着不容易坏。

下面这段是完整代码,一行没删,你可以直接复制去跑:

# simple_strategy.py
# 标的:AXON | 2026-06-18 08:27

import pandas as pd
import numpy as np
import yfinance as yf
import vectorbt as vbt

# -------------------------
# 下载数据
# -------------------------

symbol = "AXON"
start_date = "1960-01-01"
end_date = "2026-08-21"
interval = "1d"

df = yf.download(symbol, start=start_date, end=end_date, interval=interval, multi_level_index=False)
df.reset_index().to_csv("AXON_clean.csv", index=False)

# -------------------------
# 必要参数
# -------------------------

CCI_PERIOD = 14          # CCI 计算周期
CCI_SHIFT = 5            # CCI 比较移位步长

# -------------------------
# 指标函数
# -------------------------

def cci_falling(df, period=CCI_PERIOD, shifts=CCI_SHIFT):
    """判断 CCI 是否持续下降(当前值小于 shifts 天前的值)"""
    df['CCI_main'] = calculate_cci(df, period)['CCI']
    return df['CCI_main'] < df['CCI_main'].shift(shifts)

def calculate_cci(df, period=CCI_PERIOD):
    """
    计算 Commodity Channel Index (CCI) —— 商品通道指数
    """
    df = df.copy()
    # 典型价格 (TP) = (最高价 + 最低价 + 收盘价) / 3
    df['TP'] = (df['High'] + df['Low'] + df['Close']) / 3
    # TP 的简单移动平均
    df['SMA_TP'] = df['TP'].rolling(period).mean()
    # 平均绝对偏差
    df['MeanDev'] = df['TP'].rolling(period).apply(
        lambda x: np.mean(np.abs(x - np.mean(x))), raw=True
    )
    # CCI 计算公式:(TP - SMA_TP) / (0.015 * MeanDev)
    df['CCI'] = (df['TP'] - df['SMA_TP']) / (0.015 * df['MeanDev'])
    return df

def morning_star(df):
    """
    早晨之星(Morning Star)形态识别:
    c1: 前两日收盘价 < 前两日开盘价(阴线)
    c2: 前一日实体长度 < 前两日实体长度的 50%(十字星或小实体)
    c3: 当日收盘价 > 当日开盘价(阳线)
    """
    c1 = df['Close'].shift(2) < df['Open'].shift(2)
    c2 = abs(df['Close'].shift(1) - df['Open'].shift(1)) < \
        abs(df['Close'].shift(2) - df['Open'].shift(2)) * 0.5
    c3 = df['Close'] > df['Open']
    return c1 & c2 & c3

# -------------------------
# 入场条件
# -------------------------

df["CCI_Falling"] = cci_falling(df)          # CCI 下降

# -------------------------
# 出场条件
# -------------------------

df["Morning_Star"] = morning_star(df)        # 出现早晨之星

# -------------------------
# 信号合成
# -------------------------

entry_conditions = [
    'CCI_Falling',          # 所有入场条件(可扩展)
]
exit_conditions = [
    'Morning_Star',         # 所有出场条件(可扩展)
]

df['entry_signal'] = df[entry_conditions].all(axis=1)   # 入场信号(同时满足所有入场条件)
df['exit_signal'] = df[exit_conditions].all(axis=1)     # 出场信号(同时满足所有出场条件)

# -------------------------
# 回测引擎
# -------------------------

# 将信号向后偏移一天,避免使用未来数据(用昨日信号决定今日操作)
shift_entries = df['entry_signal'].shift(1).astype(bool).fillna(False).to_numpy()
shift_exits = df['exit_signal'].shift(1).astype(bool).fillna(False).to_numpy()

pf = vbt.Portfolio.from_signals(
    close=df['Open'],           # 以开盘价成交
    entries=shift_entries,
    exits=shift_exits,
    init_cash=100_000,          # 初始资金 10 万美元
    fees=0.001,                 # 手续费率 0.1%
    slippage=0.002,             # 滑点 0.2%
    freq='1d'                   # 日频交易
)

# -------------------------
# 输出绩效统计 / 绘图
# -------------------------

print(pf.stats())

参数值yfinance负责抓行情,vectorbt负责算回测。信号延迟一根K线,也就是今天算出信号,第二天开盘才成交,防止”早知道”作弊。手续费 0.1%,滑点 0.2%,这两个成本不加进去,回测数字会好看得离谱。

② 为什么不能只看单次回测

单次回测有一个致命问题,它有可能偷看了答案。

拿着 2005 到 2026 的完整数据,不断调参数调到收益最好。这回答的是”如果提前知道答案,能赚多少”。但真实交易里,你永远不知道明天会怎样。

我用的办法叫”前推验证”,英文叫 Walk-forward。用过去 4 年的数据定参数,第 5 年完全不看结果、盲跑一年,然后整体往前挪一年,再重复。这样跑出来的 2779%,每一笔交易的参数,都只来自它之前的历史数据,没有一笔是偷看过未来的。

12年的逐年成绩单长这样:

年份CCI周期偏移收益夏普最大回撤
2005114-74.22%-1.287-78.01%
2006114+31.67%0.995-22.38%
2007114+70.98%1.508-32.11%
2008114-34.98%-0.229-64.78%
2009114-13.05%-0.066-35.14%
2012175+70.87%1.856-23.91%
2014174+58.67%1.381-48.83%

注意 CCI 周期这一列,在 11 到 17 之间来回变。这说明市场状态在变,参数必须跟着调,你没法一套参数吃一辈子。也说明这个策略的赚钱能力是看行情吃饭的,不是稳定的。12 年里有 5 年是亏的,赚钱的年份也得扛 25% 到 50% 的回撤。

③ 压力测试才是重点

跑赢长期持有,只是上半场。下半场是搞清楚,里面到底藏着什么。

我们把 147 笔交易当成一副牌,洗了 1000 次,每次洗牌就是一条新的资金曲线。同样的交易,不同的先后顺序,不同的运气。结果如下:

分位总回报最大回撤夏普
5%(最差)-52.23%-96.73%0.18
50%(中位)+2646.43%-82.74%0.56
95%(最好)+156997.42%-66.53%0.93

得到了两个信息,一个让人安心,一个让人清醒。

安心的那个。真实回测的 2779%,正好落在分布的中位附近。说明这个结果不是靠运气排出来的,是它该有的位置。

需要我们清醒的是:哪怕运气最好的 5% 路径,也要回撤 66%,中位路径回撤 83%。也就是说,无论交易顺序怎么排,这条资金曲线都不会舒服。

但这个结果不该是大家放弃本策略的理由,而该是用来控制仓位的工具。

④ 仓位怎么定,让数字说话

回撤 79%,如果压上全部身家,谁也扛不住。但同样的回撤,如果只占总资金的 10%,那对整体也就是 7.9% 的损失,肉疼,但不致命。

把压力测试的结果套上不同的仓位,一目了然:

仓位分配中位回撤最差5%回撤
100%-82.7%-96.7%
25%-20.7%-24.2%
10%-8.3%-9.7%
5%-4.1%-4.8%

10% 仓位下,这个策略最坏的情况,让你亏的还没一次普通股市回调多,而它赚钱的那一面,复利照样在总账户里体现。蒙特卡洛模拟不是告诉你”要不要做这个策略”的判决书,是告诉你”该放多少钱”的尺子。

还有一件事必须老实交代。AXON 是一只股票,到2026 年时,我们都知道它已经涨了 13 倍。如果一个策略只在你早就知道答案的那只票上有效,那它就不是真本事。下一步该做的是拿一篮子股票跑,赚钱的、亏钱的都跑一遍。如果只有这只票能跑出收益,整个结论都得推翻。

⑤ 这套流程才是真正该学的

生成 → 前推验证 → 压力测试 → 定仓位。第一步只要 90 秒,后面加起来大约一小时。

这个循环才是精髓。策略可以换,CCI 换成 RSI,AXON 换成沪深 300 的 ETF,vectorbt 换成 backtrader,都行。但流程一步不能省。

回测告诉你”能不能做”,压力测试告诉你”放多少钱”。两个问题,两个答案,缺一不可。


#量化策略 #蒙特卡罗 #仓位管理 #压力测试 #CCI指标 #Walk-Forward #回测 #风险控制 #AXON #策略评估 #量化交易 #交易系统

Published inAI&Invest专栏

Be First to Comment

    发表回复