77、C++项目实战:量化交易策略回测

量化交易,说白了就是用数学模型和代码来做投资决策。而回测,就是把这些策略放到历史数据里跑一遍,看看它到底能不能赚钱。

我个人习惯把回测系统比作一台「时光机」。你写一个策略,告诉它「如果A发生就买,如果B发生就卖」,然后把它丢回2018年的股市里。它跑完告诉你:当年能赚30%。但别高兴太早——回测和实盘之间,隔着一条马里亚纳海沟。

今天我们就手撸一个轻量级的回测引擎。用C++,因为性能够硬,处理海量K线数据不拖后腿。

回测系统的核心架构

先看整体结构。一个回测引擎,通常包含这几个模块:

  • 数据源:喂给策略的历史行情数据(OHLCV)
  • 策略逻辑:你的买卖规则
  • 订单管理:记录每次下单、成交、持仓
  • 资金管理:计算盈亏、最大回撤、夏普比率
  • 报告输出:把结果可视化

我当年第一次写回测引擎时,把策略和订单管理写在一起,结果改一个参数就要改半天代码。后来学乖了——用策略基类+虚函数,把逻辑解耦。

核心原则:回测引擎只负责「执行」和「记录」,不负责「决策」。决策权交给策略子类。

回测引擎架构图 数据源 CSV / 数据库 / API 策略引擎 基类 + 虚函数 订单管理 买入/卖出/持仓 资金管理 盈亏 / 回撤 / 夏普 报告输出 CSV / 图表 / 统计 逐根K线循环

数据源:喂给策略的「燃料」

回测的第一步,是拿到干净的历史数据。我一般用CSV文件,格式简单,调试方便。

struct Bar {
    std::string datetime;   // 时间戳
    double open;            // 开盘价
    double high;            // 最高价
    double low;             // 最低价
    double close;           // 收盘价
    double volume;          // 成交量
};

class DataSource {
public:
    std::vector<Bar> loadFromCSV(const std::string& filepath) {
        std::vector<Bar> bars;
        std::ifstream file(filepath);
        std::string line;
        // 跳过表头
        std::getline(file, line);
        while (std::getline(file, line)) {
            std::stringstream ss(line);
            Bar bar;
            std::string token;
            std::getline(ss, bar.datetime, ',');
            std::getline(ss, token, ','); bar.open = std::stod(token);
            std::getline(ss, token, ','); bar.high = std::stod(token);
            std::getline(ss, token, ','); bar.low = std::stod(token);
            std::getline(ss, token, ','); bar.close = std::stod(token);
            std::getline(ss, token, ','); bar.volume = std::stod(token);
            bars.push_back(bar);
        }
        return bars;
    }
};

小技巧:数据里经常有空值或除权除息导致的跳空。我习惯在加载后做一次「数据清洗」——把缺失值用前值填充,把复权价格算好。否则回测结果会失真。

策略基类:把「规则」抽象出来

策略是回测的灵魂。但你不能每写一个策略就重写一遍回测引擎。所以,我设计了一个纯虚基类:

class Strategy {
public:
    virtual ~Strategy() = default;

    // 每来一根K线,调用一次
    virtual void onBar(const Bar& bar) = 0;

    // 返回当前是否持仓
    virtual bool hasPosition() const = 0;

    // 返回当前持仓数量(正数=多头,负数=空头)
    virtual int positionSize() const = 0;
};

你想想看,这样设计的好处是什么?

策略作者只需要关注 onBar 里的逻辑。至于怎么下单、怎么算手续费、怎么记录成交——引擎帮你搞定。

我曾经见过一个团队,把策略逻辑和订单执行写在一个类里,3000行代码,改一个止损参数要翻半天。后来重构了,清爽多了。

一个简单的双均线策略

来,写一个经典的双均线金叉死叉策略:

class MovingAverageCrossStrategy : public Strategy {
public:
    MovingAverageCrossStrategy(int shortPeriod, int longPeriod)
        : shortPeriod_(shortPeriod), longPeriod_(longPeriod), position_(0) {}

    void onBar(const Bar& bar) override {
        prices_.push_back(bar.close);

        if (prices_.size() < longPeriod_) return;

        double shortMA = calcMA(shortPeriod_);
        double longMA  = calcMA(longPeriod_);

        // 金叉:买入
        if (shortMA > longMA && position_ == 0) {
            position_ = 1;  // 持有多头
            std::cout << "买入信号 @ " << bar.datetime << " 价格: " << bar.close << "\n";
        }
        // 死叉:卖出
        else if (shortMA < longMA && position_ == 1) {
            position_ = 0;  // 清仓
            std::cout << "卖出信号 @ " << bar.datetime << " 价格: " << bar.close << "\n";
        }
    }

    bool hasPosition() const override { return position_ != 0; }
    int positionSize() const override { return position_; }

private:
    double calcMA(int period) {
        double sum = 0;
        for (int i = prices_.size() - period; i < prices_.size(); ++i) {
            sum += prices_[i];
        }
        return sum / period;
    }

    int shortPeriod_;
    int longPeriod_;
    std::vector<double> prices_;
    int position_;
};

注意:这个策略没有考虑滑点和手续费。实盘中,每次买卖都有成本。如果你回测时忽略这点,结果会非常乐观——但实盘会让你哭。我一般会在订单管理里加一个「手续费率」参数,默认千分之一。

订单管理与资金计算

订单管理模块,负责记录每一笔交易。我习惯用结构体来存:

struct Order {
    std::string datetime;
    double price;
    int size;       // 正数买入,负数卖出
    double commission; // 手续费
};

class Portfolio {
public:
    Portfolio(double initialCash) : cash_(initialCash), position_(0) {}

    void executeOrder(const Order& order) {
        double cost = order.price * std::abs(order.size) + order.commission;
        if (order.size > 0) {  // 买入
            if (cash_ < cost) {
                std::cerr << "资金不足,无法买入\n";
                return;
            }
            cash_ -= cost;
            position_ += order.size;
        } else {  // 卖出
            if (position_ + order.size < 0) {
                std::cerr << "持仓不足,无法卖出\n";
                return;
            }
            cash_ += order.price * std::abs(order.size) - order.commission;
            position_ += order.size;  // order.size 为负数
        }
        orders_.push_back(order);
    }

    double totalEquity(double currentPrice) const {
        return cash_ + position_ * currentPrice;
    }

private:
    double cash_;
    int position_;
    std::vector<Order> orders_;
};

这里有个坑:浮点数精度。C++ 的 double 在累加大量交易后,误差会累积。我建议用整数表示价格(比如按最小变动价位放大10000倍),或者用高精度库。不过对于教学演示,double 够用了。

回测引擎:把一切串起来

引擎负责「驱动」整个流程。说白了就是:一根K线一根K线地喂给策略,策略决定买卖,引擎执行订单,最后算收益。

class BacktestEngine {
public:
    BacktestEngine(std::unique_ptr<Strategy> strategy, std::unique_ptr<Portfolio> portfolio)
        : strategy_(std::move(strategy)), portfolio_(std::move(portfolio)) {}

    void run(const std::vector<Bar>& bars) {
        for (const auto& bar : bars) {
            strategy_->onBar(bar);

            // 如果策略有持仓变化,生成订单
            // 这里简化处理:假设策略在 onBar 里直接输出信号
            // 实际项目中,策略应该返回信号,引擎再生成订单
        }
    }

    void report() {
        // 输出最终权益、最大回撤、夏普比率等
        std::cout << "最终权益: " << portfolio_->totalEquity(100.0) << "\n";
    }

private:
    std::unique_ptr<Strategy> strategy_;
    std::unique_ptr<Portfolio> portfolio_;
};

核心思想:引擎不关心策略怎么算的,只关心策略告诉它「买多少」或「卖多少」。这就是解耦。

回测结果的关键指标

跑完回测,不能只看「赚了多少钱」。你得看这几个指标:

指标 含义 我的经验值
总收益率 最终权益 / 初始资金 - 1 年化 15% 以上算不错
最大回撤 从最高点到最低点的最大跌幅 超过 20% 就要警惕
夏普比率 (收益率 - 无风险利率) / 波动率 大于 1 算合格,大于 2 很优秀
胜率 盈利交易次数 / 总交易次数 40% 以上就算不错
盈亏比 平均盈利 / 平均亏损 大于 2 比较好

我见过很多人只看总收益率,觉得「哇,年化50%!」,结果一看最大回撤60%——这策略实盘根本拿不住。回撤大的策略,心理压力极大,很容易在最低点割肉。

避坑指南:回测中常见的陷阱

  • 前视偏差:用未来的数据做决策。比如用当天的收盘价计算均线,却在当天开盘时买入。我犯过这个错,查了三天才发现。
  • 过拟合:参数调得太完美,换一个时间段就失效。解决办法:用「样本外测试」,留一部分数据不参与调参。
  • 幸存者偏差:只测试现在还活着的股票。退市的股票数据也要包含,否则结果偏乐观。
  • 忽略流动性:小盘股一天成交几百万,你回测里一次买卖几千万——根本成交不了。

我的习惯:每次回测完,我会把策略放到一个「随机数据」上跑一遍。如果随机数据上也能赚钱,那说明策略有漏洞——它在拟合噪声,而不是真正的规律。

扩展思考:从回测到实盘

回测只是第一步。从回测到实盘,中间还有很长的路:

  • 加入滑点模型(比如每次买卖多算0.1%的损耗)
  • 加入交易延迟(信号出现到实际成交有延迟)
  • 加入资金管理(凯利公式、固定比例等)
  • 加入风险控制(止损、止盈、仓位限制)

我个人的建议是:回测阶段可以乐观一点,但实盘前一定要做「压力测试」——把最坏的情况模拟一遍。比如2015年股灾、2020年疫情暴跌,你的策略扛得住吗?

嗯,量化交易这条路,回测是起点,不是终点。代码写完了,真正的挑战才刚刚开始。


公众号:蓝海资料掘金营,微信deep3321