读完你能得到:一段可直接运行的完整回测脚本——用历史行情接口拉十年日线,跑一个经典双均线策略,输出年化收益、最大回撤、夏普比率,并且处理了 T+1 撮合、100 股整数倍申报、停牌日检测这三件"教程里从来不讲、实盘一跑就露馅"的事。
第 0 步:准备三样东西
pip install requests pandas
- 一个行情接口密钥(本文用迪雅数据的历史行情接口 lsjy,免费版可直接试用)
- 一只标的:示例用
sz000001(换任何股票代码都可以) - Python 3.8+
第 1 步:拉数据——注意"口径"这个第一道坎
同一个接口,adjust 参数决定你拿到的是哪种价格:趋势类策略用前复权或后复权都行(收益率序列相同),但绝不能用复权数据算涨跌停、也不能跨口径拼接。本策略是均线趋势类,用 qfq(前复权):
import requests, pandas as pd
def fetch(symbol, adjust="qfq"):
url = "https://api.cxdy.vip/api/lsjy"
params = {"apiKey": "你的key", "symbol": symbol,
"adjust": adjust, "start_date": "20150101", "end_date": "20251231"}
rows = requests.get(url, params=params, timeout=30).json()
df = pd.DataFrame(rows)
df["date"] = pd.to_datetime(df["date"])
return df.sort_values("date").reset_index(drop=True)
df = fetch("sz000001", adjust="qfq") # 均线策略:复权口径(与 hfq 收益率相同)
为什么不复权数据也要拉一份?后面算涨跌停可成交性时要它。两种口径的关系见站内文章《前复权、后复权、不复权到底有什么区别》。
第 2 步:量纲校验——别让字段单位骗了你
不同数据源的量、额字段单位五花八门(手/股、元/万元),接入任何新数据源都先做一次交叉校验:
# 成交额 ≈ 成交量 × 均价:量级一对,单位现形
# 看 amount 与 close×100×10000 的量级对照,判定字段单位组合
print(df[["date", "close", "amount"]].tail(3))
# 判定后把换算固化在接入层:统一成"股 + 元"
完整的判定表和原理见《A股成交量到底是手还是股》——差一个数量级,回测就是另一个故事。
第 3 步:停牌检测——时间轴上有洞吗
# 数据源停牌行为检测:日期缺口 = 舍弃型,空值 = 空值型
full_days = pd.bdate_range(df["date"].min(), df["date"].max())
gaps = set(full_days) - set(df["date"])
print(f"日期缺口 {len(gaps)} 个") # 舍弃型源会在这里现形
df = df.dropna(subset=["open", "close", "high", "low"]).reset_index(drop=True) # 防空值
为什么这个洞危险、两种填充方案怎么选,见《停牌日的K线去哪了》。本教程从简:缺口存在时信号在断档两侧计算,撮合层用下一根K线执行即可。
第 4 步:策略信号——双均线,且只在次日执行
经典 5/20 日均线金叉买、死叉卖。关键规矩:信号在 T 日收盘确认,T+1 日开盘才执行——既满足 T+1 制度,也杜绝"用今天的收盘价算出今天该买"的未来函数:
df["ma5"] = df["close"].rolling(5).mean()
df["ma20"] = df["close"].rolling(20).mean()
golden = (df["ma5"] > df["ma20"]) & (df["ma5"].shift(1) <= df["ma20"].shift(1))
dead = (df["ma5"] < df["ma20"]) & (df["ma5"].shift(1) >= df["ma20"].shift(1))
df.loc[golden, "signal"] = 1
df.loc[dead, "signal"] = -1
df["target"] = df["signal"].ffill().fillna(0) # 目标仓位:1 持有 / 0 空仓
第 5 步:撮合——T+1 与 100 股整数倍写进代码
cash, shares, buy_idx = 100000.0, 0, -10
records = []
for i in range(1, len(df)): # 信号次日开盘执行
o, day = df.loc[i, "open"], df.loc[i, "date"]
want = df.loc[i - 1, "target"]
if want == 1 and shares == 0 and cash >= o * 100:
shares = int(cash // (o * 100)) * 100 # 主板:100 股整数倍
cash -= shares * o
buy_idx = i # T+1:记买入日
records.append((day, "BUY", o, shares))
elif want == 0 and shares > 0 and i > buy_idx: # T+1:当日买不可当日卖
cash += shares * o
records.append((day, "SELL", o, shares))
shares = 0
df["nav"] = (cash + shares * df["close"]) / 100000.0
三条撮合约束缺一不可:100 股整数倍(主板/创业板申报规则)、当日买不可当日卖(T+1)、信号次日执行(无未来函数)。规则细节与自查方法见《T+1 到底限制了什么》。
第 6 步:绩效统计——三张数字说明一切
ret = df["nav"].iloc[-1] - 1
ann = (df["nav"].iloc[-1]) ** (252 / len(df)) - 1 # 年化
dd = (df["nav"] / df["nav"].cummax() - 1).min() # 最大回撤
daily = df["nav"].pct_change().dropna()
sharpe = daily.mean() / daily.std() * (252 ** 0.5) if daily.std() > 0 else 0
bench = df["close"].iloc[-1] / df["close"].iloc[0] - 1 # 买入持有基准
print(f"总收益 {ret:.1%} | 年化 {ann:.1%} | 最大回撤 {dd:.1%} | 夏普 {sharpe:.2f} | 基准(买入持有) {bench:.1%}")
跑出来大概是这样的量级(示例,非收益承诺):双均线在单边趋势里跑赢基准、在震荡市里被来回打脸——这正是它作为"第一个策略"的教学价值:先用一个你知道优缺点的策略,验证回测系统本身是对的。
新手最常踩的四个坑(本教程已全部处理)
| 坑 | 症状 | 本教程的对策 |
|---|---|---|
| 未来函数 | 回测收益好到不真实 | 信号 T 日收盘确认、T+1 开盘执行 |
| 口径混用 | 复权跳空被当成涨跌 | 全程 qfq,涨跌停判断另拉不复权 |
| 量纲错误 | 换手率/量比差 100 倍 | 第 2 步交叉校验,换算固化 |
| 撮合失真 | 当日买卖、不足 100 股 | 第 5 步两条硬约束 |
下一步:把这个骨架长成你的系统
- 换标的、加仓位管理(等权/波动率倒数)、加止损模块——骨架不用动
- 接入分时数据做更细粒度的执行模拟
- 不想写代码?DiyaQuant 桌面看板内置指标计算与可视化,免费下载
免责声明:本文仅作技术教学,不构成任何投资建议。历史回测收益不代表未来表现,请独立判断并自行承担交易风险。