A股量化策略回测实战:从行情数据到绩效评估(第 1 篇):回测引擎从零搭建

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=结束时间&lt=最新条数

级别可取 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={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 时跳过交易,但权益仍按收盘价估算,避免回测期间"消失"的标的虚增收益。

六、拓展练习

  1. 在 Commission 中加入印花税(卖出万五)与固定滑点(如 0.1%)。
  2. 把引擎改成支持多标的 :run 接收 {stock: bars} 字典,按日期对齐后统一调度。
  3. 给 BacktestEngine 增加 to_csv(),把权益曲线导出为 CSV 供第 4 篇算绩效。
  4. 写一个"金叉买入、死叉卖出"的策略类(和第 5--9 篇技术指标衔接)。

下篇预告

系列第 2 篇:日线数据接入与复权处理 ------ 下一篇我们把 hsstock/history 真实日线接进引擎,并讲清楚前复权 / 后复权对回测结果的实质性影响,以及除权缺口为什么必须处理。

免责申明 :本篇全部代码与回测结果仅作为量化编程与策略研究的学习演示,所有回测均为方法演示,不构成任何投资建议。市场有风险,投资需谨慎。

相关推荐
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 10 章 · Profile 与多文档:`spring.config.activate`
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 06 章 · Environment 构建:PropertySource 次序
java·后端·开源
RobinDevNotes1 小时前
SGLang 上手:重复的前缀只算一次
人工智能·python·深度学习·sglang·c4python
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 09 章 · `@ConfigurationProperties` 全生命周期:注册、绑定、校验与 AOT
java·后端·开源
Wang's Blog1 小时前
Java 项目实战: 交友项目 - 百度人脸识别:抽取自动装配组件
java·百度·交友
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 08 章 · Binder 绑定引擎:从字符串到 Java 对象的完整路径
java·后端·开源
希洛兔1 小时前
开闭原则详解:对扩展开放,对修改关闭(含图形编辑器反例 + 抽象正例 + JDK/Spring 实战)
java·开闭原则
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 03 章 · 模块化的自动配置:`*.imports`机制、classic 兼容层与 Starter 生成
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 02 章 · 构建体系深潜:Gradle 约定插件、自动配置的编译期校验与依赖治理
java·后端·开源