用历史行情接口跑通你的第一个A股回测:从 lsjy 到收益曲线

用历史行情接口跑通你的第一个A股回测:从 lsjy 到收益曲线

读完你能得到:一段可直接运行的完整回测脚本——用历史行情接口拉十年日线,跑一个经典双均线策略,输出年化收益、最大回撤、夏普比率,并且处理了 T+1 撮合、100 股整数倍申报、停牌日检测这三件"教程里从来不讲、实盘一跑就露馅"的事。

第 0 步:准备三样东西

pip install requests pandas

第 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 桌面看板内置指标计算与可视化,免费下载

免责声明:本文仅作技术教学,不构成任何投资建议。历史回测收益不代表未来表现,请独立判断并自行承担交易风险。

评分:
在线客服
工作日 9:00 - 18:00 在线回复

产品使用、订单、售后问题
点击下方按钮在线沟通

点击开始 QQ 咨询

客服QQ:64935542

官方微信自助客服

扫一扫添加微信客服
获取专属技术支持

产品使用、订单与售后问题
可直接电话沟通

拨打 13352950429

客服电话:13352950429