作 者:老余捞鱼
原创不易,转载请标明出处及原作者。

本文约 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.44 | 6 | 66.7% | 49.6% |
| 布林带(20, 2σ) | +16.60% | -29.91% | 0.32 | 16 | 80.0% | 50.9% |
| 持有不动 | -8.92% | -38.19% | -0.02 | 无 | 无 | 100% |

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工具实测 #技能系统 #均值回归 #回测踩坑
声明 本文为技术流程拆解与历史数据回测,所有标的与参数仅作示例,不构成任何操作建议。数据来自公开接口,可能存在误差,原文与官方文档为本文主要来源。
Be First to Comment