Skip to content

Commit 9d5068f

Browse files
committed
Added trading stats at the end of the run
1 parent 7915de1 commit 9d5068f

5 files changed

Lines changed: 251 additions & 38 deletions

File tree

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

source/operations.cpp

Lines changed: 48 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -18,48 +18,13 @@
1818
#include <ctime>
1919
#include <boost/decimal.hpp>
2020
#include "tradeManager.hpp"
21+
#include "exitRules.hpp"
2122
#include "models/symbolScale.hpp"
2223
#include "strategies/strategy.hpp"
2324
#include "strategies/randomStrategy.hpp"
2425

2526
namespace {
2627

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

13398
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+
}
134145
}

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/tradeManager.mm

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
#import <XCTest/XCTest.h>
88
#import <boost/decimal/literals.hpp>
99
#import "tradeManager.hpp"
10+
#import "exitRules.hpp"
1011

1112
// Pulls in the _dd user-defined literal so "1.23"_dd produces a decimal64_t
1213
// directly. decimal64_t has no implicit conversion from double — the closest
@@ -66,4 +67,137 @@ - (void)testTradeDetails {
6667
XCTAssertTrue(trade->second.direction == Direction::LONG, "Trade should be long");
6768
}
6869

70+
// Regression: with a 1-pip EURUSD spread and a 1-pip stop, a LONG must not
71+
// be stopped out on its own opening tick. The stop is measured from the
72+
// adverse side (entry bid), not the execution price (entry ask) — otherwise
73+
// the spread alone trips the stop.
74+
- (void)testLongDoesNotExitOnEntryTickWithOnePipSpread {
75+
PriceData entryTick("1.1001"_dd, "1.1000"_dd, std::chrono::system_clock::now(), "EURUSD");
76+
std::string tradeId = self.manager->openTrade(entryTick, "1.0"_dd, Direction::LONG,
77+
"1"_dd, "1"_dd);
78+
auto trades = self.manager->getActiveTrades();
79+
auto trade = trades.find(tradeId);
80+
XCTAssertNotEqual(trade, trades.end(), "Trade should exist");
81+
XCTAssertEqual(trade->second.entryPrice, "1.1001"_dd, "LONG entry price should be ask");
82+
XCTAssertEqual(trade->second.exitReferencePrice, "1.1000"_dd,
83+
"LONG exit reference should be entry bid");
84+
85+
auto exit = trading::exit_rules::checkExit(trade->second, entryTick);
86+
XCTAssertFalse(exit.has_value(),
87+
"LONG must not exit on its own entry tick when spread == stop distance");
88+
}
89+
90+
// Symmetric SHORT case: must not be stopped out on the opening tick.
91+
- (void)testShortDoesNotExitOnEntryTickWithOnePipSpread {
92+
PriceData entryTick("1.1001"_dd, "1.1000"_dd, std::chrono::system_clock::now(), "EURUSD");
93+
std::string tradeId = self.manager->openTrade(entryTick, "1.0"_dd, Direction::SHORT,
94+
"1"_dd, "1"_dd);
95+
auto trades = self.manager->getActiveTrades();
96+
auto trade = trades.find(tradeId);
97+
XCTAssertNotEqual(trade, trades.end(), "Trade should exist");
98+
XCTAssertEqual(trade->second.entryPrice, "1.1000"_dd, "SHORT entry price should be bid");
99+
XCTAssertEqual(trade->second.exitReferencePrice, "1.1001"_dd,
100+
"SHORT exit reference should be entry ask");
101+
102+
auto exit = trading::exit_rules::checkExit(trade->second, entryTick);
103+
XCTAssertFalse(exit.has_value(),
104+
"SHORT must not exit on its own entry tick when spread == stop distance");
105+
}
106+
107+
// Sanity check: once price moves enough, the stop still fires — the fix
108+
// shifts the threshold by one pip, it does not disable exits.
109+
- (void)testLongStopsOutWhenBidDropsBelowEntryBidByOnePip {
110+
PriceData entryTick("1.1001"_dd, "1.1000"_dd, std::chrono::system_clock::now(), "EURUSD");
111+
std::string tradeId = self.manager->openTrade(entryTick, "1.0"_dd, Direction::LONG,
112+
"1"_dd, "1"_dd);
113+
auto trades = self.manager->getActiveTrades();
114+
auto trade = trades.find(tradeId);
115+
116+
PriceData laterTick("1.1000"_dd, "1.0999"_dd, std::chrono::system_clock::now(), "EURUSD");
117+
auto exit = trading::exit_rules::checkExit(trade->second, laterTick);
118+
XCTAssertTrue(exit.has_value(), "LONG should stop when bid falls 1 pip below entry bid");
119+
XCTAssertEqual(*exit, "1.0999"_dd, "Stop closes at the current bid");
120+
}
121+
122+
// LONG × SL/TP × flat tick: passing the entry tick itself back through
123+
// checkExit must not fire either side. This pins the spread-vs-stop
124+
// invariant: SL is anchored on the entry bid (exitReferencePrice), not
125+
// the execution ask, so a 1-pip spread with a 1-pip stop sits exactly
126+
// at the threshold without crossing it.
127+
- (void)testCheckExit_LongFlatTick_NoFire {
128+
PriceData entryTick("1.10011"_dd, "1.10001"_dd, std::chrono::system_clock::now(), "EURUSD");
129+
std::string tradeId = self.manager->openTrade(entryTick, "1.0"_dd, Direction::LONG,
130+
"1"_dd, "1"_dd);
131+
auto trades = self.manager->getActiveTrades();
132+
auto trade = trades.find(tradeId);
133+
XCTAssertNotEqual(trade, trades.end(), "Trade should exist");
134+
135+
auto exit = trading::exit_rules::checkExit(trade->second, entryTick);
136+
XCTAssertFalse(exit.has_value(),
137+
"LONG with 1-pip SL and TP must not fire on its own entry tick");
138+
}
139+
140+
- (void)testCheckExit_LongSL_FiresAtBid {
141+
PriceData entryTick("1.10011"_dd, "1.10001"_dd, std::chrono::system_clock::now(), "EURUSD");
142+
std::string tradeId = self.manager->openTrade(entryTick, "1.0"_dd, Direction::LONG,
143+
"1"_dd, "0"_dd);
144+
auto trades = self.manager->getActiveTrades();
145+
auto trade = trades.find(tradeId);
146+
XCTAssertNotEqual(trade, trades.end(), "Trade should exist");
147+
148+
// 1 pip on EURUSD == 0.0001 (the 4th decimal), so entry-bid 1.10001 minus
149+
// 1 pip is 1.09991; the 5th decimal is a fractional pip and not enough
150+
// to trip a 1-pip stop on its own.
151+
PriceData nextTick("1.10001"_dd, "1.09991"_dd, std::chrono::system_clock::now(), "EURUSD");
152+
auto exit = trading::exit_rules::checkExit(trade->second, nextTick);
153+
XCTAssertTrue(exit.has_value(), "LONG SL should fire when bid hits entry-bid - 1 pip");
154+
XCTAssertEqual(*exit, nextTick.bid, "LONG SL closes at the current bid");
155+
XCTAssertEqual(*exit, "1.09991"_dd, "LONG SL close price should equal next-tick bid");
156+
}
157+
158+
- (void)testCheckExit_ShortSL_FiresAtAsk {
159+
PriceData entryTick("1.10011"_dd, "1.10001"_dd, std::chrono::system_clock::now(), "EURUSD");
160+
std::string tradeId = self.manager->openTrade(entryTick, "1.0"_dd, Direction::SHORT,
161+
"1"_dd, "0"_dd);
162+
auto trades = self.manager->getActiveTrades();
163+
auto trade = trades.find(tradeId);
164+
XCTAssertNotEqual(trade, trades.end(), "Trade should exist");
165+
166+
PriceData nextTick("1.10021"_dd, "1.10011"_dd, std::chrono::system_clock::now(), "EURUSD");
167+
auto exit = trading::exit_rules::checkExit(trade->second, nextTick);
168+
XCTAssertTrue(exit.has_value(), "SHORT SL should fire when ask hits entry-ask + 1 pip");
169+
XCTAssertEqual(*exit, nextTick.ask, "SHORT SL closes at the current ask");
170+
XCTAssertEqual(*exit, "1.10021"_dd, "SHORT SL close price should equal next-tick ask");
171+
}
172+
173+
- (void)testCheckExit_LongTP_FiresAtBid {
174+
PriceData entryTick("1.10011"_dd, "1.10001"_dd, std::chrono::system_clock::now(), "EURUSD");
175+
std::string tradeId = self.manager->openTrade(entryTick, "1.0"_dd, Direction::LONG,
176+
"0"_dd, "1"_dd);
177+
auto trades = self.manager->getActiveTrades();
178+
auto trade = trades.find(tradeId);
179+
XCTAssertNotEqual(trade, trades.end(), "Trade should exist");
180+
181+
PriceData nextTick("1.10021"_dd, "1.10011"_dd, std::chrono::system_clock::now(), "EURUSD");
182+
auto exit = trading::exit_rules::checkExit(trade->second, nextTick);
183+
XCTAssertTrue(exit.has_value(), "LONG TP should fire when bid hits entry-bid + 1 pip");
184+
XCTAssertEqual(*exit, nextTick.bid, "LONG TP closes at the current bid");
185+
XCTAssertEqual(*exit, "1.10011"_dd, "LONG TP close price should equal next-tick bid");
186+
}
187+
188+
- (void)testCheckExit_ShortTP_FiresAtAsk {
189+
PriceData entryTick("1.10011"_dd, "1.10001"_dd, std::chrono::system_clock::now(), "EURUSD");
190+
std::string tradeId = self.manager->openTrade(entryTick, "1.0"_dd, Direction::SHORT,
191+
"0"_dd, "1"_dd);
192+
auto trades = self.manager->getActiveTrades();
193+
auto trade = trades.find(tradeId);
194+
XCTAssertNotEqual(trade, trades.end(), "Trade should exist");
195+
196+
PriceData nextTick("1.10001"_dd, "1.09991"_dd, std::chrono::system_clock::now(), "EURUSD");
197+
auto exit = trading::exit_rules::checkExit(trade->second, nextTick);
198+
XCTAssertTrue(exit.has_value(), "SHORT TP should fire when ask hits entry-ask - 1 pip");
199+
XCTAssertEqual(*exit, nextTick.ask, "SHORT TP closes at the current ask");
200+
XCTAssertEqual(*exit, "1.10001"_dd, "SHORT TP close price should equal next-tick ask");
201+
}
202+
69203
@end

0 commit comments

Comments
 (0)