Skip to content

别再手打回测规则了!我把量化策略做成 AI 技能,换标的只需一句话

作 者:老余捞鱼

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

本文约 3700 字 | 阅读预计需 6-8 分钟,前半是介绍和安装,后半有完整代码,建议先收藏。

· · ·

一、这是个什么东西

上周四晚上,我新开一个 AI 对话,准备重跑一个老策略。第一件事,把规则重新写一遍。RSI 低于 30 进场,高于 70 离场,区间五年,起步一万。打完我盯着屏幕发呆,上个对话里,它明明已经写过一模一样的东西。

这事后来让我想起一个写代码的流派,名字起得随意,叫马尾辫编程(ponytail coding)。核心就一句话,代码写到刚好能用就停笔,能少写一行是一行。有人认真算过账,这么写,喂给 AI 的 token 账单最高能省九成。GitHub 上还有个同名仓库 ponytail,我那套 RSI 回测基线就照这个路子写的,几十行,跑一次扔一次。

但懒人也有懒人的烦恼。基线写得再省事,跑一次也就完了,换个标的还得改代码,换个对话还得重新交代。我想把它变成随叫随到的能力,任何标的、任何区间,一句话就能再跑一遍。这事最后是在谷歌的 AI 编程代理 Antigravity 里做成的。

它所谓的技能(skill),你可以理解成给 AI 的一张岗位说明书。一个文件夹,里面一份 SKILL.md 加几个脚本。SKILL.md 开头两行,写清这个技能叫什么、什么时候该用它,正文写明白怎么调、默认参数是什么。AI 平时只扫一眼说明书封皮,活儿对上了才翻开细读,里面的脚本原样照跑,不问细节。官方管这套机制叫渐进式披露,听着玄,其实就是平时不占 AI 的脑子,干活才让它翻开看。

这个格式也不是谷歌独一份。Gemini CLI、Claude Code 这些工具,认的都是同一套 SKILL.md,我自己写文章用的这套 AI 工作流,技能也是同一个写法。谷歌甚至把自己更老的 workflow 机制都停了,今年 11 月 1 日正式退役,全部并到技能上。大厂们押的是同一个方向,这比什么都有说服力。

说回量化。你的每一条回测规则,都可以这样装进一个文件夹,变成 AI 随叫随到的一项手艺。想换标的换区间,张嘴就行。

二、我实测的结果,先给答案

我装了两个技能。第一条 RSI(14),低于 30 进场,高于 70 离场,只做多。第二条布林带(20, 2σ),收盘跌破下轨进场,升破上轨离场,同样是只做多。各自包成一份 SKILL.md 加一份脚本。

然后是使用方式。我在对话框里敲一行字,执行 RSI 技能,分析 TSLA。五年区间,一万美元起步,绩效表和交易流水一分钟内摆在面前。布林带技能同理。

先看好的一面。技能化之后确实丝滑。换标的改两个词,换区间改一个数,代理全程自己跑。规则逻辑一次都不用再解释。特斯拉那组数字我从原教程里摘的,策略 +36.14%,拿着不动 +47.55%。最大回撤 -61.84% 对 -73.63%,夏普 0.32。

TSLA 五年布林带技能绩效表

但真正该盯着看的在数字里。布林带在特斯拉上没跑赢拿着不动,少赚 11 个点。唯一能安慰人的是回撤,浅了将近 12 个点。工具很听话。数字很诚实。

我把同样的两条规则搬到了国内。标的 510300,akshare 新浪日线,同样五年。区间 2021 年 9 月 14 日到 2026 年 9 月 14 日,1211 根K线。一万元起步,免手续费,收盘出信号收盘成交。结果反转了。

规则五年总收益最大回撤夏普交易次数胜率在场时间
RSI(14)+24.23%-22.13%0.44666.7%49.6%
布林带(20, 2σ)+16.60%-29.91%0.321680.0%50.9%
持有不动-8.92%-38.19%-0.02100%

510300 五年三条权益曲线(我复现的数据,akshare 新浪日线)

沪深300ETF 这五年走得磨人。躺着不动,到今天还亏 8.92%,中间最大回撤 38.19%。两条规则的思路其实是一路的,都在市场最难看的时候进场。这种震荡下行的行情恰好喂到它们嘴里,RSI 规则 +24.23%,布林带 +16.60%,回撤还都比拿着不动浅。

三条结论摆在这。第一,工具层完全成立,一条指令出全套结果。这跟策略好不好无关。第二,同两条规则换个标的,结论可以整个倒过来。特斯拉上输给持有,510300 上赢了持有,单一市场的结论别当真理。第三,这套数字我第一次算出来不是这样的。差点被自己的脚本骗过去,坑在哪,第五节展开。

先交代样本局限。单一标的,五年窗口免手续费,结论外推边界你心里要有数。

三、怎么装

全程不用写胶水代码。拢共五步。

第一步建文件夹。工作区技能放 .agents/skills/rsi-backtest/。想跨项目用,就放全局目录 ~/.gemini/config/skills/ 下。

第二步写 SKILL.md。开头 YAML 两行是关键。description 是代理决定用不用这个技能的唯一依据。写的时候交代清楚两件事,什么时候用,默认参数是什么。

---
name: rsi-backtest
description: 回测 14 周期 RSI 规则(低于 30 进场,高于 70 离场,只做多)。当用户要求回测 RSI 策略、对比持有收益或生成交易流水时使用。
---

# RSI 回测技能

## 步骤
1. 接收标的代码(默认 510300)与回测年数(默认 5 年)
2. 执行 scripts/rsi_backtest.py,计算 RSI、生成信号、模拟权益曲线
3. 输出总收益、持有收益、最大回撤、夏普与交易流水

## 执行命令
python scripts/rsi_backtest.py --ticker 510300 --years 5

第三步放脚本。scripts/rsi_backtest.py,就是文末那份。技能只管调用,不关心脚本内部怎么算。

第四步让代理认识它。新开对话,代理会自动把技能描述编进索引。拿不准就在指令里直接点名。

第五步一句话调用。执行 RSI 技能,分析 510300,区间五年。代理自己读 SKILL.md,跑脚本,把绩效表和交易流水递回来。

四、装好之后怎么用

日常使用就一句话的事。想换标的改指令里的代码,6 位数字自动补沪深前缀这件事我写进了脚本。想换区间改年数。

输出是固定的两件套,绩效表加交易流水。技能返回的交易流水值得多看一眼。每笔进场离场的价格、持仓份数、当时权益都在上面,回测不再是一个孤零零的收益率。

TSLA 布林带技能交易流水

想加第三条规则也简单,复制粘贴改个名。SKILL.md 换一段描述,脚本换一个信号函数。MACD 也好 ADX 也好,只要能写成信号生成加组合模拟的形状,都能挂上去。

我自己日常跑数据用的这套 AI 工作流,技能也是同一个 SKILL.md 格式。这条约定已经是行业通用货,学会了不亏。

以上是普通读者的部分,装上就能用。往下是动手部分,写给想自己跑回测、想改规则的人。

五、数据踩坑实录

yfinance在国内连接不稳,我直接换了akshare的新浪接口。fund_etf_hist_sina(symbol=”sh510300″) 一把过。1211 根K线,没断点。东财的接口单只没问题,批量拉全市场日K会被限流,别贪。

最大的坑在成交口径。教程里常见的向量化写法,是拿仓位序列直接乘日收益率。一 shift 都不做,等于把信号当天的涨跌也算进策略收益。同一条规则三种口径,我各跑了一遍。

口径RSI(14)布林带(20, 2σ)
教程常见错误版(信号日涨跌算进收益)-3.36%-26.07%
收盘出信号收盘成交(主口径)+24.23%+16.60%
收盘出信号次日收盘成交(保守)+28.81%+6.10%

同一套仓位,布林带两个口径相差 42.7 个点,RSI 相差 27.6 个点。这个坑跟策略好坏无关,全在成交时点上。口径的选择,比规则的选择影响还大。

更麻烦的是方向。这个 bug 会看策略下菜。在动量策略里它往高了吹业绩,突破那根大阳线的涨幅被白拿。在均值回归规则里它往死了踩业绩,进场那天往往是大跌日,跌幅被白吃。我之前拆过一个动量策略,同款 bug 把一条不赚钱的规则吹成了印钞机。这次它反过来,把两条能打的规则打成了废铁。同一个坑,两个方向,全看策略吃的是哪一边的当日涨跌。

第三个坑是手续费。主表免手续费,是为了跟原文口径对齐。真算账的话,布林带 16 个来回,双边各万五,约吃掉 1.6 个点。优势从 16.6% 薄到 15% 上下,还在,但没那么好看。RSI 只有 6 个来回,影响不到 1 个点。

还有一个提醒。新浪这份日线是不复权口径。510300 这类宽基 ETF 分红扰动小,问题不大。真拿个股跑这套规则,记得换后复权数据,不然除权日会造出一堆假信号。

回测里最贵的坑,往往就藏在没写出来的假设里。

六、动手区核心代码

照旧,分段讲,文末给完整脚本。

① 拉数据(新浪日线,自动补沪深前缀)

import argparseimport jsonimport osimport numpy as npimport pandas as pdimport akshare as akTICKER = "sh510300"YEARS = 5INIT_CAPITAL = 10000.0OUT_JSON = "repro_results.json"OUT_PNG = "illustrations/chart-repro-510300.png"def normalize_code(code):    # 510300 自动补前缀,深市代码给 sz    code = code.strip().lower()    if code.isdigit() and len(code) == 6:        return ("sh" if code[0] in "569" else "sz") + code    return codedef load_close(ticker, years):    df = ak.fund_etf_hist_sina(symbol=ticker)    df["date"] = pd.to_datetime(df["date"])    df = df.sort_values("date").set_index("date")    cutoff = df.index.max() - pd.DateOffset(years=years)    df = df.loc[df.index >= cutoff]    print(f"数据 {len(df)} 根K线, {df.index[0].date()} ~ {df.index[-1].date()}")    return df["close"].astype(float)

② RSI 与仓位状态机(Wilder 平滑)

def rsi_wilder(close, n=14):    diff = close.diff()    up = diff.clip(lower=0).ewm(alpha=1 / n, adjust=False).mean()    dn = (-diff.clip(upper=0)).ewm(alpha=1 / n, adjust=False).mean()    rs = up / dn    return 100 - 100 / (1 + rs)def pos_rsi(close):    r = rsi_wilder(close)    pos, holding = [], False    for v in r:        if not holding and v < 30:            holding = True        elif holding and v > 70:            holding = False        pos.append(1.0 if holding else 0.0)    return pd.Series(pos, index=close.index)

③ 布林带仓位(20 日均线 ± 2 倍标准差)

def pos_bbands(close, n=20, k=2.0):    mid = close.rolling(n).mean()    std = close.rolling(n).std()    lower, upper = mid - k * std, mid + k * std    pos, holding = [], False    for c, lo, hi in zip(close, lower, upper):        if isinstance(lo, float):            if not holding and c < lo:                holding = True            elif holding and c > hi:                holding = False        pos.append(1.0 if holding else 0.0)    return pd.Series(pos, index=close.index)

④ 三种口径的回测与绩效(全文七寸在这)

def perf(close, pos, mode="signal_close"):    """    三种回测口径:    - signal_close: 收盘出信号,收盘成交(主口径)    - next_close:   收盘出信号,次日收盘成交(保守)    - buggy_same:   教程常见错误,把信号日涨跌也算进策略    """    ret = close.pct_change().fillna(0.0)    lag = {"signal_close": 1, "next_close": 2, "buggy_same": 0}[mode]    strat = pos.shift(lag) * ret    equity = INIT_CAPITAL * (1 + strat).cumprod()    dd = (equity / equity.cummax() - 1).min()    sharpe = strat.mean() / (strat.std() or np.nan) * np.sqrt(252)    entries = int((pos.diff() == 1).sum())    wins, done = 0, 0    entry_px = None    for i in range(len(close)):        if pos.iloc[i] == 1 and entry_px is None:            entry_px = close.iloc[i]        elif pos.iloc[i] == 0 and entry_px is not None:            done += 1            wins += close.iloc[i] > entry_px            entry_px = None    return {        "total_return_pct": round((equity.iloc[-1] / INIT_CAPITAL - 1) * 100, 2),        "final_equity": round(float(equity.iloc[-1]), 2),        "max_drawdown_pct": round(dd * 100, 2),        "sharpe": round(float(sharpe), 2),        "trades": entries,        "win_trades": int(wins),        "win_rate_pct": round(wins / done * 100, 1) if done else 0.0,        "time_in_market_pct": round(float(pos.mean()) * 100, 1),    }

⑤ 权益曲线一张图

import matplotlibmatplotlib.use("Agg")import matplotlib.pyplot as pltplt.rcParams["font.family"] = "Microsoft YaHei"fig, ax = plt.subplots(figsize=(8.96, 5.12), dpi=150)for pos, label in [(rsi_pos, "RSI(14) 规则"), (bb_pos, "布林带(20, 2σ) 规则")]:    eq = INIT_CAPITAL * (pos.shift(1) * close.pct_change().fillna(0) + 1).cumprod()    ax.plot(eq.index, eq.values, label=label, linewidth=1.4)ax.plot(close.index, INIT_CAPITAL * close / close.iloc[0],        label="持有不动", linewidth=1.4, color="#999999")ax.set_title("510300 五年回测,三条路径(10000 元起步,免手续费)")ax.legend()ax.grid(alpha=0.3)fig.tight_layout()fig.savefig(OUT_PNG)  

下面是完整脚本。标的和年数走参数,--ticker 510300 --years 5 就能换。这份脚本我完整跑过,数字就是正文表格里那些。

# -*- coding: utf-8 -*-"""把一条回测规则打包成 Agent 技能之前的基线脚本标的:510300(沪深300ETF),区间:最近 5 年两条做多规则:  RSI(14):RSI 低于 30 进场,高于 70 离场(Wilder 平滑)  布林带(20, 2σ):收盘跌破下轨进场,升破上轨离场口径:收盘出信号收盘成交为主,另给次日成交与教程错误口径对照数据源:akshare 新浪日线,免手续费口径"""import argparseimport jsonimport osimport numpy as npimport pandas as pdimport akshare as akTICKER = "sh510300"YEARS = 5INIT_CAPITAL = 10000.0OUT_JSON = "repro_results.json"OUT_PNG = "illustrations/chart-repro-510300.png"def normalize_code(code):    """510300 自动补前缀,宁德时代这类深市代码给 sz"""    code = code.strip().lower()    if code.isdigit() and len(code) == 6:        return ("sh" if code[0] in "569" else "sz") + code    return codedef load_close(ticker, years):    df = ak.fund_etf_hist_sina(symbol=ticker)    df["date"] = pd.to_datetime(df["date"])    df = df.sort_values("date").set_index("date")    cutoff = df.index.max() - pd.DateOffset(years=years)    df = df.loc[df.index >= cutoff]    print(f"数据 {len(df)} 根K线, {df.index[0].date()} ~ {df.index[-1].date()}")    return df["close"].astype(float)def rsi_wilder(close, n=14):    diff = close.diff()    up = diff.clip(lower=0).ewm(alpha=1 / n, adjust=False).mean()    dn = (-diff.clip(upper=0)).ewm(alpha=1 / n, adjust=False).mean()    rs = up / dn    return 100 - 100 / (1 + rs)def pos_rsi(close):    r = rsi_wilder(close)    pos, holding = [], False    for v in r:        if not holding and v < 30:            holding = True        elif holding and v > 70:            holding = False        pos.append(1.0 if holding else 0.0)    return pd.Series(pos, index=close.index)def pos_bbands(close, n=20, k=2.0):    mid = close.rolling(n).mean()    std = close.rolling(n).std()    lower, upper = mid - k * std, mid + k * std    pos, holding = [], False    for c, lo, hi in zip(close, lower, upper):        if isinstance(lo, float):            if not holding and c < lo:                holding = True            elif holding and c > hi:                holding = False        pos.append(1.0 if holding else 0.0)    return pd.Series(pos, index=close.index)def perf(close, pos, mode="signal_close"):    """三种口径。signal_close 收盘出信号、收盘成交(主口径)。    next_close 收盘出信号、次日收盘成交(保守对照)。    buggy_same 教程常见错误,把信号当天的涨跌也算进策略。"""    ret = close.pct_change().fillna(0.0)    lag = {"signal_close": 1, "next_close": 2, "buggy_same": 0}[mode]    strat = pos.shift(lag) * ret    equity = INIT_CAPITAL * (1 + strat).cumprod()    dd = (equity / equity.cummax() - 1).min()    sharpe = strat.mean() / (strat.std() or np.nan) * np.sqrt(252)    entries = int((pos.diff() == 1).sum())    # 逐笔统计(同口径配对进场/离场)    wins, done = 0, 0    entry_px = None    for i in range(len(close)):        if pos.iloc[i] == 1 and entry_px is None:            entry_px = close.iloc[i]        elif pos.iloc[i] == 0 and entry_px is not None:            done += 1            wins += close.iloc[i] > entry_px            entry_px = None    return {        "total_return_pct": round((equity.iloc[-1] / INIT_CAPITAL - 1) * 100, 2),        "final_equity": round(float(equity.iloc[-1]), 2),        "max_drawdown_pct": round(dd * 100, 2),        "sharpe": round(float(sharpe), 2),        "trades": entries,        "win_trades": int(wins),        "win_rate_pct": round(wins / done * 100, 1) if done else 0.0,        "time_in_market_pct": round(float(pos.mean()) * 100, 1),    }def parse_args():    ap = argparse.ArgumentParser(description="RSI 与布林带规则的 ETF 回测基线")    ap.add_argument("--ticker", default=TICKER, help="标的代码,6位数字自动补沪深前缀")    ap.add_argument("--years", type=int, default=YEARS, help="回测年数")    return ap.parse_args()def main():    args = parse_args()    ticker = normalize_code(args.ticker)    os.makedirs(os.path.dirname(OUT_PNG), exist_ok=True)    close = load_close(ticker, args.years)    bh = perf(close, pd.Series(1.0, index=close.index))    rsi_pos = pos_rsi(close)    bb_pos = pos_bbands(close)    out = {        "config": {            "ticker": ticker, "years": args.years, "init_capital": INIT_CAPITAL,            "fee": 0.0, "main_mode": "signal_close",            "range": f"{close.index[0].date()} ~ {close.index[-1].date()}",        },        "buy_hold": bh,        "rsi14": perf(close, rsi_pos),        "rsi14_next_close": {"total_return_pct": perf(close, rsi_pos, "next_close")["total_return_pct"]},        "rsi14_buggy_same": {"total_return_pct": perf(close, rsi_pos, "buggy_same")["total_return_pct"]},        "bbands": perf(close, bb_pos),        "bbands_next_close": {"total_return_pct": perf(close, bb_pos, "next_close")["total_return_pct"]},        "bbands_buggy_same": {"total_return_pct": perf(close, bb_pos, "buggy_same")["total_return_pct"]},    }    print(json.dumps(out, ensure_ascii=False, indent=2))    with open(OUT_JSON, "w", encoding="utf-8") as f:        json.dump(out, f, ensure_ascii=False, indent=2)    import matplotlib    matplotlib.use("Agg")    import matplotlib.pyplot as plt    plt.rcParams["font.family"] = "Microsoft YaHei"    fig, ax = plt.subplots(figsize=(8.96, 5.12), dpi=150)    for pos, label in [(rsi_pos, "RSI(14) 规则"), (bb_pos, "布林带(20, 2σ) 规则")]:        eq = INIT_CAPITAL * (pos.shift(1) * close.pct_change().fillna(0) + 1).cumprod()        ax.plot(eq.index, eq.values, label=label, linewidth=1.4)    ax.plot(close.index, INIT_CAPITAL * close / close.iloc[0],            label="持有不动", linewidth=1.4, color="#999999")    ax.set_title("510300 五年回测,三条路径(10000 元起步,免手续费)")    ax.legend()    ax.grid(alpha=0.3)    fig.tight_layout()    fig.savefig(OUT_PNG)    print("图已保存", OUT_PNG)if __name__ == "__main__":    main()

七、技能的价值在哪

省下的几分钟是小事。真正的价值在交接。规则写进 SKILL.md 的那一刻,它就从你脑子里的默契变成了随时可查的文件。AI 有没有照做,你翻一眼就知道。技能攒得多了,代理才算从一次性的帮手变成长期的员工,不用每次重新带。

我下一步打算接着挂 MACD 和 ADX,等挂满一排,照旧换标的再跑一遍给大家看。

最后留一个问题。你的回测脚本里,信号是哪天的收盘算的,成交又算在哪天?

觉得有用的话,把这篇转给同样在搞量化的朋友。


#AI编程 #AI代理 #Antigravity #量化回测 #RSI指标 #布林带 #510300 #Python量化 #AI工具实测 #技能系统 #均值回归 #回测踩坑

声明 本文为技术流程拆解与历史数据回测,所有标的与参数仅作示例,不构成任何操作建议。数据来自公开接口,可能存在误差,原文与官方文档为本文主要来源。

Published inAI&Invest专栏

Be First to Comment

    发表回复