From 1d58735d1dd83bc74c37ad734c5dc217c0589857 Mon Sep 17 00:00:00 2001 From: Ryan <16667079+mccaffers@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:47:55 +0100 Subject: [PATCH] =?UTF-8?q?-=20SQL=20injection=20fix:=20symbols=20from=20R?= =?UTF-8?q?edis=20payloads=20are=20validated=20against=20the=20canonical?= =?UTF-8?q?=20symbol=5Fscale=20table=20before=20query=20interpolation;=20u?= =?UTF-8?q?nknown=20symbols=20are=20rejected=20with=20a=20clear=20error.?= =?UTF-8?q?=20=20=20-=20JSON=20parse=20errors=20no=20longer=20swallowed:?= =?UTF-8?q?=20parse=20failures=20propagate=20with=20byte-offset=20detail?= =?UTF-8?q?=20instead=20of=20continuing=20into=20a=20misleading=20downstre?= =?UTF-8?q?am=20exception;=20leftover=20debug=20prints=20removed.=20=20=20?= =?UTF-8?q?-=20Thread-safety:=20timestamp-parse=20date=20cache=20moved=20f?= =?UTF-8?q?rom=20function-local=20statics=20to=20caller-owned=20state=20(s?= =?UTF-8?q?afe=20if=20tick=20loading=20moves=20onto=20the=20thread=20pool)?= =?UTF-8?q?;=20unchecked=20from=5Fchars=20price=20parses=20now=20throw=20i?= =?UTF-8?q?nstead=20of=20producing=20garbage=20values=20(also=20covers=20N?= =?UTF-8?q?ULL=20DB=20columns).=20=20=20-=20Dead=20code=20removed:=20scaff?= =?UTF-8?q?olding=20files=20(serviceA,=20configManager,=20empty=20trade.cp?= =?UTF-8?q?p),=20unused=20base64/timestamp=20utilities=20(one=20with=20a?= =?UTF-8?q?=20ms/=C2=B5s=20conversion=20bug),=20printResults;=20Xcode=20pr?= =?UTF-8?q?oject=20references=20scrubbed.=20=20=20-=20Build=20fixes:=20mai?= =?UTF-8?q?n.cpp=20no=20longer=20compiled=20into=20both=20the=20static=20l?= =?UTF-8?q?ib=20and=20the=20executable;=20macOS-only=20xcrun=20guarded=20b?= =?UTF-8?q?y=20if(APPLE);=20libomp=20located=20via=20brew=20--prefix=20(fi?= =?UTF-8?q?xes=20Intel=20Macs);=20duplicate=20find=5Fpackage(OpenMP)=20rem?= =?UTF-8?q?oved;=20global=20include=5Fdirectories=20converted=20to=20targe?= =?UTF-8?q?t-scoped.=20=20=20-=20Minor:=20redundant=20PnL=20recomputation?= =?UTF-8?q?=20in=20Reporting::collect=20removed;=20unnecessary=20heap=20al?= =?UTF-8?q?location=20of=20TradeManager=20replaced=20with=20a=20stack=20lo?= =?UTF-8?q?cal.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 48 +++-- .../project.pbxproj | 22 --- include/configManager.hpp | 27 --- include/databaseConnection.hpp | 1 - include/elasticClient.hpp | 18 -- include/models/trade.hpp | 18 +- include/reporting/elasticClient.hpp | 8 +- include/serviceA.hpp | 23 --- include/trading/runLoop.hpp | 73 +++++++- include/trading/tradeManager.hpp | 20 +- include/tradingResults.hpp | 16 ++ include/trading_definitions/configuration.hpp | 43 ++++- .../trading_definitions/run_configuration.hpp | 52 +++++- include/utilities/base64.hpp | 13 +- source/commands/loadCommand.cpp | 17 +- source/configManager.cpp | 10 - source/databaseConnection.cpp | 53 +++--- source/elasticClient.cpp | 94 ---------- source/models/trade.cpp | 7 - source/operations.cpp | 85 ++++++--- source/redisImpl.cpp | 11 -- source/redisRunner.cpp | 12 +- source/reporting/elasticClient.cpp | 19 +- source/serviceA.cpp | 7 - source/sqlManager.cpp | 11 ++ source/trading/reporting.cpp | 5 +- source/trading/tradeManager.cpp | 56 +++++- source/tradingResults.cpp | 12 ++ source/utilities/base64.cpp | 44 ----- source/utilities/jsonParser.cpp | 48 +---- tests/tradeManager.mm | 173 ++++++++++++++++++ 31 files changed, 617 insertions(+), 429 deletions(-) delete mode 100644 include/configManager.hpp delete mode 100644 include/elasticClient.hpp delete mode 100644 include/serviceA.hpp delete mode 100644 source/configManager.cpp delete mode 100644 source/elasticClient.cpp delete mode 100644 source/models/trade.cpp delete mode 100644 source/redisImpl.cpp delete mode 100644 source/serviceA.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 538001c..d96c97a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,12 +1,13 @@ cmake_minimum_required(VERSION 3.30) # CMAKE_OSX_SYSROOT is a macOS-specific setting that specifies the SDK path. -# This is ignored on non-Apple platforms, so it's safe to include in cross-platform builds. -execute_process( - COMMAND xcrun --show-sdk-path - OUTPUT_VARIABLE CMAKE_OSX_SYSROOT - OUTPUT_STRIP_TRAILING_WHITESPACE -) +if(APPLE) + execute_process( + COMMAND xcrun --show-sdk-path + OUTPUT_VARIABLE CMAKE_OSX_SYSROOT + OUTPUT_STRIP_TRAILING_WHITESPACE + ) +endif() project(BacktestingEngine) @@ -34,8 +35,19 @@ set(CMAKE_MESSAGE_LOG_LEVEL "WARNING") # Build libpqxx from source add_subdirectory(external/libpqxx EXCLUDE_FROM_ALL) -# Include directories -include_directories( +# Collect all .cpp files in the src directory +file(GLOB_RECURSE SOURCES "source/*.cpp") + +# main.cpp belongs to the executable only — keeping it in the library too +# means it gets compiled twice and the lib carries a competing main(). +list(REMOVE_ITEM SOURCES ${CMAKE_SOURCE_DIR}/source/main.cpp) + +# Create a library of your project's code +add_library(BacktestingEngineLib STATIC ${SOURCES}) + +# Include directories — PUBLIC so they propagate to the executable (and any +# other consumer) through target_link_libraries. +target_include_directories(BacktestingEngineLib PUBLIC ${CMAKE_SOURCE_DIR}/include ${CMAKE_SOURCE_DIR}/include/commands ${CMAKE_SOURCE_DIR}/include/utilities @@ -47,22 +59,23 @@ include_directories( ${CMAKE_SOURCE_DIR}/external ) -# Collect all .cpp files in the src directory -file(GLOB_RECURSE SOURCES "source/*.cpp") - -# Create a library of your project's code -add_library(BacktestingEngineLib STATIC ${SOURCES}) - # Configure OpenMP. On Apple, provide Homebrew libomp hints before discovery. +# brew --prefix resolves the right location on both Apple Silicon +# (/opt/homebrew) and Intel (/usr/local). if(APPLE) + execute_process( + COMMAND brew --prefix libomp + OUTPUT_VARIABLE LIBOMP_PREFIX + OUTPUT_STRIP_TRAILING_WHITESPACE + ) set(OpenMP_C_FLAGS "-Xclang -fopenmp") set(OpenMP_CXX_FLAGS "-Xclang -fopenmp") set(OpenMP_C_LIB_NAMES "omp") set(OpenMP_CXX_LIB_NAMES "omp") - set(OpenMP_omp_LIBRARY /opt/homebrew/opt/libomp/lib/libomp.dylib) - find_package(OpenMP REQUIRED) - target_include_directories(BacktestingEngineLib PRIVATE /opt/homebrew/opt/libomp/include) + set(OpenMP_omp_LIBRARY ${LIBOMP_PREFIX}/lib/libomp.dylib) + target_include_directories(BacktestingEngineLib PRIVATE ${LIBOMP_PREFIX}/include) endif() +find_package(OpenMP REQUIRED) # Boost (Homebrew) — Boost.Redis is header-only but its implementation is # compiled into one dedicated TU via (source/ @@ -82,7 +95,6 @@ target_link_libraries(BacktestingEngineLib PUBLIC add_subdirectory(external/boost-decimal) target_link_libraries(BacktestingEngineLib PUBLIC Boost::decimal) -find_package(OpenMP REQUIRED) target_link_libraries(BacktestingEngineLib PUBLIC pqxx OpenMP::OpenMP_CXX) # Main executable diff --git a/backtesting-engine-cpp.xcodeproj/project.pbxproj b/backtesting-engine-cpp.xcodeproj/project.pbxproj index 6bf65de..4e835a5 100644 --- a/backtesting-engine-cpp.xcodeproj/project.pbxproj +++ b/backtesting-engine-cpp.xcodeproj/project.pbxproj @@ -7,10 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - 940A61132C92CE210083FEB8 /* configManager.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 940A61112C92CE210083FEB8 /* configManager.cpp */; }; - 940A61142C92CE210083FEB8 /* configManager.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 940A61112C92CE210083FEB8 /* configManager.cpp */; }; - 940A61172C92CE960083FEB8 /* serviceA.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 940A61152C92CE960083FEB8 /* serviceA.cpp */; }; - 940A61182C92CE960083FEB8 /* serviceA.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 940A61152C92CE960083FEB8 /* serviceA.cpp */; }; 941408AE2D59F93F000ED1F9 /* sqlManager.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 941408AD2D59F93F000ED1F9 /* sqlManager.cpp */; }; 941408AF2D59F93F000ED1F9 /* sqlManager.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 941408AD2D59F93F000ED1F9 /* sqlManager.cpp */; }; 941B549A2D3BBADE00E3BF64 /* trading_definitions_json.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 941B54992D3BBADD00E3BF64 /* trading_definitions_json.cpp */; }; @@ -41,8 +37,6 @@ 94674B872D533B4000973137 /* tradeManager.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 94674B852D533B4000973137 /* tradeManager.cpp */; }; 94674B882D533B4000973137 /* tradeManager.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 94674B852D533B4000973137 /* tradeManager.cpp */; }; 94674B8A2D533BDA00973137 /* tradeManager.mm in Sources */ = {isa = PBXBuildFile; fileRef = 94674B892D533BDA00973137 /* tradeManager.mm */; }; - 94674B8D2D533E7800973137 /* trade.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 94674B8B2D533E7800973137 /* trade.cpp */; }; - 94674B8E2D533E7800973137 /* trade.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 94674B8B2D533E7800973137 /* trade.cpp */; }; 946EFF7E2FB9F44E008D9647 /* reporting.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 946EFF7D2FB9F44E008D9647 /* reporting.cpp */; }; 946EFF7F2FB9F44E008D9647 /* reporting.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 946EFF7D2FB9F44E008D9647 /* reporting.cpp */; }; 9470B5A42C8C5AD0007D9CC6 /* main.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 9470B5A32C8C5AD0007D9CC6 /* main.cpp */; }; @@ -75,10 +69,6 @@ /* Begin PBXFileReference section */ 9409A61B2FAA6411002C30FF /* strategy.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = strategy.hpp; sourceTree = ""; }; - 940A61112C92CE210083FEB8 /* configManager.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = configManager.cpp; sourceTree = ""; }; - 940A61122C92CE210083FEB8 /* configManager.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = configManager.hpp; sourceTree = ""; }; - 940A61152C92CE960083FEB8 /* serviceA.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = serviceA.cpp; sourceTree = ""; }; - 940A61162C92CE960083FEB8 /* serviceA.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = serviceA.hpp; sourceTree = ""; }; 941408AD2D59F93F000ED1F9 /* sqlManager.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = sqlManager.cpp; sourceTree = ""; }; 941408B02D59F954000ED1F9 /* sqlManager.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = sqlManager.hpp; sourceTree = ""; }; 941B54902D3BBA4900E3BF64 /* ohlc_variables.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = ohlc_variables.hpp; sourceTree = ""; }; @@ -126,7 +116,6 @@ 94674B832D533B2F00973137 /* tradeManager.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = tradeManager.hpp; sourceTree = ""; }; 94674B852D533B4000973137 /* tradeManager.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = tradeManager.cpp; sourceTree = ""; }; 94674B892D533BDA00973137 /* tradeManager.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = tradeManager.mm; sourceTree = ""; }; - 94674B8B2D533E7800973137 /* trade.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = trade.cpp; sourceTree = ""; }; 94674BA02D533B2F00973137 /* exitRules.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = exitRules.hpp; sourceTree = ""; }; 94674BA12F8B92C10029B940 /* reviewStopAndLimit.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = reviewStopAndLimit.hpp; sourceTree = ""; }; 94685CCE2D384A8B00863D04 /* json.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = json.hpp; sourceTree = ""; }; @@ -1455,7 +1444,6 @@ 94674B8C2D533E7800973137 /* models */ = { isa = PBXGroup; children = ( - 94674B8B2D533E7800973137 /* trade.cpp */, ); path = models; sourceTree = ""; @@ -1484,8 +1472,6 @@ 942FDDDF2FC5C8B20096F318 /* tradingResults.cpp */, 942EC5662FBEF95000CCBB5D /* redisRunner.cpp */, 9470B5A32C8C5AD0007D9CC6 /* main.cpp */, - 940A61112C92CE210083FEB8 /* configManager.cpp */, - 940A61152C92CE960083FEB8 /* serviceA.cpp */, 94CD8B9F2D2E8CE500041BBA /* databaseConnection.cpp */, 941408AD2D59F93F000ED1F9 /* sqlManager.cpp */, 94724A822F8B92C10029B940 /* operations.cpp */, @@ -3641,10 +3627,8 @@ 942EC55E2FBEF93A00CCBB5D /* backtestRunner.hpp */, 942EC55F2FBEF93A00CCBB5D /* redisLoader.hpp */, 942EC5602FBEF93A00CCBB5D /* redisRunner.hpp */, - 940A61162C92CE960083FEB8 /* serviceA.hpp */, 943398222D57E52900287A2D /* jsonParser.hpp */, 942FDDDD2FC5C8A30096F318 /* tradingResults.hpp */, - 940A61122C92CE210083FEB8 /* configManager.hpp */, 941408B02D59F954000ED1F9 /* sqlManager.hpp */, 94724A852F8B92E30029B940 /* operations.hpp */, 94CD8B9E2D2E8CE500041BBA /* databaseConnection.hpp */, @@ -3757,16 +3741,13 @@ 94829FC52FCC1D1A00710E6E /* env.cpp in Sources */, 942EC56C2FBEF95000CCBB5D /* redisRunner.cpp in Sources */, 94280BA32D2FC00200F1CF56 /* base64.cpp in Sources */, - 94674B8E2D533E7800973137 /* trade.cpp in Sources */, 943770402FD42FC000317424 /* elasticClient.cpp in Sources */, 941B549B2D3BBADE00E3BF64 /* trading_definitions_json.cpp in Sources */, 946EFF7E2FB9F44E008D9647 /* reporting.cpp in Sources */, 94674B872D533B4000973137 /* tradeManager.cpp in Sources */, 94CD8BA02D2E8CE500041BBA /* databaseConnection.cpp in Sources */, 942EC5622FBEF94700CCBB5D /* redisConnection.cpp in Sources */, - 940A61132C92CE210083FEB8 /* configManager.cpp in Sources */, 94724A842F8B92C10029B940 /* operations.cpp in Sources */, - 940A61172C92CE960083FEB8 /* serviceA.cpp in Sources */, 94D601112FA9CD700066F51A /* randomStrategy.cpp in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -3785,7 +3766,6 @@ 94D3A7262FC1B3AD00EBEA32 /* loadCommand.cpp in Sources */, 942EC5692FBEF95000CCBB5D /* redisRunner.cpp in Sources */, 94280BA42D2FC00200F1CF56 /* base64.cpp in Sources */, - 94674B8D2D533E7800973137 /* trade.cpp in Sources */, 943770462FD4396F00317424 /* boostRedisImpl.cpp in Sources */, 941B549A2D3BBADE00E3BF64 /* trading_definitions_json.cpp in Sources */, 94D3A7292FC1B41500EBEA32 /* runCommand.cpp in Sources */, @@ -3793,7 +3773,6 @@ 94674B8A2D533BDA00973137 /* tradeManager.mm in Sources */, 94829FC62FCC1D1A00710E6E /* env.cpp in Sources */, 94724A832F8B92C10029B940 /* operations.cpp in Sources */, - 940A61182C92CE960083FEB8 /* serviceA.cpp in Sources */, 94674B882D533B4000973137 /* tradeManager.cpp in Sources */, 946EFF7F2FB9F44E008D9647 /* reporting.cpp in Sources */, 943398272D57E54000287A2D /* jsonParser.mm in Sources */, @@ -3801,7 +3780,6 @@ 9437703F2FD42FC000317424 /* elasticClient.cpp in Sources */, 9470B5B62C8C5BFD007D9CC6 /* main.cpp in Sources */, 94364CB62D416D8D00F35B55 /* db.mm in Sources */, - 940A61142C92CE210083FEB8 /* configManager.cpp in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/include/configManager.hpp b/include/configManager.hpp deleted file mode 100644 index fe7d25c..0000000 --- a/include/configManager.hpp +++ /dev/null @@ -1,27 +0,0 @@ -// Backtesting Engine in C++ -// -// (c) 2025 Ryan McCaffery | https://mccaffers.com -// This code is licensed under MIT license (see LICENSE.txt for details) -// --------------------------------------- -#pragma once -#include -#include - -class ConfigManager { -private: - ConfigManager() = default; - ConfigManager(const ConfigManager&) = delete; - ConfigManager& operator=(const ConfigManager&) = delete; - -public: - - static std::shared_ptr getInstance() { - static auto instance = std::shared_ptr(new ConfigManager()); - return instance; - } - - std::string getConfig() const { - return "config data"; // Replace with actual implementation - } - -}; diff --git a/include/databaseConnection.hpp b/include/databaseConnection.hpp index 404bef5..2a84567 100644 --- a/include/databaseConnection.hpp +++ b/include/databaseConnection.hpp @@ -18,7 +18,6 @@ class DatabaseConnection { const std::string& user = "admin", const std::string& password = ""); - void printResults(const std::vector& results) const; std::vector executeQuery(const std::string& query) const; const std::string& getConnectionString() const { diff --git a/include/elasticClient.hpp b/include/elasticClient.hpp deleted file mode 100644 index 7411546..0000000 --- a/include/elasticClient.hpp +++ /dev/null @@ -1,18 +0,0 @@ -// 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 "tradingResults.hpp" - -// Minimal Elasticsearch HTTP client — PUT-only, for indexing TradingResults. -// Host is read from $ELASTIC_HOST (default http://localhost:9200) with optional -// HTTP basic auth from $ELASTIC_USER / $ELASTIC_USER_PASSWORD; docs land in -// index "trading_results" with a freshly generated UUID per put. -class ElasticClient { -public: - static int putTradingResults(const TradingResults& results); -}; diff --git a/include/models/trade.hpp b/include/models/trade.hpp index 495dad2..5ea1f97 100644 --- a/include/models/trade.hpp +++ b/include/models/trade.hpp @@ -40,11 +40,25 @@ struct Trade { // Pip-PnL = price difference * scalingFactor * size (sign flipped for SHORT). boost::decimal::decimal64_t pnl; + // Mark-to-market state while the trade is open, maintained by + // TradeManager: the most recent close-side price seen for this symbol and + // the floating PnL at that price. Feeds the account loss limit, and + // lastMarkPrice is the liquidation price if the run is cut off. + // floatingPnl is zeroed on close — its value has been realized into pnl. + boost::decimal::decimal64_t lastMarkPrice; + boost::decimal::decimal64_t floatingPnl; + + // True when the close was forced by the account loss limit (liquidation + // at the last marked price) rather than earned via SL/TP or strategy + // logic — lets reporting separate forced closes from organic ones. + bool liquidated = false; + // Default constructor Trade() : entryPrice(0), entryBid(0), entryAsk(0), size(0), direction(Direction::LONG), scalingFactor(0), stopDistancePips(0), limitDistancePips(0), exitReferencePrice(0), closePrice(0), pnl(0), + lastMarkPrice(0), floatingPnl(0), openTime(std::chrono::system_clock::now()) {} // Copy constructor @@ -65,7 +79,9 @@ struct Trade { stopDistancePips(0), limitDistancePips(0), exitReferencePrice(0), - pnl(0) { + pnl(0), + lastMarkPrice(0), + floatingPnl(0) { } }; diff --git a/include/reporting/elasticClient.hpp b/include/reporting/elasticClient.hpp index 7411546..8f9a4c9 100644 --- a/include/reporting/elasticClient.hpp +++ b/include/reporting/elasticClient.hpp @@ -8,11 +8,13 @@ #include "tradingResults.hpp" -// Minimal Elasticsearch HTTP client — PUT-only, for indexing TradingResults. +// Minimal Elasticsearch HTTP client — PUT-only, for indexing run outcomes. // Host is read from $ELASTIC_HOST (default http://localhost:9200) with optional -// HTTP basic auth from $ELASTIC_USER / $ELASTIC_USER_PASSWORD; docs land in -// index "trading_results" with a freshly generated UUID per put. +// HTTP basic auth from $ELASTIC_USER / $ELASTIC_USER_PASSWORD. Completed runs +// land in index "trading_results"; runs cut off early (loss limit) land in +// "trading_failures". Each doc gets a freshly generated UUID. class ElasticClient { public: static int putTradingResults(const TradingResults& results); + static int putTradingFailure(const TradingFailure& failure); }; diff --git a/include/serviceA.hpp b/include/serviceA.hpp deleted file mode 100644 index f383ae3..0000000 --- a/include/serviceA.hpp +++ /dev/null @@ -1,23 +0,0 @@ -// Backtesting Engine in C++ -// -// (c) 2025 Ryan McCaffery | https://mccaffers.com -// This code is licensed under MIT license (see LICENSE.txt for details) -// --------------------------------------- - -#pragma once -#include -#include "configManager.hpp" - -class ServiceA { -private: - std::shared_ptr configManager; - -public: - explicit ServiceA(std::shared_ptr cm = ConfigManager::getInstance()) - : configManager(cm) {} - - void doSomething() const { - std::cout << "ServiceA using config: " << configManager->getConfig() << std::endl; - } -}; - diff --git a/include/trading/runLoop.hpp b/include/trading/runLoop.hpp index 3a3395d..9842f09 100644 --- a/include/trading/runLoop.hpp +++ b/include/trading/runLoop.hpp @@ -10,10 +10,28 @@ #include "reviewStopAndLimit.hpp" #include "models/priceData.hpp" #include "strategies/strategy.hpp" // IStrategy +#include "trading_definitions/run_configuration.hpp" // DEFAULT_STARTING_BALANCE #include "trading_definitions/trading_variables.hpp" namespace trading { +// Run-level risk limits, shared by every strategy in a sweep (they come from +// RunConfiguration). <= 0 disables the respective check, so unconstrained +// experiments need no extra flag. +struct RiskLimits { + boost::decimal::decimal64_t startingBalance{ + trading_definitions::DEFAULT_STARTING_BALANCE}; + boost::decimal::decimal64_t maxLossPercent{0}; + int maxOpenTrades{0}; +}; + +// How a run ended: ran out of ticks, or was cut off because realized losses +// reached the account loss limit (the fail-fast path). +enum class RunStatus { + Completed, + LossLimitBreached, +}; + // The per-tick backtest loop, factored out of Operations::run so it can be // driven with a deterministic strategy and an inspectable TradeManager in // tests. Operations::run remains the production entry point — it constructs the @@ -24,18 +42,53 @@ namespace trading { // the strategy may then re-enter on that same tick. `strategy` is taken by // mutable reference because IStrategy::decide is non-const (the real strategy // mutates RNG state per call). -inline void runTicks(TradeManager& tradeManager, - IStrategy& strategy, - const std::vector& ticks, - const trading_definitions::TradingVariables& vars) { +// +// The loss limit is checked against account equity — realized PnL plus the +// floating (mark-to-market) PnL of open trades, revalued each tick — right +// after the close phase, so a breaching run stops before opening anything +// new. On breach every open trade is liquidated at its last marked price +// (the close side of the most recent tick seen for its symbol), so the +// reported PnL is the true account PnL at the cutoff. +inline RunStatus runTicks(TradeManager& tradeManager, + IStrategy& strategy, + const std::vector& ticks, + const trading_definitions::TradingVariables& vars, + const RiskLimits& limits = {}) { + const boost::decimal::decimal64_t zero{0}; + const bool lossLimitActive = limits.maxLossPercent > zero; + // Lowest equity PnL the run may reach, e.g. 10000 at 5% -> -500. + const auto pnlFloor = + -(limits.startingBalance * limits.maxLossPercent / 100); + const auto lossLimitBreached = [&] { + return lossLimitActive && + tradeManager.calculatePnl() + tradeManager.unrealizedPnl() <= pnlFloor; + }; + for (const auto& tick : ticks) { + // Revalue this symbol's open trades at the new price so the equity + // check below sees current floating drawdown, not stale marks. + tradeManager.markToMarket(tick); + // Close any trade whose stop-loss or take-profit fired on this tick // before we consider opening a new one otherwise an exit and an // entry could race within the same tick. trading::reviewStopAndLimit(tradeManager, tick); - if (!tradeManager.hasActiveTradeForSymbol(tick.symbol)) { + if (lossLimitBreached()) { + tradeManager.closeAllTrades(tick); + return RunStatus::LossLimitBreached; + } + + // Entry gates: at most one open trade per symbol, and (when capped) + // no more than maxOpenTrades positions across the whole run. The cap + // is checked first so a capped run skips decide() entirely. + const bool belowOpenTradeCap = + limits.maxOpenTrades <= 0 || + tradeManager.reviewAccount() < + static_cast(limits.maxOpenTrades); + if (belowOpenTradeCap && + !tradeManager.hasActiveTradeForSymbol(tick.symbol)) { if (auto signal = strategy.decide(tick)) { tradeManager.openTrade(tick, vars.TRADING_SIZE, @@ -51,6 +104,16 @@ inline void runTicks(TradeManager& tradeManager, // handled by reviewStopAndLimit above. strategy.during(tick, tradeManager); } + + // Entries and during() run after the per-tick check; catch a breach on + // the final tick so the outcome is reported consistently. + if (lossLimitBreached()) { + if (!ticks.empty()) { + tradeManager.closeAllTrades(ticks.back()); + } + return RunStatus::LossLimitBreached; + } + return RunStatus::Completed; } } // namespace trading diff --git a/include/trading/tradeManager.hpp b/include/trading/tradeManager.hpp index ed3f6bd..7576b74 100644 --- a/include/trading/tradeManager.hpp +++ b/include/trading/tradeManager.hpp @@ -17,6 +17,11 @@ class TradeManager { private: std::unordered_map activeTrades; std::vector closedTrades; + // Running sums maintained by openTrade/markToMarket/closeTrade so the + // per-tick loss-limit check in runTicks stays O(1): realized PnL across + // closed trades, and floating (mark-to-market) PnL across open ones. + boost::decimal::decimal64_t closedPnl{0}; + boost::decimal::decimal64_t openPnl{0}; public: TradeManager() = default; @@ -27,10 +32,23 @@ class TradeManager { boost::decimal::decimal64_t limitDistancePips = boost::decimal::decimal64_t{0}); size_t reviewAccount() const; bool hasActiveTradeForSymbol(std::string_view symbol) const; + // `liquidated` marks the close as forced by the account loss limit + // rather than earned via SL/TP or strategy logic. bool closeTrade(const std::string& tradeId, boost::decimal::decimal64_t closePrice, - const PriceData& tick); + const PriceData& tick, + bool liquidated = false); const std::unordered_map& getActiveTrades() const; const std::vector& getClosedTrades() const; + // Realized PnL across all closed trades. O(1): returns the running sum. boost::decimal::decimal64_t calculatePnl() const; + // Floating (mark-to-market) PnL across all open trades, as of each + // trade's last marked price. O(1): returns the running sum. + boost::decimal::decimal64_t unrealizedPnl() const; + // Revalue open trades for this tick's symbol at its close-side price + // (bid for LONG, ask for SHORT), updating their floating PnL. + void markToMarket(const PriceData& tick); + // Liquidate every open trade at its last marked price (timestamped with + // `tick`), realizing the floating PnL — used when a run is cut off. + void closeAllTrades(const PriceData& tick); }; diff --git a/include/tradingResults.hpp b/include/tradingResults.hpp index 242ca17..e186e61 100644 --- a/include/tradingResults.hpp +++ b/include/tradingResults.hpp @@ -28,6 +28,9 @@ struct TradingResultsStats { std::size_t winners; std::size_t losers; std::size_t breakeven; + // Closes forced by the account loss limit, so winners/losers/avgPnl can + // be read net of liquidation noise. + std::size_t liquidated; std::optional avgPnl; }; @@ -43,5 +46,18 @@ struct TradingResults { static std::string nowIsoUtc(); }; +// Wire shape for a run that was cut off early (e.g. it breached the account +// loss limit). Carries the stats as they stood at the cutoff so a failed run +// is still fully inspectable in Kibana; `reason` says why it was stopped. +struct TradingFailure { + std::string RUN_ID; + std::string timestamp; + double durationSeconds; // wall-clock seconds until the cutoff + std::string reason; + trading_definitions::Configuration config; + TradingResultsStats results; +}; + void to_json(nlohmann::json& j, const TradingResultsStats& s); void to_json(nlohmann::json& j, const TradingResults& r); +void to_json(nlohmann::json& j, const TradingFailure& f); diff --git a/include/trading_definitions/configuration.hpp b/include/trading_definitions/configuration.hpp index 68fb351..6d0350e 100644 --- a/include/trading_definitions/configuration.hpp +++ b/include/trading_definitions/configuration.hpp @@ -6,21 +6,52 @@ #pragma once #include +#include #include +#include "trading_definitions/run_configuration.hpp" #include "trading_definitions/strategy.hpp" +#include "utilities/decimal_json.hpp" namespace trading_definitions { +// The risk fields (STARTING_BALANCE, MAX_LOSS_PERCENT, MAX_OPEN_TRADES, +// REPORT_FAILURES) mirror RunConfiguration — see the comments there. struct Configuration { std::string RUN_ID; std::string SYMBOLS; int LAST_MONTHS; + boost::decimal::decimal64_t STARTING_BALANCE{DEFAULT_STARTING_BALANCE}; + boost::decimal::decimal64_t MAX_LOSS_PERCENT{0}; + int MAX_OPEN_TRADES{0}; + bool REPORT_FAILURES{true}; Strategy STRATEGY; }; -NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(Configuration, - RUN_ID, - SYMBOLS, - LAST_MONTHS, - STRATEGY - ); + +// Hand-written so the original fields stay strictly required while the risk +// fields fall back to the struct defaults — configs written before they +// existed still parse. +inline void to_json(nlohmann::json& j, const Configuration& c) { + j = nlohmann::json{ + {"RUN_ID", c.RUN_ID}, + {"SYMBOLS", c.SYMBOLS}, + {"LAST_MONTHS", c.LAST_MONTHS}, + {"STARTING_BALANCE", c.STARTING_BALANCE}, + {"MAX_LOSS_PERCENT", c.MAX_LOSS_PERCENT}, + {"MAX_OPEN_TRADES", c.MAX_OPEN_TRADES}, + {"REPORT_FAILURES", c.REPORT_FAILURES}, + {"STRATEGY", c.STRATEGY}, + }; +} + +inline void from_json(const nlohmann::json& j, Configuration& c) { + j.at("RUN_ID").get_to(c.RUN_ID); + j.at("SYMBOLS").get_to(c.SYMBOLS); + j.at("LAST_MONTHS").get_to(c.LAST_MONTHS); + j.at("STRATEGY").get_to(c.STRATEGY); + const Configuration defaults{}; + c.STARTING_BALANCE = j.value("STARTING_BALANCE", defaults.STARTING_BALANCE); + c.MAX_LOSS_PERCENT = j.value("MAX_LOSS_PERCENT", defaults.MAX_LOSS_PERCENT); + c.MAX_OPEN_TRADES = j.value("MAX_OPEN_TRADES", defaults.MAX_OPEN_TRADES); + c.REPORT_FAILURES = j.value("REPORT_FAILURES", defaults.REPORT_FAILURES); +} }; diff --git a/include/trading_definitions/run_configuration.hpp b/include/trading_definitions/run_configuration.hpp index a67f550..dc11a28 100644 --- a/include/trading_definitions/run_configuration.hpp +++ b/include/trading_definitions/run_configuration.hpp @@ -6,22 +6,64 @@ #pragma once #include +#include #include +#include "utilities/decimal_json.hpp" namespace trading_definitions { + +// Single source of truth for the default account balance. Configuration and +// trading::RiskLimits reference this rather than repeating the literal. +inline constexpr boost::decimal::decimal64_t DEFAULT_STARTING_BALANCE{10000}; + // Run-level descriptor: the unit of QuestDB tick data shared by every strategy // in a sweep. Carried on BACKTESTING_QUEUE_RUN and linked to its strategies via // RUN_ID (see queueKeys.hpp). The runner reassembles a full Configuration from // this plus each popped Strategy. +// +// The risk settings apply to every strategy in the sweep: +// - STARTING_BALANCE / MAX_LOSS_PERCENT: each run starts from +// STARTING_BALANCE and is cut off early (open trades liquidated) once its +// equity loss reaches MAX_LOSS_PERCENT of it. <= 0 disables the cutoff. +// - MAX_OPEN_TRADES: cap on simultaneously open positions per run; entries +// are skipped while at the cap. <= 0 means unlimited. +// - REPORT_FAILURES: when false, runs cut off by the loss limit are NOT +// reported to Elasticsearch (silencer for large sweeps where liquidated +// runs are expected noise). Completed runs always report. struct RunConfiguration { std::string RUN_ID; std::string SYMBOLS; int LAST_MONTHS; + boost::decimal::decimal64_t STARTING_BALANCE{DEFAULT_STARTING_BALANCE}; + boost::decimal::decimal64_t MAX_LOSS_PERCENT{0}; + int MAX_OPEN_TRADES{0}; + bool REPORT_FAILURES{true}; }; -NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(RunConfiguration, - RUN_ID, - SYMBOLS, - LAST_MONTHS - ); + +// Hand-written (rather than NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE) so the +// original fields stay strictly required while the risk fields fall back to +// the struct defaults — payloads written before they existed still parse. +inline void to_json(nlohmann::json& j, const RunConfiguration& c) { + j = nlohmann::json{ + {"RUN_ID", c.RUN_ID}, + {"SYMBOLS", c.SYMBOLS}, + {"LAST_MONTHS", c.LAST_MONTHS}, + {"STARTING_BALANCE", c.STARTING_BALANCE}, + {"MAX_LOSS_PERCENT", c.MAX_LOSS_PERCENT}, + {"MAX_OPEN_TRADES", c.MAX_OPEN_TRADES}, + {"REPORT_FAILURES", c.REPORT_FAILURES}, + }; +} + +inline void from_json(const nlohmann::json& j, RunConfiguration& c) { + j.at("RUN_ID").get_to(c.RUN_ID); + j.at("SYMBOLS").get_to(c.SYMBOLS); + j.at("LAST_MONTHS").get_to(c.LAST_MONTHS); + const RunConfiguration defaults{}; + c.STARTING_BALANCE = j.value("STARTING_BALANCE", defaults.STARTING_BALANCE); + c.MAX_LOSS_PERCENT = j.value("MAX_LOSS_PERCENT", defaults.MAX_LOSS_PERCENT); + c.MAX_OPEN_TRADES = j.value("MAX_OPEN_TRADES", defaults.MAX_OPEN_TRADES); + c.REPORT_FAILURES = j.value("REPORT_FAILURES", defaults.REPORT_FAILURES); +} }; diff --git a/include/utilities/base64.hpp b/include/utilities/base64.hpp index fb2dc26..b419671 100644 --- a/include/utilities/base64.hpp +++ b/include/utilities/base64.hpp @@ -4,11 +4,7 @@ // This code is licensed under MIT license (see LICENSE.txt for details) // --------------------------------------- #pragma once -#include -#include -#include -#include -#include +#include #include class Base64 { @@ -17,12 +13,5 @@ class Base64 { static const std::string b64decode(const unsigned char* data, const size_t &len); static std::string b64encode(const std::string& str); static std::string b64decode(const std::string& str64); - static bool isValidBase64(const std::string& input); - static std::string checkInput(const std::string& base64_input); -}; - -class Utilities { -public: - static std::chrono::system_clock::time_point parseTimestamp(const std::string& ts); }; diff --git a/source/commands/loadCommand.cpp b/source/commands/loadCommand.cpp index d9e1b9d..2689cb5 100644 --- a/source/commands/loadCommand.cpp +++ b/source/commands/loadCommand.cpp @@ -67,13 +67,26 @@ sweep::ParameterGenerator buildRandomStrategySweep() { return generator; } -// The run-level descriptor: what tick data to pull from QuestDB. Shared by every -// strategy in this sweep and linked to them by RUN_ID. +// The run-level descriptor: what tick data to pull from QuestDB, plus the risk +// limits every strategy in the sweep runs under. Shared by every strategy in +// this sweep and linked to them by RUN_ID. RunConfiguration makeRunConfiguration(const std::string& runId) { + using namespace boost::decimal::literals; return RunConfiguration{ .RUN_ID = runId, .SYMBOLS = "EURUSD", .LAST_MONTHS = 6, + .STARTING_BALANCE = trading_definitions::DEFAULT_STARTING_BALANCE, + // Cut a run off once it has lost 5% of the account (fail fast); + // set <= 0 to run without any loss cutoff. + .MAX_LOSS_PERCENT = 5_DD, + // Cap on simultaneously open positions per run (<= 0 = unlimited). + // The per-symbol gate already limits to one trade per symbol, so this + // only bites on multi-symbol runs. + .MAX_OPEN_TRADES = 10, + // Flip to false to silence liquidated runs from Elasticsearch once + // sweeps scale up and loss-limit cutoffs are expected noise. + .REPORT_FAILURES = true, }; } diff --git a/source/configManager.cpp b/source/configManager.cpp deleted file mode 100644 index f6fc657..0000000 --- a/source/configManager.cpp +++ /dev/null @@ -1,10 +0,0 @@ -// Backtesting Engine in C++ -// -// (c) 2025 Ryan McCaffery | https://mccaffers.com -// This code is licensed under MIT license (see LICENSE.txt for details) -// --------------------------------------- - -#include "configManager.hpp" -#include - - diff --git a/source/databaseConnection.cpp b/source/databaseConnection.cpp index 7e8d708..e6f6154 100644 --- a/source/databaseConnection.cpp +++ b/source/databaseConnection.cpp @@ -8,7 +8,6 @@ #include #include #include -#include #include #include @@ -17,7 +16,15 @@ class InvalidTimestampFormatError : public std::runtime_error { using std::runtime_error::runtime_error; }; -static std::chrono::system_clock::time_point fastParseTimestamp(const char* ts) { +// Caches timegm per date — tick data is time-ordered so the date changes +// rarely. The caller owns the cache, so each thread/query gets its own and +// the parse stays safe if loading ever moves onto the ThreadPool. +struct DateCache { + std::string date; + time_t epoch = 0; +}; + +static std::chrono::system_clock::time_point fastParseTimestamp(const char* ts, DateCache& cache) { int year = 0; int month = 0; int day = 0; @@ -31,21 +38,18 @@ static std::chrono::system_clock::time_point fastParseTimestamp(const char* ts) throw InvalidTimestampFormatError("Invalid timestamp format: " + std::string(ts)); } - // Cache timegm per date — tick data is time-ordered so date changes rarely - static std::string cachedDate; - static time_t cachedEpoch = 0; const std::string_view date(ts, 10); - if (cachedDate != date) { - cachedDate.assign(date); + if (cache.date != date) { + cache.date.assign(date); std::tm tm = {}; tm.tm_year = year - 1900; tm.tm_mon = month - 1; tm.tm_mday = day; tm.tm_isdst = 0; - cachedEpoch = timegm(&tm); + cache.epoch = timegm(&tm); } - time_t t = cachedEpoch + hour * 3600 + min * 60 + sec; + time_t t = cache.epoch + hour * 3600 + min * 60 + sec; return std::chrono::system_clock::from_time_t(t) + std::chrono::microseconds(usec); } @@ -64,6 +68,7 @@ std::vector DatabaseConnection::executeQuery(const std::string& query pqxx::result result = txn.exec(query); std::vector results(result.size()); + DateCache dateCache; for (std::size_t i = 0; i < result.size(); ++i) { const auto& row = result[static_cast(i)]; @@ -74,30 +79,14 @@ std::vector DatabaseConnection::executeQuery(const std::string& query auto sv2 = row[2].view(); // boost::decimal ships its own from_chars overload — std::from_chars // doesn't know about decimal64_t. - boost::decimal::from_chars(sv1.data(), sv1.data() + sv1.size(), ask); - boost::decimal::from_chars(sv2.data(), sv2.data() + sv2.size(), bid); - results[i] = PriceData(ask, bid, fastParseTimestamp(row[3].c_str()), std::string(symbol)); + const auto askResult = boost::decimal::from_chars(sv1.data(), sv1.data() + sv1.size(), ask); + const auto bidResult = boost::decimal::from_chars(sv2.data(), sv2.data() + sv2.size(), bid); + if (askResult.ec != std::errc{} || bidResult.ec != std::errc{}) { + throw std::runtime_error(std::format( + "Failed to parse price for {}: ask='{}' bid='{}'", symbol, sv1, sv2)); + } + results[i] = PriceData(ask, bid, fastParseTimestamp(row[3].c_str(), dateCache), std::string(symbol)); } return results; } - -// Example usage function to demonstrate how to work with the results -void DatabaseConnection::printResults(const std::vector& results) const { - for (const auto& data : results) { - // Convert timestamp back to string for display - auto time_t = std::chrono::system_clock::to_time_t(data.timestamp); - struct tm tm = {}; - if (localtime_r(&time_t, &tm) == nullptr) { - std::cerr << "Error: failed to convert timestamp" << std::endl; - continue; - } - std::stringstream ss; - ss << std::put_time(&tm, "%Y-%m-%d %H:%M:%S"); - - std::cout << std::fixed << std::setprecision(4) - << data.ask << "\t" - << data.bid << "\t" - << ss.str() << std::endl; - } -} diff --git a/source/elasticClient.cpp b/source/elasticClient.cpp deleted file mode 100644 index df27b53..0000000 --- a/source/elasticClient.cpp +++ /dev/null @@ -1,94 +0,0 @@ -// Backtesting Engine in C++ -// -// (c) 2026 Ryan McCaffery | https://mccaffers.com -// This code is licensed under MIT license (see LICENSE.txt for details) -// --------------------------------------- - -#include "elasticClient.hpp" - -#include -#include - -#include -#include -#include -#include - -#include "env.hpp" - -namespace { - -void ensureCurlInit() { - static const struct CurlGlobal { - CurlGlobal() { curl_global_init(CURL_GLOBAL_ALL); } - ~CurlGlobal() { curl_global_cleanup(); } - } guard; - (void)guard; -} - -std::string generateUuid() { - static thread_local boost::uuids::random_generator gen; - return boost::uuids::to_string(gen()); -} - -} // namespace - -int ElasticClient::putTradingResults(const TradingResults& results) { - ensureCurlInit(); - - const std::string host = env::getOr("ELASTIC_HOST", "http://localhost:9200"); - const std::string user = env::getOr("ELASTIC_USER", ""); - const std::string password = env::getOr("ELASTIC_USER_PASSWORD", ""); - const std::string url = host + "/trading_results/_doc/" + generateUuid(); - const std::string body = nlohmann::json(results).dump(); - - CURL* curl = curl_easy_init(); - if (!curl) { - std::cerr << "ElasticClient: curl_easy_init failed" << std::endl; - return 1; - } - - struct curl_slist* headers = nullptr; - headers = curl_slist_append(headers, "Content-Type: application/json"); - - curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); - curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); - - // HTTP basic auth when credentials are supplied via the environment. - if (!user.empty()) { - curl_easy_setopt(curl, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); - curl_easy_setopt(curl, CURLOPT_USERNAME, user.c_str()); - curl_easy_setopt(curl, CURLOPT_PASSWORD, password.c_str()); - } - curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "PUT"); - curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str()); - curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, static_cast(body.size())); - - // Require TLS 1.2 or newer and enforce certificate / hostname verification - // for any HTTPS endpoint (Sonar cpp:S4423 / S5527). - curl_easy_setopt(curl, CURLOPT_SSLVERSION, CURL_SSLVERSION_TLSv1_2); - curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); - curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); - - const CURLcode rc = curl_easy_perform(curl); - - long httpStatus = 0; - curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &httpStatus); - - curl_slist_free_all(headers); - curl_easy_cleanup(curl); - - if (rc != CURLE_OK) { - std::cerr << "ElasticClient: PUT failed: " << curl_easy_strerror(rc) - << std::endl; - return 2; - } - if (httpStatus < 200 || httpStatus >= 300) { - std::cerr << "ElasticClient: HTTP " << httpStatus << " from " << url - << std::endl; - return 3; - } - std::cout << "ElasticClient: PUT " << url << " (HTTP " << httpStatus << ")" - << std::endl; - return 0; -} diff --git a/source/models/trade.cpp b/source/models/trade.cpp deleted file mode 100644 index c34cad9..0000000 --- a/source/models/trade.cpp +++ /dev/null @@ -1,7 +0,0 @@ -// Backtesting Engine in C++ -// -// (c) 2025 Ryan McCaffery | https://mccaffers.com -// This code is licensed under MIT license (see LICENSE.txt for details) -// --------------------------------------- - -#include "trade.hpp" diff --git a/source/operations.cpp b/source/operations.cpp index e4c855f..763282e 100644 --- a/source/operations.cpp +++ b/source/operations.cpp @@ -7,6 +7,7 @@ #include "operations.hpp" #include #include +#include #include #include #include @@ -46,16 +47,23 @@ void Operations::run(const std::vector& ticks, config.RUN_ID, config.STRATEGY.TRADING_VARIABLES.STRATEGY); - auto tradeManager = std::make_unique(); + TradeManager tradeManager; auto strategy = selectStrategy(config); // The per-tick loop (exit review -> re-entry gate -> entry -> manage) lives // in trading::runTicks so it can be driven with a deterministic strategy and - // an inspectable TradeManager under test. Behaviour here is unchanged. - trading::runTicks(*tradeManager, *strategy, ticks, - config.STRATEGY.TRADING_VARIABLES); + // an inspectable TradeManager under test. The run-level risk limits make a + // breaching run stop early (fail fast) instead of burning ticks. + const trading::RiskLimits riskLimits{ + .startingBalance = config.STARTING_BALANCE, + .maxLossPercent = config.MAX_LOSS_PERCENT, + .maxOpenTrades = config.MAX_OPEN_TRADES, + }; + const trading::RunStatus status = + trading::runTicks(tradeManager, *strategy, ticks, + config.STRATEGY.TRADING_VARIABLES, riskLimits); - Reporting::summarise(*tradeManager); + Reporting::summarise(tradeManager); // Elapsed backtest time for this run, measured from the top of run(). The // Elasticsearch PUT below is deliberately excluded so the duration reflects @@ -67,29 +75,60 @@ void Operations::run(const std::vector& ticks, // Per-run completion line, suppressed under concurrent (quiet) sweeps to // match the other per-run logs. if (!backtest_log::quiet) { - std::println("Operations: run RUN_ID={} completed in {:.3f}s", - config.RUN_ID, durationSeconds); + if (status == trading::RunStatus::LossLimitBreached) { + std::println("Operations: run RUN_ID={} stopped after {:.3f}s — account loss limit reached", + config.RUN_ID, durationSeconds); + } else { + std::println("Operations: run RUN_ID={} completed in {:.3f}s", + config.RUN_ID, durationSeconds); + } } - // Best-effort: persist this run's results to Elasticsearch. The backtest - // has already produced its summary, so nothing here may abort the run. - // putTradingResults logs every transport/HTTP outcome itself (so we do not - // re-log on a non-zero return, that would double the warning), but a throw - // from JSON serialisation or the network layer bypasses its logging, so the - // catch handlers emit the single warning for that path. + // Best-effort: persist this run's outcome to Elasticsearch — results for a + // completed run, a failure doc for one cut off by the loss limit. The + // backtest has already produced its summary, so nothing here may abort the + // run. The put functions log every transport/HTTP outcome themselves (so + // we do not re-log on a non-zero return, that would double the warning), + // but a throw from JSON serialisation or the network layer bypasses their + // logging, so the catch handlers emit the single warning for that path. try { - const TradingResults results{ - config.RUN_ID, - TradingResults::nowIsoUtc(), - durationSeconds, - config, - Reporting::collect(*tradeManager), - }; - ElasticClient::putTradingResults(results); + if (status == trading::RunStatus::LossLimitBreached) { + // Silencer for large sweeps: liquidated runs are expected noise + // once the system is trusted, so the run config can opt out of + // reporting them. Completed runs always report. + if (!config.REPORT_FAILURES) { + return; + } + // Open trades were liquidated at their last marked prices on the + // breach, so calculatePnl() is the true account PnL at cutoff. + std::ostringstream reason; + reason << "account loss limit reached: PnL " + << tradeManager.calculatePnl() << " breached " + << config.MAX_LOSS_PERCENT << "% of starting balance " + << config.STARTING_BALANCE << " (open trades liquidated)"; + const TradingFailure failure{ + config.RUN_ID, + TradingResults::nowIsoUtc(), + durationSeconds, + reason.str(), + config, + Reporting::collect(tradeManager), + }; + ElasticClient::putTradingFailure(failure); + } else { + const TradingResults results{ + config.RUN_ID, + TradingResults::nowIsoUtc(), + durationSeconds, + config, + Reporting::collect(tradeManager), + }; + ElasticClient::putTradingResults(results); + } } catch (const std::exception& e) { - backtest_log::error(std::string("Operations: trading-results put failed: ") + backtest_log::error(std::string("Operations: outcome put failed: ") + e.what()); } catch (...) { - backtest_log::error("Operations: trading-results put failed: unknown error"); + backtest_log::error("Operations: outcome put failed: unknown error"); } } diff --git a/source/redisImpl.cpp b/source/redisImpl.cpp deleted file mode 100644 index 8fa26ad..0000000 --- a/source/redisImpl.cpp +++ /dev/null @@ -1,11 +0,0 @@ -// Backtesting Engine in C++ -// -// (c) 2026 Ryan McCaffery | https://mccaffers.com -// This code is licensed under MIT license (see LICENSE.txt for details) -// --------------------------------------- - -// Dedicated translation unit for the Boost.Redis implementation. -// Kept isolated because transitively pulls in C-style -// , which redefines signbit/isnan/isinf as macros on macOS and breaks -// any header in the same TU that uses std::signbit (e.g. nlohmann/json). -#include diff --git a/source/redisRunner.cpp b/source/redisRunner.cpp index 9b703f9..4e86ff9 100644 --- a/source/redisRunner.cpp +++ b/source/redisRunner.cpp @@ -149,10 +149,14 @@ asio::awaitable drainRuns(std::shared_ptr conn, // Reassemble the Configuration the rest of the pipeline expects. // Parsing stays on this thread; the worker only runs the backtest. trading_definitions::Configuration config{ - runCfg.RUN_ID, - runCfg.SYMBOLS, - runCfg.LAST_MONTHS, - JsonParser::parseStrategyFromBase64(*strategyB64), + .RUN_ID = runCfg.RUN_ID, + .SYMBOLS = runCfg.SYMBOLS, + .LAST_MONTHS = runCfg.LAST_MONTHS, + .STARTING_BALANCE = runCfg.STARTING_BALANCE, + .MAX_LOSS_PERCENT = runCfg.MAX_LOSS_PERCENT, + .MAX_OPEN_TRADES = runCfg.MAX_OPEN_TRADES, + .REPORT_FAILURES = runCfg.REPORT_FAILURES, + .STRATEGY = JsonParser::parseStrategyFromBase64(*strategyB64), }; pool.submit([&ticks, cfg = std::move(config)]() { runBacktestOnTicks(ticks, cfg); diff --git a/source/reporting/elasticClient.cpp b/source/reporting/elasticClient.cpp index 825d8a4..0f3d3a7 100644 --- a/source/reporting/elasticClient.cpp +++ b/source/reporting/elasticClient.cpp @@ -38,9 +38,9 @@ size_t discardResponse(char* /*ptr*/, size_t size, size_t nmemb, void* /*userdat return size * nmemb; } -} // namespace - -int ElasticClient::putTradingResults(const TradingResults& results) { +// Shared PUT of one JSON document into the given index. Both public entry +// points only differ in target index and body shape. +int putDocument(const std::string& index, const std::string& body) { // Allow runs to opt out of result reporting entirely (e.g. local backtests // with no Elastic instance). On by default to preserve existing behaviour. if (env::getOr("ELASTIC_ENABLED", "1") == "0") { @@ -52,8 +52,7 @@ int ElasticClient::putTradingResults(const TradingResults& results) { const std::string host = env::getOr("ELASTIC_HOST", "http://localhost:9200"); const std::string user = env::getOr("ELASTIC_USER", ""); const std::string password = env::getOr("ELASTIC_USER_PASSWORD", ""); - const std::string url = host + "/trading_results/_doc/" + generateUuid(); - const std::string body = nlohmann::json(results).dump(); + const std::string url = host + "/" + index + "/_doc/" + generateUuid(); CURL* curl = curl_easy_init(); if (!curl) { @@ -111,3 +110,13 @@ int ElasticClient::putTradingResults(const TradingResults& results) { } return 0; } + +} // namespace + +int ElasticClient::putTradingResults(const TradingResults& results) { + return putDocument("trading_results", nlohmann::json(results).dump()); +} + +int ElasticClient::putTradingFailure(const TradingFailure& failure) { + return putDocument("trading_failures", nlohmann::json(failure).dump()); +} diff --git a/source/serviceA.cpp b/source/serviceA.cpp deleted file mode 100644 index 94b8d4c..0000000 --- a/source/serviceA.cpp +++ /dev/null @@ -1,7 +0,0 @@ -// Backtesting Engine in C++ -// -// (c) 2025 Ryan McCaffery | https://mccaffers.com -// This code is licensed under MIT license (see LICENSE.txt for details) -// --------------------------------------- - -#include "serviceA.hpp" diff --git a/source/sqlManager.cpp b/source/sqlManager.cpp index e64f7a1..05391b8 100644 --- a/source/sqlManager.cpp +++ b/source/sqlManager.cpp @@ -4,8 +4,10 @@ // This code is licensed under MIT license (see LICENSE.txt for details) // --------------------------------------- #include "sqlManager.hpp" +#include "models/symbolScale.hpp" #include #include +#include #include #include @@ -14,6 +16,15 @@ std::vector SqlManager::loadPriceData(const DatabaseConnection& db, c return {}; } + // Symbols arrive via Redis payloads and are interpolated into the query + // below, so only accept names from the canonical table. This blocks SQL + // injection and catches typos before they become QuestDB errors. + for (const auto& symbol : symbols) { + if (symbol_scale::get(symbol) == symbol_scale::kUnknown) { + throw std::invalid_argument("Unknown symbol rejected: " + symbol); + } + } + std::ostringstream query; for (size_t i = 0; i < symbols.size(); ++i) { if (i > 0) { diff --git a/source/trading/reporting.cpp b/source/trading/reporting.cpp index e8d4197..ce98534 100644 --- a/source/trading/reporting.cpp +++ b/source/trading/reporting.cpp @@ -32,6 +32,7 @@ TradingResultsStats Reporting::collect(const TradeManager& tradeManager) { std::size_t winners = 0; std::size_t losers = 0; std::size_t breakeven = 0; + std::size_t liquidated = 0; boost::decimal::decimal64_t pnlSum{0}; const boost::decimal::decimal64_t zero{0}; for (const auto& trade : closedTrades) { @@ -40,13 +41,14 @@ TradingResultsStats Reporting::collect(const TradeManager& tradeManager) { if (trade.pnl > zero) ++winners; else if (trade.pnl < zero) ++losers; else ++breakeven; + if (trade.liquidated) ++liquidated; pnlSum += trade.pnl; } openedLong += closedLong; openedShort += closedShort; TradingResultsStats stats; - stats.finalPnl = tradeManager.calculatePnl(); + stats.finalPnl = pnlSum; stats.tradesOpened = openedCount; stats.tradesClosed = closedCount; stats.openedLong = openedLong; @@ -56,6 +58,7 @@ TradingResultsStats Reporting::collect(const TradeManager& tradeManager) { stats.winners = winners; stats.losers = losers; stats.breakeven = breakeven; + stats.liquidated = liquidated; if (closedCount == 0) { stats.avgPnl = std::nullopt; } else { diff --git a/source/trading/tradeManager.cpp b/source/trading/tradeManager.cpp index 1ec8acf..48b1dc4 100644 --- a/source/trading/tradeManager.cpp +++ b/source/trading/tradeManager.cpp @@ -20,6 +20,16 @@ std::string nextTradeId() { static std::atomic counter{0}; return std::format("T{}", counter.fetch_add(1)); } + +// Floating PnL of an open trade valued at `mark` — the same formula +// closeTrade uses for realized PnL, so liquidating at the last mark realizes +// exactly the floating amount. +boost::decimal::decimal64_t floatingPnlAt(const Trade& trade, + boost::decimal::decimal64_t mark) { + auto diff = mark - trade.entryPrice; + if (trade.direction == Direction::SHORT) diff = -diff; + return diff * trade.scalingFactor * trade.size; +} } std::string TradeManager::openTrade(const PriceData& tick, @@ -35,10 +45,39 @@ std::string TradeManager::openTrade(const PriceData& tick, trade.stopDistancePips = stopDistancePips; trade.limitDistancePips = limitDistancePips; trade.exitReferencePrice = (direction == Direction::LONG) ? tick.bid : tick.ask; + // Mark the trade at its entry tick: the close side of the spread. The + // initial floating PnL is therefore the spread cost — true mark-to-market + // equity dips by the spread the moment a trade opens. + trade.lastMarkPrice = trade.exitReferencePrice; + trade.floatingPnl = floatingPnlAt(trade, trade.lastMarkPrice); + openPnl += trade.floatingPnl; activeTrades[trade.id] = trade; return trade.id; } +void TradeManager::markToMarket(const PriceData& tick) { + for (auto& [id, trade] : activeTrades) { + if (trade.symbol != tick.symbol) continue; + const auto mark = (trade.direction == Direction::LONG) ? tick.bid : tick.ask; + const auto updated = floatingPnlAt(trade, mark); + openPnl += updated - trade.floatingPnl; + trade.floatingPnl = updated; + trade.lastMarkPrice = mark; + } +} + +void TradeManager::closeAllTrades(const PriceData& tick) { + // Snapshot ids/prices first: closeTrade mutates activeTrades. + std::vector> toClose; + toClose.reserve(activeTrades.size()); + for (const auto& [id, trade] : activeTrades) { + toClose.emplace_back(id, trade.lastMarkPrice); + } + for (const auto& [id, price] : toClose) { + closeTrade(id, price, tick, /*liquidated=*/true); + } +} + size_t TradeManager::reviewAccount() const { return activeTrades.size(); } @@ -52,17 +91,22 @@ bool TradeManager::hasActiveTradeForSymbol(std::string_view symbol) const { bool TradeManager::closeTrade(const std::string& tradeId, boost::decimal::decimal64_t closePrice, - const PriceData& tick) { + const PriceData& tick, + bool liquidated) { auto it = activeTrades.find(tradeId); if (it != activeTrades.end()) { Trade closed = it->second; closed.closePrice = closePrice; closed.closeTime = tick.timestamp; + closed.liquidated = liquidated; auto diff = closePrice - closed.entryPrice; if (closed.direction == Direction::SHORT) diff = -diff; // scalingFactor stays an int — boost::decimal has overloads for builtin // integer types, so no conversion is needed there. closed.pnl = diff * closed.scalingFactor * closed.size; + closedPnl += closed.pnl; + openPnl -= it->second.floatingPnl; // realized now, no longer floating + closed.floatingPnl = boost::decimal::decimal64_t{0}; closedTrades.push_back(closed); activeTrades.erase(it); @@ -97,11 +141,11 @@ const std::vector& TradeManager::getClosedTrades() const { } boost::decimal::decimal64_t TradeManager::calculatePnl() const { - boost::decimal::decimal64_t pnl{0}; - for (const auto& trade : closedTrades) { - pnl += trade.pnl; - } - return pnl; + return closedPnl; +} + +boost::decimal::decimal64_t TradeManager::unrealizedPnl() const { + return openPnl; } diff --git a/source/tradingResults.cpp b/source/tradingResults.cpp index 6f393a9..2f4fa9f 100644 --- a/source/tradingResults.cpp +++ b/source/tradingResults.cpp @@ -31,6 +31,7 @@ void to_json(nlohmann::json& j, const TradingResultsStats& s) { {"winners", s.winners}, {"losers", s.losers}, {"breakeven", s.breakeven}, + {"liquidated", s.liquidated}, }; if (s.avgPnl) { j["avgPnl"] = *s.avgPnl; @@ -48,3 +49,14 @@ void to_json(nlohmann::json& j, const TradingResults& r) { {"results", r.results}, }; } + +void to_json(nlohmann::json& j, const TradingFailure& f) { + j = nlohmann::json{ + {"RUN_ID", f.RUN_ID}, + {"@timestamp", f.timestamp}, + {"durationSeconds", f.durationSeconds}, + {"reason", f.reason}, + {"config", f.config}, + {"results", f.results}, + }; +} diff --git a/source/utilities/base64.cpp b/source/utilities/base64.cpp index ff040fa..ae1ee97 100644 --- a/source/utilities/base64.cpp +++ b/source/utilities/base64.cpp @@ -4,9 +4,6 @@ // This code is licensed under MIT license (see LICENSE.txt for details) // --------------------------------------- -#include // Add this header for std::get_time -#include -#include #include "base64.hpp" static const char* const B64chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; @@ -23,15 +20,6 @@ static const int B64index[256] = 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51 }; -std::string Base64::checkInput(const std::string& base64_input) { - std::string result = base64_input; - result.erase( - std::remove_if(result.begin(), result.end(), ::isspace), - result.end() - ); - return result; -} - // Code adapted from Stack Overflow https://stackoverflow.com/a/37109258/20806857 const std::string Base64::b64encode(const unsigned char* data, const size_t &len) { @@ -100,35 +88,3 @@ std::string Base64::b64decode(const std::string& str64) { return b64decode(reinterpret_cast(str64.c_str()), str64.size()); } - -bool Base64::isValidBase64(const std::string& input) { - // Check if string length is valid (multiple of 4) - if (input.length() % 4 != 0) { - return false; - } - - // Check if all characters are valid base64 characters - return std::all_of(input.begin(), input.end(), - [](char c) { - const char* valid = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="; - return strchr(valid, c) != nullptr; - }); -} - -std::chrono::system_clock::time_point Utilities::parseTimestamp(const std::string& ts) { - std::tm tm = {}; - std::istringstream ss(ts); - ss >> std::get_time(&tm, "%Y-%m-%d %H:%M:%S"); - - auto timePoint = std::chrono::system_clock::from_time_t(std::mktime(&tm)); - - // Parse milliseconds if present - if (ss.peek() == '.') { - ss.ignore(); // Skip the dot - int milliseconds; - ss >> milliseconds; - timePoint += std::chrono::milliseconds(milliseconds / 1000); // Convert microseconds to milliseconds - } - - return timePoint; -} diff --git a/source/utilities/jsonParser.cpp b/source/utilities/jsonParser.cpp index 559a747..4868dfb 100644 --- a/source/utilities/jsonParser.cpp +++ b/source/utilities/jsonParser.cpp @@ -6,55 +6,21 @@ #include "jsonParser.hpp" #include "base64.hpp" -#include using json = nlohmann::json; -trading_definitions::Configuration JsonParser::parseConfigurationFromBase64(const std::string& input) { - // Ingest parameters - std::string output = Base64::b64decode(input); - - // Debug, console print out - std::cout << output; - - json j; - try { - j = json::parse(output); - } - catch (json::parse_error& ex) { - std::cerr << "parse error at byte " << ex.byte << std::endl; - } - - auto config = j.get(); - std::cout << config.RUN_ID << std::endl; +// Parse errors propagate to the caller — json::parse_error carries the byte +// offset, which is more useful than the downstream type_error a discarded +// json value would produce. - return config; +trading_definitions::Configuration JsonParser::parseConfigurationFromBase64(const std::string& input) { + return json::parse(Base64::b64decode(input)).get(); } trading_definitions::RunConfiguration JsonParser::parseRunConfigurationFromBase64(const std::string& input) { - const std::string output = Base64::b64decode(input); - - json j; - try { - j = json::parse(output); - } - catch (json::parse_error& ex) { - std::cerr << "parse error at byte " << ex.byte << std::endl; - } - - return j.get(); + return json::parse(Base64::b64decode(input)).get(); } trading_definitions::Strategy JsonParser::parseStrategyFromBase64(const std::string& input) { - const std::string output = Base64::b64decode(input); - - json j; - try { - j = json::parse(output); - } - catch (json::parse_error& ex) { - std::cerr << "parse error at byte " << ex.byte << std::endl; - } - - return j.get(); + return json::parse(Base64::b64decode(input)).get(); } diff --git a/tests/tradeManager.mm b/tests/tradeManager.mm index af58d1b..d0d84c0 100644 --- a/tests/tradeManager.mm +++ b/tests/tradeManager.mm @@ -717,4 +717,177 @@ - (void)testRunTicks_MultiSymbol { XCTAssertEqual(aus->entryPrice, "7000.5"_dd, "AUSIDXAUD LONG enters at its ask"); } +#pragma mark - Account loss limit (fail fast) + +// FLOATING drawdown alone must trigger the cutoff: no stop-loss, so the open +// LONG's mark-to-market loss is the only thing the limit can see. The crash +// tick marks the trade at -101 (floor: 10000 * 1% = 100), the run stops, and +// the trade is liquidated at that mark — so the reported PnL is the true +// account PnL, not 0 realized. +- (void)testRunTicks_FloatingDrawdownBreach_LiquidatesAtMark { + TradeManager tm; + ScriptedStrategy strategy({Direction::LONG}); + const auto vars = makeVars("0"_dd, "0"_dd, "1.0"_dd); // no SL/TP at all + const auto now = std::chrono::system_clock::now(); + const std::vector ticks{ + PriceData("1.1001"_dd, "1.1000"_dd, now, "EURUSD"), // open LONG @ ask 1.1001 + PriceData("1.0901"_dd, "1.0900"_dd, now, "EURUSD"), // mark at bid: floating -101 + PriceData("1.0901"_dd, "1.0900"_dd, now, "EURUSD"), // must never be processed + }; + const trading::RiskLimits limits{.startingBalance = "10000"_dd, + .maxLossPercent = "1"_dd}; + + const auto status = trading::runTicks(tm, strategy, ticks, vars, limits); + + XCTAssertTrue(status == trading::RunStatus::LossLimitBreached, + "Floating drawdown must count toward the loss limit"); + XCTAssertEqual(tm.getActiveTrades().size(), 0, "The open trade must be liquidated"); + XCTAssertEqual(tm.getClosedTrades().size(), 1, "Exactly one (liquidated) closed trade"); + const Trade& closed = tm.getClosedTrades().front(); + XCTAssertEqual(closed.closePrice, "1.0900"_dd, "Liquidation closes at the marked bid"); + XCTAssertEqual(closed.pnl, -"101"_dd, "Liquidated PnL is the marked floating loss"); + XCTAssertTrue(closed.liquidated, "Forced close must carry the liquidated flag"); + XCTAssertEqual(closed.floatingPnl, "0"_dd, "floatingPnl is zeroed once realized"); + XCTAssertEqual(tm.calculatePnl(), -"101"_dd, "True account PnL is fully realized"); + XCTAssertEqual(tm.unrealizedPnl(), "0"_dd, "Nothing floating remains after liquidation"); +} + +// On breach, EVERY open trade is liquidated — each at its own symbol's last +// marked price, not the breaching tick's. The EURUSD crash (-101) breaches the +// -100 floor; the AUSIDXAUD position, marked only at its entry tick, closes at +// its own mark 7000.0 for the -0.5 spread cost. True PnL = -101.5. +- (void)testRunTicks_Liquidation_ClosesEverySymbolAtItsOwnMark { + TradeManager tm; + ScriptedStrategy strategy({Direction::LONG, Direction::LONG}); + const auto vars = makeVars("0"_dd, "0"_dd, "1.0"_dd); // no SL/TP at all + const auto now = std::chrono::system_clock::now(); + const std::vector ticks{ + PriceData("1.1001"_dd, "1.1000"_dd, now, "EURUSD"), // open EURUSD LONG + PriceData("7000.5"_dd, "7000.0"_dd, now, "AUSIDXAUD"), // open AUSIDXAUD LONG + PriceData("1.0901"_dd, "1.0900"_dd, now, "EURUSD"), // EURUSD -101: breach + }; + const trading::RiskLimits limits{.startingBalance = "10000"_dd, + .maxLossPercent = "1"_dd}; + + const auto status = trading::runTicks(tm, strategy, ticks, vars, limits); + + XCTAssertTrue(status == trading::RunStatus::LossLimitBreached, + "Combined floating drawdown must breach the limit"); + XCTAssertEqual(tm.getActiveTrades().size(), 0, "Both positions must be liquidated"); + XCTAssertEqual(tm.getClosedTrades().size(), 2, "Both symbols produce a closed trade"); + XCTAssertEqual(tm.calculatePnl(), -"101.5"_dd, + "True PnL combines both liquidations (-101 EURUSD, -0.5 AUSIDXAUD spread)"); + for (const Trade& closed : tm.getClosedTrades()) { + XCTAssertTrue(closed.liquidated, "Every forced close must carry the liquidated flag"); + if (closed.symbol == "EURUSD") { + XCTAssertEqual(closed.closePrice, "1.0900"_dd, "EURUSD closes at the crash bid"); + XCTAssertEqual(closed.pnl, -"101"_dd, "EURUSD realizes the crash drawdown"); + } else { + XCTAssertEqual(closed.closePrice, "7000.0"_dd, + "AUSIDXAUD closes at its own last mark, not a EURUSD price"); + XCTAssertEqual(closed.pnl, -"0.5"_dd, "AUSIDXAUD realizes only its spread cost"); + } + } +} + +// MAX_OPEN_TRADES caps simultaneous positions across the whole run: with a +// cap of 1, the second symbol's signal is skipped while the first position is +// open. The uncapped variant of this setup is testRunTicks_MultiSymbol. +- (void)testRunTicks_MaxOpenTradesCap_BlocksSecondEntry { + TradeManager tm; + ScriptedStrategy strategy({Direction::LONG, Direction::LONG}); + const auto vars = makeVars("0"_dd, "0"_dd, "1.0"_dd); // no exits — first stays open + const auto now = std::chrono::system_clock::now(); + const std::vector ticks{ + PriceData("1.1001"_dd, "1.1000"_dd, now, "EURUSD"), + PriceData("7000.5"_dd, "7000.0"_dd, now, "AUSIDXAUD"), + }; + const trading::RiskLimits limits{.maxOpenTrades = 1}; + + const auto status = trading::runTicks(tm, strategy, ticks, vars, limits); + + XCTAssertTrue(status == trading::RunStatus::Completed, "Cap is not a failure condition"); + XCTAssertEqual(tm.getActiveTrades().size(), 1, "Cap of 1 must block the second entry"); + XCTAssertEqual(tm.getActiveTrades().begin()->second.symbol, std::string("EURUSD"), + "The first signal wins the only slot"); +} + +// A stop-out that breaches the loss limit must end the run on that tick, +// BEFORE the entry phase — so unlike testRunTicks_ExitThenSameTickReentry the +// always-LONG strategy gets no same-tick re-entry, and later ticks never run. +// Floor here: 10000 * 0.1% = 10; the single stop-out loses 11 (incl. spread). +- (void)testRunTicks_LossLimitBreach_StopsRunBeforeReentry { + TradeManager tm; + AlwaysLongStrategy strategy; + const auto vars = makeVars("10"_dd, "0"_dd, "1.0"_dd); // SL only + const auto now = std::chrono::system_clock::now(); + const std::vector ticks{ + PriceData("1.1001"_dd, "1.1000"_dd, now, "EURUSD"), // LONG#1 @ ask 1.1001 + PriceData("1.0991"_dd, "1.0990"_dd, now, "EURUSD"), // stops LONG#1: pnl -11, breach + PriceData("1.0991"_dd, "1.0990"_dd, now, "EURUSD"), // must never be processed + }; + const trading::RiskLimits limits{.startingBalance = "10000"_dd, + .maxLossPercent = "0.1"_dd}; + + const auto status = trading::runTicks(tm, strategy, ticks, vars, limits); + + XCTAssertTrue(status == trading::RunStatus::LossLimitBreached, + "Run must report the loss-limit breach"); + XCTAssertEqual(tm.getClosedTrades().size(), 1, "Only the stopped-out trade should exist"); + XCTAssertFalse(tm.getClosedTrades().front().liquidated, + "A stop-out is an organic close, not a liquidation"); + XCTAssertEqual(tm.getActiveTrades().size(), 0, + "Breach is checked before entries — no re-entry may open"); + XCTAssertEqual(tm.calculatePnl(), -"11"_dd, "Realized PnL at cutoff is the single stop-out"); +} + +// A realized loss inside the limit must not stop the run: same stop-out, but a +// 5% limit (floor -500) comfortably absorbs -11, so the run completes and the +// same-tick re-entry happens as normal. +- (void)testRunTicks_LossWithinLimit_RunsToCompletion { + TradeManager tm; + AlwaysLongStrategy strategy; + const auto vars = makeVars("10"_dd, "0"_dd, "1.0"_dd); // SL only + const auto now = std::chrono::system_clock::now(); + const std::vector ticks{ + PriceData("1.1001"_dd, "1.1000"_dd, now, "EURUSD"), + PriceData("1.0991"_dd, "1.0990"_dd, now, "EURUSD"), // stop-out -11, within -500 + }; + const trading::RiskLimits limits{.startingBalance = "10000"_dd, + .maxLossPercent = "5"_dd}; + + const auto status = trading::runTicks(tm, strategy, ticks, vars, limits); + + XCTAssertTrue(status == trading::RunStatus::Completed, "Run should not be cut off"); + XCTAssertEqual(tm.getClosedTrades().size(), 1, "The stop-out still closes"); + XCTAssertEqual(tm.getActiveTrades().size(), 1, "Same-tick re-entry proceeds as normal"); +} + +// maxLossPercent <= 0 disables the check entirely — losses far past any +// percentage are ignored and the run completes (the experimentation escape +// hatch, no extra flag needed). Cover both 0 and -1 spellings. +- (void)testRunTicks_LossLimitDisabled_ZeroAndNegative { + const auto vars = makeVars("10"_dd, "0"_dd, "1.0"_dd); // SL only + const auto now = std::chrono::system_clock::now(); + const std::vector ticks{ + PriceData("1.1001"_dd, "1.1000"_dd, now, "EURUSD"), + PriceData("1.0991"_dd, "1.0990"_dd, now, "EURUSD"), // stop-out -11 + }; + + for (const auto percent : {"0"_dd, -"1"_dd}) { + TradeManager tm; + AlwaysLongStrategy strategy; + // Tiny balance: -11 realized is over 100% of the account, yet with the + // limit disabled the run must still complete. + const trading::RiskLimits limits{.startingBalance = "10"_dd, + .maxLossPercent = percent}; + + const auto status = trading::runTicks(tm, strategy, ticks, vars, limits); + + XCTAssertTrue(status == trading::RunStatus::Completed, + "maxLossPercent <= 0 must disable the cutoff"); + XCTAssertEqual(tm.getActiveTrades().size(), 1, "Re-entry proceeds unchecked"); + } +} + @end