algotradelib 0.0.0.9000
C++ header-only library for algorithmic trading
Loading...
Searching...
No Matches
example-backtest-compare.cpp
1// Your own strategy, replayed beside the shipped ones on identical bars.
2//
3// `example-backtest-offline` compares the vanilla strategies to each other. This one answers the
4// question a new consumer actually has: **where does my strategy go, and how do I put it next to
5// yours?** Everything above `MyStrategy` is scaffolding you will replace; `MyStrategy` itself is the
6// part you own.
7//
8// This file has two jobs, and it is one file so that they cannot disagree. It is an example built
9// inside this repository, and it is what `tools/atl-new-project.sh` copies into a generated project
10// as `src/main.cpp`. A template kept separately from a compiled example is a second copy that
11// nothing builds, and it soon goes stale.
12//
13// No credentials and no network: the bars are built in code, so the output is identical on every
14// run and on every machine. Replacing `closes()` with your own history is the first thing to do
15// after the numbers below stop surprising you -- see "Using it" on the documentation main page
16// (`doxygen/index.md`) for where bars come from, because this library deliberately does not read files.
17//
18// Run it as:
19//
20// example-backtest-compare
21
22#include <algotradelib/backtest>
23#include <algotradelib/vanilla>
24
25#include <chrono>
26#include <cstddef>
27#include <iomanip>
28#include <iostream>
29#include <optional>
30#include <sstream>
31#include <string>
32#include <vector>
33
34namespace {
35
37namespace backtest = algotradelib::backtest;
38namespace vanilla = algotradelib::strategy::vanilla;
39namespace math = algotradelib::math;
40
52
53InstrumentId traded() { return InstrumentId{Country::KR, Venue::COMBINED, "005930"}; }
54
55// ---------------------------------------------------------------------------------------------
56// Your strategy.
57//
58// A strategy is a plain struct with **template** member functions. It is not templated on a broker,
59// holds no session, and performs no I/O, which is exactly why this same source runs in a dry run and
60// in a live session without being rewritten. The template is load-bearing rather than stylistic: it
61// is what lets one strategy be handed three different runner contexts. `ATL_ASSERT_STRATEGY` below
62// compiles every handler against two stub contexts, so a handler accidentally written against one
63// concrete context, or one the runner's call does not fit, fails here rather than when somebody first
64// runs it live.
65//
66// Emit a **target position**, not an order. A strategy that emits orders has to remember whether it
67// already entered, and after a restart it does not know -- so it either misses an exit or re-enters a
68// position it already holds. Stating the position you want leaves that to reconciliation, which can
69// see the portfolio and the working orders; you cannot.
70// ---------------------------------------------------------------------------------------------
71struct MyStrategy {
73 std::size_t lookback_{5};
75 Decimal64 quantity_{Decimal64::parse("1000")};
76
77 std::vector<Decimal64> history_;
78
79 template <class Ctx>
80 void on_bar(Ctx& ctx, const strategy::BarEvent& bar) {
81 history_.push_back(bar.bar_.close_);
82 if (history_.size() < lookback_) {
83 return;
84 }
85
86 // Kept in `Decimal64` rather than converted to `math::Scalar`, for two reasons worth knowing
87 // before you write the real thing.
88 //
89 // The first is that this strategy then needs no linear-algebra backend, so it compiles in a
90 // build configured with `ATL_USE_EIGEN=OFF`. The shipped indicator strategies do need one, which
91 // is why `SmaCrossover` and `DonchianBreakout` below sit inside a guard and this does not.
92 //
93 // The second is the comparison itself. Rather than divide the sum to get a mean -- which is the
94 // one operation on an exact decimal that has to choose a scale and therefore round -- multiply
95 // the other side. `close * n < sum` is the same question as `close < sum / n`, and it is exact.
96 // Every arithmetic function returns `std::optional` because it checks for overflow, so a
97 // strategy that cannot compute its own signal emits nothing instead of guessing.
98 //
99 // For real indicator work, cross into `math::Scalar` through `analytics::to_scalar` and come
100 // back through `PriceRule::round_to_tick`. That gate is not decoration: it is what stops an
101 // indicator's value reaching the wire as an off-tick limit price.
102 std::optional<Decimal64> sum = Decimal64::parse("0");
103 for (std::size_t at = history_.size() - lookback_; sum && at < history_.size(); ++at) {
104 sum = algotradelib::decimal_add(*sum, history_[at]);
105 }
106 const auto scaled = algotradelib::decimal_multiply(
107 history_.back(), Decimal64::parse(std::to_string(lookback_))
108 );
109 if (!sum || !scaled) {
110 return;
111 }
112
113 // Buy what has fallen below its own recent average, hold nothing otherwise. It is a toy, and
114 // the point of the run below is that you can watch it lose to buying and holding.
115 const bool cheap = algotradelib::decimal_less(*scaled, *sum);
116
117 strategy::TargetPosition target;
118 target.instrument_ = bar.instrument_;
119 target.target_quantity_ = cheap ? quantity_ : Decimal64::parse("0");
120 ctx.submit(target);
121 }
122};
123
124// ---------------------------------------------------------------------------------------------
125// Everything below is the harness. Replace the bars; the rest can stay as it is.
126// ---------------------------------------------------------------------------------------------
127
129Instrument reference() {
130 Instrument instrument;
131 instrument.id_ = traded();
132 instrument.name_ = "Samsung Electronics";
133 instrument.type_ = SecurityType::STOCK;
134 instrument.currency_ = Currency{"KRW"};
135 instrument.lot_size_ = Decimal64::parse("1");
136 // The tick ladder is this library's own versioned table, because neither broker publishes one.
137 // An unknown ladder refuses every price rather than guessing, since a guessed tick is an order the
138 // exchange rejects.
139 instrument.price_rule_ = PriceRule::krx_equity();
140 return instrument;
141}
142
144const std::vector<int>& closes() {
145 static const std::vector<int> path = {
146 60000, 60500, 61200, 61000, 62000, 63000, 64500, 65000, 66200, 67000,
147 68500, 69000, 70200, 71000, 72500, 73000, 72800, 72000, 71000, 69500,
148 68000, 66500, 65000, 63500, 62000, 61000, 60000, 59000, 58500, 58000,
149 58500, 59500, 61000, 62500, 64000, 65500, 67000, 68500, 70000, 71500,
150 };
151 return path;
152}
153
154backtest::ReplayConfig config_for() {
155 backtest::ReplayConfig config;
156 config.cash_ = Decimal64::parse("100000000");
157 config.instruments_ = {reference()};
158 config.series_capacity_ = 64;
159 // Any phase, so the point stays the strategies rather than the operator's session limits.
160 config.limits_.permitted_phases_ = std::nullopt;
161 return config;
162}
163
165backtest::NextBarFillModel costs() {
166 return backtest::NextBarFillModel(
167 backtest::CommissionModel::of(Decimal64::parse("0.00015"), Decimal64::parse("100")),
168 backtest::SlippageModel::of_ticks(1)
169 );
170}
171
180template <typename Strategy>
181backtest::Backtester<Strategy> replay(Strategy instance) {
182 backtest::Backtester<Strategy> engine(std::move(instance), config_for(), costs());
183 const auto start = std::chrono::system_clock::time_point{} + std::chrono::hours(24 * 20000);
184 for (std::size_t at = 0; at < closes().size(); ++at) {
185 Bar bar;
186 bar.timestamp_ = start + std::chrono::hours(24 * static_cast<int>(at));
187 bar.open_ = Decimal64::parse(std::to_string(closes()[at]));
188 bar.high_ = *bar.open_;
189 bar.low_ = *bar.open_;
190 bar.close_ = *bar.open_;
191 bar.volume_ = Decimal64::parse("1200000");
192 engine.feed(strategy::make_bar_event(traded(), BarInterval::ONE_DAY, BarStamp::CLOSE, bar));
193 }
194 engine.finish();
195 return engine;
196}
197
198#if defined(ATL_HAS_EIGEN)
199
201std::string percent(math::Scalar value) {
202 if (!math::observed(value)) {
203 return math::is_undefined(value) ? "undefined" : " --";
204 }
205 std::ostringstream text;
206 text << std::fixed << std::setprecision(2) << std::setw(7) << value * 100.0 << " %";
207 return text.str();
208}
209
211std::string plain(math::Scalar value) {
212 if (!math::observed(value)) {
213 return math::is_undefined(value) ? "undefined" : " --";
214 }
215 std::ostringstream text;
216 text << std::fixed << std::setprecision(3) << std::setw(9) << value;
217 return text.str();
218}
219
221template <typename Strategy>
222void compare(const std::string& name, Strategy instance, math::Scalar periods) {
223 const auto engine = replay(std::move(instance));
224 const auto report = backtest::summarize(engine.result(), engine.fills(), periods);
225 std::cout << " " << std::left << std::setw(21) << name << std::right
226 << percent(report.total_return_) << " " << plain(report.sharpe_) << " "
227 << percent(report.max_drawdown_) << " " << std::setw(5) << report.executions_ << "\n";
228}
229
230#endif
231
232} // namespace
233
234// Compiles every handler against a stub with no capability and one with every capability, so both
235// branches of each capability gate are checked. Each runner checks it again with its own context.
238
239int main() {
240 std::cout << "005930, " << closes().size()
241 << " bars built in code -- no credentials, no network\n\n";
242
243#if defined(ATL_HAS_EIGEN)
244 // Annualizing takes its period count from the caller rather than assuming a bar is a day. Get this
245 // wrong and the annualized figure is wrong by the square root of the ratio.
246 const auto periods =
247 backtest::periods_per_year(BarInterval::ONE_DAY, strategy::SessionWindow::krx(), 245.0);
248
249 std::cout << " " << std::left << std::setw(21) << "strategy" << std::right
250 << std::setw(9) << "return" << " " << std::setw(9) << "Sharpe" << " "
251 << std::setw(9) << "max dd" << " " << std::setw(5) << "fills" << "\n"
252 << " " << std::string(59, '-') << "\n";
253
254 compare("MyStrategy(5)", MyStrategy{}, *periods);
255
256 vanilla::BuyAndHold holding;
257 holding.params_.instrument_ = traded();
258 holding.params_.quantity_ = Decimal64::parse("1000");
259 compare("BuyAndHold", holding, *periods);
260
261 vanilla::MovingAverageCrossover::Params crossing;
262 crossing.instrument_ = traded();
263 crossing.fast_ = 3;
264 crossing.slow_ = 8;
265 crossing.quantity_ = Decimal64::parse("1000");
266 compare("SmaCrossover(3,8)", vanilla::SmaCrossover{crossing}, *periods);
267
268 vanilla::DonchianBreakout breaking;
269 breaking.params_.instrument_ = traded();
270 breaking.params_.lookback_ = 5;
271 breaking.params_.quantity_ = Decimal64::parse("1000");
272 compare("DonchianBreakout(5)", breaking, *periods);
273
274 std::cout
275 << "\nForty invented bars rank nothing. What this shows is that the comparison is honest: every\n"
276 << "strategy above saw identical bars, paid identical costs, and filled against the bar after the\n"
277 << "one it decided on -- which is structural here, not a rule anybody has to remember. Read\n"
278 << "`report.caveats_` before you believe a number, and the reading-a-backtest vignette before you\n"
279 << "believe a ranking.\n";
280#else
281 // No backend, so there is no `Report` to print and no vanilla to compare against either: every
282 // vanilla but `BuyAndHold` and `TimedLiquidation` sits in a header that needs `<algotradelib/analytics>`,
283 // so `<algotradelib/vanilla>` does not ship them here. The replay engine itself needs no backend at all, which is worth showing
284 // rather than asserting -- so the strategy is still run, and what it did is still counted.
285 const auto engine = replay(MyStrategy{});
286 std::cout << "MyStrategy replayed " << engine.result().bars_ << " bars and filled "
287 << engine.result().executions_.size() << " orders.\n\n"
288 << "Scoring those fills needs a linear algebra backend and this build has none.\n"
289 << "Configure with -DATL_USE_EIGEN=ON for the metrics and the vanilla comparison.\n";
290#endif
291
292 return 0;
293}
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
#define ATL_ASSERT_STRATEGY(S, Ctx)
Asserts at compile time that S is a strategy a runner with context Ctx can drive.
Definition concept.hpp:712
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
ProbeContextT< NoCaps > MinimalContext
A probe context whose broker implements nothing optional.
Definition concept.hpp:651
ProbeContextT< AllCaps > FullContext
A probe context whose broker implements everything optional.
Definition concept.hpp:654
BarStamp
Which edge of its period a bar's timestamp marks.
SecurityType
What kind of security an instrument is.
std::optional< Decimal64 > decimal_add(const Decimal64 &lhs, const Decimal64 &rhs)
Adds two decimals exactly.
Definition types.hpp:509
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
bool decimal_less(const Decimal64 &lhs, const Decimal64 &rhs)
Compares two decimals numerically, whatever their scales.
Definition types.hpp:665
std::optional< Decimal64 > decimal_multiply(const Decimal64 &lhs, const Decimal64 &rhs)
Multiplies two decimals exactly; the scales add.
Definition types.hpp:571
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
InstrumentId instrument_
The instrument.
Definition intent.hpp:444
Decimal64 target_quantity_
The position to hold, signed: negative is short, and zero means flat.
Definition intent.hpp:446