algotradelib 0.0.0.9000
C++ header-only library for algorithmic trading
Loading...
Searching...
No Matches
example-backtest-offline.cpp
1// A backtest with no broker, no credentials, and no network.
2//
3// Bars are built in code, so the output is identical every time. That makes this the place to see what
4// a backtest does and, more usefully, what it declines to tell you: the caveats print alongside the
5// figures because a reader who has to remember which assumptions applied to their run eventually will
6// not remember.
7//
8// The same strategies are replayed at daily and at minute granularity, and the engine is told nothing
9// about which it is getting. Neither KIS nor Toss makes intraday history easy, but that is a
10// limitation of those adapters rather than of backtesting.
11//
12// Run it as:
13//
14// example-backtest-offline
15
16#include <algotradelib/backtest>
17#include <algotradelib/vanilla>
18
19#include <chrono>
20#include <cstddef>
21#include <iomanip>
22#include <iostream>
23#include <optional>
24#include <sstream>
25#include <string>
26#include <vector>
27
28namespace {
29
31namespace backtest = algotradelib::backtest;
32namespace vanilla = algotradelib::strategy::vanilla;
33namespace math = algotradelib::math;
34
41std::string money_or_absent(const std::optional<algotradelib::Decimal64>& amount) {
42 return amount ? amount->str() : std::string("(absent: see the caveats)");
43}
44
45
57
58InstrumentId samsung() { return InstrumentId{Country::KR, Venue::COMBINED, "005930"}; }
59
60Instrument samsung_reference() {
61 Instrument reference;
62 reference.id_ = samsung();
63 reference.name_ = "Samsung Electronics";
64 reference.type_ = SecurityType::STOCK;
65 reference.currency_ = Currency{"KRW"};
66 reference.lot_size_ = Decimal64::parse("1");
67 reference.price_rule_ = PriceRule::krx_equity();
68 return reference;
69}
70
72const std::vector<int>& closes() {
73 static const std::vector<int> path = {
74 60000, 60500, 61200, 61000, 62000, 63000, 64500, 65000, 66200, 67000,
75 68500, 69000, 70200, 71000, 72500, 73000, 72800, 72000, 71000, 69500,
76 68000, 66500, 65000, 63500, 62000, 61000, 60000, 59000, 58500, 58000,
77 58500, 59500, 61000, 62500, 64000, 65500, 67000, 68500, 70000, 71500,
78 };
79 return path;
80}
81
83backtest::NextBarFillModel realistic_costs() {
84 return backtest::NextBarFillModel(
85 backtest::CommissionModel::of(Decimal64::parse("0.00015"), Decimal64::parse("100")),
86 backtest::SlippageModel::of_ticks(1)
87 );
88}
89
90backtest::ReplayConfig config_for() {
91 backtest::ReplayConfig config;
92 config.cash_ = Decimal64::parse("100000000");
93 config.instruments_ = {samsung_reference()};
94 config.series_capacity_ = 64;
95 // Any phase, so a minute replay's pre-open and post-close bars are not refused while the point here
96 // is the strategy rather than the operator's limits.
97 config.limits_.permitted_phases_ = std::nullopt;
98 return config;
99}
100
102template <typename Strategy>
103backtest::Backtester<Strategy> replay(Strategy instance, BarInterval interval) {
104 backtest::Backtester<Strategy> engine(std::move(instance), config_for(), realistic_costs());
105 const auto start = std::chrono::system_clock::time_point{} + std::chrono::hours(24 * 20000);
106 for (std::size_t at = 0; at < closes().size(); ++at) {
107 const auto step = interval == BarInterval::ONE_MINUTE ?
108 std::chrono::minutes(static_cast<int>(at)) :
109 std::chrono::duration_cast<std::chrono::minutes>(
110 std::chrono::hours(24 * static_cast<int>(at))
111 );
112 Bar bar;
113 bar.timestamp_ = start + step;
114 bar.open_ = Decimal64::parse(std::to_string(closes()[at]));
115 bar.high_ = *bar.open_;
116 bar.low_ = *bar.open_;
117 bar.close_ = *bar.open_;
118 bar.volume_ = Decimal64::parse("1200000");
119 engine.feed(strategy::make_bar_event(samsung(), interval, BarStamp::CLOSE, bar));
120 }
121 engine.finish();
122 return engine;
123}
124
126std::string percent(math::Scalar value) {
127 if (!math::observed(value)) {
128 return math::is_undefined(value) ? "undefined" : " --";
129 }
130 std::ostringstream text;
131 text << std::fixed << std::setprecision(2) << std::setw(7) << value * 100.0 << " %";
132 return text.str();
133}
134
136std::string plain(math::Scalar value) {
137 if (!math::observed(value)) {
138 return math::is_undefined(value) ? "undefined" : " --";
139 }
140 std::ostringstream text;
141 text << std::fixed << std::setprecision(3) << std::setw(9) << value;
142 return text.str();
143}
144
145#if defined(ATL_HAS_EIGEN)
147void print(const std::string& title, const backtest::Report& report) {
148 std::cout << title << "\n"
149 << " bars " << report.bars_ << "\n"
150 << " executions " << report.executions_ << "\n"
151 << " total return " << percent(report.total_return_) << "\n"
152 << " Sharpe " << plain(report.sharpe_) << " (per bar)\n"
153 << " annualized " << plain(report.annualized_sharpe_) << "\n"
154 << " Sortino " << plain(report.sortino_) << "\n"
155 << " max drawdown " << percent(report.max_drawdown_) << "\n"
156 << " turnover " << percent(report.total_turnover_) << "\n"
157 << " commission " << money_or_absent(report.commission_paid_) << " KRW\n"
158 << " slippage " << money_or_absent(report.slippage_paid_) << " KRW\n";
159 std::cout << " assumed limit fills "
160 << backtest::limit_fill_price_name(report.caveats_.limit_fill_) << ", slippage "
161 << report.caveats_.slippage_ticks_ << " tick\n";
162 // The rates that actually charged something, rather than one number: a run spanning a rate change
163 // or costing by venue charges more than one, and printing the first would name an accident.
164 for (const auto& applied : report.caveats_.applied_rates_) {
165 std::cout << " commission rate " << applied.rate_.str() << " with a "
166 << applied.minimum_.str() << " floor, "
167 << commission_origin_name(applied.origin_) << ", charged "
168 << applied.executions_ << " execution(s)\n";
169 }
170 if (report.caveats_.cost_totals_incomplete_) {
171 // The third reason a total can be absent, and the one a reader must not miss: it is not that
172 // the run spanned currencies or traded nothing, it is that an addition did not fit and the
173 // figures below it are short by an amount nobody can name.
174 std::cout << " cost totals INCOMPLETE -- an exact sum did not fit; slippage is understated"
175 << " and the fee totals are withheld\n";
176 }
177 if (report.caveats_.inclusive_commission_executions_ > 0) {
178 std::cout << " commission is inclusive of any institution charge on "
179 << report.caveats_.inclusive_commission_executions_ << " execution(s)\n";
180 }
181 std::cout << " not modelled volume, queue position, market impact, borrow\n";
182 if (report.caveats_.gaps_ > 0) {
183 std::cout << " gaps " << report.caveats_.gaps_
184 << " (not interpolated across)\n";
185 }
186 if (report.caveats_.unfilled_ > 0) {
187 std::cout << " unfilled " << report.caveats_.unfilled_
188 << " (no next bar to fill against)\n";
189 }
190 if (report.caveats_.expired_day_orders_ > 0) {
191 std::cout << " expired " << report.caveats_.expired_day_orders_
192 << " DAY order(s) unfilled at their session's close, as at a broker\n";
193 }
194 if (report.caveats_.multiple_instruments_) {
195 std::cout << " CAVEAT more than one instrument: this result may carry survivorship\n"
196 << " bias, because reference data lists what exists now\n";
197 }
198 std::cout << "\n";
199}
200#endif
201
202} // namespace
203
204int main() {
205 std::cout << "005930, " << closes().size()
206 << " bars built in code -- no credentials, no network\n\n";
207
208#if defined(ATL_HAS_EIGEN)
209 const auto daily_periods =
210 backtest::periods_per_year(BarInterval::ONE_DAY, strategy::SessionWindow::krx(), 245.0);
211 const auto minute_periods =
212 backtest::periods_per_year(BarInterval::ONE_MINUTE, strategy::SessionWindow::krx(), 245.0);
213 std::cout << "periods per year: " << *daily_periods << " daily, " << *minute_periods
214 << " minute -- which is why annualizing takes the count rather than assuming it\n\n";
215
216 vanilla::BuyAndHold holding;
217 holding.params_.instrument_ = samsung();
218 holding.params_.quantity_ = Decimal64::parse("1000");
219 {
220 const auto engine = replay(holding, BarInterval::ONE_DAY);
221 print("BuyAndHold, daily", backtest::summarize(engine.result(), engine.fills(), *daily_periods));
222 }
223
224 vanilla::MovingAverageCrossover::Params crossing;
225 crossing.instrument_ = samsung();
226 crossing.fast_ = 3;
227 crossing.slow_ = 8;
228 crossing.quantity_ = Decimal64::parse("1000");
229 {
230 const auto engine = replay(vanilla::SmaCrossover{crossing}, BarInterval::ONE_DAY);
231 print(
232 "SmaCrossover(3, 8), daily",
233 backtest::summarize(engine.result(), engine.fills(), *daily_periods)
234 );
235 }
236
237 // The same strategy over the same path, at a different granularity. The engine is told nothing about
238 // which it is getting; only the annualizing constant differs, and it differs because the caller says
239 // so rather than because the engine guessed.
240 {
241 const auto engine = replay(vanilla::SmaCrossover{crossing}, BarInterval::ONE_MINUTE);
242 print(
243 "SmaCrossover(3, 8), minute bars -- identical decisions, different annualizing",
244 backtest::summarize(engine.result(), engine.fills(), *minute_periods)
245 );
246 std::cout
247 << " ^ Note the annualized Sharpe against the daily run above. The per-bar figure is\n"
248 << " identical -- the decisions were identical -- and only the constant differs, because\n"
249 << " sqrt(95550) is about twenty times sqrt(245). The larger number is arithmetically\n"
250 << " right and practically meaningless here: this path moves like daily data, so calling\n"
251 << " its bars minutes claims a frequency the returns do not have. Two lessons. Mislabel\n"
252 << " your bar frequency and the annualized figure is wrong by the square root of the\n"
253 << " ratio. And annualizing assumes returns are serially uncorrelated, which a trending\n"
254 << " strategy violates by construction.\n\n";
255 }
256
257 vanilla::DonchianBreakout breaking;
258 breaking.params_.instrument_ = samsung();
259 breaking.params_.lookback_ = 5;
260 breaking.params_.quantity_ = Decimal64::parse("1000");
261 {
262 const auto engine = replay(breaking, BarInterval::ONE_DAY);
263 print(
264 "DonchianBreakout(5), daily",
265 backtest::summarize(engine.result(), engine.fills(), *daily_periods)
266 );
267 }
268
269 // And the same strategy at no cost at all, to show what costs were doing.
270 {
271 backtest::Backtester<vanilla::SmaCrossover> engine(
272 vanilla::SmaCrossover{crossing}, config_for(), backtest::NextBarFillModel{}
273 );
274 const auto start = std::chrono::system_clock::time_point{} + std::chrono::hours(24 * 20000);
275 for (std::size_t at = 0; at < closes().size(); ++at) {
276 Bar bar;
277 bar.timestamp_ = start + std::chrono::hours(24 * static_cast<int>(at));
278 bar.open_ = Decimal64::parse(std::to_string(closes()[at]));
279 bar.high_ = *bar.open_;
280 bar.low_ = *bar.open_;
281 bar.close_ = *bar.open_;
282 bar.volume_ = Decimal64::parse("1200000");
283 engine.feed(strategy::make_bar_event(samsung(), BarInterval::ONE_DAY, BarStamp::CLOSE, bar));
284 }
285 engine.finish();
286 print(
287 "SmaCrossover(3, 8), daily, at zero cost -- the difference is what costs cost",
288 backtest::summarize(engine.result(), engine.fills(), *daily_periods)
289 );
290 }
291
292 std::cout
293 << "Read the caveats, not only the figures. A backtest is the easiest place to produce a\n"
294 << "confident wrong number, and the ones above assume every fill was available in full at\n"
295 << "the price shown, which no bar can actually tell us.\n";
296#else
297 std::cout
298 << "The report needs a linear algebra backend and this build has none.\n"
299 << "Configure with -DATL_USE_EIGEN=ON to see the metrics. The engine itself runs either way.\n";
300#endif
301
302 return 0;
303}
A three-letter currency code, such as KRW or USD (ISO 4217 style).
Definition types.hpp:234
An exact signed decimal: an int64 coefficient times 10^-scale.
Definition types.hpp:297
Replays a strategy over stored bars: fill and cost models, the replay ledger, and the report.
Definition clock.hpp:14
The numeric vocabulary: Scalar, missing(), and the matrix and vector types.
Definition eigen.hpp:10
void start(S &strategy, Ctx &context)
Calls strategy.on_start(context) if the strategy declares on_start; otherwise does nothing.
Definition concept.hpp:149
void bar(S &strategy, Ctx &context, const BarEvent &event)
Calls strategy.on_bar(context, event) if the strategy declares on_bar; otherwise does nothing.
Definition concept.hpp:191
The strategies this library ships (buy and hold, crossovers, mean reversion, sizing,...
What a strategy sees and emits: events, intents, the portfolio, reconciliation and risk limits.
Definition concept.hpp:16
BarEvent make_bar_event(InstrumentId instrument, BarInterval interval, BarStamp stamp, Bar bar)
Makes a bar event, computing its period_end_ with bar_period_end.
Definition event.hpp:273
BarStamp
Which edge of its period a bar's timestamp marks.
SecurityType
What kind of security an instrument is.
constexpr std::string_view commission_origin_name(CommissionOrigin origin) noexcept
Returns a lowercase name for an origin, such as "caller_stated", for reports.
Definition types.hpp:1416
Country
A country (market) the library supports.
Definition types.hpp:30
Venue
A trading venue: an exchange, or the combined view across exchanges.
Definition types.hpp:43
BarInterval
The period one bar covers.
One bar: the prices and volume of one period, as exact decimals.
Identifies an instrument: its country, venue and symbol.
Definition types.hpp:78
Reference data for one instrument.
The price grid (tick sizes) an instrument's order prices must sit on.
static SessionWindow krx()
Returns the KRX regular session, 09:00-15:30 KST.
Definition event.hpp:120