77、C++项目实战:量化交易策略回测
量化交易,说白了就是用数学模型和代码来做投资决策。而回测,就是把这些策略放到历史数据里跑一遍,看看它到底能不能赚钱。
我个人习惯把回测系统比作一台「时光机」。你写一个策略,告诉它「如果A发生就买,如果B发生就卖」,然后把它丢回2018年的股市里。它跑完告诉你:当年能赚30%。但别高兴太早——回测和实盘之间,隔着一条马里亚纳海沟。
今天我们就手撸一个轻量级的回测引擎。用C++,因为性能够硬,处理海量K线数据不拖后腿。
回测系统的核心架构
先看整体结构。一个回测引擎,通常包含这几个模块:
- 数据源:喂给策略的历史行情数据(OHLCV)
- 策略逻辑:你的买卖规则
- 订单管理:记录每次下单、成交、持仓
- 资金管理:计算盈亏、最大回撤、夏普比率
- 报告输出:把结果可视化
我当年第一次写回测引擎时,把策略和订单管理写在一起,结果改一个参数就要改半天代码。后来学乖了——用策略基类+虚函数,把逻辑解耦。
核心原则:回测引擎只负责「执行」和「记录」,不负责「决策」。决策权交给策略子类。
数据源:喂给策略的「燃料」
回测的第一步,是拿到干净的历史数据。我一般用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