Skip to content

Commit 8828128

Browse files
authored
Removing OHLC_RSI for the moment, and focusing on a Random Trading St… (#28)
* Removing OHLC_RSI for the moment, and focusing on a Random Trading Strategy * Added trading stats at the end of the run * updating xcode project
1 parent 012108a commit 8828128

9 files changed

Lines changed: 274 additions & 46 deletions

File tree

backtesting-engine-cpp.xcodeproj/project.pbxproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@
8484
9464E5F02FA7467200D82BAD /* symbolScale.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = symbolScale.mm; sourceTree = "<group>"; };
8585
94674B822D533B1D00973137 /* trade.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = trade.hpp; sourceTree = "<group>"; };
8686
94674B832D533B2F00973137 /* tradeManager.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = tradeManager.hpp; sourceTree = "<group>"; };
87+
94674BA02D533B2F00973137 /* exitRules.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = exitRules.hpp; sourceTree = "<group>"; };
8788
94674B852D533B4000973137 /* tradeManager.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = tradeManager.cpp; sourceTree = "<group>"; };
8889
94674B892D533BDA00973137 /* tradeManager.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = tradeManager.mm; sourceTree = "<group>"; };
8990
94674B8B2D533E7800973137 /* trade.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = trade.cpp; sourceTree = "<group>"; };
@@ -1362,6 +1363,7 @@
13621363
isa = PBXGroup;
13631364
children = (
13641365
94674B832D533B2F00973137 /* tradeManager.hpp */,
1366+
94674BA02D533B2F00973137 /* exitRules.hpp */,
13651367
);
13661368
path = trading;
13671369
sourceTree = "<group>";

include/models/trade.hpp

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ enum class Direction {
1919
struct Trade {
2020
std::string id;
2121
boost::decimal::decimal64_t entryPrice;
22+
boost::decimal::decimal64_t entryBid;
23+
boost::decimal::decimal64_t entryAsk;
2224
boost::decimal::decimal64_t size;
2325
std::chrono::system_clock::time_point openTime;
2426
Direction direction;
@@ -28,6 +30,7 @@ struct Trade {
2830
int scalingFactor;
2931
boost::decimal::decimal64_t stopDistancePips;
3032
boost::decimal::decimal64_t limitDistancePips;
33+
boost::decimal::decimal64_t exitReferencePrice;
3134
std::string strategyId;
3235
std::string strategyName;
3336

@@ -38,8 +41,9 @@ struct Trade {
3841
boost::decimal::decimal64_t pnl;
3942

4043
// Default constructor
41-
Trade() : entryPrice(0), size(0), direction(Direction::LONG),
44+
Trade() : entryPrice(0), entryBid(0), entryAsk(0), size(0), direction(Direction::LONG),
4245
scalingFactor(0), stopDistancePips(0), limitDistancePips(0),
46+
exitReferencePrice(0),
4347
closePrice(0), pnl(0),
4448
openTime(std::chrono::system_clock::now()) {}
4549

@@ -51,13 +55,16 @@ struct Trade {
5155
// regardless of where these appear in the list.
5256
Trade(boost::decimal::decimal64_t price, boost::decimal::decimal64_t quantity, Direction dir, std::string_view tradeSymbol)
5357
: entryPrice(price),
58+
entryBid(0),
59+
entryAsk(0),
5460
size(quantity),
5561
openTime(std::chrono::system_clock::now()),
5662
direction(dir),
5763
symbol(tradeSymbol),
5864
scalingFactor(symbol_scale::get(tradeSymbol)),
5965
stopDistancePips(0),
6066
limitDistancePips(0),
67+
exitReferencePrice(0),
6168
pnl(0) {
6269
}
6370

include/trading/exitRules.hpp

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Backtesting Engine in C++
2+
//
3+
// (c) 2026 Ryan McCaffery | https://mccaffers.com
4+
// This code is licensed under MIT license (see LICENSE.txt for details)
5+
// ---------------------------------------
6+
7+
#pragma once
8+
#include <optional>
9+
#include <boost/decimal.hpp>
10+
#include "models/trade.hpp"
11+
#include "models/priceData.hpp"
12+
13+
namespace trading::exit_rules {
14+
15+
// Decide whether the current tick has hit a trade's stop-loss or
16+
// take-profit boundary. Returns the price at which the position would
17+
// close (bid for LONG exits, ask for SHORT exits); `std::nullopt`
18+
// means "no exit on this tick".
19+
//
20+
// SL/TP distances are anchored on the close-side of the entry spread
21+
// (`trade.exitReferencePrice`: the entry-tick bid for LONG, entry-tick
22+
// ask for SHORT) — the price the trade would actually exit at — not
23+
// from the execution price. So a 1-pip stop on a LONG means "exit when
24+
// bid drops 1 pip below the entry bid", which prevents the spread
25+
// itself from triggering an exit on the opening tick.
26+
//
27+
// Pip → price conversion uses the symbol's scaling factor: a 1.5-pip
28+
// distance on EURUSD (scale 10000) is 0.00015; on USDJPY (scale 100)
29+
// it's 0.015. Trades on unknown symbols (scale 0) are skipped — there
30+
// is no sensible pip distance to apply.
31+
inline std::optional<boost::decimal::decimal64_t>
32+
checkExit(const Trade& trade, const PriceData& tick) {
33+
if (trade.scalingFactor == 0) return std::nullopt;
34+
if (trade.stopDistancePips == 0 &&
35+
trade.limitDistancePips == 0) {
36+
return std::nullopt;
37+
}
38+
39+
const auto stopOffset = trade.stopDistancePips / trade.scalingFactor;
40+
const auto limitOffset = trade.limitDistancePips / trade.scalingFactor;
41+
42+
if (trade.direction == Direction::LONG) {
43+
const auto stopPrice = trade.exitReferencePrice - stopOffset;
44+
const auto limitPrice = trade.exitReferencePrice + limitOffset;
45+
// Exit a long at the bid (the price the broker pays us).
46+
if (trade.stopDistancePips != 0 && tick.bid <= stopPrice) return tick.bid;
47+
if (trade.limitDistancePips != 0 && tick.bid >= limitPrice) return tick.bid;
48+
} else {
49+
const auto stopPrice = trade.exitReferencePrice + stopOffset;
50+
const auto limitPrice = trade.exitReferencePrice - limitOffset;
51+
// Exit a short at the ask (the price we pay to buy back).
52+
if (trade.stopDistancePips != 0 && tick.ask >= stopPrice) return tick.ask;
53+
if (trade.limitDistancePips != 0 && tick.ask <= limitPrice) return tick.ask;
54+
}
55+
return std::nullopt;
56+
}
57+
58+
} // namespace trading::exit_rules

scripts/arguments/build.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ json='{
55
"STRATEGY": {
66
"UUID": "",
77
"TRADING_VARIABLES": {
8-
"STRATEGY": "OHLC_RSI",
8+
"STRATEGY": "RandomStrategy",
99
"STOP_DISTANCE_IN_PIPS": 1,
1010
"LIMIT_DISTANCE_IN_PIPS": 1,
1111
"TRADING_SIZE": 1

scripts/run.sh

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,14 @@ fi
2323
json='{
2424
"RUN_ID": "UNIQUE_IDENTIFIER",
2525
"SYMBOLS": "EURUSD",
26-
"LAST_MONTHS": 1,
26+
"LAST_MONTHS": 2,
2727
"STRATEGY": {
2828
"UUID": "",
2929
"TRADING_VARIABLES": {
30-
"STRATEGY": "OHLC_RSI",
30+
"STRATEGY": "RandomStrategy",
3131
"STOP_DISTANCE_IN_PIPS": "1.5",
3232
"LIMIT_DISTANCE_IN_PIPS": "1.5",
33-
"TRADING_SIZE": "0.01"
33+
"TRADING_SIZE": "1"
3434
},
3535
"OHLC_VARIABLES": [
3636
{

source/operations.cpp

Lines changed: 64 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -10,54 +10,21 @@
1010
#include <vector>
1111
#include <memory>
1212
#include <optional>
13+
#include <stdexcept>
1314
#include <string>
1415
#include <utility>
1516
#include <iomanip>
1617
#include <cstdio>
1718
#include <ctime>
1819
#include <boost/decimal.hpp>
1920
#include "tradeManager.hpp"
21+
#include "exitRules.hpp"
2022
#include "models/symbolScale.hpp"
23+
#include "strategies/strategy.hpp"
2124
#include "strategies/randomStrategy.hpp"
2225

2326
namespace {
2427

25-
// Decide whether the current tick has hit a trade's stop-loss or
26-
// take-profit boundary. Returns the price at which the position would
27-
// close (bid for LONG exits, ask for SHORT exits) along with a flag
28-
// — `std::nullopt` means "no exit on this tick".
29-
//
30-
// Pip → price conversion uses the symbol's scaling factor: a 1.5-pip
31-
// distance on EURUSD (scale 10000) is 0.00015; on USDJPY (scale 100)
32-
// it's 0.015. Trades on unknown symbols (scale 0) are skipped — there
33-
// is no sensible pip distance to apply.
34-
std::optional<boost::decimal::decimal64_t>
35-
checkExit(const Trade& trade, const PriceData& tick) {
36-
if (trade.scalingFactor == 0) return std::nullopt;
37-
if (trade.stopDistancePips == 0 &&
38-
trade.limitDistancePips == 0) {
39-
return std::nullopt;
40-
}
41-
42-
const auto stopOffset = trade.stopDistancePips / trade.scalingFactor;
43-
const auto limitOffset = trade.limitDistancePips / trade.scalingFactor;
44-
45-
if (trade.direction == Direction::LONG) {
46-
const auto stopPrice = trade.entryPrice - stopOffset;
47-
const auto limitPrice = trade.entryPrice + limitOffset;
48-
// Exit a long at the bid (the price the broker pays us).
49-
if (trade.stopDistancePips != 0 && tick.bid <= stopPrice) return tick.bid;
50-
if (trade.limitDistancePips != 0 && tick.bid >= limitPrice) return tick.bid;
51-
} else {
52-
const auto stopPrice = trade.entryPrice + stopOffset;
53-
const auto limitPrice = trade.entryPrice - limitOffset;
54-
// Exit a short at the ask (the price we pay to buy back).
55-
if (trade.stopDistancePips != 0 && tick.ask >= stopPrice) return tick.ask;
56-
if (trade.limitDistancePips != 0 && tick.ask <= limitPrice) return tick.ask;
57-
}
58-
return std::nullopt;
59-
}
60-
6128
// Walk every active trade, close any whose SL/TP has been hit on this
6229
// tick. Two-phase to avoid invalidating the map iterator while erasing.
6330
void reviewStopAndLimit(TradeManager& tradeManager, const PriceData& tick) {
@@ -67,7 +34,7 @@ void reviewStopAndLimit(TradeManager& tradeManager, const PriceData& tick) {
6734
std::vector<std::pair<std::string, boost::decimal::decimal64_t>> toClose;
6835
toClose.reserve(openTrades.size());
6936
for (const auto& [id, trade] : openTrades) {
70-
if (auto exitPrice = checkExit(trade, tick)) {
37+
if (auto exitPrice = trading::exit_rules::checkExit(trade, tick)) {
7138
toClose.emplace_back(id, *exitPrice);
7239
}
7340
}
@@ -76,13 +43,24 @@ void reviewStopAndLimit(TradeManager& tradeManager, const PriceData& tick) {
7643
}
7744
}
7845

46+
// Adding a new strategy means adding one branch here; nothing else in
47+
// Operations needs to know about the concrete type.
48+
std::unique_ptr<IStrategy>
49+
selectStrategy(const trading_definitions::Configuration& config) {
50+
const auto& name = config.STRATEGY.TRADING_VARIABLES.STRATEGY;
51+
if (name == "RandomStrategy") {
52+
return std::make_unique<RandomStrategy>(config.STRATEGY);
53+
}
54+
throw std::runtime_error("Unknown strategy: '" + name + "'");
55+
}
56+
7957
} // namespace
8058

8159
void Operations::run(const std::vector<PriceData>& ticks,
8260
const trading_definitions::Configuration& config) {
8361

8462
auto tradeManager = std::make_unique<TradeManager>();
85-
RandomStrategy strategy(config.STRATEGY);
63+
auto strategy = selectStrategy(config);
8664

8765
const auto& tradingVars = config.STRATEGY.TRADING_VARIABLES;
8866

@@ -99,7 +77,7 @@ void Operations::run(const std::vector<PriceData>& ticks,
9977
// only open a trade if there is zero
10078
if (openTrades == 0) {
10179
// optional is false
102-
if (auto signal = strategy.decide(tick)) {
80+
if (auto signal = strategy->decide(tick)) {
10381
tradeManager->openTrade(tick,
10482
tradingVars.TRADING_SIZE,
10583
*signal,
@@ -112,10 +90,56 @@ void Operations::run(const std::vector<PriceData>& ticks,
11290
// (e.g. trailing stops, partial closes). The default
11391
// RandomStrategy implementation is a no-op now that exits are
11492
// handled by reviewStopAndLimit above.
115-
strategy.during(tickIndex, tick, *tradeManager);
93+
strategy->during(tickIndex, tick, *tradeManager);
11694

11795
++tickIndex;
11896
}
11997

12098
std::cout << "Final PnL: " << std::fixed << std::setprecision(2) << tradeManager->calculatePnl() << std::endl;
99+
100+
const auto& activeTrades = tradeManager->getActiveTrades();
101+
const auto& closedTrades = tradeManager->getClosedTrades();
102+
103+
const std::size_t openedCount = activeTrades.size() + closedTrades.size();
104+
const std::size_t closedCount = closedTrades.size();
105+
106+
std::size_t openedLong = 0;
107+
std::size_t openedShort = 0;
108+
for (const auto& [id, trade] : activeTrades) {
109+
if (trade.direction == Direction::LONG) ++openedLong;
110+
else ++openedShort;
111+
}
112+
113+
std::size_t closedLong = 0;
114+
std::size_t closedShort = 0;
115+
std::size_t winners = 0;
116+
std::size_t losers = 0;
117+
std::size_t breakeven = 0;
118+
boost::decimal::decimal64_t pnlSum{0};
119+
const boost::decimal::decimal64_t zero{0};
120+
for (const auto& trade : closedTrades) {
121+
if (trade.direction == Direction::LONG) ++closedLong;
122+
else ++closedShort;
123+
if (trade.pnl > zero) ++winners;
124+
else if (trade.pnl < zero) ++losers;
125+
else ++breakeven;
126+
pnlSum += trade.pnl;
127+
}
128+
openedLong += closedLong;
129+
openedShort += closedShort;
130+
131+
std::cout << "Trades opened: " << openedCount
132+
<< " (LONG: " << openedLong << ", SHORT: " << openedShort << ")" << std::endl;
133+
std::cout << "Trades closed: " << closedCount
134+
<< " (LONG: " << closedLong << ", SHORT: " << closedShort << ")" << std::endl;
135+
std::cout << "Winners: " << winners
136+
<< " Losers: " << losers
137+
<< " Breakeven: " << breakeven << std::endl;
138+
if (closedCount == 0) {
139+
std::cout << "Average PnL per closed trade: n/a (0 closed)" << std::endl;
140+
} else {
141+
const auto avgPnl = pnlSum / boost::decimal::decimal64_t{static_cast<long long>(closedCount)};
142+
std::cout << "Average PnL per closed trade: "
143+
<< std::fixed << std::setprecision(2) << avgPnl << std::endl;
144+
}
121145
}

source/trading/tradeManager.cpp

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@ std::string TradeManager::openTrade(const PriceData& tick,
2121
boost::decimal::decimal64_t limitDistancePips) {
2222
auto price = (direction == Direction::LONG) ? tick.ask : tick.bid;
2323
Trade trade(price, size, direction, tick.symbol);
24+
trade.entryBid = tick.bid;
25+
trade.entryAsk = tick.ask;
2426
trade.id = nextTradeId();
2527
trade.stopDistancePips = stopDistancePips;
2628
trade.limitDistancePips = limitDistancePips;
29+
trade.exitReferencePrice = (direction == Direction::LONG) ? tick.bid : tick.ask;
2730
activeTrades[trade.id] = trade;
2831
return trade.id;
2932
}

tests/jsonParser.mm

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ - (void)testValidJsonParsing {
3030
"STRATEGY": {
3131
"UUID": "",
3232
"TRADING_VARIABLES": {
33-
"STRATEGY": "OHLC_RSI",
33+
"STRATEGY": "RandomStrategy",
3434
"STOP_DISTANCE_IN_PIPS": "1",
3535
"LIMIT_DISTANCE_IN_PIPS": "1",
3636
"TRADING_SIZE": "1"

0 commit comments

Comments
 (0)