Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backtesting-engine-cpp.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
9464E5F02FA7467200D82BAD /* symbolScale.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = symbolScale.mm; sourceTree = "<group>"; };
94674B822D533B1D00973137 /* trade.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = trade.hpp; sourceTree = "<group>"; };
94674B832D533B2F00973137 /* tradeManager.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = tradeManager.hpp; sourceTree = "<group>"; };
94674BA02D533B2F00973137 /* exitRules.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = exitRules.hpp; sourceTree = "<group>"; };
94674B852D533B4000973137 /* tradeManager.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = tradeManager.cpp; sourceTree = "<group>"; };
94674B892D533BDA00973137 /* tradeManager.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = tradeManager.mm; sourceTree = "<group>"; };
94674B8B2D533E7800973137 /* trade.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = trade.cpp; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1362,6 +1363,7 @@
isa = PBXGroup;
children = (
94674B832D533B2F00973137 /* tradeManager.hpp */,
94674BA02D533B2F00973137 /* exitRules.hpp */,
);
path = trading;
sourceTree = "<group>";
Expand Down
9 changes: 8 additions & 1 deletion include/models/trade.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ enum class Direction {
struct Trade {
std::string id;
boost::decimal::decimal64_t entryPrice;
boost::decimal::decimal64_t entryBid;
boost::decimal::decimal64_t entryAsk;
boost::decimal::decimal64_t size;
std::chrono::system_clock::time_point openTime;
Direction direction;
Expand All @@ -28,6 +30,7 @@ struct Trade {
int scalingFactor;
boost::decimal::decimal64_t stopDistancePips;
boost::decimal::decimal64_t limitDistancePips;
boost::decimal::decimal64_t exitReferencePrice;
std::string strategyId;
std::string strategyName;

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

// Default constructor
Trade() : entryPrice(0), size(0), direction(Direction::LONG),
Trade() : entryPrice(0), entryBid(0), entryAsk(0), size(0), direction(Direction::LONG),
scalingFactor(0), stopDistancePips(0), limitDistancePips(0),
exitReferencePrice(0),
closePrice(0), pnl(0),
openTime(std::chrono::system_clock::now()) {}

Expand All @@ -51,13 +55,16 @@ struct Trade {
// regardless of where these appear in the list.
Trade(boost::decimal::decimal64_t price, boost::decimal::decimal64_t quantity, Direction dir, std::string_view tradeSymbol)
: entryPrice(price),
entryBid(0),
entryAsk(0),
size(quantity),
openTime(std::chrono::system_clock::now()),
direction(dir),
symbol(tradeSymbol),
scalingFactor(symbol_scale::get(tradeSymbol)),
stopDistancePips(0),
limitDistancePips(0),
exitReferencePrice(0),
pnl(0) {
}

Expand Down
58 changes: 58 additions & 0 deletions include/trading/exitRules.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Backtesting Engine in C++
//
// (c) 2026 Ryan McCaffery | https://mccaffers.com
// This code is licensed under MIT license (see LICENSE.txt for details)
// ---------------------------------------

#pragma once
#include <optional>
#include <boost/decimal.hpp>
#include "models/trade.hpp"
#include "models/priceData.hpp"

namespace trading::exit_rules {

// Decide whether the current tick has hit a trade's stop-loss or
// take-profit boundary. Returns the price at which the position would
// close (bid for LONG exits, ask for SHORT exits); `std::nullopt`
// means "no exit on this tick".
//
// SL/TP distances are anchored on the close-side of the entry spread
// (`trade.exitReferencePrice`: the entry-tick bid for LONG, entry-tick
// ask for SHORT) — the price the trade would actually exit at — not
// from the execution price. So a 1-pip stop on a LONG means "exit when
// bid drops 1 pip below the entry bid", which prevents the spread
// itself from triggering an exit on the opening tick.
//
// Pip → price conversion uses the symbol's scaling factor: a 1.5-pip
// distance on EURUSD (scale 10000) is 0.00015; on USDJPY (scale 100)
// it's 0.015. Trades on unknown symbols (scale 0) are skipped — there
// is no sensible pip distance to apply.
inline std::optional<boost::decimal::decimal64_t>
checkExit(const Trade& trade, const PriceData& tick) {
if (trade.scalingFactor == 0) return std::nullopt;
if (trade.stopDistancePips == 0 &&
trade.limitDistancePips == 0) {
return std::nullopt;
}

const auto stopOffset = trade.stopDistancePips / trade.scalingFactor;
const auto limitOffset = trade.limitDistancePips / trade.scalingFactor;

if (trade.direction == Direction::LONG) {
const auto stopPrice = trade.exitReferencePrice - stopOffset;
const auto limitPrice = trade.exitReferencePrice + limitOffset;
// Exit a long at the bid (the price the broker pays us).
if (trade.stopDistancePips != 0 && tick.bid <= stopPrice) return tick.bid;
if (trade.limitDistancePips != 0 && tick.bid >= limitPrice) return tick.bid;
} else {
const auto stopPrice = trade.exitReferencePrice + stopOffset;
const auto limitPrice = trade.exitReferencePrice - limitOffset;
// Exit a short at the ask (the price we pay to buy back).
if (trade.stopDistancePips != 0 && tick.ask >= stopPrice) return tick.ask;
if (trade.limitDistancePips != 0 && tick.ask <= limitPrice) return tick.ask;
}
return std::nullopt;
}

} // namespace trading::exit_rules
2 changes: 1 addition & 1 deletion scripts/arguments/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ json='{
"STRATEGY": {
"UUID": "",
"TRADING_VARIABLES": {
"STRATEGY": "OHLC_RSI",
"STRATEGY": "RandomStrategy",
"STOP_DISTANCE_IN_PIPS": 1,
"LIMIT_DISTANCE_IN_PIPS": 1,
"TRADING_SIZE": 1
Expand Down
6 changes: 3 additions & 3 deletions scripts/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,14 @@ fi
json='{
"RUN_ID": "UNIQUE_IDENTIFIER",
"SYMBOLS": "EURUSD",
"LAST_MONTHS": 1,
"LAST_MONTHS": 2,
"STRATEGY": {
"UUID": "",
"TRADING_VARIABLES": {
"STRATEGY": "OHLC_RSI",
"STRATEGY": "RandomStrategy",
"STOP_DISTANCE_IN_PIPS": "1.5",
"LIMIT_DISTANCE_IN_PIPS": "1.5",
"TRADING_SIZE": "0.01"
"TRADING_SIZE": "1"
},
"OHLC_VARIABLES": [
{
Expand Down
104 changes: 64 additions & 40 deletions source/operations.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,54 +10,21 @@
#include <vector>
#include <memory>
#include <optional>
#include <stdexcept>
#include <string>
#include <utility>
#include <iomanip>
#include <cstdio>
#include <ctime>
#include <boost/decimal.hpp>
#include "tradeManager.hpp"
#include "exitRules.hpp"
#include "models/symbolScale.hpp"
#include "strategies/strategy.hpp"
#include "strategies/randomStrategy.hpp"

namespace {

// Decide whether the current tick has hit a trade's stop-loss or
// take-profit boundary. Returns the price at which the position would
// close (bid for LONG exits, ask for SHORT exits) along with a flag
// — `std::nullopt` means "no exit on this tick".
//
// Pip → price conversion uses the symbol's scaling factor: a 1.5-pip
// distance on EURUSD (scale 10000) is 0.00015; on USDJPY (scale 100)
// it's 0.015. Trades on unknown symbols (scale 0) are skipped — there
// is no sensible pip distance to apply.
std::optional<boost::decimal::decimal64_t>
checkExit(const Trade& trade, const PriceData& tick) {
if (trade.scalingFactor == 0) return std::nullopt;
if (trade.stopDistancePips == 0 &&
trade.limitDistancePips == 0) {
return std::nullopt;
}

const auto stopOffset = trade.stopDistancePips / trade.scalingFactor;
const auto limitOffset = trade.limitDistancePips / trade.scalingFactor;

if (trade.direction == Direction::LONG) {
const auto stopPrice = trade.entryPrice - stopOffset;
const auto limitPrice = trade.entryPrice + limitOffset;
// Exit a long at the bid (the price the broker pays us).
if (trade.stopDistancePips != 0 && tick.bid <= stopPrice) return tick.bid;
if (trade.limitDistancePips != 0 && tick.bid >= limitPrice) return tick.bid;
} else {
const auto stopPrice = trade.entryPrice + stopOffset;
const auto limitPrice = trade.entryPrice - limitOffset;
// Exit a short at the ask (the price we pay to buy back).
if (trade.stopDistancePips != 0 && tick.ask >= stopPrice) return tick.ask;
if (trade.limitDistancePips != 0 && tick.ask <= limitPrice) return tick.ask;
}
return std::nullopt;
}

// Walk every active trade, close any whose SL/TP has been hit on this
// tick. Two-phase to avoid invalidating the map iterator while erasing.
void reviewStopAndLimit(TradeManager& tradeManager, const PriceData& tick) {
Expand All @@ -67,7 +34,7 @@
std::vector<std::pair<std::string, boost::decimal::decimal64_t>> toClose;
toClose.reserve(openTrades.size());
for (const auto& [id, trade] : openTrades) {
if (auto exitPrice = checkExit(trade, tick)) {
if (auto exitPrice = trading::exit_rules::checkExit(trade, tick)) {
toClose.emplace_back(id, *exitPrice);
}
}
Expand All @@ -76,13 +43,24 @@
}
}

// Adding a new strategy means adding one branch here; nothing else in
// Operations needs to know about the concrete type.
std::unique_ptr<IStrategy>
selectStrategy(const trading_definitions::Configuration& config) {
const auto& name = config.STRATEGY.TRADING_VARIABLES.STRATEGY;
if (name == "RandomStrategy") {
return std::make_unique<RandomStrategy>(config.STRATEGY);
}
throw std::runtime_error("Unknown strategy: '" + name + "'");

Check warning on line 54 in source/operations.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define and throw a dedicated exception instead of using a generic one.

See more on https://sonarcloud.io/project/issues?id=mccaffers_backtesting-engine-cpp&issues=AZ4ogzfiORrDl11IOvp-&open=AZ4ogzfiORrDl11IOvp-&pullRequest=28
}

} // namespace

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

auto tradeManager = std::make_unique<TradeManager>();
RandomStrategy strategy(config.STRATEGY);
auto strategy = selectStrategy(config);

const auto& tradingVars = config.STRATEGY.TRADING_VARIABLES;

Expand All @@ -99,7 +77,7 @@
// only open a trade if there is zero
if (openTrades == 0) {
// optional is false
if (auto signal = strategy.decide(tick)) {
if (auto signal = strategy->decide(tick)) {
tradeManager->openTrade(tick,
tradingVars.TRADING_SIZE,
*signal,
Expand All @@ -112,10 +90,56 @@
// (e.g. trailing stops, partial closes). The default
// RandomStrategy implementation is a no-op now that exits are
// handled by reviewStopAndLimit above.
strategy.during(tickIndex, tick, *tradeManager);
strategy->during(tickIndex, tick, *tradeManager);

++tickIndex;
}

std::cout << "Final PnL: " << std::fixed << std::setprecision(2) << tradeManager->calculatePnl() << std::endl;

const auto& activeTrades = tradeManager->getActiveTrades();
const auto& closedTrades = tradeManager->getClosedTrades();

const std::size_t openedCount = activeTrades.size() + closedTrades.size();
const std::size_t closedCount = closedTrades.size();

std::size_t openedLong = 0;
std::size_t openedShort = 0;
for (const auto& [id, trade] : activeTrades) {
if (trade.direction == Direction::LONG) ++openedLong;
else ++openedShort;
}

std::size_t closedLong = 0;
std::size_t closedShort = 0;
std::size_t winners = 0;
std::size_t losers = 0;
std::size_t breakeven = 0;
boost::decimal::decimal64_t pnlSum{0};
const boost::decimal::decimal64_t zero{0};
for (const auto& trade : closedTrades) {
if (trade.direction == Direction::LONG) ++closedLong;
else ++closedShort;
if (trade.pnl > zero) ++winners;
else if (trade.pnl < zero) ++losers;
else ++breakeven;
pnlSum += trade.pnl;
}
openedLong += closedLong;
openedShort += closedShort;

std::cout << "Trades opened: " << openedCount
<< " (LONG: " << openedLong << ", SHORT: " << openedShort << ")" << std::endl;
std::cout << "Trades closed: " << closedCount
<< " (LONG: " << closedLong << ", SHORT: " << closedShort << ")" << std::endl;
std::cout << "Winners: " << winners
<< " Losers: " << losers
<< " Breakeven: " << breakeven << std::endl;
if (closedCount == 0) {
std::cout << "Average PnL per closed trade: n/a (0 closed)" << std::endl;
} else {
const auto avgPnl = pnlSum / boost::decimal::decimal64_t{static_cast<long long>(closedCount)};
std::cout << "Average PnL per closed trade: "
<< std::fixed << std::setprecision(2) << avgPnl << std::endl;
}
}
3 changes: 3 additions & 0 deletions source/trading/tradeManager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,12 @@ std::string TradeManager::openTrade(const PriceData& tick,
boost::decimal::decimal64_t limitDistancePips) {
auto price = (direction == Direction::LONG) ? tick.ask : tick.bid;
Trade trade(price, size, direction, tick.symbol);
trade.entryBid = tick.bid;
trade.entryAsk = tick.ask;
trade.id = nextTradeId();
trade.stopDistancePips = stopDistancePips;
trade.limitDistancePips = limitDistancePips;
trade.exitReferencePrice = (direction == Direction::LONG) ? tick.bid : tick.ask;
activeTrades[trade.id] = trade;
return trade.id;
}
Expand Down
2 changes: 1 addition & 1 deletion tests/jsonParser.mm
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ - (void)testValidJsonParsing {
"STRATEGY": {
"UUID": "",
"TRADING_VARIABLES": {
"STRATEGY": "OHLC_RSI",
"STRATEGY": "RandomStrategy",
"STOP_DISTANCE_IN_PIPS": "1",
"LIMIT_DISTANCE_IN_PIPS": "1",
"TRADING_SIZE": "1"
Expand Down
Loading
Loading