diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2a65572..a7f22d9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -80,6 +80,15 @@ jobs: fetch-depth: 0 - name: Install dependencies run: sudo apt install -y libssl-dev libpq-dev libcurl4-openssl-dev + # ubuntu-latest (24.04) defaults to GCC 13, whose libstdc++ lacks the + # C++23 header (std::print/std::println). That header was added + # in GCC 14, so install it and make it the default compiler for every + # subsequent step (dependency build, CMake configure, build-wrapper). + - name: Install GCC 14 (for C++23 ) + run: | + sudo apt install -y gcc-14 g++-14 + sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-14 100 + sudo update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-14 100 # Ubuntu's apt Boost (1.83 on 24.04) predates Boost.Redis, which was added # in Boost 1.84, so is absent. Install a newer Boost. # Boost.Redis/Asio are header-only, so we only need the headers plus the diff --git a/.gitignore b/.gitignore index 797a47b..3abd6bb 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,7 @@ xcuserdata/ xcuserstate/ TestResult.xcresult/ sonarqube-generic-coverage.xml -external/libpqxx \ No newline at end of file +.cache/clangd/ +.clangd +dump.rdb +.infisical.json diff --git a/CMakeLists.txt b/CMakeLists.txt index 8886861..538001c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -11,7 +11,7 @@ execute_process( project(BacktestingEngine) # Set the C++ standard -set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD 23) set(CMAKE_CXX_STANDARD_REQUIRED ON) # Configure libpqxx build @@ -40,6 +40,7 @@ include_directories( ${CMAKE_SOURCE_DIR}/include/commands ${CMAKE_SOURCE_DIR}/include/utilities ${CMAKE_SOURCE_DIR}/include/models + ${CMAKE_SOURCE_DIR}/include/reporting ${CMAKE_SOURCE_DIR}/include/trading ${CMAKE_SOURCE_DIR}/include/trading_definitions ${CMAKE_SOURCE_DIR}/include/strategies @@ -64,9 +65,9 @@ if(APPLE) endif() # Boost (Homebrew) — Boost.Redis is header-only but its implementation is -# compiled into one TU via (included in -# source/redisRunner.cpp). It pulls in Boost.Asio's SSL layer, which needs -# OpenSSL. +# compiled into one dedicated TU via (source/ +# boostRedisImpl.cpp), which isolates its macro/SSL pollution from the rest of +# the build. It pulls in Boost.Asio's SSL layer, which needs OpenSSL. find_package(Boost REQUIRED CONFIG) find_package(OpenSSL REQUIRED) find_package(Threads REQUIRED) diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 4d5fb0a..509d17d 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -1,11 +1,8 @@ ### Headers folder (./source/include) -Header files are saved within ./source/include +Header-only headers in include/ are globally available and source/*.cpp is globbed automatically. ### Pragma once -Headers should use `#pragma once` directive. It's non-standard but widely supported preprocessor directive in C and C++. It's used as an include guard to prevent multiple inclusions of the same header file. +Headers should use `#pragma once` directive to guard to prevent multiple inclusions of the same header file. ### Lower Camel Case file names -application.cpp / class Application() - -* Often preferred in Unix-like environments and by many open-source projects. -* Adheres to the convention that most filenames are lowercase, which can be easier to type and less error-prone in case-sensitive file systems +application.cpp / class Application() \ No newline at end of file diff --git a/backtesting-engine-cpp.xcodeproj/project.pbxproj b/backtesting-engine-cpp.xcodeproj/project.pbxproj index be60366..6bf65de 100644 --- a/backtesting-engine-cpp.xcodeproj/project.pbxproj +++ b/backtesting-engine-cpp.xcodeproj/project.pbxproj @@ -26,15 +26,17 @@ 942EC56B2FBEF95000CCBB5D /* redisLoader.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 942EC5652FBEF95000CCBB5D /* redisLoader.cpp */; }; 942EC56C2FBEF95000CCBB5D /* redisRunner.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 942EC5662FBEF95000CCBB5D /* redisRunner.cpp */; }; 942FDDE02FC5C8B20096F318 /* tradingResults.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 942FDDDF2FC5C8B20096F318 /* tradingResults.cpp */; }; - 942FDDE12FC5C8B20096F318 /* elasticClient.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 942FDDDE2FC5C8B20096F318 /* elasticClient.cpp */; }; 942FDDE22FC5C8B20096F318 /* tradingResults.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 942FDDDF2FC5C8B20096F318 /* tradingResults.cpp */; }; - 942FDDE32FC5C8B20096F318 /* elasticClient.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 942FDDDE2FC5C8B20096F318 /* elasticClient.cpp */; }; 942FDDE52FC5C9D30096F318 /* libcurl.4.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 942FDDE42FC5C9D30096F318 /* libcurl.4.tbd */; }; 942FDDE62FC5C9DB0096F318 /* libcurl.4.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 942FDDE42FC5C9D30096F318 /* libcurl.4.tbd */; }; 943398242D57E53400287A2D /* jsonParser.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 943398232D57E53400287A2D /* jsonParser.cpp */; }; 943398252D57E53400287A2D /* jsonParser.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 943398232D57E53400287A2D /* jsonParser.cpp */; }; 943398272D57E54000287A2D /* jsonParser.mm in Sources */ = {isa = PBXBuildFile; fileRef = 943398262D57E54000287A2D /* jsonParser.mm */; }; 94364CB62D416D8D00F35B55 /* db.mm in Sources */ = {isa = PBXBuildFile; fileRef = 94364CB52D416D8000F35B55 /* db.mm */; }; + 9437703F2FD42FC000317424 /* elasticClient.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 9437703D2FD42FC000317424 /* elasticClient.cpp */; }; + 943770402FD42FC000317424 /* elasticClient.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 9437703D2FD42FC000317424 /* elasticClient.cpp */; }; + 943770452FD4396F00317424 /* boostRedisImpl.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 943770442FD4396F00317424 /* boostRedisImpl.cpp */; }; + 943770462FD4396F00317424 /* boostRedisImpl.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 943770442FD4396F00317424 /* boostRedisImpl.cpp */; }; 9464E5F12FA7467200D82BAD /* symbolScale.mm in Sources */ = {isa = PBXBuildFile; fileRef = 9464E5F02FA7467200D82BAD /* symbolScale.mm */; }; 94674B872D533B4000973137 /* tradeManager.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 94674B852D533B4000973137 /* tradeManager.cpp */; }; 94674B882D533B4000973137 /* tradeManager.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 94674B852D533B4000973137 /* tradeManager.cpp */; }; @@ -98,23 +100,26 @@ 942EC5642FBEF95000CCBB5D /* backtestRunner.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = backtestRunner.cpp; sourceTree = ""; }; 942EC5652FBEF95000CCBB5D /* redisLoader.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = redisLoader.cpp; sourceTree = ""; }; 942EC5662FBEF95000CCBB5D /* redisRunner.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = redisRunner.cpp; sourceTree = ""; }; - 942FDDDC2FC5C8950096F318 /* elasticClient.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = elasticClient.hpp; sourceTree = ""; }; 942FDDDD2FC5C8A30096F318 /* tradingResults.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = tradingResults.hpp; sourceTree = ""; }; - 942FDDDE2FC5C8B20096F318 /* elasticClient.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = elasticClient.cpp; sourceTree = ""; }; 942FDDDF2FC5C8B20096F318 /* tradingResults.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = tradingResults.cpp; sourceTree = ""; }; 942FDDE42FC5C9D30096F318 /* libcurl.4.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libcurl.4.tbd; path = usr/lib/libcurl.4.tbd; sourceTree = SDKROOT; }; 943398222D57E52900287A2D /* jsonParser.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = jsonParser.hpp; sourceTree = ""; }; 943398232D57E53400287A2D /* jsonParser.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = jsonParser.cpp; sourceTree = ""; }; 943398262D57E54000287A2D /* jsonParser.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = jsonParser.mm; sourceTree = ""; }; 94364CB52D416D8000F35B55 /* db.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = db.mm; sourceTree = ""; }; + 9437703D2FD42FC000317424 /* elasticClient.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = elasticClient.cpp; sourceTree = ""; }; + 943770412FD42FDD00317424 /* elasticClient.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = elasticClient.hpp; sourceTree = ""; }; + 943770432FD4351100317424 /* parameterSweep.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = parameterSweep.hpp; sourceTree = ""; }; + 943770442FD4396F00317424 /* boostRedisImpl.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = boostRedisImpl.cpp; sourceTree = ""; }; 944D0DC82C8C3704004DD0FC /* LICENSE.MD */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = LICENSE.MD; sourceTree = ""; }; 944D0DC92C8C3704004DD0FC /* build.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = build.sh; sourceTree = ""; }; 944D0DCA2C8C3704004DD0FC /* clean.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = clean.sh; sourceTree = ""; }; 944D0DCC2C8C3704004DD0FC /* run.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = run.sh; sourceTree = ""; }; 944D0DCD2C8C3704004DD0FC /* test.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = test.sh; sourceTree = ""; }; - 944D0DCF2C8C3704004DD0FC /* sonar-project.properties */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "sonar-project.properties"; sourceTree = ""; }; 944D0DD02C8C3704004DD0FC /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 944D0DD32C8C3704004DD0FC /* CMakeLists.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = CMakeLists.txt; sourceTree = ""; }; + 945F475C2FD5607E00D19164 /* queueKeys.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = queueKeys.hpp; sourceTree = ""; }; + 945F475D2FD5614600D19164 /* run_configuration.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = run_configuration.hpp; sourceTree = ""; }; 9464E5EF2FA7466900D82BAD /* symbolScale.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = symbolScale.hpp; sourceTree = ""; }; 9464E5F02FA7467200D82BAD /* symbolScale.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = symbolScale.mm; sourceTree = ""; }; 94674B822D533B1D00973137 /* trade.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = trade.hpp; sourceTree = ""; }; @@ -135,6 +140,9 @@ 94829FC32FCC1D1200710E6E /* env.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = env.hpp; sourceTree = ""; }; 94829FC42FCC1D1A00710E6E /* env.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = env.cpp; sourceTree = ""; }; 948A9CCD2C906A5600E23669 /* CONVENTIONS.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CONVENTIONS.md; sourceTree = ""; }; + 94B1A5992FD735F100CB7C9F /* runLoop.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = runLoop.hpp; sourceTree = ""; }; + 94B4F02A2FD618B300B08FB4 /* backtestLog.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = backtestLog.hpp; sourceTree = ""; }; + 94B4F02B2FD618B300B08FB4 /* threadPool.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = threadPool.hpp; sourceTree = ""; }; 94BBA4512D2EA2640010E04D /* build.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = build.sh; sourceTree = ""; }; 94C331A02FA899A8006BD690 /* decimal_json.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = decimal_json.hpp; sourceTree = ""; }; 94CD832A2D2D22C900041BBA /* config.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = config.yml; sourceTree = ""; }; @@ -1329,6 +1337,7 @@ 941B548F2D3BBA3B00E3BF64 /* trading_definitions */ = { isa = PBXGroup; children = ( + 945F475D2FD5614600D19164 /* run_configuration.hpp */, 941B54972D3BBAA200E3BF64 /* configuration.hpp */, 941B54942D3BBA8300E3BF64 /* strategy.hpp */, 941B54932D3BBA7300E3BF64 /* strategy_variables.hpp */, @@ -1368,6 +1377,22 @@ path = models; sourceTree = ""; }; + 9437703E2FD42FC000317424 /* reporting */ = { + isa = PBXGroup; + children = ( + 9437703D2FD42FC000317424 /* elasticClient.cpp */, + ); + path = reporting; + sourceTree = ""; + }; + 943770422FD42FDD00317424 /* reporting */ = { + isa = PBXGroup; + children = ( + 943770412FD42FDD00317424 /* elasticClient.hpp */, + ); + path = reporting; + sourceTree = ""; + }; 944D0DB52C8C36C0004DD0FC = { isa = PBXGroup; children = ( @@ -1376,7 +1401,6 @@ 94DE4F772C8C3E7C00FE48FF /* include */, 9470B5A22C8C5AD0007D9CC6 /* source */, 9470B5AD2C8C5B99007D9CC6 /* tests */, - 944D0DCF2C8C3704004DD0FC /* sonar-project.properties */, 944D0DD32C8C3704004DD0FC /* CMakeLists.txt */, 944D0DC82C8C3704004DD0FC /* LICENSE.MD */, 944D0DD02C8C3704004DD0FC /* README.md */, @@ -1410,6 +1434,7 @@ 94674B842D533B2F00973137 /* trading */ = { isa = PBXGroup; children = ( + 94B1A5992FD735F100CB7C9F /* runLoop.hpp */, 946EFF802FB9F457008D9647 /* reporting.hpp */, 94674B832D533B2F00973137 /* tradeManager.hpp */, 94674BA02D533B2F00973137 /* exitRules.hpp */, @@ -1446,6 +1471,8 @@ 9470B5A22C8C5AD0007D9CC6 /* source */ = { isa = PBXGroup; children = ( + 943770442FD4396F00317424 /* boostRedisImpl.cpp */, + 9437703E2FD42FC000317424 /* reporting */, 941B54982D3BBAD800E3BF64 /* trading_definitions */, 94674B862D533B4000973137 /* trading */, 94674B8C2D533E7800973137 /* models */, @@ -1454,7 +1481,6 @@ 94280BA72D2FC29F00F1CF56 /* utilities */, 942EC5642FBEF95000CCBB5D /* backtestRunner.cpp */, 942EC5652FBEF95000CCBB5D /* redisLoader.cpp */, - 942FDDDE2FC5C8B20096F318 /* elasticClient.cpp */, 942FDDDF2FC5C8B20096F318 /* tradingResults.cpp */, 942EC5662FBEF95000CCBB5D /* redisRunner.cpp */, 9470B5A32C8C5AD0007D9CC6 /* main.cpp */, @@ -1481,6 +1507,10 @@ 94B8C7932D3D770800E17EB6 /* utilities */ = { isa = PBXGroup; children = ( + 94B4F02A2FD618B300B08FB4 /* backtestLog.hpp */, + 94B4F02B2FD618B300B08FB4 /* threadPool.hpp */, + 945F475C2FD5607E00D19164 /* queueKeys.hpp */, + 943770432FD4351100317424 /* parameterSweep.hpp */, 94829FC32FCC1D1200710E6E /* env.hpp */, 942EC55D2FBEF92F00CCBB5D /* redisConnection.hpp */, 94C331A02FA899A8006BD690 /* decimal_json.hpp */, @@ -3600,6 +3630,7 @@ 94DE4F772C8C3E7C00FE48FF /* include */ = { isa = PBXGroup; children = ( + 943770422FD42FDD00317424 /* reporting */, 94D3A7232FC1B3A600EBEA32 /* commands */, 942966D72D48E84100532862 /* models */, 94674B842D533B2F00973137 /* trading */, @@ -3611,7 +3642,6 @@ 942EC55F2FBEF93A00CCBB5D /* redisLoader.hpp */, 942EC5602FBEF93A00CCBB5D /* redisRunner.hpp */, 940A61162C92CE960083FEB8 /* serviceA.hpp */, - 942FDDDC2FC5C8950096F318 /* elasticClient.hpp */, 943398222D57E52900287A2D /* jsonParser.hpp */, 942FDDDD2FC5C8A30096F318 /* tradingResults.hpp */, 940A61122C92CE210083FEB8 /* configManager.hpp */, @@ -3720,14 +3750,15 @@ 9470B5A42C8C5AD0007D9CC6 /* main.cpp in Sources */, 943398252D57E53400287A2D /* jsonParser.cpp in Sources */, 942FDDE02FC5C8B20096F318 /* tradingResults.cpp in Sources */, - 942FDDE12FC5C8B20096F318 /* elasticClient.cpp in Sources */, 94D3A72A2FC1B41500EBEA32 /* runCommand.cpp in Sources */, + 943770452FD4396F00317424 /* boostRedisImpl.cpp in Sources */, 942EC56A2FBEF95000CCBB5D /* backtestRunner.cpp in Sources */, 942EC56B2FBEF95000CCBB5D /* redisLoader.cpp in Sources */, 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 */, @@ -3755,6 +3786,7 @@ 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 */, 94D601102FA9CD700066F51A /* randomStrategy.cpp in Sources */, @@ -3766,7 +3798,7 @@ 946EFF7F2FB9F44E008D9647 /* reporting.cpp in Sources */, 943398272D57E54000287A2D /* jsonParser.mm in Sources */, 942FDDE22FC5C8B20096F318 /* tradingResults.cpp in Sources */, - 942FDDE32FC5C8B20096F318 /* elasticClient.cpp in Sources */, + 9437703F2FD42FC000317424 /* elasticClient.cpp in Sources */, 9470B5B62C8C5BFD007D9CC6 /* main.cpp in Sources */, 94364CB62D416D8D00F35B55 /* db.mm in Sources */, 940A61142C92CE210083FEB8 /* configManager.cpp in Sources */, @@ -3783,7 +3815,7 @@ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++23"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; @@ -3844,7 +3876,7 @@ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++23"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; diff --git a/include/backtestRunner.hpp b/include/backtestRunner.hpp index 9095295..dc72244 100644 --- a/include/backtestRunner.hpp +++ b/include/backtestRunner.hpp @@ -6,7 +6,20 @@ #pragma once #include +#include +#include "models/priceData.hpp" #include "trading_definitions/configuration.hpp" +// Pulls all tick data for the run's symbols/window out of QuestDB. Expensive — +// call once per run and reuse the result across that run's strategies. +std::vector loadTicks(const std::string& questdbHost, + const std::string& symbolsCsv, + int lastMonths); + +// Runs one backtest against already-loaded ticks (no QuestDB access). +void runBacktestOnTicks(const std::vector& ticks, + const trading_definitions::Configuration& config); + +// Convenience for the direct path: loads ticks then runs a single backtest. int runBacktest(const std::string& questdbHost, const trading_definitions::Configuration& config); diff --git a/include/commands/loadCommand.hpp b/include/commands/loadCommand.hpp index b68e82e..8b952a1 100644 --- a/include/commands/loadCommand.hpp +++ b/include/commands/loadCommand.hpp @@ -6,8 +6,9 @@ #pragma once -// Backs the `load` subcommand: LPUSHes a strategy JSON (defined in -// source/commands/loadCommand.cpp) onto the Redis `strategy_queue`. +// Backs the `load` subcommand: for one RUN_ID, LPUSHes every swept strategy onto +// BACKTESTING_QUEUE_STRATEGY:, then LPUSHes the run descriptor onto +// BACKTESTING_QUEUE_RUN (see source/commands/loadCommand.cpp). class LoadCommand { public: static int run(); diff --git a/include/commands/runCommand.hpp b/include/commands/runCommand.hpp index dd4fb07..75ade68 100644 --- a/include/commands/runCommand.hpp +++ b/include/commands/runCommand.hpp @@ -6,8 +6,9 @@ #pragma once -// Backs the `run` subcommand: executes one strategy popped from the Redis -// `strategy_queue`, or a Base64 config supplied directly on the command line. +// Backs the `run` subcommand: drains BACKTESTING_QUEUE_RUN (loading each run's +// QuestDB ticks once, then running its strategies), or runs a single Base64 +// Configuration supplied directly on the command line. class RunCommand { public: static int run(int argc, const char* argv[]); diff --git a/include/databaseConnection.hpp b/include/databaseConnection.hpp index 1f81d45..404bef5 100644 --- a/include/databaseConnection.hpp +++ b/include/databaseConnection.hpp @@ -4,7 +4,6 @@ // This code is licensed under MIT license (see LICENSE.txt for details) // --------------------------------------- #pragma once -#include #include #include "models/priceData.hpp" @@ -20,7 +19,7 @@ class DatabaseConnection { const std::string& password = ""); void printResults(const std::vector& results) const; - std::vector streamQuery(const std::string& query) const; + std::vector executeQuery(const std::string& query) const; const std::string& getConnectionString() const { return connection_string; diff --git a/include/jsonParser.hpp b/include/jsonParser.hpp index 330da7a..821fc9a 100644 --- a/include/jsonParser.hpp +++ b/include/jsonParser.hpp @@ -13,4 +13,6 @@ class JsonParser { public: static trading_definitions::Configuration parseConfigurationFromBase64(const std::string& input); + static trading_definitions::RunConfiguration parseRunConfigurationFromBase64(const std::string& input); + static trading_definitions::Strategy parseStrategyFromBase64(const std::string& input); }; diff --git a/include/redisLoader.hpp b/include/redisLoader.hpp index 665ca9f..a43b16b 100644 --- a/include/redisLoader.hpp +++ b/include/redisLoader.hpp @@ -7,17 +7,23 @@ #pragma once #include +#include // LPUSH pairs with RedisRunner's RPOP so consumers observe FIFO ordering. class RedisLoader { public: - static int load(const std::string& rawJson, - const std::string& redisHost = "127.0.0.1", - int redisPort = 6379, - const std::string& queueKey = "strategy_queue"); - + // LPUSHes a single Base64-encoded payload onto queueKey without assuming a + // payload type (run descriptor or strategy). static int loadPayload(const std::string& redisHost, int redisPort, const std::string& queueKey, const std::string& rawJson); + + // LPUSHes many payloads onto queueKey over one connection. Each payload is + // Base64-encoded; order is preserved (RedisRunner's RPOP then yields them + // in insertion order). + static int loadPayloadBatch(const std::string& redisHost, + int redisPort, + const std::string& queueKey, + const std::vector& rawJsonPayloads); }; diff --git a/include/redisRunner.hpp b/include/redisRunner.hpp index 763fcd1..55ef5b6 100644 --- a/include/redisRunner.hpp +++ b/include/redisRunner.hpp @@ -7,10 +7,14 @@ #pragma once #include +// Worker entry point. Drains BACKTESTING_QUEUE_RUN: for each run it loads the +// QuestDB tick data once, then drains that run's per-RUN_ID strategy list, +// running every strategy against the cached ticks. Safe to launch many workers +// concurrently — they peek the same run, load ticks once each, and compete on +// RPOP of the shared strategy list. class RedisRunner { public: static int run(const std::string& questdbHost, const std::string& redisHost = "127.0.0.1", - int redisPort = 6379, - const std::string& queueKey = "strategy_queue"); + int redisPort = 6379); }; diff --git a/include/reporting/elasticClient.hpp b/include/reporting/elasticClient.hpp new file mode 100644 index 0000000..7411546 --- /dev/null +++ b/include/reporting/elasticClient.hpp @@ -0,0 +1,18 @@ +// 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/sqlManager.hpp b/include/sqlManager.hpp index 04ff5a8..592b007 100644 --- a/include/sqlManager.hpp +++ b/include/sqlManager.hpp @@ -11,5 +11,5 @@ class SqlManager { public: - static std::vector streamPriceData(const DatabaseConnection& db, const std::vector& symbols, int LAST_MONTHS = 1); + static std::vector loadPriceData(const DatabaseConnection& db, const std::vector& symbols, int LAST_MONTHS = 1); }; diff --git a/include/trading/runLoop.hpp b/include/trading/runLoop.hpp new file mode 100644 index 0000000..3a3395d --- /dev/null +++ b/include/trading/runLoop.hpp @@ -0,0 +1,56 @@ +// 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 +#include "tradeManager.hpp" +#include "reviewStopAndLimit.hpp" +#include "models/priceData.hpp" +#include "strategies/strategy.hpp" // IStrategy +#include "trading_definitions/trading_variables.hpp" + +namespace trading { + +// 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 +// TradeManager and (random) strategy and delegates the loop here. +// +// Ordering matters: reviewStopAndLimit runs BEFORE the entry check on each +// tick, so a trade that hits its stop/limit closes first and frees its symbol; +// 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) { + for (const auto& tick : ticks) { + + // 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 (auto signal = strategy.decide(tick)) { + tradeManager.openTrade(tick, + vars.TRADING_SIZE, + *signal, + vars.STOP_DISTANCE_IN_PIPS, + vars.LIMIT_DISTANCE_IN_PIPS); + } + } + + // Strategy-driven management hook for non-SL/TP exit logic + // (e.g. trailing stops, partial closes). The default + // RandomStrategy implementation is a no-op now that exits are + // handled by reviewStopAndLimit above. + strategy.during(tick, tradeManager); + } +} + +} // namespace trading diff --git a/include/tradingResults.hpp b/include/tradingResults.hpp index ea8d740..242ca17 100644 --- a/include/tradingResults.hpp +++ b/include/tradingResults.hpp @@ -36,6 +36,7 @@ struct TradingResultsStats { struct TradingResults { std::string RUN_ID; std::string timestamp; + double durationSeconds; // wall-clock seconds for this run's backtest trading_definitions::Configuration config; TradingResultsStats results; diff --git a/include/trading_definitions.hpp b/include/trading_definitions.hpp index 3d6cba2..b454130 100644 --- a/include/trading_definitions.hpp +++ b/include/trading_definitions.hpp @@ -11,3 +11,4 @@ #include "trading_definitions/strategy_variables.hpp" #include "trading_definitions/strategy.hpp" #include "trading_definitions/configuration.hpp" +#include "trading_definitions/run_configuration.hpp" diff --git a/include/trading_definitions/configuration.hpp b/include/trading_definitions/configuration.hpp index c542ee8..68fb351 100644 --- a/include/trading_definitions/configuration.hpp +++ b/include/trading_definitions/configuration.hpp @@ -7,7 +7,7 @@ #pragma once #include #include -#include "strategy.hpp" +#include "trading_definitions/strategy.hpp" namespace trading_definitions { struct Configuration { diff --git a/include/trading_definitions/run_configuration.hpp b/include/trading_definitions/run_configuration.hpp new file mode 100644 index 0000000..a67f550 --- /dev/null +++ b/include/trading_definitions/run_configuration.hpp @@ -0,0 +1,27 @@ +// 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 +#include + +namespace trading_definitions { +// 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. +struct RunConfiguration { + std::string RUN_ID; + std::string SYMBOLS; + int LAST_MONTHS; +}; +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(RunConfiguration, + RUN_ID, + SYMBOLS, + LAST_MONTHS + ); + +}; diff --git a/include/utilities/backtestLog.hpp b/include/utilities/backtestLog.hpp new file mode 100644 index 0000000..32bdb14 --- /dev/null +++ b/include/utilities/backtestLog.hpp @@ -0,0 +1,38 @@ +// Backtesting Engine in C++ +// +// (c) 2026 Ryan McCaffery | https://mccaffers.com +// This code is licensed under MIT license (see LICENSE.txt for details) +// --------------------------------------- + +#ifndef UTILITIES_BACKTEST_LOG_HPP +#define UTILITIES_BACKTEST_LOG_HPP + +#include +#include +#include +#include + +// Cross-cutting logging switches for backtest execution. +// +// When backtests run concurrently (see RedisRunner's thread pool) the chatty +// per-strategy output would both interleave and race on the shared std::cout / +// std::cerr. RedisRunner sets `quiet` so those call sites skip their output +// entirely (no stream access -> no race), while error() serialises the rare +// failure path behind a mutex so genuine problems still surface safely. +namespace backtest_log { + +inline std::atomic quiet{false}; + +inline std::mutex& errorMutex() { + static std::mutex m; + return m; +} + +inline void error(std::string_view message) { + std::scoped_lock lock(errorMutex()); + std::cerr << message << std::endl; +} + +} // namespace backtest_log + +#endif // UTILITIES_BACKTEST_LOG_HPP diff --git a/include/utilities/parameterSweep.hpp b/include/utilities/parameterSweep.hpp new file mode 100644 index 0000000..1108a9b --- /dev/null +++ b/include/utilities/parameterSweep.hpp @@ -0,0 +1,182 @@ +// 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 +#include +#include +#include +#include +#include +#include +#include + +// Parameter sweep generator: declare named numeric ranges and expand them into +// every combination (the cartesian product), so a strategy can be backtested +// across a grid of parameters in a single `load`. +// +// This is the C++ analogue of the C# StrategyParameterGenerator. C++ has no +// reflection, so a combination is returned keyed by parameter name +// (sweep::Combination) and the caller maps those values onto the strongly-typed +// Configuration fields (see source/commands/loadCommand.cpp). +namespace sweep { + +// Abstract numeric range, the analogue of C#'s IParameterRange. A range knows +// how to enumerate the discrete values a single parameter should take. +class ParameterRange { +public: + virtual ~ParameterRange() = default; + virtual std::vector generateValues() const = 0; +}; + +// Inclusive [start, end] stepped by `step`, e.g. {80, 20, 140} -> 80,100,120,140. +// The epsilon absorbs floating point drift so the upper bound stays inclusive. +class LinearRange final : public ParameterRange { +public: + LinearRange(double start, double step, double end) + : start_(start), step_(step), end_(end) {} + + std::vector generateValues() const override { + std::vector values; + if (step_ <= 0.0) { // defensive: a non-positive step cannot advance + values.push_back(start_); + return values; + } + for (double value = start_; value <= end_ + 1e-9; value += step_) { + values.push_back(value); + } + return values; + } + +private: + double start_; + double step_; + double end_; +}; + +// An explicit set of values, e.g. {1, 3, 5, 8}, analogue of C#'s +// ExplicitParameterRange / AddParameterList. +class ExplicitRange final : public ParameterRange { +public: + explicit ExplicitRange(std::vector values) + : values_(std::move(values)) {} + + std::vector generateValues() const override { return values_; } + +private: + std::vector values_; +}; + +// base * multiplier^i for `iterations` values, e.g. {10, 2, 5} -> 10,20,40,80,160. +class ExponentialRange final : public ParameterRange { +public: + ExponentialRange(double base, double multiplier, int iterations) + : base_(base), multiplier_(multiplier), iterations_(iterations) {} + + std::vector generateValues() const override { + std::vector values; + if (iterations_ > 0) { + values.reserve(static_cast(iterations_)); + } + double current = base_; + for (int i = 0; i < iterations_; ++i) { + values.push_back(current); + current *= multiplier_; + } + return values; + } + +private: + double base_; + double multiplier_; + int iterations_; +}; + +// One point in the parameter grid: parameter name -> chosen value. Values are +// stored as doubles; integer parameters round-trip exactly, so getInt() is safe. +class Combination { +public: + bool has(const std::string& name) const { return values_.count(name) > 0; } + + double get(const std::string& name) const { + const auto it = values_.find(name); + if (it == values_.end()) { + throw std::out_of_range( + "sweep::Combination: unknown parameter '" + name + "'"); + } + return it->second; + } + + int getInt(const std::string& name) const { + return static_cast(std::lround(get(name))); + } + + const std::map& values() const { return values_; } + +private: + friend class ParameterGenerator; + std::map values_; +}; + +// Holds the registered ranges and expands them into every combination. +class ParameterGenerator { +public: + // Register an arbitrary range under `name`. The convenience overloads below + // mirror the C# Add* helpers and cover the common cases. + ParameterGenerator& addRange(std::string name, + std::unique_ptr range) { + ranges_.emplace_back(std::move(name), std::move(range)); + return *this; + } + + ParameterGenerator& addRange(std::string name, double start, double step, + double end) { + return addRange(std::move(name), + std::make_unique(start, step, end)); + } + + ParameterGenerator& addList(std::string name, std::vector values) { + return addRange(std::move(name), + std::make_unique(std::move(values))); + } + + ParameterGenerator& addExponential(std::string name, double base, + double multiplier, int iterations) { + return addRange( + std::move(name), + std::make_unique(base, multiplier, iterations)); + } + + // Expand every registered range into the full cartesian product. Ranges are + // expanded in registration order, so the last-registered parameter varies + // fastest, keeping the output order stable and predictable. + std::vector generateAllCombinations() const { + std::vector result(1); // seed with one empty combination + for (const auto& [name, range] : ranges_) { + const std::vector values = range->generateValues(); + std::vector expanded; + expanded.reserve(result.size() * values.size()); + for (const Combination& base : result) { + for (const double value : values) { + Combination next = base; + next.values_[name] = value; + expanded.push_back(std::move(next)); + } + } + result = std::move(expanded); + } + return result; + } + + std::size_t parameterCount() const { return ranges_.size(); } + +private: + // A vector (not a map) preserves registration order for reproducible output. + std::vector>> ranges_; +}; + +} // namespace sweep diff --git a/include/utilities/queueKeys.hpp b/include/utilities/queueKeys.hpp new file mode 100644 index 0000000..e98d1d2 --- /dev/null +++ b/include/utilities/queueKeys.hpp @@ -0,0 +1,22 @@ +// 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 + +// Redis keys for the two-tier work queue. A sweep produces one run descriptor on +// RUN and N strategy payloads on STRATEGY_PREFIX + RUN_ID, all linked by RUN_ID. +namespace queue_keys { + +inline constexpr const char* RUN = "BACKTESTING_QUEUE_RUN"; +inline constexpr const char* STRATEGY_PREFIX = "BACKTESTING_QUEUE_STRATEGY:"; + +inline std::string strategyKey(const std::string& runId) { + return std::string(STRATEGY_PREFIX) + runId; +} + +} // namespace queue_keys diff --git a/include/utilities/threadPool.hpp b/include/utilities/threadPool.hpp new file mode 100644 index 0000000..f47da35 --- /dev/null +++ b/include/utilities/threadPool.hpp @@ -0,0 +1,119 @@ +// Backtesting Engine in C++ +// +// (c) 2026 Ryan McCaffery | https://mccaffers.com +// This code is licensed under MIT license (see LICENSE.txt for details) +// --------------------------------------- + +#ifndef UTILITIES_THREAD_POOL_HPP +#define UTILITIES_THREAD_POOL_HPP + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// A small fixed-size pool for CPU-bound work, built on std::jthread. +// +// std::jthread (not std::thread) buys us three things here: +// * Constructor exception safety: if the OS throws while spawning the Nth +// worker, unwinding the vector auto-request_stop()s and joins the workers +// already started. std::thread would hit un-joined threads -> terminate(). +// * A trivial destructor: ~std::jthread does request_stop() then join(), so +// there is no manual stop flag, signal, or join loop to maintain. +// * Native cancellation: each worker takes a std::stop_token and waits with +// condition_variable_any, so a stop request wakes the wait directly. +// +// submit() applies backpressure: it blocks once `capacity_` tasks are in flight +// (queued + executing). This bounds memory and paces the producer to the +// workers. wait() blocks until everything has finished and never throws, so it +// is safe to call during stack unwinding (e.g. from a destructor). The first +// exception thrown by any task is captured and surfaced via takeError(). +class ThreadPool { +public: + explicit ThreadPool(std::size_t threads) : capacity_(threads * 2) { + workers_.reserve(threads); + for (std::size_t i = 0; i < threads; ++i) { + workers_.emplace_back([this](std::stop_token st) { workerLoop(st); }); + } + } + + ThreadPool(const ThreadPool&) = delete; + ThreadPool& operator=(const ThreadPool&) = delete; + + // Enqueue a task, blocking while `capacity_` tasks are already in flight. + void submit(std::function task) { + std::unique_lock lock(mutex_); + slotFree_.wait(lock, [this] { return inFlight_ < capacity_; }); + tasks_.push(std::move(task)); + ++inFlight_; + workAvailable_.notify_one(); + } + + // Block until no task is queued or executing. Never throws. + void wait() { + std::unique_lock lock(mutex_); + idle_.wait(lock, [this] { return inFlight_ == 0; }); + } + + // Returns and clears the first exception captured from a task, if any. + std::exception_ptr takeError() { + std::scoped_lock lock(mutex_); + return std::exchange(firstError_, nullptr); + } + +private: + void workerLoop(std::stop_token st) { + for (;;) { + std::function task; + { + std::unique_lock lock(mutex_); + workAvailable_.wait(lock, st, [this] { return !tasks_.empty(); }); + if (tasks_.empty()) { + return; // woken by a stop request with nothing left to do + } + task = std::move(tasks_.front()); + tasks_.pop(); + } + + std::exception_ptr err; + try { + task(); + } catch (...) { + err = std::current_exception(); + } + + { + std::scoped_lock lock(mutex_); + if (err && !firstError_) { + firstError_ = err; + } + --inFlight_; + if (inFlight_ == 0) { + idle_.notify_all(); + } + } + slotFree_.notify_one(); + } + } + + std::mutex mutex_; + std::condition_variable_any workAvailable_; // workers wait here (stop-aware) + std::condition_variable slotFree_; // submit() waits here + std::condition_variable idle_; // wait() waits here + std::queue> tasks_; + std::size_t inFlight_ = 0; + std::size_t capacity_; + std::exception_ptr firstError_; + + // Declared last so the jthreads stop and join before the synchronisation + // members they touch above are destroyed. + std::vector workers_; +}; + +#endif // UTILITIES_THREAD_POOL_HPP diff --git a/scripts/build.sh b/scripts/build.sh index 9313381..0bb830f 100644 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -27,7 +27,7 @@ fi # 1. Generate build files (Passing your CXX flags directly to CMake instead of configure) cmake .. \ - -DCMAKE_CXX_STANDARD=20 \ + -DCMAKE_CXX_STANDARD=23 \ -DCMAKE_BUILD_TYPE=Release \ -DSKIP_BUILD_TEST=ON \ -DCMAKE_EXPORT_COMPILE_COMMANDS=ON diff --git a/scripts/build_dep.sh b/scripts/build_dep.sh index e70943f..6ddabe9 100644 --- a/scripts/build_dep.sh +++ b/scripts/build_dep.sh @@ -12,21 +12,35 @@ EXTERNAL_DIR="$SCRIPT_DIR/../external" JOBS=$(sysctl -n hw.ncpu 2>/dev/null || nproc 2>/dev/null || echo 4) +# On macOS, pin the SDK the same way the top-level CMakeLists does, via +# `xcrun --show-sdk-path`. Without this, the standalone dependency build picks +# whatever SDK CMake guesses and caches it — which breaks the moment that SDK +# path changes (e.g. an Xcode update), leaving clang pointed at a deleted +# sysroot and unable to find the C++ standard headers. Empty (and unused) off +# macOS, where xcrun does not exist. +SDK_PATH="$(xcrun --show-sdk-path 2>/dev/null || true)" + # Build a CMake dependency. The body runs in a subshell `( ... )`, # so the `cd` only affects this build — control returns to the caller # in its original directory automatically. build_dep() { local dep_dir="$1" + local -a cmake_args=( + -DCMAKE_CXX_STANDARD=23 + -DCMAKE_BUILD_TYPE=Release + -DSKIP_BUILD_TEST=ON + -DCMAKE_CXX_FLAGS="-w" + -DCMAKE_C_FLAGS="-w" + ) + [ -n "$SDK_PATH" ] && cmake_args+=("-DCMAKE_OSX_SYSROOT=$SDK_PATH") ( cd "$dep_dir" + # Drop any stale CMake cache so a previously recorded (and possibly + # now-removed) SDK/compiler path can't poison the reconfigure. + rm -rf build mkdir -p build cd build - cmake .. \ - -DCMAKE_CXX_STANDARD=20 \ - -DCMAKE_BUILD_TYPE=Release \ - -DSKIP_BUILD_TEST=ON \ - -DCMAKE_CXX_FLAGS="-w" \ - -DCMAKE_C_FLAGS="-w" + cmake .. "${cmake_args[@]}" cmake --build . --parallel "$JOBS" ) } diff --git a/scripts/local_test_coverage.sh b/scripts/local_test_coverage.sh index 91d2e25..c93a086 100644 --- a/scripts/local_test_coverage.sh +++ b/scripts/local_test_coverage.sh @@ -1,3 +1,4 @@ +#!/bin/bash rm -rf ./TestResult; rm -rf ./TestResult.xcresult; rm -rf ./sonarqube-generic-coverage.xml @@ -15,4 +16,4 @@ xcodebuild \ -derivedDataPath "/tmp" \ clean build test -bash ./.github/workflows/xccov-to-sonarqube-generic.sh *.xcresult/ > sonarqube-generic-coverage.xml \ No newline at end of file +bash ./.github/workflows/scripts/xccov-to-sonarqube-generic.sh *.xcresult/ > sonarqube-generic-coverage.xml \ No newline at end of file diff --git a/scripts/run.sh b/scripts/run.sh index c1d9f12..c42427b 100644 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -8,6 +8,8 @@ for var in "${required_vars[@]}"; do done if [[ ${#missing[@]} -gt 0 ]]; then echo "Error: missing required environment variables: ${missing[*]}" + echo "eg. infisical run -- sh ./scripts/run.sh (if using infisical)" + echo "or ELASTIC_HOST=localhost ELASTIC_USER=elastic ELASTIC_USER_PASSWORD=password REDIS_HOST=localhost sh scripts/run.sh" exit 1 fi diff --git a/source/backtestRunner.cpp b/source/backtestRunner.cpp index 7d9ceae..c2935ba 100644 --- a/source/backtestRunner.cpp +++ b/source/backtestRunner.cpp @@ -15,24 +15,37 @@ #include "operations.hpp" #include "sqlManager.hpp" -int runBacktest(const std::string& questdbHost, - const trading_definitions::Configuration& config) { +std::vector loadTicks(const std::string& questdbHost, + const std::string& symbolsCsv, + int lastMonths) { DatabaseConnection db(questdbHost, 8812, "qdb", "admin", "quest"); // Get a list of symbols std::vector symbols; - std::istringstream ss(config.SYMBOLS); + std::istringstream ss(symbolsCsv); for (std::string token; std::getline(ss, token, ',');) { symbols.push_back(token); } // Get all the tick data out of QuestDB for these symbols std::vector ticks = - SqlManager::streamPriceData(db, symbols, config.LAST_MONTHS); + SqlManager::loadPriceData(db, symbols, lastMonths); printf("Total ticks streamed: %zu\n", ticks.size()); + return ticks; +} + +void runBacktestOnTicks(const std::vector& ticks, + const trading_definitions::Configuration& config) { Operations::run(ticks, config); +} + +int runBacktest(const std::string& questdbHost, + const trading_definitions::Configuration& config) { + const std::vector ticks = + loadTicks(questdbHost, config.SYMBOLS, config.LAST_MONTHS); + runBacktestOnTicks(ticks, config); return 0; } diff --git a/source/boostRedisImpl.cpp b/source/boostRedisImpl.cpp new file mode 100644 index 0000000..c2b9401 --- /dev/null +++ b/source/boostRedisImpl.cpp @@ -0,0 +1,15 @@ +// Backtesting Engine in C++ +// +// (c) 2026 Ryan McCaffery | https://mccaffers.com +// This code is licensed under MIT license (see LICENSE.txt for details) +// --------------------------------------- +// +// Single translation unit dedicated to compiling the Boost.Redis (header-only) +// implementation. Boost.Redis pulls in Boost.Asio's SSL layer and OpenSSL, +// which leak C-style macros (Apple's signbit, OpenSSL namespace pollutants, +// Windows min/max). Confining src.hpp to its own object file keeps that blast +// radius away from nlohmann/json and the core business logic, and means the +// heavy C++20 coroutine/template machinery is parsed and compiled only once. +// +// Nothing else belongs here — no project headers, no JSON, no business logic. +#include diff --git a/source/commands/loadCommand.cpp b/source/commands/loadCommand.cpp index 1e0b1f6..d9e1b9d 100644 --- a/source/commands/loadCommand.cpp +++ b/source/commands/loadCommand.cpp @@ -6,38 +6,147 @@ #include "loadCommand.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include #include #include +#include +#include + #include "env.hpp" +#include "parameterSweep.hpp" +#include "queueKeys.hpp" #include "redisLoader.hpp" -#include "trading_definitions.hpp" +#include "run_configuration.hpp" +#include "trading_definitions/strategy.hpp" -int LoadCommand::run() { +namespace { + +using trading_definitions::RunConfiguration; +using trading_definitions::Strategy; + +// Converts a swept double into a decimal64_t via its shortest round-trip decimal +// string. Routing through text (rather than constructing from the binary double) +// stops clean decimals like 1.5 / 2.0 from snapping to an IEEE-754 neighbour, +// consistent with how decimal_json.hpp moves values through JSON. Sweep with +// binary-exact steps (halves, quarters) or explicit lists to keep this exact. +boost::decimal::decimal64_t toDecimal(double value) { + std::array buffer; + const auto [ptr, ec] = + std::to_chars(buffer.data(), buffer.data() + buffer.size(), value); + if (ec != std::errc{}) { + throw std::runtime_error("loadCommand: to_chars failed for swept decimal"); + } + boost::decimal::decimal64_t result; + const auto parsed = boost::decimal::from_chars(buffer.data(), ptr, result); + if (parsed.ec != std::errc{}) { + throw std::runtime_error("loadCommand: from_chars failed for swept decimal"); + } + return result; +} + +// Declares which parameters to sweep for the RandomStrategy. Keeping the ranges +// in one place means a new strategy (or extra swept parameter) is a localised +// edit: register it here, then read it back in makeConfiguration(). +sweep::ParameterGenerator buildRandomStrategySweep() { + sweep::ParameterGenerator generator; + // generator.addRange("OHLC_COUNT", 80, 20, 140); // 80, 100, 120, 140 + // generator.addList("OHLC_MINUTES", {1, 3, 5, 8}); + generator.addList("STOP_DISTANCE_IN_PIPS", {1.0, 1.5, 10.0}); + generator.addList("LIMIT_DISTANCE_IN_PIPS", {1.0, 1.5, 10.0}); + 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. +RunConfiguration makeRunConfiguration(const std::string& runId) { + return RunConfiguration{ + .RUN_ID = runId, + .SYMBOLS = "EURUSD", + .LAST_MONTHS = 6, + }; +} + +// Maps one point in the parameter grid onto a Strategy. Fields not being swept +// keep their fixed defaults; swept fields are pulled from `combo`. +Strategy makeStrategy(const sweep::Combination& combo) { using namespace boost::decimal::literals; using namespace trading_definitions; - const Configuration config{ - .RUN_ID = "UNIQUE_IDENTIFIER", - .SYMBOLS = "EURUSD,AUDUSD", - .LAST_MONTHS = 2, - .STRATEGY = Strategy{ - .UUID = "", - .TRADING_VARIABLES = TradingVariables{ - .STRATEGY = "RandomStrategy", - .STOP_DISTANCE_IN_PIPS = 1.5_DD, - .LIMIT_DISTANCE_IN_PIPS = 1.5_DD, - .TRADING_SIZE = 1_DD, - }, - .OHLC_VARIABLES = { - OHLCVariables{.OHLC_COUNT = 60, .OHLC_MINUTES = 100}, - }, - .STRATEGY_VARIABLES = StrategyVariables{ - .OHLC_RSI_VARIABLES = OHLCRSIVariables{.RSI_LONG = 60, .RSI_SHORT = 40}, + return Strategy{ + // Each parameter combination gets its own UUID so a single backtest + // result is uniquely identifiable and traceable back to its inputs. + .UUID = boost::uuids::to_string(boost::uuids::random_generator()()), + .TRADING_VARIABLES = TradingVariables{ + .STRATEGY = "RandomStrategy", + .STOP_DISTANCE_IN_PIPS = toDecimal(combo.get("STOP_DISTANCE_IN_PIPS")), + .LIMIT_DISTANCE_IN_PIPS = toDecimal(combo.get("LIMIT_DISTANCE_IN_PIPS")), + .TRADING_SIZE = 1_DD, + }, + .OHLC_VARIABLES = { + OHLCVariables{ + // Only read OHLC params when the sweep actually registers them; + // they default to 0 otherwise (see buildRandomStrategySweep). + .OHLC_COUNT = combo.has("OHLC_COUNT") ? combo.getInt("OHLC_COUNT") : 0, + .OHLC_MINUTES = combo.has("OHLC_MINUTES") ? combo.getInt("OHLC_MINUTES") : 0, }, }, + .STRATEGY_VARIABLES = StrategyVariables{ + .OHLC_RSI_VARIABLES = OHLCRSIVariables{.RSI_LONG = 60, .RSI_SHORT = 40}, + }, }; +} + +} // namespace + +int LoadCommand::run() { + // One RUN_ID identifies the whole sweep; each combination becomes its own + // queue entry, distinguished by its parameter values. + const auto runId = boost::uuids::to_string(boost::uuids::random_generator()()); + const auto redisHost = env::getOr("REDIS_HOST", "127.0.0.1"); + + // Build random here + const auto generator = buildRandomStrategySweep(); + + const auto combinations = generator.generateAllCombinations(); + + std::println("LoadCommand: sweeping {} parameter combination(s) for RUN_ID={}", + combinations.size(), runId); + + // Serialise every swept strategy first. combinations is a sized range, so + // std::ranges::to reserves up front (no manual reserve needed). The json type + // is pinned explicitly because makeStrategy returns a Strategy and relies on + // the implicit conversion for .dump(). + const auto strategyPayloads = + combinations | std::views::transform([](const sweep::Combination& combo) { + const nlohmann::json j = makeStrategy(combo); + return j.dump(); + }) | std::ranges::to>(); + + // Push all strategies BEFORE the run descriptor. A worker that sees the run + // immediately drains the strategy list and retires the run when empty, so the + // full set must already be present the moment the run becomes visible. + const auto strategyKey = queue_keys::strategyKey(runId); + const auto strategyStatus = RedisLoader::loadPayloadBatch( + redisHost, 6379, strategyKey, strategyPayloads); + if (strategyStatus != 0) { + return strategyStatus; + } - const nlohmann::json j = config; - return RedisLoader::load(j.dump(), env::getOr("REDIS_HOST", "127.0.0.1")); + // Now advertise the run so workers can pick it up. runJson is pinned to + // nlohmann::json (not auto) because makeRunConfiguration returns a + // RunConfiguration and relies on the implicit conversion for .dump(). + const nlohmann::json runJson = makeRunConfiguration(runId); + return RedisLoader::loadPayload(redisHost, 6379, queue_keys::RUN, + runJson.dump()); } diff --git a/source/commands/runCommand.cpp b/source/commands/runCommand.cpp index fe0a9b3..e306433 100644 --- a/source/commands/runCommand.cpp +++ b/source/commands/runCommand.cpp @@ -6,7 +6,7 @@ #include "runCommand.hpp" -#include +#include #include #include "backtestRunner.hpp" @@ -14,25 +14,19 @@ #include "jsonParser.hpp" #include "redisRunner.hpp" -namespace { - -int runBacktestFromBase64(const std::string& questdbHost, - const std::string& base64Config) { - auto config = JsonParser::parseConfigurationFromBase64(base64Config); - return runBacktest(questdbHost, config); -} - -} // namespace - int RunCommand::run(int argc, const char* argv[]) { + if (argc < 3) { - std::cerr << "Usage: BacktestingEngine run \n" - << " BacktestingEngine run " - << std::endl; + std::println(stderr, "Usage: BacktestingEngine run \n" + " BacktestingEngine run "); return 1; } + if (argc == 3) { return RedisRunner::run(argv[2], env::getOr("REDIS_HOST", "127.0.0.1")); } - return runBacktestFromBase64(argv[2], argv[3]); -} + + // Else we'll just parse one base64 blob + auto config = JsonParser::parseConfigurationFromBase64(argv[3]); + return runBacktest(argv[2], config); +} \ No newline at end of file diff --git a/source/databaseConnection.cpp b/source/databaseConnection.cpp index c173f22..7e8d708 100644 --- a/source/databaseConnection.cpp +++ b/source/databaseConnection.cpp @@ -5,11 +5,10 @@ // --------------------------------------- #include "databaseConnection.hpp" -#include "base64.hpp" #include #include -#include #include +#include #include #include @@ -59,7 +58,7 @@ DatabaseConnection::DatabaseConnection(const std::string& endpoint, int port, } -std::vector DatabaseConnection::streamQuery(const std::string& query) const { +std::vector DatabaseConnection::executeQuery(const std::string& query) const { pqxx::connection conn(this->connection_string); pqxx::nontransaction txn(conn); pqxx::result result = txn.exec(query); diff --git a/source/operations.cpp b/source/operations.cpp index 4c1456d..e4c855f 100644 --- a/source/operations.cpp +++ b/source/operations.cpp @@ -5,16 +5,18 @@ // --------------------------------------- #include "operations.hpp" +#include +#include #include #include #include #include -#include +#include "backtestLog.hpp" #include "tradeManager.hpp" -#include "reviewStopAndLimit.hpp" +#include "runLoop.hpp" #include "reporting.hpp" #include "tradingResults.hpp" -#include "elasticClient.hpp" +#include "reporting/elasticClient.hpp" #include "strategies/strategy.hpp" #include "strategies/randomStrategy.hpp" #include "strategies/strategyErrors.hpp" @@ -36,37 +38,39 @@ std::unique_ptr selectStrategy(const trading_definitions::Configurati void Operations::run(const std::vector& ticks, const trading_definitions::Configuration& config) { + // Function-local (stack) start time: each worker thread times only its own + // run. steady_clock is monotonic, the correct clock for elapsed durations. + const auto runStart = std::chrono::steady_clock::now(); + + std::println("Operations: new run starting RUN_ID={} strategy={}", + config.RUN_ID, + config.STRATEGY.TRADING_VARIABLES.STRATEGY); + auto tradeManager = std::make_unique(); auto strategy = selectStrategy(config); - const auto& tradingVars = config.STRATEGY.TRADING_VARIABLES; - - for (const auto& tick : ticks) { + // 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); - // 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); + Reporting::summarise(*tradeManager); - if (!tradeManager->hasActiveTradeForSymbol(tick.symbol)) { - if (auto signal = strategy->decide(tick)) { - tradeManager->openTrade(tick, - tradingVars.TRADING_SIZE, - *signal, - tradingVars.STOP_DISTANCE_IN_PIPS, - tradingVars.LIMIT_DISTANCE_IN_PIPS); - } - } + // Elapsed backtest time for this run, measured from the top of run(). The + // Elasticsearch PUT below is deliberately excluded so the duration reflects + // compute, not network latency. + const std::chrono::duration elapsed = + std::chrono::steady_clock::now() - runStart; + const double durationSeconds = elapsed.count(); - // Strategy-driven management hook for non-SL/TP exit logic - // (e.g. trailing stops, partial closes). The default - // RandomStrategy implementation is a no-op now that exits are - // handled by reviewStopAndLimit above. - strategy->during(tick, *tradeManager); + // 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); } - Reporting::summarise(*tradeManager); - // 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 @@ -77,15 +81,15 @@ void Operations::run(const std::vector& ticks, const TradingResults results{ config.RUN_ID, TradingResults::nowIsoUtc(), + durationSeconds, config, Reporting::collect(*tradeManager), }; ElasticClient::putTradingResults(results); } catch (const std::exception& e) { - std::cerr << "Operations: trading-results put failed: " << e.what() - << std::endl; + backtest_log::error(std::string("Operations: trading-results put failed: ") + + e.what()); } catch (...) { - std::cerr << "Operations: trading-results put failed: unknown error" - << std::endl; + backtest_log::error("Operations: trading-results put failed: unknown error"); } } diff --git a/source/redisImpl.cpp b/source/redisImpl.cpp new file mode 100644 index 0000000..8fa26ad --- /dev/null +++ b/source/redisImpl.cpp @@ -0,0 +1,11 @@ +// 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/redisLoader.cpp b/source/redisLoader.cpp index 24ce5cf..524f60a 100644 --- a/source/redisLoader.cpp +++ b/source/redisLoader.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -22,10 +23,8 @@ #include #include -#include - #include "base64.hpp" -#include "jsonParser.hpp" +#include "redisConnection.hpp" namespace asio = boost::asio; namespace redis = boost::redis; @@ -46,26 +45,39 @@ asio::awaitable pushOnce( co_return; } +// Pipelines one LPUSH per payload over a single connection (one round trip). +// LPUSH prepends, so RPOP later yields the payloads in insertion order. +asio::awaitable pushManyOnce( + std::shared_ptr conn, + std::string queueKey, + std::vector encodedPayloads) { + redis::request req; + for (const std::string& encoded : encodedPayloads) { + req.push("LPUSH", queueKey, encoded); + } + + redis::generic_response resp; + co_await conn->async_exec(req, resp, asio::use_awaitable); + + conn->cancel(); + co_return; +} + } // namespace -int RedisLoader::load(const std::string& rawJson, - const std::string& redisHost, - int redisPort, - const std::string& queueKey) { - if (rawJson.empty()) { - std::cerr << "RedisLoader: empty JSON payload" << std::endl; +int RedisLoader::loadPayload(const std::string& redisHost, + int redisPort, + const std::string& queueKey, + const std::string& rawJson) { + const bool isBlank = std::all_of(rawJson.begin(), rawJson.end(), + [](unsigned char c) { + return std::isspace(c); + }); + if (isBlank) { + std::cerr << "RedisLoader: empty payload rejected" << std::endl; return 1; } - nlohmann::json parsed; - try { - parsed = nlohmann::json::parse(rawJson); - } catch (const nlohmann::json::parse_error& ex) { - std::cerr << "RedisLoader: invalid JSON payload: " << ex.what() - << std::endl; - return 2; - } - const std::string encoded = Base64::b64encode(rawJson); asio::io_context ioc; @@ -99,42 +111,39 @@ int RedisLoader::load(const std::string& rawJson, return 3; } - const auto runId = parsed.value("RUN_ID", std::string{}); - std::cout << "RedisLoader: LPUSH " << queueKey << " RUN_ID=" << runId - << std::endl; - return 0; } -int RedisLoader::loadPayload(const std::string& redisHost, - int redisPort, - const std::string& queueKey, - const std::string& rawJson) { - const bool isBlank = std::all_of(rawJson.begin(), rawJson.end(), - [](unsigned char c) { - return std::isspace(c); - }); - if (isBlank) { - std::cerr << "RedisLoader: empty payload rejected" << std::endl; - return 1; +int RedisLoader::loadPayloadBatch(const std::string& redisHost, + int redisPort, + const std::string& queueKey, + const std::vector& rawJsonPayloads) { + if (rawJsonPayloads.empty()) { + return 0; // nothing to push } - const std::string encoded = Base64::b64encode(rawJson); + std::vector encoded; + encoded.reserve(rawJsonPayloads.size()); + for (const std::string& rawJson : rawJsonPayloads) { + const bool isBlank = std::all_of(rawJson.begin(), rawJson.end(), + [](unsigned char c) { + return std::isspace(c); + }); + if (isBlank) { + std::cerr << "RedisLoader: empty payload rejected" << std::endl; + return 1; + } + encoded.push_back(Base64::b64encode(rawJson)); + } asio::io_context ioc; - auto conn = std::make_shared(ioc); - - redis::config cfg; - cfg.addr.host = redisHost; - cfg.addr.port = std::to_string(redisPort); - - conn->async_run(cfg, {}, asio::consign(asio::detached, conn)); + auto conn = redis_util::makeRedisConnection(ioc, redisHost, redisPort); std::exception_ptr pushError; asio::co_spawn( ioc, - pushOnce(conn, queueKey, encoded), + pushManyOnce(conn, queueKey, std::move(encoded)), [&pushError](std::exception_ptr e) { if (e) { pushError = e; @@ -152,13 +161,8 @@ int RedisLoader::loadPayload(const std::string& redisHost, return 3; } - try { - JsonParser::parseConfigurationFromBase64(encoded); - } catch (const nlohmann::json::exception& ex) { - std::cerr << "RedisLoader: failed to parse payload: " << ex.what() - << std::endl; - return 2; - } + std::cout << "RedisLoader: LPUSH " << rawJsonPayloads.size() + << " payload(s) to " << queueKey << std::endl; return 0; } diff --git a/source/redisRunner.cpp b/source/redisRunner.cpp index 49fb739..9b703f9 100644 --- a/source/redisRunner.cpp +++ b/source/redisRunner.cpp @@ -6,96 +6,235 @@ #include "redisRunner.hpp" +#include +#include +#include #include -#include #include #include +#include #include +#include +#include +#include #include #include +#include +#include #include #include -// Single TU that compiles the Boost.Redis implementation. -#include +#include "backtestLog.hpp" #include "backtestRunner.hpp" #include "jsonParser.hpp" +#include "queueKeys.hpp" #include "redisConnection.hpp" +#include "threadPool.hpp" namespace asio = boost::asio; namespace redis = boost::redis; namespace { -asio::awaitable> popOnce( +// One Redis command on the shared connection, returning a bulk string (or nil). +// A nil reply (RPOP/LINDEX out of range) maps to nullopt. The connection is NOT +// cancelled here, it stays open for the rest of the worker loop. +asio::awaitable> execOptionalString( std::shared_ptr conn, - std::string queueKey) { - redis::request req; - req.push("RPOP", queueKey); - + redis::request req) { redis::response> resp; co_await conn->async_exec(req, resp, asio::use_awaitable); + co_return std::move(std::get<0>(resp).value()); +} - // Tear down the connection loop now that the single command is done. - conn->cancel(); +// One Redis command on the shared connection whose reply we ignore (e.g. LREM). +asio::awaitable execIgnore(std::shared_ptr conn, + redis::request req) { + redis::generic_response resp; + co_await conn->async_exec(req, resp, asio::use_awaitable); + co_return; +} - co_return std::move(std::get<0>(resp).value()); +// Non-destructively reads the run that RPOP would take (the queue tail, i.e. the +// oldest run since LoadCommand LPUSHes onto the head). Multiple workers all +// observe the same run and pile onto it. +asio::awaitable> peekRunTail(std::shared_ptr conn) { + redis::request req; + req.push("LINDEX", queue_keys::RUN, "-1"); + co_return co_await execOptionalString(conn, std::move(req)); +} + +// Claims one strategy off the run's per-RUN_ID list. nullopt once drained. +asio::awaitable> popStrategy( + std::shared_ptr conn, + std::string strategyKey) { + redis::request req; + req.push("RPOP", strategyKey); + co_return co_await execOptionalString(conn, std::move(req)); +} + +// Retires a run by removing its descriptor. Idempotent: LREM removes 0 if a peer +// worker already retired it. +asio::awaitable removeRun(std::shared_ptr conn, + std::string descriptorB64) { + redis::request req; + req.push("LREM", queue_keys::RUN, "0", descriptorB64); + co_await execIgnore(conn, std::move(req)); +} + +// Drains BACKTESTING_QUEUE_RUN on a single long-lived connection. For each run it +// loads the QuestDB ticks once, drains that run's strategy list, then retires the +// run. When the queue is empty it waits and re-peeks rather than exiting, so the +// worker stays up as a daemon; only a Redis/DB/decode error leaves the loop +// (return 3). +asio::awaitable drainRuns(std::shared_ptr conn, + std::string questdbHost) { + int exitCode = 0; + + try { + + // Get CPU threads available, max of 6 + const unsigned hw = std::thread::hardware_concurrency(); + ThreadPool pool(std::clamp(hw == 0 ? 6u : hw, 1u, 6u)); + + // Loop forever, claiming one run per iteration. An empty queue makes us + // wait and re-peek (below); only an exception blows the loop + bool waitingLogged = false; + for (;;) { + const std::optional descriptorB64 = co_await peekRunTail(conn); + if (!descriptorB64.has_value()) { + // Queue empty: stay alive and poll until work reappears. The timer + // is co_awaited, so this suspends (not a busy wait) while keeping + // the io_context and the Redis connection alive. + if (!waitingLogged) { + std::println("RedisRunner: queue empty, waiting for work..."); + waitingLogged = true; + } + asio::steady_timer timer(co_await asio::this_coro::executor); + timer.expires_after(std::chrono::seconds(1)); + co_await timer.async_wait(asio::use_awaitable); + continue; // re-peek; never exit just because the queue is empty + } + waitingLogged = false; // got a run; re-arm the idle log for next time + + const trading_definitions::RunConfiguration runCfg = JsonParser::parseRunConfigurationFromBase64(*descriptorB64); + const std::string strategyKey = queue_keys::strategyKey(runCfg.RUN_ID); + std::println("RedisRunner: picked up RUN_ID={} SYMBOLS={} LAST_MONTHS={}", runCfg.RUN_ID, runCfg.SYMBOLS, runCfg.LAST_MONTHS); + + // Pull this run's tick data once, then reuse across every strategy. + const std::vector ticks = loadTicks(questdbHost, runCfg.SYMBOLS, runCfg.LAST_MONTHS); + + // Backtests run on the pool but every task reads this run's `ticks` + // by reference (no copies). This guard joins all in-flight backtests + // before `ticks` is destroyed, even if a co_await below throws. + // wait() never throws, so it is safe during stack unwinding. + struct Quiesce { + ThreadPool& pool; + ~Quiesce() { pool.wait(); } + } quiesce{pool}; + + // Drain the run's strategy list, competing with any other workers. + // Redis stays on this coroutine thread; only the CPU-bound backtest + // is handed to the pool. submit() applies backpressure, so we keep + // popping at the rate the workers can absorb. + int strategiesRun = 0; + for (;;) { + const std::optional strategyB64 = + co_await popStrategy(conn, strategyKey); + if (!strategyB64.has_value()) { + break; // strategy list drained + } + + // 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), + }; + pool.submit([&ticks, cfg = std::move(config)]() { + runBacktestOnTicks(ticks, cfg); + }); + ++strategiesRun; + } + + // Wait for this run's backtests to finish, then surface the first + // failure (if any) so it aborts the drain exactly as the old + // synchronous call did. + pool.wait(); + if (std::exception_ptr err = pool.takeError()) { + std::rethrow_exception(err); + } + + // Retire the run. A failure here propagates and aborts the loop, we + // never re-peek the same run and reload its ticks in a tight loop. + co_await removeRun(conn, *descriptorB64); + + std::println("RedisRunner: completed RUN_ID={} ({} strateg{})", + runCfg.RUN_ID, strategiesRun, + strategiesRun == 1 ? "y" : "ies"); + } + } catch (const std::exception& ex) { + std::println(stderr, "RedisRunner aborted: {}", ex.what()); + exitCode = 3; + } catch (...) { + std::println(stderr, "RedisRunner aborted: unknown error"); + exitCode = 3; + } + + // Only reached when an exception aborted the drain — the loop waits on an + // empty queue rather than exiting. Tear the connection down so + // io_context::run() can return. + conn->cancel(); + co_return exitCode; } } // namespace int RedisRunner::run(const std::string& questdbHost, const std::string& redisHost, - int redisPort, - const std::string& queueKey) { - - // Event loop that drives all async Redis I/O on this thread. + int redisPort) { + + // Mute logs to prevent interleaved thread spam + backtest_log::quiet = true; + + // Set up the async event loop asio::io_context ioc; + + // Establish Redis connection auto conn = redis_util::makeRedisConnection(ioc, redisHost, redisPort); - // Outputs of the coroutine — filled in by the completion handler below. - std::optional popped; - std::exception_ptr popError; + // State trackers for the coroutine's outcome + int result = 0; + std::exception_ptr error; - // Schedule popOnce() onto the io_context. When the coroutine finishes, - // the lambda is invoked with either an exception or the popped value. + // Launch the async task to process runs asio::co_spawn( ioc, - popOnce(conn, queueKey), - [&popped, &popError](std::exception_ptr e, - std::optional r) { + drainRuns(conn, questdbHost), + [&result, &error](std::exception_ptr e, int r) { // Completion callback if (e) { - popError = e; + error = e; // Save exception if it failed return; } - popped = std::move(r); + result = r; // Save exit code if it succeeded }); - // Block until the coroutine (and the Redis connection loop) finish. + // Block the current thread and execute the async loop until finished ioc.run(); - if (popError) { + // Unwrap and log any exceptions caught during the async execution + if (error) { try { - std::rethrow_exception(popError); + std::rethrow_exception(error); } catch (const std::exception& ex) { - std::cerr << "Redis pop failed: " << ex.what() << std::endl; + std::println(stderr, "RedisRunner failed: {}", ex.what()); } - return 3; - } - - if (!popped.has_value()) { - std::cerr << "strategy_queue empty — nothing to run" << std::endl; - return 2; + return 3; // Exit with error status } - std::cout << "Popped strategy from Redis (RUN_ID will follow after decode)" - << std::endl; - - auto config = JsonParser::parseConfigurationFromBase64(*popped); - - std::cout << "Redis pop succeeded for RUN_ID=" << config.RUN_ID - << std::endl; - - return runBacktest(questdbHost, config); -} + // Return the final execution status code + return result; +} \ No newline at end of file diff --git a/source/reporting/elasticClient.cpp b/source/reporting/elasticClient.cpp new file mode 100644 index 0000000..825d8a4 --- /dev/null +++ b/source/reporting/elasticClient.cpp @@ -0,0 +1,113 @@ +// Backtesting Engine in C++ +// +// (c) 2026 Ryan McCaffery | https://mccaffers.com +// This code is licensed under MIT license (see LICENSE.txt for details) +// --------------------------------------- + +#include "reporting/elasticClient.hpp" + +#include +#include + +#include +#include +#include +#include + +#include "backtestLog.hpp" +#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()); +} + +// Swallow the response body so curl does not dump it to stdout (its default +// behaviour when no write callback is configured). +size_t discardResponse(char* /*ptr*/, size_t size, size_t nmemb, void* /*userdata*/) { + return size * nmemb; +} + +} // namespace + +int ElasticClient::putTradingResults(const TradingResults& results) { + // 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") { + return 0; + } + + 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) { + backtest_log::error("ElasticClient: curl_easy_init failed"); + 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())); + + // Discard the response body instead of letting curl print it to stdout. + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, discardResponse); + + // 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) { + backtest_log::error(std::string("ElasticClient: PUT failed: ") + + curl_easy_strerror(rc)); + return 2; + } + if (httpStatus < 200 || httpStatus >= 300) { + backtest_log::error("ElasticClient: HTTP " + std::to_string(httpStatus) + + " from " + url); + return 3; + } + // Per-strategy success line is skipped under concurrent backtests (quiet). + if (!backtest_log::quiet) { + std::cout << "ElasticClient: PUT " << url << " (HTTP " << httpStatus << ")" + << std::endl; + } + return 0; +} diff --git a/source/sqlManager.cpp b/source/sqlManager.cpp index 29da1b0..e64f7a1 100644 --- a/source/sqlManager.cpp +++ b/source/sqlManager.cpp @@ -9,7 +9,7 @@ #include #include -std::vector SqlManager::streamPriceData(const DatabaseConnection& db, const std::vector& symbols, int LAST_MONTHS) { +std::vector SqlManager::loadPriceData(const DatabaseConnection& db, const std::vector& symbols, int LAST_MONTHS) { if (symbols.empty()) { return {}; } @@ -25,5 +25,5 @@ std::vector SqlManager::streamPriceData(const DatabaseConnection& db, query << " ORDER BY timestamp"; std::cout << "Executing query: " << query.str() << std::endl; - return db.streamQuery(query.str()); + return db.executeQuery(query.str()); } diff --git a/source/trading/reporting.cpp b/source/trading/reporting.cpp index dc7849f..e8d4197 100644 --- a/source/trading/reporting.cpp +++ b/source/trading/reporting.cpp @@ -9,6 +9,7 @@ #include #include #include +#include "backtestLog.hpp" #include "trade.hpp" #include "tradingResults.hpp" @@ -64,6 +65,11 @@ TradingResultsStats Reporting::collect(const TradeManager& tradeManager) { } void Reporting::summarise(const TradeManager& tradeManager) { + // Per-strategy summary is skipped under concurrent backtests (quiet). + if (backtest_log::quiet) { + return; + } + const auto stats = collect(tradeManager); std::cout << "Final PnL: " << std::fixed << std::setprecision(2) << stats.finalPnl << std::endl; diff --git a/source/trading/tradeManager.cpp b/source/trading/tradeManager.cpp index b8aef89..1ec8acf 100644 --- a/source/trading/tradeManager.cpp +++ b/source/trading/tradeManager.cpp @@ -13,6 +13,7 @@ #include #include #include +#include "backtestLog.hpp" namespace { std::string nextTradeId() { @@ -65,19 +66,23 @@ bool TradeManager::closeTrade(const std::string& tradeId, closedTrades.push_back(closed); activeTrades.erase(it); - auto t = std::chrono::system_clock::to_time_t(tick.timestamp); - std::tm utc{}; - gmtime_r(&t, &utc); - std::ostringstream ts; - ts << std::put_time(&utc, "%Y-%m-%dT%H:%M:%SZ"); + // Per-trade chatter is skipped under concurrent backtests (quiet), + // which also avoids the formatting work below. + if (!backtest_log::quiet) { + auto t = std::chrono::system_clock::to_time_t(tick.timestamp); + std::tm utc{}; + gmtime_r(&t, &utc); + std::ostringstream ts; + ts << std::put_time(&utc, "%Y-%m-%dT%H:%M:%SZ"); - const char* side = (closed.direction == Direction::LONG) ? "BUY" : "SELL"; - std::cout << ts.str() - << ", Trade Closed, " << closed.symbol - << ", " << side - << ", " << std::showpos << std::fixed << std::setprecision(2) << closed.pnl - << std::noshowpos - << std::endl; + const char* side = (closed.direction == Direction::LONG) ? "BUY" : "SELL"; + std::cout << ts.str() + << ", Trade Closed, " << closed.symbol + << ", " << side + << ", " << std::showpos << std::fixed << std::setprecision(2) << closed.pnl + << std::noshowpos + << std::endl; + } return true; } return false; diff --git a/source/tradingResults.cpp b/source/tradingResults.cpp index e9619d8..6f393a9 100644 --- a/source/tradingResults.cpp +++ b/source/tradingResults.cpp @@ -43,6 +43,7 @@ void to_json(nlohmann::json& j, const TradingResults& r) { j = nlohmann::json{ {"RUN_ID", r.RUN_ID}, {"@timestamp", r.timestamp}, + {"durationSeconds", r.durationSeconds}, {"config", r.config}, {"results", r.results}, }; diff --git a/source/utilities/jsonParser.cpp b/source/utilities/jsonParser.cpp index f7affcd..559a747 100644 --- a/source/utilities/jsonParser.cpp +++ b/source/utilities/jsonParser.cpp @@ -30,3 +30,31 @@ trading_definitions::Configuration JsonParser::parseConfigurationFromBase64(cons return config; } + +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(); +} + +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(); +} diff --git a/source/utilities/redisConnection.cpp b/source/utilities/redisConnection.cpp index 4601317..3e75ac1 100644 --- a/source/utilities/redisConnection.cpp +++ b/source/utilities/redisConnection.cpp @@ -6,6 +6,7 @@ #include "redisConnection.hpp" +#include #include #include @@ -25,6 +26,13 @@ std::shared_ptr makeRedisConnection( cfg.addr.host = host; cfg.addr.port = std::to_string(port); + // Disable the periodic PING health check. The runner holds one connection + // across long synchronous tick-loads/backtests that block this single + // io_context thread; a firing health check would otherwise cancel the next + // command ("Operation canceled"). A dead connection still surfaces as an + // async_exec error, which the caller handles. + cfg.health_check_interval = std::chrono::seconds::zero(); + conn->async_run(cfg, {}, boost::asio::consign(boost::asio::detached, conn)); diff --git a/tests/tradeManager.mm b/tests/tradeManager.mm index ecd403a..af58d1b 100644 --- a/tests/tradeManager.mm +++ b/tests/tradeManager.mm @@ -5,16 +5,86 @@ // --------------------------------------- #import +#import // setenv — disable Elastic reporting for run() smoke tests +#import +#import +#import #import #import "tradeManager.hpp" #import "exitRules.hpp" #import "reviewStopAndLimit.hpp" +#import "runLoop.hpp" +#import "operations.hpp" +#import "strategies/strategy.hpp" +#import "trading_definitions/configuration.hpp" // Pulls in the _dd user-defined literal so "1.23"_dd produces a decimal64_t // directly. decimal64_t has no implicit conversion from double — the closest // C# analogue is having to write `1.23m` instead of `1.23` for a decimal. using namespace boost::decimal::literals; +namespace { + +// Minimal Configuration that drives Operations::run down the RandomStrategy +// path. selectStrategy only reads STRATEGY.TRADING_VARIABLES.STRATEGY, and +// RandomStrategy ignores everything else in the strategy block, so the OHLC +// and strategy-variable sections are left default-constructed. The pip / size +// values are plausible but irrelevant to the no-throw assertions below. +trading_definitions::Configuration makeRandomStrategyConfig() { + trading_definitions::Configuration config; + config.RUN_ID = "TEST_RUN"; + config.SYMBOLS = "EURUSD"; + config.LAST_MONTHS = 1; + config.STRATEGY.UUID = "test-strategy-uuid"; + auto& vars = config.STRATEGY.TRADING_VARIABLES; + vars.STRATEGY = "RandomStrategy"; + vars.STOP_DISTANCE_IN_PIPS = "10"_dd; + vars.LIMIT_DISTANCE_IN_PIPS = "10"_dd; + vars.TRADING_SIZE = "1.0"_dd; + return config; +} + +// Per-test TradingVariables so each runTicks case can dial SL/TP independently +// (e.g. stop=0 to isolate the take-profit path). STRATEGY is unused by runTicks. +trading_definitions::TradingVariables makeVars(boost::decimal::decimal64_t stopPips, + boost::decimal::decimal64_t limitPips, + boost::decimal::decimal64_t size) { + trading_definitions::TradingVariables vars; + vars.STRATEGY = "Scripted"; + vars.STOP_DISTANCE_IN_PIPS = stopPips; + vars.LIMIT_DISTANCE_IN_PIPS = limitPips; + vars.TRADING_SIZE = size; + return vars; +} + +// Deterministic strategy that replays a pre-scripted sequence of signals, one +// per decide() call, returning nullopt ("no trade") once exhausted. This is the +// injectable seam that lets us assert exact bid/ask outcomes from the run loop +// without RandomStrategy's coin flips. +struct ScriptedStrategy : IStrategy { + std::vector> script; + std::size_t index = 0; + + explicit ScriptedStrategy(std::vector> signals) + : script(std::move(signals)) {} + + std::optional decide(const PriceData& /*tick*/) override { + return index < script.size() ? script[index++] : std::nullopt; + } + void during(const PriceData& /*tick*/, TradeManager& /*tradeManager*/) override {} +}; + +// Always signals LONG — used to probe re-entry behaviour (gating while a +// position is open; same-tick re-entry after a stop-out). +struct AlwaysLongStrategy : IStrategy { + std::optional decide(const PriceData& /*tick*/) override { + return Direction::LONG; + } + void during(const PriceData& /*tick*/, TradeManager& /*tradeManager*/) override {} +}; + +} // namespace + @interface TradeManagerTests : XCTestCase @property (nonatomic) TradeManager* manager; @end @@ -23,6 +93,10 @@ @implementation TradeManagerTests - (void)setUp { self.manager = new TradeManager(); + // Operations::run finishes by PUTting results to Elasticsearch. There is no + // Elastic instance under test, so opt out: putTradingResults returns early + // (no network, no JSON serialisation) when ELASTIC_ENABLED=0. + setenv("ELASTIC_ENABLED", "0", 1); } - (void)tearDown { @@ -323,4 +397,324 @@ - (void)testHelperStillReportsSymbolActiveAfterFirstOpen { "re-entry gating depends on this"); } +#pragma mark - Entry-side bid/ask handling + +// Simple round-number spread (ask 100, bid 99) to pin which side of the spread +// each direction uses on entry. A LONG buys at the ask, but its stop/limit must +// be measured from the bid (the price it would exit at), so: +// entryPrice == ask, exitReferencePrice == bid. +// entryBid/entryAsk record the raw spread regardless of direction. +- (void)testOpenTrade_LongEntersAtAskAndRecordsSpread { + PriceData tick("100.0"_dd, "99.0"_dd, std::chrono::system_clock::now(), "EURUSD"); + std::string tradeId = self.manager->openTrade(tick, "1.0"_dd, Direction::LONG); + auto trades = self.manager->getActiveTrades(); + auto trade = trades.find(tradeId); + XCTAssertNotEqual(trade, trades.end(), "Trade should exist"); + + XCTAssertEqual(trade->second.entryPrice, "100.0"_dd, "LONG executes at the ask"); + XCTAssertEqual(trade->second.exitReferencePrice, "99.0"_dd, + "LONG exit reference is the entry bid (close side)"); + XCTAssertEqual(trade->second.entryAsk, "100.0"_dd, "entryAsk records the tick ask"); + XCTAssertEqual(trade->second.entryBid, "99.0"_dd, "entryBid records the tick bid"); +} + +// Symmetric SHORT: sells at the bid, exit reference is the ask. +- (void)testOpenTrade_ShortEntersAtBidAndRecordsSpread { + PriceData tick("100.0"_dd, "99.0"_dd, std::chrono::system_clock::now(), "EURUSD"); + std::string tradeId = self.manager->openTrade(tick, "1.0"_dd, Direction::SHORT); + auto trades = self.manager->getActiveTrades(); + auto trade = trades.find(tradeId); + XCTAssertNotEqual(trade, trades.end(), "Trade should exist"); + + XCTAssertEqual(trade->second.entryPrice, "99.0"_dd, "SHORT executes at the bid"); + XCTAssertEqual(trade->second.exitReferencePrice, "100.0"_dd, + "SHORT exit reference is the entry ask (close side)"); + XCTAssertEqual(trade->second.entryAsk, "100.0"_dd, "entryAsk records the tick ask"); + XCTAssertEqual(trade->second.entryBid, "99.0"_dd, "entryBid records the tick bid"); +} + +#pragma mark - Operations::run loop invariants + +// Operations::run gates same-symbol re-entry on hasActiveTradeForSymbol and +// runs reviewStopAndLimit *before* the entry check on each tick, so a trade +// that stops out on a tick frees its symbol for re-entry on a later tick. +// This drives that exact sequence through the public building blocks the loop +// uses (reviewStopAndLimit + hasActiveTradeForSymbol) without invoking the +// random strategy, pinning the ordering the smoke tests below cannot observe. +- (void)testRunLoopInvariant_ExitFreesSymbolForReentry { + PriceData entryTick("1.1001"_dd, "1.1000"_dd, std::chrono::system_clock::now(), "EURUSD"); + std::string firstId = self.manager->openTrade(entryTick, "1.0"_dd, Direction::LONG, + "1"_dd, "1"_dd); + XCTAssertTrue(self.manager->hasActiveTradeForSymbol("EURUSD"), + "EURUSD must be active after the first open — re-entry is gated on this"); + + // Stop tick: bid drops 1 pip below the entry bid, so the LONG stops out. + PriceData stopTick("1.1000"_dd, "1.0999"_dd, std::chrono::system_clock::now(), "EURUSD"); + trading::reviewStopAndLimit(*self.manager, stopTick); + + XCTAssertFalse(self.manager->hasActiveTradeForSymbol("EURUSD"), + "Symbol must be free again once the trade stops out"); + const auto& closed = self.manager->getClosedTrades(); + XCTAssertEqual(closed.size(), 1, "Exactly one trade should have closed"); + XCTAssertEqual(closed.front().closePrice, stopTick.bid, + "LONG closes at the tick bid (the exit side)"); + + // The gate is open, so the loop would now allow a fresh entry on this symbol. + std::string secondId = self.manager->openTrade(stopTick, "1.0"_dd, Direction::LONG, + "1"_dd, "1"_dd); + XCTAssertNotEqual(firstId, secondId, "Re-entry must be a distinct trade"); + XCTAssertTrue(self.manager->hasActiveTradeForSymbol("EURUSD"), + "EURUSD active again after re-entry"); +} + +#pragma mark - Operations::run smoke tests + +// Operations::run owns its TradeManager internally and reports only to stdout +// and Elasticsearch, so there is no return value to assert against. These are +// deliberately smoke tests: with ELASTIC_ENABLED=0 (see setUp) the run must +// drive the full per-tick loop, summarise, and results path without throwing. +// The deterministic bid/ask behaviour the loop relies on is covered by the +// building-block tests above; RandomStrategy makes per-trade outcomes here +// non-deterministic, so only the no-throw contract is checked. + +- (void)testOperationsRun_EmptyTicks_DoesNotThrow { + const std::vector ticks; + const auto config = makeRandomStrategyConfig(); + XCTAssertNoThrow(Operations::run(ticks, config), + "run must handle an empty tick stream without throwing"); +} + +- (void)testOperationsRun_SingleTick_DoesNotThrow { + const std::vector ticks{ + PriceData("1.1001"_dd, "1.1000"_dd, std::chrono::system_clock::now(), "EURUSD"), + }; + const auto config = makeRandomStrategyConfig(); + XCTAssertNoThrow(Operations::run(ticks, config), + "run must process a single tick without throwing"); +} + +- (void)testOperationsRun_MultipleTicks_DoesNotThrow { + // A drifting EURUSD series so the 10-pip SL/TP can actually fire across the + // run, exercising both the entry and the reviewStopAndLimit exit paths. + const auto now = std::chrono::system_clock::now(); + const std::vector ticks{ + PriceData("1.1001"_dd, "1.1000"_dd, now, "EURUSD"), + PriceData("1.1011"_dd, "1.1010"_dd, now, "EURUSD"), + PriceData("1.1021"_dd, "1.1020"_dd, now, "EURUSD"), + PriceData("1.1006"_dd, "1.1005"_dd, now, "EURUSD"), + PriceData("1.0991"_dd, "1.0990"_dd, now, "EURUSD"), + }; + const auto config = makeRandomStrategyConfig(); + XCTAssertNoThrow(Operations::run(ticks, config), + "run must process a multi-tick stream without throwing"); +} + +#pragma mark - Operations run loop (deterministic, end-to-end) + +// These drive trading::runTicks — the exact per-tick loop Operations::run +// executes — with a deterministic injected strategy and a TradeManager we own, +// so trade outcomes (entry side, exit side, realised PnL) can be asserted +// directly. EURUSD scale is 10000, so 1 pip == 0.0001. + +// LONG entry executes at the ask; the stop/limit reference is the bid (the +// close side), and both raw spread prices are recorded on the trade. +- (void)testRunTicks_LongOpensAtAsk { + TradeManager tm; + ScriptedStrategy strategy({Direction::LONG}); + const auto vars = makeVars("10"_dd, "10"_dd, "1.0"_dd); + const std::vector ticks{ + PriceData("1.1001"_dd, "1.1000"_dd, std::chrono::system_clock::now(), "EURUSD"), + }; + + trading::runTicks(tm, strategy, ticks, vars); + + XCTAssertEqual(tm.getActiveTrades().size(), 1, "Exactly one trade should be open"); + XCTAssertEqual(tm.getClosedTrades().size(), 0, "Nothing should have closed"); + const Trade& trade = tm.getActiveTrades().begin()->second; + XCTAssertTrue(trade.direction == Direction::LONG, "Trade should be LONG"); + XCTAssertEqual(trade.entryPrice, "1.1001"_dd, "LONG executes at the ask"); + XCTAssertEqual(trade.exitReferencePrice, "1.1000"_dd, "LONG exit reference is the bid"); + XCTAssertEqual(trade.entryAsk, "1.1001"_dd, "entryAsk records the tick ask"); + XCTAssertEqual(trade.entryBid, "1.1000"_dd, "entryBid records the tick bid"); +} + +// Symmetric SHORT: executes at the bid, exit reference is the ask. +- (void)testRunTicks_ShortOpensAtBid { + TradeManager tm; + ScriptedStrategy strategy({Direction::SHORT}); + const auto vars = makeVars("10"_dd, "10"_dd, "1.0"_dd); + const std::vector ticks{ + PriceData("1.1001"_dd, "1.1000"_dd, std::chrono::system_clock::now(), "EURUSD"), + }; + + trading::runTicks(tm, strategy, ticks, vars); + + XCTAssertEqual(tm.getActiveTrades().size(), 1, "Exactly one trade should be open"); + const Trade& trade = tm.getActiveTrades().begin()->second; + XCTAssertTrue(trade.direction == Direction::SHORT, "Trade should be SHORT"); + XCTAssertEqual(trade.entryPrice, "1.1000"_dd, "SHORT executes at the bid"); + XCTAssertEqual(trade.exitReferencePrice, "1.1001"_dd, "SHORT exit reference is the ask"); + XCTAssertEqual(trade.entryAsk, "1.1001"_dd, "entryAsk records the tick ask"); + XCTAssertEqual(trade.entryBid, "1.1000"_dd, "entryBid records the tick bid"); +} + +// No signal -> no position, across many ticks. +- (void)testRunTicks_NoSignalOpensNothing { + TradeManager tm; + ScriptedStrategy strategy({}); // empty script: decide() always returns nullopt + const auto vars = makeVars("10"_dd, "10"_dd, "1.0"_dd); + const auto now = std::chrono::system_clock::now(); + const std::vector ticks{ + PriceData("1.1001"_dd, "1.1000"_dd, now, "EURUSD"), + PriceData("1.1011"_dd, "1.1010"_dd, now, "EURUSD"), + PriceData("1.1021"_dd, "1.1020"_dd, now, "EURUSD"), + }; + + trading::runTicks(tm, strategy, ticks, vars); + + XCTAssertEqual(tm.getActiveTrades().size(), 0, "No trade should open without a signal"); + XCTAssertEqual(tm.getClosedTrades().size(), 0, "Nothing should close either"); +} + +// Re-entry is gated while a position is open: an always-signalling strategy on +// flat ticks (price never reaches the 10-pip SL/TP) must still open only one +// trade for the symbol, not one per tick. +- (void)testRunTicks_ReentryGatedWhileActive { + TradeManager tm; + AlwaysLongStrategy strategy; + const auto vars = makeVars("10"_dd, "10"_dd, "1.0"_dd); + const auto now = std::chrono::system_clock::now(); + const std::vector ticks{ + PriceData("1.1001"_dd, "1.1000"_dd, now, "EURUSD"), + PriceData("1.1001"_dd, "1.1000"_dd, now, "EURUSD"), + PriceData("1.1001"_dd, "1.1000"_dd, now, "EURUSD"), + }; + + trading::runTicks(tm, strategy, ticks, vars); + + XCTAssertEqual(tm.getActiveTrades().size(), 1, + "Gate must suppress duplicate same-symbol entries while one is open"); + XCTAssertEqual(tm.getClosedTrades().size(), 0, "Flat price must not trigger SL/TP"); +} + +// LONG take-profit: a 10-pip favourable move nets only 9 pips because entry was +// at the ask (1.1001) while the TP is measured from the entry bid (1.1000) and +// closes at the bid. This pins that the spread is accounted for end-to-end. +- (void)testRunTicks_LongTP_ClosesAtBid_PnlNetOfSpread { + TradeManager tm; + ScriptedStrategy strategy({Direction::LONG}); // opens once, no re-entry + const auto vars = makeVars("0"_dd, "10"_dd, "1.0"_dd); // TP only + 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.1011"_dd, "1.1010"_dd, now, "EURUSD"), // bid 1.1010 hits TP (ref 1.1000 + 10p) + }; + + trading::runTicks(tm, strategy, ticks, vars); + + XCTAssertEqual(tm.getActiveTrades().size(), 0, "Position should be closed by TP"); + XCTAssertEqual(tm.getClosedTrades().size(), 1, "Exactly one closed trade"); + const Trade& closed = tm.getClosedTrades().front(); + XCTAssertEqual(closed.closePrice, "1.1010"_dd, "LONG TP closes at the tick bid"); + XCTAssertEqual(closed.pnl, "9"_dd, "10-pip move nets 9 pips after the 1-pip spread"); +} + +// Symmetric SHORT take-profit: enters at the bid (1.1000), TP measured from the +// entry ask (1.1001), closes at the ask. Again 9 pips net of the spread. +- (void)testRunTicks_ShortTP_ClosesAtAsk_PnlNetOfSpread { + TradeManager tm; + ScriptedStrategy strategy({Direction::SHORT}); + const auto vars = makeVars("0"_dd, "10"_dd, "1.0"_dd); // TP only + const auto now = std::chrono::system_clock::now(); + const std::vector ticks{ + PriceData("1.1001"_dd, "1.1000"_dd, now, "EURUSD"), // open SHORT @ bid 1.1000 + PriceData("1.0991"_dd, "1.0990"_dd, now, "EURUSD"), // ask 1.0991 hits TP (ref 1.1001 - 10p) + }; + + trading::runTicks(tm, strategy, ticks, vars); + + XCTAssertEqual(tm.getActiveTrades().size(), 0, "Position should be closed by TP"); + XCTAssertEqual(tm.getClosedTrades().size(), 1, "Exactly one closed trade"); + const Trade& closed = tm.getClosedTrades().front(); + XCTAssertEqual(closed.closePrice, "1.0991"_dd, "SHORT TP closes at the tick ask"); + XCTAssertEqual(closed.pnl, "9"_dd, "10-pip move nets 9 pips after the 1-pip spread"); +} + +// LONG stop-loss: a 10-pip adverse move (entry bid 1.1000 down to 1.0990) loses +// 11 pips because entry was at the ask 1.1001. Loss includes the spread. +- (void)testRunTicks_LongSL_ClosesAtBid_NegativePnl { + TradeManager tm; + ScriptedStrategy strategy({Direction::LONG}); + 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"), // open LONG @ ask 1.1001 + PriceData("1.0991"_dd, "1.0990"_dd, now, "EURUSD"), // bid 1.0990 hits SL (ref 1.1000 - 10p) + }; + + trading::runTicks(tm, strategy, ticks, vars); + + XCTAssertEqual(tm.getActiveTrades().size(), 0, "Position should be stopped out"); + XCTAssertEqual(tm.getClosedTrades().size(), 1, "Exactly one closed trade"); + const Trade& closed = tm.getClosedTrades().front(); + XCTAssertEqual(closed.closePrice, "1.0990"_dd, "LONG SL closes at the tick bid"); + XCTAssertEqual(closed.pnl, -"11"_dd, "10-pip adverse move loses 11 pips including the spread"); +} + +// Exit-before-entry ordering within a single tick: on the tick that stops the +// first LONG out, reviewStopAndLimit closes it first, the symbol frees, and the +// always-LONG strategy immediately re-enters on that same tick — at the new +// tick's ask. Pins that the loop reviews exits before considering entries. +- (void)testRunTicks_ExitThenSameTickReentry { + 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, re-opens LONG#2 + }; + + trading::runTicks(tm, strategy, ticks, vars); + + XCTAssertEqual(tm.getClosedTrades().size(), 1, "The first LONG should have closed"); + XCTAssertEqual(tm.getActiveTrades().size(), 1, "A re-entry should be open on the same tick"); + + const Trade& closed = tm.getClosedTrades().front(); + XCTAssertEqual(closed.entryPrice, "1.1001"_dd, "Closed trade was LONG#1, entered at tick1 ask"); + XCTAssertEqual(closed.closePrice, "1.0990"_dd, "LONG#1 stopped out at tick2 bid"); + + const Trade& reentry = tm.getActiveTrades().begin()->second; + XCTAssertTrue(reentry.direction == Direction::LONG, "Re-entry should be LONG"); + XCTAssertEqual(reentry.entryPrice, "1.0991"_dd, "Re-entry executes at tick2 ask"); + XCTAssertEqual(reentry.exitReferencePrice, "1.0990"_dd, "Re-entry exit reference is tick2 bid"); +} + +// Two symbols open simultaneously, each entering on the correct side of its own +// spread regardless of price scale (EURUSD ~1.10 vs AUSIDXAUD ~7000). +- (void)testRunTicks_MultiSymbol { + TradeManager tm; + ScriptedStrategy strategy({Direction::LONG, Direction::LONG}); + const auto vars = makeVars("0"_dd, "0"_dd, "1.0"_dd); // no exits — both persist + 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"), + }; + + trading::runTicks(tm, strategy, ticks, vars); + + XCTAssertEqual(tm.getActiveTrades().size(), 2, "Both symbols should be open"); + const Trade* eur = nullptr; + const Trade* aus = nullptr; + for (const auto& [id, trade] : tm.getActiveTrades()) { + if (trade.symbol == "EURUSD") eur = ™ + else if (trade.symbol == "AUSIDXAUD") aus = ™ + } + XCTAssertTrue(eur != nullptr, "EURUSD trade should exist"); + XCTAssertTrue(aus != nullptr, "AUSIDXAUD trade should exist"); + XCTAssertEqual(eur->entryPrice, "1.1001"_dd, "EURUSD LONG enters at its ask"); + XCTAssertEqual(aus->entryPrice, "7000.5"_dd, "AUSIDXAUD LONG enters at its ask"); +} + @end