A股量化策略回测实战:从行情数据到绩效评估(第 1 篇):回测引擎从零搭建
系列说明:本系列使用公开量化数据 REST 接口提供的真实行情/财务数据,演示如何把交易思想变成可回测、可评价的策略。约定:①接口只返回裸字段,所有指标、信号、绩效均由代码二次计算;②涨红跌绿(A 股惯例),上涨为红、下跌为绿;③金额以 ¥ 计;④客观演示,不构成任何投资建议。
前言
前面几个系列把各类数据接口的用法讲透了------行情、财务、技术指标、资金流、龙虎榜,应有尽有。但"拿到数据"和"用数据赚到认知"之间,还差一层:回测。
回测回答一个问题:如果我在过去按某条规则买卖,结果会怎样?它不预测未来,但它能帮我们筛掉明显亏钱的想法、量化策略的风险收益特征、避免"看着 K 线编故事"。
本篇不依赖任何具体策略,只搭一个最小可用回测引擎:数据加载、事件循环、订单/持仓、手续费模型。后面 29 篇的所有策略,都跑在这个引擎上。引擎本身刻意做得简单、可读、可改,方便你逐步加功能。
一、接口原始字段梳理
1)回测引擎的数据契约
回测引擎不绑定某个具体接口,它消费的是统一的"日线 bar"。本系列约定,最底层 bar 以 hsstock/history 返回的日线为准(字段来自官网接口说明,零编造):
| 字段 | 含义 | 类型 |
|---|---|---|
t |
交易日(如 20250430) |
str |
o |
开盘价 | float |
h |
最高价 | float |
l |
最低价 | float |
c |
收盘价 | float |
v |
成交量 | float |
a |
成交额 | float |
pc |
前收盘价 | float |
sf |
停牌标记(1 停牌 / 0 正常) | int |
请求路径(官网文档,零编造):
https://api.biyingapi.com/hsstock/history/{代码.市场 如 000001.SZ}/{级别 如 d}/{除权方式 如 n}/{licence}?st=开始时间&et=结束时间<=最新条数
级别可取 5/15/30/60/d/w/m/y,日线用 d;除权方式可取 n/f/b/fr/br,日线回测一般用前复权 f(复权处理见第 2 篇)。引擎只要求输入是"按日期升序的 bar 列表",数据从哪来、是否复权,由调用方决定。
二、自研衍生统计指标
接口只给裸 OHLCV,下面这些量全部由引擎自己算(不依赖接口返回):
- 权益(equity) = 现金 + 持仓股数 × 当日收盘价
- 日收益率 =
equity_t / equity_{t-1} - 1 - 仓位(position ratio) = 持仓市值 / 总权益
- 累计收益率 =
equity_last / init_cash - 1
这些量是所有绩效指标(夏普、回撤等,第 4 篇)的输入,本篇先把它们稳定地产出来。
三、数据表设计
引擎把原始 bar、成交记录、权益曲线都落本地 SQLite,方便复盘与后续分析。
sql
-- 日线原始行情(按代码+日期唯一)
CREATE TABLE IF NOT EXISTS daily_bar (
stock TEXT NOT NULL,
t TEXT NOT NULL,
o REAL, h REAL, l REAL, c REAL,
v REAL, a REAL, pc REAL, sf INTEGER,
PRIMARY KEY (stock, t)
);
-- 回测成交记录
CREATE TABLE IF NOT EXISTS backtest_trade (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_at TEXT, -- 回测批次标识
stock TEXT,
date TEXT,
side TEXT, -- BUY / SELL
price REAL,
shares INTEGER,
fee REAL
);
-- 每日权益曲线
CREATE TABLE IF NOT EXISTS backtest_equity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_at TEXT,
date TEXT,
equity REAL
);
四、完整代码
4.1 引擎核心:数据加载、订单、事件循环
python
import sqlite3, json, time, datetime
BASE = "https://api.biyingapi.com"
LICENCE = "YOUR_LICENCE_HERE" # 替换为你的 licence
DB_PATH = "backtest.db"
def biying_api_get_retry(url, timeout=15, max_retry=3):
"""带重试的接口请求:仅负责把 JSON 拿回来,不做任何二次计算。"""
import urllib.request
last = None
for i in range(max_retry):
try:
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode('utf-8', 'ignore'))
except Exception as e:
last = e
if i < max_retry - 1:
time.sleep(1 + i)
raise last
def build_history_url(stock, start, end, adj='n', level='d', lt=500):
"""拼接 hsstock/history 日线路径(字段/路径均来自官网说明)。"""
return (f"{BASE}/hsstock/history/{stock}/{level}/{adj}/{LICENCE}"
f"?st={start}&et={end}<={lt}")
class Commission:
"""手续费模型:按比例费率 + 最低消费(A 股常见 5 元起)。"""
def __init__(self, rate=0.0003, min_fee=5.0):
self.rate = rate
self.min_fee = min_fee
def fee(self, amount):
f = amount * self.rate
return self.min_fee if f < self.min_fee else f
class Position:
"""持仓:只维护股数与持仓成本,买卖时更新。"""
def __init__(self):
self.shares = 0
self.cost = 0.0
def update_on_buy(self, price, shares, fee):
total = self.shares + shares
if total > 0:
self.cost = (self.cost * self.shares + price * shares + fee) / total
self.shares = total
def update_on_sell(self, shares, fee):
# 卖出不改成本基准(成本在卖出后按剩余股数摊薄由下次买入处理)
self.shares -= shares
class BacktestEngine:
"""最小回测引擎:事件驱动,逐根 bar 产生信号并执行。"""
def __init__(self, cash=1_000_000.0, commission=None, run_at=None):
self.init_cash = cash
self.cash = cash
self.commission = commission or Commission()
self.position = Position()
self.equity_curve = [] # [(date, equity), ...]
self.trades = [] # [(date, side, price, shares, fee), ...]
self.run_at = run_at or datetime.date.today().isoformat()
def load_bars(self, stock, start, end, adj='n'):
"""从接口加载日线,按日期升序返回。"""
url = build_history_url(stock, start, end, adj)
data = biying_api_get_retry(url)
bars = data if isinstance(data, list) else data.get('data', [])
return sorted(bars, key=lambda b: b['t'])
def run(self, bars, strategy):
"""事件循环:每根 bar 先让策略看历史,再下单。"""
for bar in bars:
date = bar['t']
close = float(bar['c'])
sf = int(bar.get('sf', 0))
# 停牌日不交易
if sf == 1:
equity = self.cash + self.position.shares * close
self.equity_curve.append((date, equity))
continue
signal = strategy.on_bar(bar, self)
if signal and signal.get('side') == 'buy' and self.position.shares == 0:
price = close
budget = self.cash * 0.95 # 留 5% 现金缓冲
shares = int(budget / (price * 100)) * 100 # A 股整百股
if shares > 0:
amount = price * shares
fee = self.commission.fee(amount)
self.cash -= amount + fee
self.position.update_on_buy(price, shares, fee)
self.trades.append((date, 'BUY', price, shares, fee))
elif signal and signal.get('side') == 'sell' and self.position.shares > 0:
price = close
shares = self.position.shares
amount = price * shares
fee = self.commission.fee(amount)
self.cash += amount - fee
self.trades.append((date, 'SELL', price, shares, fee))
self.position.update_on_sell(shares, fee)
equity = self.cash + self.position.shares * close
self.equity_curve.append((date, equity))
return self
def total_return(self):
if not self.equity_curve:
return 0.0
return self.equity_curve[-1][1] / self.init_cash - 1.0
def persist(self):
"""把权益曲线与成交落库,供复盘使用。"""
con = sqlite3.connect(DB_PATH)
cur = con.cursor()
cur.executemany(
"INSERT OR REPLACE INTO backtest_equity(run_at, date, equity) VALUES(?,?,?)",
[(self.run_at, d, e) for d, e in self.equity_curve])
cur.executemany(
"INSERT INTO backtest_trade(run_at, stock, date, side, price, shares, fee) VALUES(?,?,?,?,?,?,?)",
[(self.run_at, '', d, s, p, sh, f) for d, s, p, sh, f in self.trades])
con.commit()
con.close()
4.2 一个最小策略 + 演示运行
下面用示例数据(仅用于演示引擎逻辑,非真实行情)跑一遍买入持有,验证引擎能正常产权益曲线。真实数据接入见第 2 篇。
python
class BuyAndHoldStrategy:
"""演示策略:首日买入,末日卖出。仅用于验证引擎。"""
def __init__(self):
self.entered = False
def on_bar(self, bar, engine):
if not self.entered:
self.entered = True
return {'side': 'buy'}
# 最后一根卖出(演示:用索引近似)
if getattr(engine, '_last', False):
return {'side': 'sell'}
return None
def demo_with_synthetic_bars():
"""构造 6 根示例 bar,验证引擎跑通(非真实行情)。"""
bars = [
{'t': '20250102', 'o': 10.0, 'h': 10.2, 'l': 9.9, 'c': 10.1, 'v': 1e6, 'a': 1e7, 'pc': 10.0, 'sf': 0},
{'t': '20250103', 'o': 10.1, 'h': 10.5, 'l': 10.0, 'c': 10.4, 'v': 1e6, 'a': 1e7, 'pc': 10.1, 'sf': 0},
{'t': '20250106', 'o': 10.4, 'h': 10.6, 'l': 10.3, 'c': 10.3, 'v': 1e6, 'a': 1e7, 'pc': 10.4, 'sf': 0},
{'t': '20250107', 'o': 10.3, 'h': 10.4, 'l': 10.0, 'c': 10.1, 'v': 1e6, 'a': 1e7, 'pc': 10.3, 'sf': 0},
{'t': '20250108', 'o': 10.1, 'h': 10.3, 'l': 9.9, 'c': 9.95, 'v': 1e6, 'a': 1e7, 'pc': 10.1, 'sf': 0},
{'t': '20250109', 'o': 9.95, 'h': 10.2, 'l': 9.9, 'c': 10.2, 'v': 1e6, 'a': 1e7, 'pc': 9.95, 'sf': 0},
]
eng = BacktestEngine(cash=1_000_000.0)
bars[-1] and setattr(eng, '_last', False)
# 标记最后一根用于演示卖出
strat = BuyAndHoldStrategy()
eng.run(bars, strat)
eng._last = True # 触发末日卖出(演示用,正式策略请勿这样写)
# 重新跑一遍以在末日卖出
eng2 = BacktestEngine(cash=1_000_000.0)
# 用带末日信号的简单策略
class BH2(BuyAndHoldStrategy):
def on_bar(self, bar, engine):
idx = bars.index(bar)
if idx == 0:
return {'side': 'buy'}
if idx == len(bars) - 1:
return {'side': 'sell'}
return None
eng2.run(bars, BH2())
print("示例权益曲线:", [(d, round(e, 2)) for d, e in eng2.equity_curve])
print("示例累计收益率: {:.4%}".format(eng2.total_return()))
if __name__ == '__main__':
demo_with_synthetic_bars()
五、关键业务说明
- 事件驱动 vs 向量化:本引擎逐根 bar 处理,信号只能看到"当前及之前"的数据,天然防未来函数;向量化写法快但容易不小心用到未来值,新手优先事件驱动。
- 未来函数(look-ahead bias) :最常见的回测陷阱。规则是------第
t根 bar 的下单,只能依据t及之前收盘。hsstock/history的c是当日收盘,用收盘价下单意味着你假设"收盘瞬间成交",实战需结合下一根开盘,第 3 篇讲基准对齐时会处理。 - 手续费模型 :A 股双边收费(佣金+印花税),本篇佣金按万三、最低 5 元;印花税、滑点可在
Commission基础上扩展。 - 整手交易 :A 股 100 股/手,
shares = int(budget / (price*100)) * 100保证整手,避免碎股。 - 停牌处理 :
sf == 1时跳过交易,但权益仍按收盘价估算,避免回测期间"消失"的标的虚增收益。
六、拓展练习
- 在
Commission中加入印花税(卖出万五)与固定滑点(如 0.1%)。 - 把引擎改成支持多标的 :
run接收{stock: bars}字典,按日期对齐后统一调度。 - 给
BacktestEngine增加to_csv(),把权益曲线导出为 CSV 供第 4 篇算绩效。 - 写一个"金叉买入、死叉卖出"的策略类(和第 5--9 篇技术指标衔接)。
下篇预告
系列第 2 篇:日线数据接入与复权处理 ------ 下一篇我们把 hsstock/history 真实日线接进引擎,并讲清楚前复权 / 后复权对回测结果的实质性影响,以及除权缺口为什么必须处理。
免责申明 :本篇全部代码与回测结果仅作为量化编程与策略研究的学习演示,所有回测均为方法演示,不构成任何投资建议。市场有风险,投资需谨慎。