diff --git a/CMakeLists.txt b/CMakeLists.txt index 6105888..34ded42 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -195,9 +195,10 @@ find_package(OpenMP REQUIRED) string(APPEND CMAKE_CXX_FLAGS " ${OpenMP_CXX_FLAGS}") # Boost (Homebrew) — Boost.Redis is header-only but its implementation is -# 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. +# compiled into one dedicated TU via +# (source/shared/redis/connection/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/source/load/loadCommand.cppm b/source/load/loadCommand.cppm index 01cf17e..87e2d01 100644 --- a/source/load/loadCommand.cppm +++ b/source/load/loadCommand.cppm @@ -14,50 +14,22 @@ module; #include "shared/utilities/env.hpp" #include "shared/utilities/parameterSweep.hpp" #include "shared/utilities/queueKeys.hpp" -#include "shared/redis/redisLoader.hpp" -#include "shared/tradingDefinitions/runConfiguration.hpp" // RunConfiguration (json conversion) -#include "shared/tradingDefinitions/strategy.hpp" +#include "shared/redis/producer/redisLoader.hpp" +#include "shared/tradingDefinitions/config/runConfiguration.hpp" // RunConfiguration -> json +#include "shared/tradingDefinitions/strategy.hpp" // Strategy -> json (makeStrategy result) export module loadCommand; import std; // replaces , , , import randomStrategySweep; // buildRandomStrategySweep import runConfigurationBuilder; // makeRunConfiguration +import makeStrategy; // sweep::makeStrategy export class LoadCommand { public: static int run(); }; -using tradingDefinitions::Strategy; - -Strategy makeStrategy(const sweep::Combination& combo) { - using namespace tradingDefinitions; - - 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 = combo.getInt("STOP_DISTANCE_IN_PIPS"), - .LIMIT_DISTANCE_IN_PIPS = combo.getInt("LIMIT_DISTANCE_IN_PIPS"), - .TRADING_SIZE = 1, - }, - .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}, - }, - }; -} - int LoadCommand::run() { // One RUN_ID identifies the whole sweep; each combination becomes its own @@ -79,7 +51,7 @@ int LoadCommand::run() { const auto strategyPayloads = combinations | std::views::transform([](const sweep::Combination& combo) { // Pin to JSON explicitly to trigger implicit Strategy conversion - const nlohmann::json j = makeStrategy(combo); + const nlohmann::json j = sweep::makeStrategy(combo); return j.dump(); }) | std::ranges::to(); diff --git a/source/load/makeStrategy.cppm b/source/load/makeStrategy.cppm new file mode 100644 index 0000000..3be1330 --- /dev/null +++ b/source/load/makeStrategy.cppm @@ -0,0 +1,49 @@ +// Backtesting Engine in C++ +// +// (c) 2026 Ryan McCaffery | https://mccaffers.com +// This code is licensed under MIT license (see LICENSE.txt for details) +// --------------------------------------- + +module; + +#include +#include + +#include "shared/utilities/parameterSweep.hpp" // sweep::Combination +#include "shared/tradingDefinitions/strategy.hpp" + +export module makeStrategy; + +import std; + +export namespace sweep { + +// Map one swept parameter combination onto a concrete Strategy. Each +// combination gets its own UUID so a single backtest result is uniquely +// identifiable and traceable back to its inputs. +tradingDefinitions::Strategy makeStrategy(const sweep::Combination& combo) { + using namespace tradingDefinitions; + + return Strategy{ + .UUID = boost::uuids::to_string(boost::uuids::random_generator()()), + .TRADING_VARIABLES = TradingVariables{ + .STRATEGY = "RandomStrategy", + .STOP_DISTANCE_IN_PIPS = combo.getInt("STOP_DISTANCE_IN_PIPS"), + .LIMIT_DISTANCE_IN_PIPS = combo.getInt("LIMIT_DISTANCE_IN_PIPS"), + .TRADING_SIZE = 1, + }, + .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 sweep diff --git a/source/load/sweep/runConfigurationBuilder.cppm b/source/load/sweep/runConfigurationBuilder.cppm index 4e9b326..026928b 100644 --- a/source/load/sweep/runConfigurationBuilder.cppm +++ b/source/load/sweep/runConfigurationBuilder.cppm @@ -9,7 +9,7 @@ module; #include #include -#include "shared/tradingDefinitions/runConfiguration.hpp" +#include "shared/tradingDefinitions/config/runConfiguration.hpp" export module runConfigurationBuilder; diff --git a/source/main.cpp b/source/main.cpp index ec8e420..d4b7ad5 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -4,29 +4,24 @@ // This code is licensed under MIT license (see LICENSE.txt for details) // --------------------------------------- -import std; // replaces , +import std; // backtesting engine headers import loadCommand; import runCommand; -// Entry point int main(const int argc, const char* argv[]) { - if (argc < 2) { - std::cerr << "BacktestingEngine: missing a subcommand. See README.md for usage" - << std::endl; - return 1; - } + if (argc < 2) { + std::println(std::cerr, "Error: missing subcommand"); + return 1; + } - const std::string_view subcommand = argv[1]; + const std::string_view subcommand = argv[1]; - // Two paths, load or run - if (subcommand == "load") return LoadCommand::run(); - if (subcommand == "run") return RunCommand::run(argc, argv); + if (subcommand == "load") return LoadCommand::run(); + if (subcommand == "run") return RunCommand::run(argc, argv); - std::cerr << "BacktestingEngine: unknown subcommand. See README.md for usage." - << std::endl; - - return 1; -} + std::println(std::cerr, "Error: unknown subcommand '{}'.", subcommand); + return 1; +} \ No newline at end of file diff --git a/source/shared/redis/backtestRunner.cppm b/source/run/execution/backtestRunner.cppm similarity index 97% rename from source/shared/redis/backtestRunner.cppm rename to source/run/execution/backtestRunner.cppm index b798d9d..768d8f7 100644 --- a/source/shared/redis/backtestRunner.cppm +++ b/source/run/execution/backtestRunner.cppm @@ -7,7 +7,7 @@ module; #include "shared/utilities/env.hpp" -#include "shared/tradingDefinitions/configuration.hpp" +#include "shared/tradingDefinitions/config/configuration.hpp" export module backtestRunner; diff --git a/source/shared/redis/runnerBridge.cpp b/source/run/execution/runnerBridge.cpp similarity index 96% rename from source/shared/redis/runnerBridge.cpp rename to source/run/execution/runnerBridge.cpp index f7be6d1..8fa0f5e 100644 --- a/source/shared/redis/runnerBridge.cpp +++ b/source/run/execution/runnerBridge.cpp @@ -4,7 +4,7 @@ // This code is licensed under MIT license (see LICENSE.txt for details) // --------------------------------------- -#include "shared/redis/runnerBridge.hpp" +#include "run/execution/runnerBridge.hpp" import std; import priceData; // PriceData diff --git a/source/shared/redis/runnerBridge.hpp b/source/run/execution/runnerBridge.hpp similarity index 95% rename from source/shared/redis/runnerBridge.hpp rename to source/run/execution/runnerBridge.hpp index 49ed090..71b0e7e 100644 --- a/source/shared/redis/runnerBridge.hpp +++ b/source/run/execution/runnerBridge.hpp @@ -6,7 +6,7 @@ #pragma once #include #include -#include "shared/tradingDefinitions/configuration.hpp" +#include "shared/tradingDefinitions/config/configuration.hpp" // Opaque handle to a run's loaded tick data, defined in runnerBridge.cpp. // diff --git a/source/run/operations.cppm b/source/run/operations.cppm index a37f809..7eea7e7 100644 --- a/source/run/operations.cppm +++ b/source/run/operations.cppm @@ -7,7 +7,7 @@ module; #include "shared/utilities/backtestLog.hpp" -#include "shared/tradingDefinitions/configuration.hpp" +#include "shared/tradingDefinitions/config/configuration.hpp" #include "run/reporting/tradingResults.hpp" export module operations; @@ -76,7 +76,7 @@ void Operations::run(const std::vector& ticks, trading::runTicks(tradeManager, *strategy, ticks, config.STRATEGY.TRADING_VARIABLES, riskLimits); - ResultsSummary::summarise(tradeManager); + ResultsSummary::summarise(tradeManager, config); // Elapsed backtest time for this run, measured from the top of run(). The // Elasticsearch PUT below is deliberately excluded so the duration reflects @@ -128,7 +128,7 @@ void Operations::run(const std::vector& ticks, durationSeconds, reason.str(), config, - ResultsSummary::collect(tradeManager), + ResultsSummary::collect(tradeManager, config), }; ElasticClient::putTradingFailure(failure); } else { @@ -137,7 +137,7 @@ void Operations::run(const std::vector& ticks, TradingResults::nowIsoUtc(), durationSeconds, config, - ResultsSummary::collect(tradeManager), + ResultsSummary::collect(tradeManager, config), }; ElasticClient::putTradingResults(results); } diff --git a/source/run/reporting/resultsSummary.cppm b/source/run/reporting/resultsSummary.cppm index 595741e..d3170d0 100644 --- a/source/run/reporting/resultsSummary.cppm +++ b/source/run/reporting/resultsSummary.cppm @@ -16,14 +16,118 @@ export module resultsSummary; import std; // replaces , , import tradeManager; // TradeManager import trade; // Trade, Direction +import symbolScale; // symbol_scale::get — points-per-pip for the run's symbol export class ResultsSummary { public: - static TradingResultsStats collect(const TradeManager& tradeManager); - static void summarise(const TradeManager& tradeManager); + static TradingResultsStats collect(const TradeManager& tradeManager, + const tradingDefinitions::Configuration& config); + static void summarise(const TradeManager& tradeManager, + const tradingDefinitions::Configuration& config); }; -TradingResultsStats ResultsSummary::collect(const TradeManager& tradeManager) { +namespace { + +// Blend the per-trade aggregates into a single comparable performance score, +// ported from the C# reference. Three parts: +// * expectancy/SQN — per-trade edge, scaled by sqrt(trades) to reward +// frequency, then expressed against opening equity; +// * Calmar — CAGR over the (realized) max drawdown; +// * confidence — a multiplier that penalises a thin trades-per-year +// sample as statistical noise. +// Units: PnL is pip-denominated while STARTING_BALANCE is currency, so the +// percentages are pip-relative proxies — internally consistent for ranking, +// and the scaling constants (50/2.0, 50/3.0) are tunable. The maths runs in +// double (we need sqrt and the engine already casts decimal->double when +// reporting); results land back on the decimal stats fields. +// +// winPipSum / lossPipSum are the summed winning / losing trade PnL in pips +// (lossPipSum <= 0); maxDropPips is the true mark-to-market max drawdown of the +// equity curve, in pips (>= 0). Guards leave every score field at 0 for an +// unscoreable run (no closed trades, non-positive balance, no horizon). +void computePerformanceScore(TradingResultsStats& stats, + const tradingDefinitions::Configuration& config, + boost::decimal::decimal64_t winPipSum, + boost::decimal::decimal64_t lossPipSum, + boost::decimal::decimal64_t maxDropPips) { + const double startingBalance = static_cast(config.STARTING_BALANCE); + if (stats.tradesClosed == 0 || startingBalance <= 0.0 || config.LAST_MONTHS <= 0) { + return; // unscoreable — leave all score fields at their 0 defaults + } + + const double positive = static_cast(stats.winners); + const double negative = static_cast(stats.losers); + const double totalSum = positive + negative; + if (totalSum == 0.0) { + return; // only breakeven trades — nothing to score + } + + // Win rate: a run with no losers wins by definition; otherwise the share of + // decisive trades that won. + double winRate = 0.0; + if (negative == 0.0) { + winRate = 1.0; + } else if (positive != 0.0) { + winRate = positive / totalSum; + } + + const double averageWin = positive > 0.0 ? static_cast(winPipSum) / positive : 0.0; + const double averageLoss = negative > 0.0 + ? std::abs(static_cast(lossPipSum) / negative) : 0.0; + + // Profit-to-loss weighting; the C# special cases pin the degenerate ends. + double tradeRatio = 0.0; + if (positive > 0.0 && negative > 0.0) { + tradeRatio = (averageWin * positive) / (averageLoss * negative); + } + if (positive == 0.0) tradeRatio = 0.0; + if (negative == 0.0) tradeRatio = 100.0; + + // Expectancy in pips, normalised to opening equity, then turned into a + // Van Tharp SQN by the sqrt(trades) frequency factor and mapped so SQN 2.0 + // ~= score 50. + const double expectancyPips = averageWin * winRate - averageLoss * (1.0 - winRate); + const double expectancyPercent = expectancyPips / startingBalance * 100.0; + const double tradeFreqFactor = std::sqrt(totalSum); + const double systemQuality = expectancyPercent * tradeFreqFactor; + const double expectancyScore = systemQuality * (50.0 / 2.0); + + // CAGR over the backtest horizon; finalPnl is already net profit in pips. + const double years = static_cast(config.LAST_MONTHS) / 12.0; + const double finalPnl = static_cast(stats.finalPnl); + const double cagrPercent = (finalPnl / startingBalance) / years * 100.0; + + // Realized max drawdown as a percent of opening equity, floored at 2% so a + // near-flat curve cannot explode the Calmar ratio. Calmar 3.0 ~= score 50. + const double maxDrawdownPercent = static_cast(maxDropPips) / startingBalance * 100.0; + const double adjustedDrawdown = std::max(maxDrawdownPercent, 2.0); + const double calmarScore = (cagrPercent / adjustedDrawdown) * (50.0 / 3.0); + + // Trades-per-year confidence: too few decisive trades for the horizon is + // noise, so we scale the whole score down rather than nudge it. + double confidenceMultiplier = 0.1; + if (totalSum >= 60.0 * years) confidenceMultiplier = 1.0; + else if (totalSum >= 40.0 * years) confidenceMultiplier = 0.9; + else if (totalSum >= 20.0 * years) confidenceMultiplier = 0.8; + else if (totalSum >= 10.0 * years) confidenceMultiplier = 0.6; + else if (totalSum >= 5.0 * years) confidenceMultiplier = 0.4; + + const double rawPerformance = expectancyScore * 0.5 + calmarScore * 0.5; + const double performance = rawPerformance * confidenceMultiplier; + + stats.winRate = boost::decimal::decimal64_t{winRate}; + stats.tradeRatio = boost::decimal::decimal64_t{tradeRatio}; + stats.expectancyScore = boost::decimal::decimal64_t{expectancyScore}; + stats.calmarScore = boost::decimal::decimal64_t{calmarScore}; + stats.confidenceMultiplier = boost::decimal::decimal64_t{confidenceMultiplier}; + stats.maxDrawdownPercent = boost::decimal::decimal64_t{maxDrawdownPercent}; + stats.performanceScore = boost::decimal::decimal64_t{performance}; +} + +} // namespace + +TradingResultsStats ResultsSummary::collect(const TradeManager& tradeManager, + const tradingDefinitions::Configuration& config) { const auto& activeTrades = tradeManager.getActiveTrades(); const auto& closedTrades = tradeManager.getClosedTrades(); @@ -43,7 +147,9 @@ TradingResultsStats ResultsSummary::collect(const TradeManager& tradeManager) { std::size_t losers = 0; std::size_t breakeven = 0; std::size_t liquidated = 0; - boost::decimal::decimal64_t pnlSum{0}; // accumulated in pips + boost::decimal::decimal64_t pnlSum{0}; // accumulated in pips + boost::decimal::decimal64_t winPipSum{0}; // sum of winning trades, pips + boost::decimal::decimal64_t lossPipSum{0}; // sum of losing trades, pips (<= 0) for (const auto& trade : closedTrades) { if (trade.direction == Direction::LONG) ++closedLong; else ++closedShort; @@ -54,9 +160,28 @@ TradingResultsStats ResultsSummary::collect(const TradeManager& tradeManager) { // trade.pnl is int64 points-per-lot; convert to pips using the trade's // own points-per-pip so mixed-symbol runs sum correctly. if (trade.scalingFactor != 0) { - pnlSum += boost::decimal::decimal64_t{trade.pnl} / trade.scalingFactor; + const boost::decimal::decimal64_t pips = + boost::decimal::decimal64_t{trade.pnl} / trade.scalingFactor; + pnlSum += pips; + if (trade.pnl > 0) winPipSum += pips; + else if (trade.pnl < 0) lossPipSum += pips; } } + + // True mark-to-market max drawdown, tracked live in TradeManager (so it + // sees intra-trade floating losses). It is an aggregate point sum, so it is + // converted to pips with the run's primary symbol's points-per-pip — exact + // for single-asset-class runs, matching the loss-limit floor convention in + // Operations::run. + const std::string primarySymbol = + config.SYMBOLS.substr(0, config.SYMBOLS.find(',')); + const int primaryPointsPerPip = symbol_scale::get(primarySymbol); + boost::decimal::decimal64_t maxDropPips{0}; + if (primaryPointsPerPip != 0) { + maxDropPips = boost::decimal::decimal64_t{tradeManager.maxDrawdownPoints()} + / primaryPointsPerPip; + } + openedLong += closedLong; openedShort += closedShort; @@ -77,16 +202,19 @@ TradingResultsStats ResultsSummary::collect(const TradeManager& tradeManager) { } else { stats.avgPnl = pnlSum / boost::decimal::decimal64_t{static_cast(closedCount)}; } + + computePerformanceScore(stats, config, winPipSum, lossPipSum, maxDropPips); return stats; } -void ResultsSummary::summarise(const TradeManager& tradeManager) { +void ResultsSummary::summarise(const TradeManager& tradeManager, + const tradingDefinitions::Configuration& config) { // Per-strategy summary is skipped under concurrent backtests (quiet). if (backtest_log::is_quiet()) { return; } - const auto stats = collect(tradeManager); + const auto stats = collect(tradeManager, config); std::cout << "Final PnL: " << std::fixed << std::setprecision(2) << stats.finalPnl << std::endl; std::cout << "Trades opened: " << stats.tradesOpened @@ -102,4 +230,10 @@ void ResultsSummary::summarise(const TradeManager& tradeManager) { std::cout << "Average PnL per closed trade: " << std::fixed << std::setprecision(2) << *stats.avgPnl << std::endl; } + std::cout << "Performance score: " << std::fixed << std::setprecision(2) + << stats.performanceScore + << " (expectancy: " << stats.expectancyScore + << ", calmar: " << stats.calmarScore + << ", confidence: " << stats.confidenceMultiplier + << ", maxDD%: " << stats.maxDrawdownPercent << ")" << std::endl; } diff --git a/source/run/reporting/tradingResults.cpp b/source/run/reporting/tradingResults.cpp index 00d57a1..8dd614b 100644 --- a/source/run/reporting/tradingResults.cpp +++ b/source/run/reporting/tradingResults.cpp @@ -32,6 +32,13 @@ void to_json(nlohmann::json& j, const TradingResultsStats& s) { {"losers", s.losers}, {"breakeven", s.breakeven}, {"liquidated", s.liquidated}, + {"performanceScore", s.performanceScore}, + {"winRate", s.winRate}, + {"tradeRatio", s.tradeRatio}, + {"expectancyScore", s.expectancyScore}, + {"calmarScore", s.calmarScore}, + {"confidenceMultiplier", s.confidenceMultiplier}, + {"maxDrawdownPercent", s.maxDrawdownPercent}, }; if (s.avgPnl) { j["avgPnl"] = *s.avgPnl; diff --git a/source/run/reporting/tradingResults.hpp b/source/run/reporting/tradingResults.hpp index c4c729b..721c87e 100644 --- a/source/run/reporting/tradingResults.hpp +++ b/source/run/reporting/tradingResults.hpp @@ -32,6 +32,21 @@ struct TradingResultsStats { // be read net of liquidation noise. std::size_t liquidated = 0; std::optional avgPnl; + + // Composite performance score and its components (see ResultsSummary). + // The score blends expectancy/SQN, Calmar, and a trades-per-year + // confidence multiplier so a sweep can be ranked by a single number. + // All default to 0 so a zero-trade (or unscoreable) run serialises cleanly. + // Units note: PnL is pip-denominated and STARTING_BALANCE is currency, so + // the percentages are pip-relative proxies — consistent for ranking, not + // an absolute account return. + boost::decimal::decimal64_t performanceScore{0}; + boost::decimal::decimal64_t winRate{0}; + boost::decimal::decimal64_t tradeRatio{0}; + boost::decimal::decimal64_t expectancyScore{0}; + boost::decimal::decimal64_t calmarScore{0}; + boost::decimal::decimal64_t confidenceMultiplier{0}; + boost::decimal::decimal64_t maxDrawdownPercent{0}; }; // Wire shape pushed to Elasticsearch: the input Configuration plus the run's diff --git a/source/run/runCommand.cppm b/source/run/runCommand.cppm index b7a6355..40a420d 100644 --- a/source/run/runCommand.cppm +++ b/source/run/runCommand.cppm @@ -8,22 +8,19 @@ module; #include "shared/utilities/env.hpp" #include "shared/utilities/jsonParser.hpp" -#include "shared/redis/redisRunner.hpp" +#include "shared/redis/consumer/redisRunner.hpp" export module runCommand; -import std; // replaces , -import backtestRunner; // runBacktest +import std; +import backtestRunner; -// 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. export class RunCommand { public: static int run(int argc, const char* argv[]); }; -int RunCommand::run(int argc, const char* argv[]) { +int RunCommand::run(const int argc, const char* argv[]) { if (argc < 3) { std::println(stderr, "Usage: BacktestingEngine run \n" diff --git a/source/run/trading/runLoop.cppm b/source/run/trading/runLoop.cppm index 33f2785..7a01ee7 100644 --- a/source/run/trading/runLoop.cppm +++ b/source/run/trading/runLoop.cppm @@ -8,8 +8,8 @@ module; #include -#include "shared/tradingDefinitions/runConfiguration.hpp" // DEFAULT_STARTING_BALANCE -#include "shared/tradingDefinitions/tradingVariables.hpp" +#include "shared/tradingDefinitions/config/runConfiguration.hpp" // DEFAULT_STARTING_BALANCE +#include "shared/tradingDefinitions/variables/tradingVariables.hpp" export module runLoop; diff --git a/source/run/trading/tradeManager.cppm b/source/run/trading/tradeManager.cppm index 2d7d4f9..9e1949f 100644 --- a/source/run/trading/tradeManager.cppm +++ b/source/run/trading/tradeManager.cppm @@ -28,6 +28,16 @@ private: // across open ones. std::int64_t closedPnl{0}; std::int64_t openPnl{0}; + // Peak account equity (closedPnl + openPnl, int64 points-per-lot) and the + // deepest peak-to-trough drop seen below it, sampled every time equity + // moves (open/mark/close). This is the TRUE mark-to-market max drawdown — + // it sees intra-trade floating losses, not just realized close-to-close. + std::int64_t peakEquity{0}; + std::int64_t maxDrawdown{0}; + // Fold the current equity into the peak/drawdown extremes. Cheap integer + // work; called from every mutation that changes equity so callers (and the + // per-tick loop) need no extra bookkeeping. + void updateDrawdown(); public: TradeManager() = default; @@ -51,6 +61,10 @@ public: // Floating (mark-to-market) PnL (int64 points-per-lot) across all open // trades, as of each trade's last marked price. O(1). std::int64_t unrealizedPnl() const; + // Deepest peak-to-trough drop in account equity over the run, in int64 + // points-per-lot (>= 0). Includes intra-trade floating drawdown. Divide by + // the symbol's points-per-pip for pips at the reporting boundary. O(1). + std::int64_t maxDrawdownPoints() const; // Revalue open trades for this tick's symbol at its close-side price // (bid for LONG, ask for SHORT), updating their floating PnL. void markToMarket(const PriceData& tick); @@ -76,6 +90,13 @@ std::int64_t floatingPnlAt(const Trade& trade, std::int32_t mark) { } } +void TradeManager::updateDrawdown() { + const std::int64_t equity = closedPnl + openPnl; + if (equity > peakEquity) peakEquity = equity; + const std::int64_t drop = peakEquity - equity; + if (drop > maxDrawdown) maxDrawdown = drop; +} + std::string TradeManager::openTrade(const PriceData& tick, std::int32_t size, Direction direction, @@ -109,6 +130,7 @@ std::string TradeManager::openTrade(const PriceData& tick, trade.floatingPnl = floatingPnlAt(trade, trade.lastMarkPrice); openPnl += trade.floatingPnl; activeTrades[trade.id] = trade; + updateDrawdown(); // equity dips by the spread the moment a trade opens return trade.id; } @@ -121,6 +143,7 @@ void TradeManager::markToMarket(const PriceData& tick) { trade.floatingPnl = updated; trade.lastMarkPrice = mark; } + updateDrawdown(); // capture floating drawdown at this tick's marks } void TradeManager::closeAllTrades(const PriceData& tick) { @@ -165,6 +188,7 @@ bool TradeManager::closeTrade(const std::string& tradeId, closed.floatingPnl = 0; closedTrades.push_back(closed); activeTrades.erase(it); + updateDrawdown(); // realized exit may differ from the last mark // Per-trade chatter is skipped under concurrent backtests (quiet), // which also avoids the formatting work below. @@ -207,3 +231,7 @@ std::int64_t TradeManager::calculatePnl() const { std::int64_t TradeManager::unrealizedPnl() const { return openPnl; } + +std::int64_t TradeManager::maxDrawdownPoints() const { + return maxDrawdown; +} diff --git a/source/shared/redis/boostRedisImpl.cpp b/source/shared/redis/connection/boostRedisImpl.cpp similarity index 100% rename from source/shared/redis/boostRedisImpl.cpp rename to source/shared/redis/connection/boostRedisImpl.cpp diff --git a/source/shared/redis/redisConnection.cpp b/source/shared/redis/connection/redisConnection.cpp similarity index 95% rename from source/shared/redis/redisConnection.cpp rename to source/shared/redis/connection/redisConnection.cpp index 30e6053..e7a867b 100644 --- a/source/shared/redis/redisConnection.cpp +++ b/source/shared/redis/connection/redisConnection.cpp @@ -4,7 +4,7 @@ // This code is licensed under MIT license (see LICENSE.txt for details) // --------------------------------------- -#include "shared/redis/redisConnection.hpp" +#include "shared/redis/connection/redisConnection.hpp" #include #include diff --git a/source/shared/redis/redisConnection.hpp b/source/shared/redis/connection/redisConnection.hpp similarity index 100% rename from source/shared/redis/redisConnection.hpp rename to source/shared/redis/connection/redisConnection.hpp diff --git a/source/shared/redis/redisRunner.cpp b/source/shared/redis/consumer/redisRunner.cpp similarity index 74% rename from source/shared/redis/redisRunner.cpp rename to source/shared/redis/consumer/redisRunner.cpp index b71097a..83a385f 100644 --- a/source/shared/redis/redisRunner.cpp +++ b/source/shared/redis/consumer/redisRunner.cpp @@ -4,7 +4,7 @@ // This code is licensed under MIT license (see LICENSE.txt for details) // --------------------------------------- -#include "shared/redis/redisRunner.hpp" +#include "shared/redis/consumer/redisRunner.hpp" #include #include @@ -16,7 +16,6 @@ #include #include #include -#include #include #include @@ -28,8 +27,9 @@ #include "shared/utilities/backtestLog.hpp" #include "shared/utilities/jsonParser.hpp" #include "shared/utilities/queueKeys.hpp" -#include "shared/redis/redisConnection.hpp" -#include "shared/redis/runnerBridge.hpp" +#include "shared/redis/connection/redisConnection.hpp" +#include "shared/redis/consumer/runQueue.hpp" +#include "run/execution/runnerBridge.hpp" #include "shared/utilities/threadPool.hpp" namespace asio = boost::asio; @@ -37,52 +37,6 @@ namespace redis = boost::redis; namespace { -// 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, - 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()); -} - -// 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; -} - -// 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 @@ -102,7 +56,7 @@ asio::awaitable drainRuns(std::shared_ptr conn, // wait and re-peek (below); only an exception blows the loop bool waitingLogged = false; for (;;) { - const std::optional descriptorB64 = co_await peekRunTail(conn); + const std::optional descriptorB64 = co_await run_queue::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 @@ -144,7 +98,7 @@ asio::awaitable drainRuns(std::shared_ptr conn, int strategiesRun = 0; for (;;) { const std::optional strategyB64 = - co_await popStrategy(conn, strategyKey); + co_await run_queue::popStrategy(conn, strategyKey); if (!strategyB64.has_value()) { break; // strategy list drained } @@ -177,7 +131,7 @@ asio::awaitable drainRuns(std::shared_ptr conn, // 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); + co_await run_queue::removeRun(conn, *descriptorB64); std::println("RedisRunner: completed RUN_ID={} ({} strateg{})", runCfg.RUN_ID, strategiesRun, @@ -202,16 +156,12 @@ asio::awaitable drainRuns(std::shared_ptr conn, int RedisRunner::run(const std::string& questdbHost, const std::string& redisHost, - int redisPort) { + const int redisPort) { - // Mute logs to prevent interleaved thread spam - backtest_log::set_quiet(true); + backtest_log::set_quiet(true); // Mute logs to prevent interleaved thread spam + asio::io_context ioc; // Set up the async event loop - // Set up the async event loop - asio::io_context ioc; - - // Establish Redis connection - auto conn = redis_util::makeRedisConnection(ioc, redisHost, redisPort); + const auto conn = redis_util::makeRedisConnection(ioc, redisHost, redisPort); // State trackers for the coroutine's outcome int result = 0; diff --git a/source/shared/redis/redisRunner.hpp b/source/shared/redis/consumer/redisRunner.hpp similarity index 100% rename from source/shared/redis/redisRunner.hpp rename to source/shared/redis/consumer/redisRunner.hpp diff --git a/source/shared/redis/consumer/runQueue.cpp b/source/shared/redis/consumer/runQueue.cpp new file mode 100644 index 0000000..2946679 --- /dev/null +++ b/source/shared/redis/consumer/runQueue.cpp @@ -0,0 +1,68 @@ +// Backtesting Engine in C++ +// +// (c) 2026 Ryan McCaffery | https://mccaffers.com +// This code is licensed under MIT license (see LICENSE.txt for details) +// --------------------------------------- + +#include "shared/redis/consumer/runQueue.hpp" + +#include +#include +#include + +#include +#include + +#include "shared/utilities/queueKeys.hpp" + +namespace asio = boost::asio; +namespace redis = boost::redis; + +namespace { + +// 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, + 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()); +} + +// 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; +} + +} // namespace + +namespace run_queue { + +asio::awaitable> peekRunTail( + std::shared_ptr conn) { + redis::request req; + req.push("LINDEX", queue_keys::RUN, "-1"); + co_return co_await execOptionalString(std::move(conn), std::move(req)); +} + +asio::awaitable> popStrategy( + std::shared_ptr conn, + std::string strategyKey) { + redis::request req; + req.push("RPOP", strategyKey); + co_return co_await execOptionalString(std::move(conn), std::move(req)); +} + +asio::awaitable removeRun(std::shared_ptr conn, + std::string descriptorB64) { + redis::request req; + req.push("LREM", queue_keys::RUN, "0", descriptorB64); + co_await execIgnore(std::move(conn), std::move(req)); +} + +} // namespace run_queue diff --git a/source/shared/redis/consumer/runQueue.hpp b/source/shared/redis/consumer/runQueue.hpp new file mode 100644 index 0000000..f35f73e --- /dev/null +++ b/source/shared/redis/consumer/runQueue.hpp @@ -0,0 +1,39 @@ +// 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 + +// Redis queue access for the runner's drain loop. These coroutines issue one +// command each on the shared, long-lived connection and never cancel it — the +// connection stays open for the rest of the worker loop. Keeping them out of +// redisRunner.cpp leaves that TU focused on orchestration (drainRuns + run()). +namespace run_queue { + +// 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. nullopt when the run queue is empty. +boost::asio::awaitable> peekRunTail( + std::shared_ptr conn); + +// Claims one strategy off the run's per-RUN_ID list. nullopt once drained. +boost::asio::awaitable> popStrategy( + std::shared_ptr conn, + std::string strategyKey); + +// Retires a run by removing its descriptor. Idempotent: LREM removes 0 if a peer +// worker already retired it. +boost::asio::awaitable removeRun( + std::shared_ptr conn, + std::string descriptorB64); + +} // namespace run_queue diff --git a/source/shared/redis/redisLoader.cpp b/source/shared/redis/producer/redisLoader.cpp similarity index 80% rename from source/shared/redis/redisLoader.cpp rename to source/shared/redis/producer/redisLoader.cpp index 5257847..c8a1cd7 100644 --- a/source/shared/redis/redisLoader.cpp +++ b/source/shared/redis/producer/redisLoader.cpp @@ -4,7 +4,7 @@ // This code is licensed under MIT license (see LICENSE.txt for details) // --------------------------------------- -#include "shared/redis/redisLoader.hpp" +#include "shared/redis/producer/redisLoader.hpp" #include #include @@ -15,22 +15,26 @@ #include #include -#include -#include #include #include -#include #include #include #include "shared/utilities/base64.hpp" -#include "shared/redis/redisConnection.hpp" +#include "shared/redis/connection/redisConnection.hpp" namespace asio = boost::asio; namespace redis = boost::redis; namespace { +// A whitespace-only (or empty) payload is rejected before it reaches Redis. +bool isBlank(const std::string& rawJson) { + return std::ranges::all_of(rawJson, [](unsigned char c) { + return std::isspace(c); + }); +} + asio::awaitable pushOnce( std::shared_ptr conn, std::string queueKey, @@ -69,11 +73,7 @@ int RedisLoader::loadPayload(const std::string& redisHost, int redisPort, const std::string& queueKey, const std::string& rawJson) { - const bool isBlank = std::ranges::all_of(rawJson, - [](unsigned char c) { - return std::isspace(c); - }); - if (isBlank) { + if (isBlank(rawJson)) { std::cerr << "RedisLoader: empty payload rejected" << std::endl; return 1; } @@ -81,13 +81,7 @@ int RedisLoader::loadPayload(const std::string& redisHost, const std::string encoded = 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; @@ -125,11 +119,7 @@ int RedisLoader::loadPayloadBatch(const std::string& redisHost, std::vector encoded; encoded.reserve(rawJsonPayloads.size()); for (const std::string& rawJson : rawJsonPayloads) { - const bool isBlank = std::ranges::all_of(rawJson, - [](unsigned char c) { - return std::isspace(c); - }); - if (isBlank) { + if (isBlank(rawJson)) { std::cerr << "RedisLoader: empty payload rejected" << std::endl; return 1; } diff --git a/source/shared/redis/redisLoader.hpp b/source/shared/redis/producer/redisLoader.hpp similarity index 100% rename from source/shared/redis/redisLoader.hpp rename to source/shared/redis/producer/redisLoader.hpp diff --git a/source/shared/tradingDefinitions.hpp b/source/shared/tradingDefinitions.hpp index 3fef651..b32107a 100644 --- a/source/shared/tradingDefinitions.hpp +++ b/source/shared/tradingDefinitions.hpp @@ -5,10 +5,10 @@ // --------------------------------------- #pragma once -#include "shared/tradingDefinitions/ohlcVariables.hpp" -#include "shared/tradingDefinitions/ohlcRsiVariables.hpp" -#include "shared/tradingDefinitions/tradingVariables.hpp" -#include "shared/tradingDefinitions/strategyVariables.hpp" +#include "shared/tradingDefinitions/variables/ohlcVariables.hpp" +#include "shared/tradingDefinitions/variables/ohlcRsiVariables.hpp" +#include "shared/tradingDefinitions/variables/tradingVariables.hpp" +#include "shared/tradingDefinitions/variables/strategyVariables.hpp" #include "shared/tradingDefinitions/strategy.hpp" -#include "shared/tradingDefinitions/configuration.hpp" -#include "shared/tradingDefinitions/runConfiguration.hpp" +#include "shared/tradingDefinitions/config/configuration.hpp" +#include "shared/tradingDefinitions/config/runConfiguration.hpp" diff --git a/source/shared/tradingDefinitions/configuration.hpp b/source/shared/tradingDefinitions/config/configuration.hpp similarity index 97% rename from source/shared/tradingDefinitions/configuration.hpp rename to source/shared/tradingDefinitions/config/configuration.hpp index c7d24cc..68d459d 100644 --- a/source/shared/tradingDefinitions/configuration.hpp +++ b/source/shared/tradingDefinitions/config/configuration.hpp @@ -8,7 +8,7 @@ #include #include #include -#include "shared/tradingDefinitions/runConfiguration.hpp" +#include "shared/tradingDefinitions/config/runConfiguration.hpp" #include "shared/tradingDefinitions/strategy.hpp" #include "shared/utilities/decimalJson.hpp" diff --git a/source/shared/tradingDefinitions/runConfiguration.hpp b/source/shared/tradingDefinitions/config/runConfiguration.hpp similarity index 100% rename from source/shared/tradingDefinitions/runConfiguration.hpp rename to source/shared/tradingDefinitions/config/runConfiguration.hpp diff --git a/source/shared/tradingDefinitions/strategy.hpp b/source/shared/tradingDefinitions/strategy.hpp index c59088b..e89f7b5 100644 --- a/source/shared/tradingDefinitions/strategy.hpp +++ b/source/shared/tradingDefinitions/strategy.hpp @@ -8,9 +8,9 @@ #include #include #include -#include "shared/tradingDefinitions/tradingVariables.hpp" -#include "shared/tradingDefinitions/ohlcVariables.hpp" -#include "shared/tradingDefinitions/strategyVariables.hpp" +#include "shared/tradingDefinitions/variables/tradingVariables.hpp" +#include "shared/tradingDefinitions/variables/ohlcVariables.hpp" +#include "shared/tradingDefinitions/variables/strategyVariables.hpp" namespace tradingDefinitions { diff --git a/source/shared/tradingDefinitions/ohlcRsiVariables.hpp b/source/shared/tradingDefinitions/variables/ohlcRsiVariables.hpp similarity index 100% rename from source/shared/tradingDefinitions/ohlcRsiVariables.hpp rename to source/shared/tradingDefinitions/variables/ohlcRsiVariables.hpp diff --git a/source/shared/tradingDefinitions/ohlcVariables.hpp b/source/shared/tradingDefinitions/variables/ohlcVariables.hpp similarity index 100% rename from source/shared/tradingDefinitions/ohlcVariables.hpp rename to source/shared/tradingDefinitions/variables/ohlcVariables.hpp diff --git a/source/shared/tradingDefinitions/tradingDefinitionsJson.cpp b/source/shared/tradingDefinitions/variables/strategyVariables.cpp similarity index 91% rename from source/shared/tradingDefinitions/tradingDefinitionsJson.cpp rename to source/shared/tradingDefinitions/variables/strategyVariables.cpp index a180af5..2c6e0bb 100644 --- a/source/shared/tradingDefinitions/tradingDefinitionsJson.cpp +++ b/source/shared/tradingDefinitions/variables/strategyVariables.cpp @@ -3,7 +3,7 @@ // (c) 2025 Ryan McCaffery | https://mccaffers.com // This code is licensed under MIT license (see LICENSE.txt for details) // --------------------------------------- -#include "shared/tradingDefinitions/strategyVariables.hpp" +#include "shared/tradingDefinitions/variables/strategyVariables.hpp" namespace tradingDefinitions { diff --git a/source/shared/tradingDefinitions/strategyVariables.hpp b/source/shared/tradingDefinitions/variables/strategyVariables.hpp similarity index 88% rename from source/shared/tradingDefinitions/strategyVariables.hpp rename to source/shared/tradingDefinitions/variables/strategyVariables.hpp index e815f57..6e6b6d1 100644 --- a/source/shared/tradingDefinitions/strategyVariables.hpp +++ b/source/shared/tradingDefinitions/variables/strategyVariables.hpp @@ -7,7 +7,7 @@ #pragma once #include #include -#include "shared/tradingDefinitions/ohlcRsiVariables.hpp" +#include "shared/tradingDefinitions/variables/ohlcRsiVariables.hpp" namespace tradingDefinitions { diff --git a/source/shared/tradingDefinitions/tradingVariables.hpp b/source/shared/tradingDefinitions/variables/tradingVariables.hpp similarity index 100% rename from source/shared/tradingDefinitions/tradingVariables.hpp rename to source/shared/tradingDefinitions/variables/tradingVariables.hpp diff --git a/tests/sweep.cpp b/tests/sweep.cpp index 81871d5..e38d065 100644 --- a/tests/sweep.cpp +++ b/tests/sweep.cpp @@ -16,7 +16,7 @@ #include #include "shared/utilities/parameterSweep.hpp" -#include "shared/tradingDefinitions/runConfiguration.hpp" +#include "shared/tradingDefinitions/config/runConfiguration.hpp" #include "shared/tradingDefinitions/strategy.hpp" import randomStrategySweep; // sweep::buildRandomStrategySweep diff --git a/tests/tradeManager.cpp b/tests/tradeManager.cpp index ce21f18..9dc9e7f 100644 --- a/tests/tradeManager.cpp +++ b/tests/tradeManager.cpp @@ -5,6 +5,7 @@ // --------------------------------------- #include +#include #include #include // setenv — disable Elastic reporting for run() smoke tests @@ -16,9 +17,11 @@ #include -#include "shared/tradingDefinitions/configuration.hpp" +#include "shared/tradingDefinitions/config/configuration.hpp" +#include "run/reporting/tradingResults.hpp" // TradingResultsStats import tradeManager; // TradeManager +import resultsSummary; // ResultsSummary::collect (performance score) import exitRules; // trading::exit_rules::checkExit import reviewStopAndLimit; // trading::reviewStopAndLimit import runLoop; // trading::runTicks, RiskLimits, RunStatus @@ -99,6 +102,16 @@ struct AlwaysLongStrategy : IStrategy { void during(const PriceData& /*tick*/, TradeManager& /*tradeManager*/) override {} }; +// Seeds a closed EURUSD trade with an exact realized PnL. A zero-spread tick at +// 110000 means a LONG enters at 110000, so closing at 110000 + pnlPoints yields +// pnl == pnlPoints (size 1). EURUSD has 10 points per pip, so 10 points == 1 pip. +// Trades close in call order, which is the order collect() walks for drawdown. +void seedClosedTrade(TradeManager& manager, std::int32_t pnlPoints) { + PriceData tick(110000, 110000, std::chrono::system_clock::now(), "EURUSD"); + std::string id = manager.openTrade(tick, 1, Direction::LONG); + manager.closeTrade(id, 110000 + pnlPoints, tick); +} + } // namespace TEST_CASE("TradeManager opens a trade", "[tradeManager]") { @@ -841,3 +854,102 @@ TEST_CASE("runTicks: loss limit disabled for zero and negative", "[tradeManager] CHECK(tm.getActiveTrades().size() == 1); } } + +// --- Performance score (ResultsSummary::collect) --- + +// Guard: with no closed trades there is nothing to score, so every score field +// stays at its 0 default rather than producing a NaN from a 0/0 division. +TEST_CASE("score: no trades leaves score fields zero", "[score]") { + TradeManager tm; + const auto config = makeRandomStrategyConfig(); // LAST_MONTHS = 1, balance 10000 + + const auto stats = ResultsSummary::collect(tm, config); + + CHECK(static_cast(stats.performanceScore) == 0.0); + CHECK(static_cast(stats.expectancyScore) == 0.0); + CHECK(static_cast(stats.calmarScore) == 0.0); + CHECK(static_cast(stats.maxDrawdownPercent) == 0.0); +} + +// Guard: a zero-length horizon (LAST_MONTHS = 0) would divide by zero in the +// CAGR/confidence maths, so the score is suppressed even with real trades. +TEST_CASE("score: zero horizon suppresses the score", "[score]") { + TradeManager tm; + seedClosedTrade(tm, 100); // a +10-pip winner + auto config = makeRandomStrategyConfig(); + config.LAST_MONTHS = 0; + + const auto stats = ResultsSummary::collect(tm, config); + + CHECK(stats.winners == 1); + CHECK(static_cast(stats.performanceScore) == 0.0); +} + +// All-winners special cases mirror the C# reference: no losers -> win rate 1 and +// trade ratio pinned to 100, and the run still produces a positive score. +TEST_CASE("score: all winners pin winRate=1 and tradeRatio=100", "[score]") { + TradeManager tm; + seedClosedTrade(tm, 100); // +10 pips + seedClosedTrade(tm, 100); // +10 pips + const auto config = makeRandomStrategyConfig(); + + const auto stats = ResultsSummary::collect(tm, config); + + CHECK(stats.winners == 2); + CHECK(stats.losers == 0); + CHECK(static_cast(stats.winRate) == 1.0); + CHECK(static_cast(stats.tradeRatio) == 100.0); + CHECK(static_cast(stats.maxDrawdownPercent) == 0.0); // monotonic up + CHECK(static_cast(stats.performanceScore) > 0.0); +} + +// Mixed run pins the derived stats to hand-computable values. PnL sequence in +// pips: +10, -4, -3, +2 -> cumulative 10, 6, 3, 5, so the realized peak-to-trough +// is 10 - 3 = 7 pips. Against a 10000 balance that is 0.07%. winRate = 2/4 = 0.5; +// averageWin = 6, averageLoss = 3.5, tradeRatio = (6*2)/(3.5*2) = 12/7. +TEST_CASE("score: drawdown and ratios match a hand-computed run", "[score]") { + TradeManager tm; + seedClosedTrade(tm, 100); // +10 pips + seedClosedTrade(tm, -40); // -4 pips + seedClosedTrade(tm, -30); // -3 pips + seedClosedTrade(tm, 20); // +2 pips + const auto config = makeRandomStrategyConfig(); + + const auto stats = ResultsSummary::collect(tm, config); + + CHECK(stats.winners == 2); + CHECK(stats.losers == 2); + CHECK(static_cast(stats.winRate) == Catch::Approx(0.5)); + CHECK(static_cast(stats.tradeRatio) == Catch::Approx(12.0 / 7.0)); + CHECK(static_cast(stats.maxDrawdownPercent) == Catch::Approx(0.07)); +} + +// True intra-trade drawdown: a single LONG that floats deep underwater and then +// recovers to a winning close. Close-to-close (realized) drawdown would be 0 +// because the only closed trade is a winner — the mark-to-market tracker must +// still see the trough the open position sat through. Open @ ask 110010, mark +// down to bid 109000 (floating -1010 points = -101 pips), then TP-close at +90. +TEST_CASE("score: drawdown captures an intra-trade float, not just closes", "[score]") { + TradeManager tm; + AlwaysLongStrategy strategy; + const auto vars = makeVars(0, 10, 1); // TP only, no SL — let it float + const auto now = std::chrono::system_clock::now(); + const std::vector ticks{ + PriceData(110010, 110000, now, "EURUSD"), // open LONG @ ask 110010 + PriceData(109010, 109000, now, "EURUSD"), // floats to -1010 points + PriceData(110110, 110100, now, "EURUSD"), // bid 110100 hits TP, closes +90 + }; + + trading::runTicks(tm, strategy, ticks, vars); + + REQUIRE(tm.getClosedTrades().size() == 1); + CHECK(tm.getClosedTrades().front().pnl == 90); // the only close is a winner + + const auto config = makeRandomStrategyConfig(); + const auto stats = ResultsSummary::collect(tm, config); + + CHECK(stats.winners == 1); + CHECK(stats.losers == 0); + // Trough was -1010 points = -101 pips from the 0 peak; 101 / 10000 * 100. + CHECK(static_cast(stats.maxDrawdownPercent) == Catch::Approx(1.01)); +}