From 188e1a87fad664e5081b235a0c0ab7352ee3a486 Mon Sep 17 00:00:00 2001 From: Ryan <16667079+mccaffers@users.noreply.github.com> Date: Wed, 10 Jun 2026 08:36:38 +0100 Subject: [PATCH] Refactored sweep code out of load, and added some tests --- CMakeLists.txt | 1 + .../project.pbxproj | 46 ++++- include/sweep/randomStrategySweep.hpp | 18 ++ include/sweep/runConfigurationBuilder.hpp | 20 ++ include/utilities/decimalConvert.hpp | 20 ++ scripts/generate_clangd.sh | 72 +++++++ source/commands/loadCommand.cpp | 75 +------ source/sweep/randomStrategySweep.cpp | 20 ++ source/sweep/runConfigurationBuilder.cpp | 34 ++++ source/utilities/decimalConvert.cpp | 33 +++ tests/sweep.mm | 192 ++++++++++++++++++ 11 files changed, 464 insertions(+), 67 deletions(-) create mode 100644 include/sweep/randomStrategySweep.hpp create mode 100644 include/sweep/runConfigurationBuilder.hpp create mode 100644 include/utilities/decimalConvert.hpp create mode 100755 scripts/generate_clangd.sh create mode 100644 source/sweep/randomStrategySweep.cpp create mode 100644 source/sweep/runConfigurationBuilder.cpp create mode 100644 source/utilities/decimalConvert.cpp create mode 100644 tests/sweep.mm diff --git a/CMakeLists.txt b/CMakeLists.txt index d96c97a..9b01e8e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -56,6 +56,7 @@ target_include_directories(BacktestingEngineLib PUBLIC ${CMAKE_SOURCE_DIR}/include/trading ${CMAKE_SOURCE_DIR}/include/trading_definitions ${CMAKE_SOURCE_DIR}/include/strategies + ${CMAKE_SOURCE_DIR}/include/sweep ${CMAKE_SOURCE_DIR}/external ) diff --git a/backtesting-engine-cpp.xcodeproj/project.pbxproj b/backtesting-engine-cpp.xcodeproj/project.pbxproj index 4e835a5..56fa503 100644 --- a/backtesting-engine-cpp.xcodeproj/project.pbxproj +++ b/backtesting-engine-cpp.xcodeproj/project.pbxproj @@ -7,6 +7,13 @@ objects = { /* Begin PBXBuildFile section */ + 940F6E532FD9479800B0364A /* randomStrategySweep.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 940F6E502FD9479800B0364A /* randomStrategySweep.cpp */; }; + 940F6E542FD9479800B0364A /* runConfigurationBuilder.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 940F6E512FD9479800B0364A /* runConfigurationBuilder.cpp */; }; + 940F6E552FD9479800B0364A /* randomStrategySweep.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 940F6E502FD9479800B0364A /* randomStrategySweep.cpp */; }; + 940F6E562FD9479800B0364A /* runConfigurationBuilder.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 940F6E512FD9479800B0364A /* runConfigurationBuilder.cpp */; }; + 940F6E582FD947A900B0364A /* decimalConvert.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 940F6E572FD947A900B0364A /* decimalConvert.cpp */; }; + 940F6E592FD947A900B0364A /* decimalConvert.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 940F6E572FD947A900B0364A /* decimalConvert.cpp */; }; + 940F6E5B2FD947C000B0364A /* sweep.mm in Sources */ = {isa = PBXBuildFile; fileRef = 940F6E5A2FD947B800B0364A /* sweep.mm */; }; 941408AE2D59F93F000ED1F9 /* sqlManager.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 941408AD2D59F93F000ED1F9 /* sqlManager.cpp */; }; 941408AF2D59F93F000ED1F9 /* sqlManager.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 941408AD2D59F93F000ED1F9 /* sqlManager.cpp */; }; 941B549A2D3BBADE00E3BF64 /* trading_definitions_json.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 941B54992D3BBADD00E3BF64 /* trading_definitions_json.cpp */; }; @@ -69,6 +76,13 @@ /* Begin PBXFileReference section */ 9409A61B2FAA6411002C30FF /* strategy.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = strategy.hpp; sourceTree = ""; }; + 940F6E4C2FD9477E00B0364A /* randomStrategySweep.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = randomStrategySweep.hpp; sourceTree = ""; }; + 940F6E4D2FD9477E00B0364A /* runConfigurationBuilder.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = runConfigurationBuilder.hpp; sourceTree = ""; }; + 940F6E4F2FD9478800B0364A /* decimalConvert.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = decimalConvert.hpp; sourceTree = ""; }; + 940F6E502FD9479800B0364A /* randomStrategySweep.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = randomStrategySweep.cpp; sourceTree = ""; }; + 940F6E512FD9479800B0364A /* runConfigurationBuilder.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = runConfigurationBuilder.cpp; sourceTree = ""; }; + 940F6E572FD947A900B0364A /* decimalConvert.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = decimalConvert.cpp; sourceTree = ""; }; + 940F6E5A2FD947B800B0364A /* sweep.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = sweep.mm; sourceTree = ""; }; 941408AD2D59F93F000ED1F9 /* sqlManager.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = sqlManager.cpp; sourceTree = ""; }; 941408B02D59F954000ED1F9 /* sqlManager.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = sqlManager.hpp; sourceTree = ""; }; 941B54902D3BBA4900E3BF64 /* ohlc_variables.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = ohlc_variables.hpp; sourceTree = ""; }; @@ -1323,6 +1337,24 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 940F6E4E2FD9477E00B0364A /* sweep */ = { + isa = PBXGroup; + children = ( + 940F6E4C2FD9477E00B0364A /* randomStrategySweep.hpp */, + 940F6E4D2FD9477E00B0364A /* runConfigurationBuilder.hpp */, + ); + path = sweep; + sourceTree = ""; + }; + 940F6E522FD9479800B0364A /* sweep */ = { + isa = PBXGroup; + children = ( + 940F6E502FD9479800B0364A /* randomStrategySweep.cpp */, + 940F6E512FD9479800B0364A /* runConfigurationBuilder.cpp */, + ); + path = sweep; + sourceTree = ""; + }; 941B548F2D3BBA3B00E3BF64 /* trading_definitions */ = { isa = PBXGroup; children = ( @@ -1348,6 +1380,7 @@ 94280BA72D2FC29F00F1CF56 /* utilities */ = { isa = PBXGroup; children = ( + 940F6E572FD947A900B0364A /* decimalConvert.cpp */, 94829FC42FCC1D1A00710E6E /* env.cpp */, 942EC5612FBEF94700CCBB5D /* redisConnection.cpp */, 94280BA22D2FC00200F1CF56 /* base64.cpp */, @@ -1459,7 +1492,7 @@ 9470B5A22C8C5AD0007D9CC6 /* source */ = { isa = PBXGroup; children = ( - 943770442FD4396F00317424 /* boostRedisImpl.cpp */, + 940F6E522FD9479800B0364A /* sweep */, 9437703E2FD42FC000317424 /* reporting */, 941B54982D3BBAD800E3BF64 /* trading_definitions */, 94674B862D533B4000973137 /* trading */, @@ -1468,6 +1501,7 @@ 94D3A7252FC1B3AD00EBEA32 /* commands */, 94280BA72D2FC29F00F1CF56 /* utilities */, 942EC5642FBEF95000CCBB5D /* backtestRunner.cpp */, + 943770442FD4396F00317424 /* boostRedisImpl.cpp */, 942EC5652FBEF95000CCBB5D /* redisLoader.cpp */, 942FDDDF2FC5C8B20096F318 /* tradingResults.cpp */, 942EC5662FBEF95000CCBB5D /* redisRunner.cpp */, @@ -1482,6 +1516,7 @@ 9470B5AD2C8C5B99007D9CC6 /* tests */ = { isa = PBXGroup; children = ( + 940F6E5A2FD947B800B0364A /* sweep.mm */, 9464E5F02FA7467200D82BAD /* symbolScale.mm */, 943398262D57E54000287A2D /* jsonParser.mm */, 94674B892D533BDA00973137 /* tradeManager.mm */, @@ -1493,6 +1528,7 @@ 94B8C7932D3D770800E17EB6 /* utilities */ = { isa = PBXGroup; children = ( + 940F6E4F2FD9478800B0364A /* decimalConvert.hpp */, 94B4F02A2FD618B300B08FB4 /* backtestLog.hpp */, 94B4F02B2FD618B300B08FB4 /* threadPool.hpp */, 945F475C2FD5607E00D19164 /* queueKeys.hpp */, @@ -3616,6 +3652,7 @@ 94DE4F772C8C3E7C00FE48FF /* include */ = { isa = PBXGroup; children = ( + 940F6E4E2FD9477E00B0364A /* sweep */, 943770422FD42FDD00317424 /* reporting */, 94D3A7232FC1B3A600EBEA32 /* commands */, 942966D72D48E84100532862 /* models */, @@ -3740,9 +3777,12 @@ 942EC56B2FBEF95000CCBB5D /* redisLoader.cpp in Sources */, 94829FC52FCC1D1A00710E6E /* env.cpp in Sources */, 942EC56C2FBEF95000CCBB5D /* redisRunner.cpp in Sources */, + 940F6E592FD947A900B0364A /* decimalConvert.cpp in Sources */, 94280BA32D2FC00200F1CF56 /* base64.cpp in Sources */, 943770402FD42FC000317424 /* elasticClient.cpp in Sources */, 941B549B2D3BBADE00E3BF64 /* trading_definitions_json.cpp in Sources */, + 940F6E532FD9479800B0364A /* randomStrategySweep.cpp in Sources */, + 940F6E542FD9479800B0364A /* runConfigurationBuilder.cpp in Sources */, 946EFF7E2FB9F44E008D9647 /* reporting.cpp in Sources */, 94674B872D533B4000973137 /* tradeManager.cpp in Sources */, 94CD8BA02D2E8CE500041BBA /* databaseConnection.cpp in Sources */, @@ -3779,6 +3819,10 @@ 942FDDE22FC5C8B20096F318 /* tradingResults.cpp in Sources */, 9437703F2FD42FC000317424 /* elasticClient.cpp in Sources */, 9470B5B62C8C5BFD007D9CC6 /* main.cpp in Sources */, + 940F6E552FD9479800B0364A /* randomStrategySweep.cpp in Sources */, + 940F6E5B2FD947C000B0364A /* sweep.mm in Sources */, + 940F6E562FD9479800B0364A /* runConfigurationBuilder.cpp in Sources */, + 940F6E582FD947A900B0364A /* decimalConvert.cpp in Sources */, 94364CB62D416D8D00F35B55 /* db.mm in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/include/sweep/randomStrategySweep.hpp b/include/sweep/randomStrategySweep.hpp new file mode 100644 index 0000000..f49dbd2 --- /dev/null +++ b/include/sweep/randomStrategySweep.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 "parameterSweep.hpp" + +namespace sweep { + +// 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 makeStrategy(). +ParameterGenerator buildRandomStrategySweep(); + +} // namespace sweep diff --git a/include/sweep/runConfigurationBuilder.hpp b/include/sweep/runConfigurationBuilder.hpp new file mode 100644 index 0000000..0132ee3 --- /dev/null +++ b/include/sweep/runConfigurationBuilder.hpp @@ -0,0 +1,20 @@ +// 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 "run_configuration.hpp" + +namespace sweep { + +// The run-level descriptor: what tick data to pull from QuestDB, plus the risk +// limits every strategy in the sweep runs under. Shared by every strategy in +// this sweep and linked to them by RUN_ID. +trading_definitions::RunConfiguration makeRunConfiguration(const std::string& runId); + +} // namespace sweep diff --git a/include/utilities/decimalConvert.hpp b/include/utilities/decimalConvert.hpp new file mode 100644 index 0000000..b01f6cb --- /dev/null +++ b/include/utilities/decimalConvert.hpp @@ -0,0 +1,20 @@ +// 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 + +namespace decimal_convert { + +// Converts a 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. Feed it +// binary-exact values (halves, quarters) or explicit lists to keep this exact. +boost::decimal::decimal64_t toDecimal(double value); + +} // namespace decimal_convert diff --git a/scripts/generate_clangd.sh b/scripts/generate_clangd.sh new file mode 100755 index 0000000..1616808 --- /dev/null +++ b/scripts/generate_clangd.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# Generates the .clangd IntelliSense config at the repo root. +# +# .clangd needs absolute, machine-specific paths (clangd resolves relative -I +# flags against the inferred command's working directory, not the repo root), +# so the file is gitignored and each user regenerates it locally with this +# script. Re-run it after adding a new include/ subdirectory — and add the new +# directory to the list below, mirroring CMakeLists.txt. + +# Fail fast: -e exits on any error, -u errors on undefined vars, +# pipefail propagates failures through pipes. +set -euo pipefail + +# Resolve paths relative to this script, not the caller's working directory, +# so the script works no matter where it's invoked from. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Homebrew lives at /opt/homebrew on Apple Silicon and /usr/local on Intel; +# `brew --prefix` resolves the right one. The keg-only formulas (libomp, +# libpq) are addressed via their own prefixes rather than /opt/... so +# the script also works if they were installed elsewhere. +BREW_PREFIX="$(brew --prefix)" +LIBOMP_PREFIX="$(brew --prefix libomp)" +LIBPQ_PREFIX="$(brew --prefix libpq)" + +# Where the XCTest framework lives for the active Xcode installation. +XCODE_FRAMEWORKS="$(xcode-select -p)/Platforms/MacOSX.platform/Developer/Library/Frameworks" + +OUTPUT="$REPO_ROOT/.clangd" + +cat > "$OUTPUT" < -#include #include #include -#include #include -#include #include #include -#include #include #include #include #include +#include "decimalConvert.hpp" #include "env.hpp" #include "parameterSweep.hpp" #include "queueKeys.hpp" +#include "randomStrategySweep.hpp" #include "redisLoader.hpp" -#include "run_configuration.hpp" +#include "runConfigurationBuilder.hpp" #include "trading_definitions/strategy.hpp" 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, plus the risk -// limits every strategy in the sweep runs under. Shared by every strategy in -// this sweep and linked to them by RUN_ID. -RunConfiguration makeRunConfiguration(const std::string& runId) { - using namespace boost::decimal::literals; - return RunConfiguration{ - .RUN_ID = runId, - .SYMBOLS = "EURUSD", - .LAST_MONTHS = 6, - .STARTING_BALANCE = trading_definitions::DEFAULT_STARTING_BALANCE, - // Cut a run off once it has lost 5% of the account (fail fast); - // set <= 0 to run without any loss cutoff. - .MAX_LOSS_PERCENT = 5_DD, - // Cap on simultaneously open positions per run (<= 0 = unlimited). - // The per-symbol gate already limits to one trade per symbol, so this - // only bites on multi-symbol runs. - .MAX_OPEN_TRADES = 10, - // Flip to false to silence liquidated runs from Elasticsearch once - // sweeps scale up and loss-limit cutoffs are expected noise. - .REPORT_FAILURES = true, - }; -} - // 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) { @@ -102,8 +43,10 @@ Strategy makeStrategy(const sweep::Combination& combo) { .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")), + .STOP_DISTANCE_IN_PIPS = + decimal_convert::toDecimal(combo.get("STOP_DISTANCE_IN_PIPS")), + .LIMIT_DISTANCE_IN_PIPS = + decimal_convert::toDecimal(combo.get("LIMIT_DISTANCE_IN_PIPS")), .TRADING_SIZE = 1_DD, }, .OHLC_VARIABLES = { @@ -129,7 +72,7 @@ int LoadCommand::run() { const auto redisHost = env::getOr("REDIS_HOST", "127.0.0.1"); // Build random here - const auto generator = buildRandomStrategySweep(); + const auto generator = sweep::buildRandomStrategySweep(); const auto combinations = generator.generateAllCombinations(); @@ -159,7 +102,7 @@ int LoadCommand::run() { // 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); + const nlohmann::json runJson = sweep::makeRunConfiguration(runId); return RedisLoader::loadPayload(redisHost, 6379, queue_keys::RUN, runJson.dump()); } diff --git a/source/sweep/randomStrategySweep.cpp b/source/sweep/randomStrategySweep.cpp new file mode 100644 index 0000000..d8e59c2 --- /dev/null +++ b/source/sweep/randomStrategySweep.cpp @@ -0,0 +1,20 @@ +// Backtesting Engine in C++ +// +// (c) 2026 Ryan McCaffery | https://mccaffers.com +// This code is licensed under MIT license (see LICENSE.txt for details) +// --------------------------------------- + +#include "randomStrategySweep.hpp" + +namespace sweep { + +ParameterGenerator buildRandomStrategySweep() { + 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; +} + +} // namespace sweep diff --git a/source/sweep/runConfigurationBuilder.cpp b/source/sweep/runConfigurationBuilder.cpp new file mode 100644 index 0000000..8fdc6fd --- /dev/null +++ b/source/sweep/runConfigurationBuilder.cpp @@ -0,0 +1,34 @@ +// Backtesting Engine in C++ +// +// (c) 2026 Ryan McCaffery | https://mccaffers.com +// This code is licensed under MIT license (see LICENSE.txt for details) +// --------------------------------------- + +#include "runConfigurationBuilder.hpp" + +#include +#include + +namespace sweep { + +trading_definitions::RunConfiguration makeRunConfiguration(const std::string& runId) { + using namespace boost::decimal::literals; + return trading_definitions::RunConfiguration{ + .RUN_ID = runId, + .SYMBOLS = "EURUSD", + .LAST_MONTHS = 6, + .STARTING_BALANCE = trading_definitions::DEFAULT_STARTING_BALANCE, + // Cut a run off once it has lost 5% of the account (fail fast); + // set <= 0 to run without any loss cutoff. + .MAX_LOSS_PERCENT = 5_DD, + // Cap on simultaneously open positions per run (<= 0 = unlimited). + // The per-symbol gate already limits to one trade per symbol, so this + // only bites on multi-symbol runs. + .MAX_OPEN_TRADES = 10, + // Flip to false to silence liquidated runs from Elasticsearch once + // sweeps scale up and loss-limit cutoffs are expected noise. + .REPORT_FAILURES = true, + }; +} + +} // namespace sweep diff --git a/source/utilities/decimalConvert.cpp b/source/utilities/decimalConvert.cpp new file mode 100644 index 0000000..20b2a92 --- /dev/null +++ b/source/utilities/decimalConvert.cpp @@ -0,0 +1,33 @@ +// Backtesting Engine in C++ +// +// (c) 2026 Ryan McCaffery | https://mccaffers.com +// This code is licensed under MIT license (see LICENSE.txt for details) +// --------------------------------------- + +#include "decimalConvert.hpp" + +#include +#include +#include +#include + +#include + +namespace decimal_convert { + +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("decimalConvert: to_chars failed for double"); + } + 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("decimalConvert: from_chars failed for double"); + } + return result; +} + +} // namespace decimal_convert diff --git a/tests/sweep.mm b/tests/sweep.mm new file mode 100644 index 0000000..135b84c --- /dev/null +++ b/tests/sweep.mm @@ -0,0 +1,192 @@ +// Backtesting Engine in C++ +// +// (c) 2026 Ryan McCaffery | https://mccaffers.com +// This code is licensed under MIT license (see LICENSE.txt for details) +// --------------------------------------- + +#import +#import +#import +#import +#import +#import +#import +#import +#import "parameterSweep.hpp" +#import "randomStrategySweep.hpp" +#import "runConfigurationBuilder.hpp" +#import "tradeManager.hpp" +#import "run_configuration.hpp" +#import "strategies/randomStrategy.hpp" +#import "trading_definitions/strategy.hpp" + +// Pulls in the _dd user-defined literal so "5"_dd produces a decimal64_t +// directly — same convention as tradeManager.mm. +using namespace boost::decimal::literals; + +@interface SweepTests : XCTestCase +@end + +@implementation SweepTests + +#pragma mark - buildRandomStrategySweep + +- (void)testBuildRandomStrategySweep_RegistersTwoParameters { + const auto generator = sweep::buildRandomStrategySweep(); + XCTAssertEqual(generator.parameterCount(), 2, + "RandomStrategy sweeps exactly STOP and LIMIT pip distances"); +} + +- (void)testBuildRandomStrategySweep_GeneratesFullCartesianProduct { + const auto combinations = + sweep::buildRandomStrategySweep().generateAllCombinations(); + XCTAssertEqual(combinations.size(), 9, + "3 stop values x 3 limit values must expand to 9 combinations"); + for (const auto& combo : combinations) { + XCTAssertTrue(combo.has("STOP_DISTANCE_IN_PIPS"), + "Every combination must carry the stop distance"); + XCTAssertTrue(combo.has("LIMIT_DISTANCE_IN_PIPS"), + "Every combination must carry the limit distance"); + // The OHLC ranges are commented out in the builder; loadCommand's + // makeStrategy relies on has() returning false so the fields default + // to 0 instead of throwing in get(). + XCTAssertFalse(combo.has("OHLC_COUNT"), + "OHLC_COUNT is not currently swept"); + XCTAssertFalse(combo.has("OHLC_MINUTES"), + "OHLC_MINUTES is not currently swept"); + } +} + +- (void)testBuildRandomStrategySweep_CoversEveryStopLimitPair { + const auto combinations = + sweep::buildRandomStrategySweep().generateAllCombinations(); + + std::set> pairs; + for (const auto& combo : combinations) { + pairs.emplace(combo.get("STOP_DISTANCE_IN_PIPS"), + combo.get("LIMIT_DISTANCE_IN_PIPS")); + } + + XCTAssertEqual(pairs.size(), 9, "All 9 combinations must be distinct"); + for (const double stop : {1.0, 1.5, 10.0}) { + for (const double limit : {1.0, 1.5, 10.0}) { + XCTAssertTrue(pairs.count({stop, limit}) == 1, + "Missing stop=%.1f / limit=%.1f from the grid", stop, limit); + } + } +} + +// generateAllCombinations expands ranges in registration order with the +// last-registered parameter varying fastest. RUN output ordering feeds the +// queue, so pin it: the first three combinations hold STOP at 1.0 while +// LIMIT walks the list. +- (void)testBuildRandomStrategySweep_OrderIsDeterministic { + const auto combinations = + sweep::buildRandomStrategySweep().generateAllCombinations(); + XCTAssertEqual(combinations.size(), 9, "Pre-condition: full grid"); + + const double stops[] = {1.0, 1.0, 1.0, 1.5, 1.5, 1.5, 10.0, 10.0, 10.0}; + const double limits[] = {1.0, 1.5, 10.0, 1.0, 1.5, 10.0, 1.0, 1.5, 10.0}; + for (std::size_t i = 0; i < combinations.size(); ++i) { + XCTAssertEqual(combinations[i].get("STOP_DISTANCE_IN_PIPS"), stops[i], + "STOP order must be stable (slow axis)"); + XCTAssertEqual(combinations[i].get("LIMIT_DISTANCE_IN_PIPS"), limits[i], + "LIMIT order must be stable (fast axis)"); + } +} + +#pragma mark - makeRunConfiguration + +- (void)testMakeRunConfiguration_CarriesRunId { + const auto config = sweep::makeRunConfiguration("test-run-id"); + XCTAssertEqual(config.RUN_ID, std::string("test-run-id"), + "RUN_ID links the run descriptor to its swept strategies"); +} + +- (void)testMakeRunConfiguration_RunDescriptorValues { + const auto config = sweep::makeRunConfiguration("test-run-id"); + XCTAssertEqual(config.SYMBOLS, std::string("EURUSD"), "Tick data symbol"); + XCTAssertEqual(config.LAST_MONTHS, 6, "Tick data window in months"); + XCTAssertEqual(config.STARTING_BALANCE, trading_definitions::DEFAULT_STARTING_BALANCE, + "Runs start from the shared default balance"); + XCTAssertEqual(config.MAX_LOSS_PERCENT, "5"_dd, + "Runs cut off after losing 5%% of the account"); + XCTAssertEqual(config.MAX_OPEN_TRADES, 10, "Cap on simultaneous open positions"); + XCTAssertTrue(config.REPORT_FAILURES, "Liquidated runs currently still report"); +} + +// The descriptor travels through Redis as JSON (loadCommand dumps it, the +// runner parses it back), so the round-trip must preserve every field — +// including the decimal ones, which move as strings via decimal_json.hpp. +- (void)testMakeRunConfiguration_SurvivesJsonRoundTrip { + const auto original = sweep::makeRunConfiguration("round-trip-id"); + + const nlohmann::json j = original; + const auto restored = j.get(); + + XCTAssertEqual(restored.RUN_ID, original.RUN_ID, "RUN_ID must survive"); + XCTAssertEqual(restored.SYMBOLS, original.SYMBOLS, "SYMBOLS must survive"); + XCTAssertEqual(restored.LAST_MONTHS, original.LAST_MONTHS, "LAST_MONTHS must survive"); + XCTAssertEqual(restored.STARTING_BALANCE, original.STARTING_BALANCE, + "STARTING_BALANCE must survive exactly (decimal-as-string)"); + XCTAssertEqual(restored.MAX_LOSS_PERCENT, original.MAX_LOSS_PERCENT, + "MAX_LOSS_PERCENT must survive exactly (decimal-as-string)"); + XCTAssertEqual(restored.MAX_OPEN_TRADES, original.MAX_OPEN_TRADES, + "MAX_OPEN_TRADES must survive"); + XCTAssertEqual(restored.REPORT_FAILURES, original.REPORT_FAILURES, + "REPORT_FAILURES must survive"); +} + +#pragma mark - RandomStrategy (the strategy the sweep drives) + +// RandomStrategy ignores everything in its config, so default-constructed +// Strategy is enough — selectStrategy only needs the name, and these tests +// construct the class directly. +- (void)testRandomStrategy_AlwaysReturnsASignal { + RandomStrategy strategy{trading_definitions::Strategy{}}; + PriceData tick("1.1001"_dd, "1.1000"_dd, std::chrono::system_clock::now(), "EURUSD"); + + for (int i = 0; i < 100; ++i) { + const auto signal = strategy.decide(tick); + XCTAssertTrue(signal.has_value(), + "The coin flip always lands — decide() must never return nullopt"); + XCTAssertTrue(*signal == Direction::LONG || *signal == Direction::SHORT, + "Signal must be one of the two directions"); + } +} + +// A fair coin over 1000 flips produces both directions with probability +// 1 - 2^-999 — a single-sided run means the distribution (or seeding) broke. +- (void)testRandomStrategy_ProducesBothDirections { + RandomStrategy strategy{trading_definitions::Strategy{}}; + PriceData tick("1.1001"_dd, "1.1000"_dd, std::chrono::system_clock::now(), "EURUSD"); + + bool sawLong = false; + bool sawShort = false; + for (int i = 0; i < 1000 && !(sawLong && sawShort); ++i) { + const auto signal = strategy.decide(tick); + sawLong = sawLong || (signal == Direction::LONG); + sawShort = sawShort || (signal == Direction::SHORT); + } + XCTAssertTrue(sawLong, "1000 fair flips must produce at least one LONG"); + XCTAssertTrue(sawShort, "1000 fair flips must produce at least one SHORT"); +} + +// Exits are owned by Operations via SL/TP — during() must not touch open +// positions, otherwise the sweep's stop/limit parameters stop being the only +// exit mechanism under test. +- (void)testRandomStrategy_DuringLeavesOpenTradesAlone { + RandomStrategy strategy{trading_definitions::Strategy{}}; + TradeManager manager; + PriceData tick("1.1001"_dd, "1.1000"_dd, std::chrono::system_clock::now(), "EURUSD"); + manager.openTrade(tick, "1.0"_dd, Direction::LONG); + + strategy.during(tick, manager); + + XCTAssertEqual(manager.getActiveTrades().size(), 1, + "during() is a no-op — the open trade must remain untouched"); + XCTAssertEqual(manager.getClosedTrades().size(), 0, + "during() must not close anything"); +} + +@end