diff --git a/CMakeLists.txt b/CMakeLists.txt index 744c4dd..fce7aae 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,6 +9,7 @@ set(CMAKE_EXPORT_COMPILE_COMMANDS ON) find_package(CURL REQUIRED) add_subdirectory(src) +add_subdirectory(strategies) option(BARCOLA_BUILD_TESTS "Build tests" ON) if(BARCOLA_BUILD_TESTS) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b83b678..170402a 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -12,6 +12,8 @@ add_library(barcola STATIC risk/slippage_model.cpp risk/dynamic_hedging.cpp simulation/monte_carlo.cpp + backtest/performance.cpp + backtest/backtest_engine.cpp ) target_include_directories(barcola PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/src/backtest/backtest_engine.cpp b/src/backtest/backtest_engine.cpp new file mode 100644 index 0000000..6ed0321 --- /dev/null +++ b/src/backtest/backtest_engine.cpp @@ -0,0 +1,140 @@ +#include "backtest/backtest_engine.h" + +#include +#include + +namespace barcola { + +BacktestEngine::BacktestEngine(BacktestConfig config) + : config_(std::move(config)) {} + +BacktestResult BacktestEngine::run(Strategy& strategy, + const PriceHistory& data) const { + const auto& points = data.getDataPoints(); + if (points.empty()) { + throw std::invalid_argument("Cannot backtest on empty price data"); + } + + BacktestResult result; + double cash = config_.initialCapital; + double shares = 0.0; + double entryPrice = 0.0; + std::string entryDate; + bool inPosition = false; + + result.equityCurve.reserve(points.size()); + + for (size_t i = 0; i < points.size(); ++i) { + const double price = points[i].getClosing(); + const Signal signal = strategy.evaluate(points, i); + + if (signal == Signal::BUY && !inPosition) { + // Enter long position + const double execPrice = applySlippage(price, true); + shares = calculateShares(cash, execPrice); + if (shares > 0.0) { + const double cost = shares * execPrice; + const double commission = applyCommission(cost); + cash -= (cost + commission); + entryPrice = execPrice; + entryDate = points[i].getDateString(); + inPosition = true; + } + } else if (signal == Signal::SELL && inPosition) { + // Exit long position + const double execPrice = applySlippage(price, false); + const double proceeds = shares * execPrice; + const double commission = applyCommission(proceeds); + cash += (proceeds - commission); + + Trade trade; + trade.symbol = data.getAssetSymbol(); + trade.entryPrice = entryPrice; + trade.exitPrice = execPrice; + trade.entryDate = entryDate; + trade.exitDate = points[i].getDateString(); + trade.shares = shares; + trade.pnl = (execPrice - entryPrice) * shares - + applyCommission(shares * entryPrice) - + applyCommission(proceeds); + trade.returnPct = (execPrice - entryPrice) / entryPrice; + result.trades.push_back(trade); + + shares = 0.0; + inPosition = false; + } + + // Record equity: cash + mark-to-market position value + const double portfolioValue = cash + shares * price; + result.equityCurve.push_back(portfolioValue); + } + + // Close any open position at the last price + if (inPosition && !points.empty()) { + const double lastPrice = points.back().getClosing(); + const double execPrice = applySlippage(lastPrice, false); + const double proceeds = shares * execPrice; + const double commission = applyCommission(proceeds); + cash += (proceeds - commission); + + Trade trade; + trade.symbol = data.getAssetSymbol(); + trade.entryPrice = entryPrice; + trade.exitPrice = execPrice; + trade.entryDate = entryDate; + trade.exitDate = points.back().getDateString(); + trade.shares = shares; + trade.pnl = (execPrice - entryPrice) * shares - + applyCommission(shares * entryPrice) - + applyCommission(proceeds); + trade.returnPct = (execPrice - entryPrice) / entryPrice; + result.trades.push_back(trade); + + shares = 0.0; + result.equityCurve.back() = cash; + } + + // Compute summary metrics + const double finalEquity = result.equityCurve.back(); + result.totalReturn = (finalEquity / config_.initialCapital) - 1.0; + + const double years = static_cast(points.size()) / + TRADING_DAYS_PER_YEAR; + if (years > 1e-12) { + result.annualizedReturn = + std::pow(1.0 + result.totalReturn, 1.0 / years) - 1.0; + } + + result.sharpeRatio = calculateSharpeRatio(result.equityCurve); + result.sortinoRatio = calculateSortinoRatio(result.equityCurve); + result.maxDrawdown = calculateMaxDrawdown(result.equityCurve); + result.calmarRatio = calculateCalmarRatio(result.equityCurve); + + const TradeStatistics stats = analyzeTradeHistory(result.trades); + result.winRate = stats.winRate; + result.profitFactor = stats.profitFactor; + result.totalTrades = stats.totalTrades; + result.avgHoldDays = stats.avgHoldDays; + + return result; +} + +double BacktestEngine::applySlippage(double price, bool isBuy) const { + const double slippageFraction = config_.slippageBps / 10000.0; + return isBuy ? price * (1.0 + slippageFraction) + : price * (1.0 - slippageFraction); +} + +double BacktestEngine::applyCommission(double tradeValue) const { + return tradeValue * config_.commissionRate; +} + +double BacktestEngine::calculateShares(double capital, double price) const { + if (price <= 0.0) { + return 0.0; + } + const double riskCapital = capital * config_.riskPerTrade; + return std::floor(riskCapital / price); +} + +} // namespace barcola diff --git a/src/backtest/backtest_engine.h b/src/backtest/backtest_engine.h new file mode 100644 index 0000000..611d8e1 --- /dev/null +++ b/src/backtest/backtest_engine.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include "backtest/strategy.h" +#include "backtest/performance.h" +#include "core/price_history.h" + +namespace barcola { + +struct BacktestConfig { + double initialCapital = 100000.0; + double commissionRate = 0.001; // 0.1% per trade + double slippageBps = 5.0; // 5 basis points + double riskPerTrade = 0.02; // 2% risk per trade +}; + +struct BacktestResult { + std::vector trades; + std::vector equityCurve; + double totalReturn = 0.0; + double annualizedReturn = 0.0; + double sharpeRatio = 0.0; + double sortinoRatio = 0.0; + double maxDrawdown = 0.0; + double calmarRatio = 0.0; + double winRate = 0.0; + double profitFactor = 0.0; + size_t totalTrades = 0; + double avgHoldDays = 0.0; +}; + +class BacktestEngine { +public: + explicit BacktestEngine(BacktestConfig config); + + [[nodiscard]] BacktestResult run(Strategy& strategy, + const PriceHistory& data) const; + +private: + BacktestConfig config_; + + [[nodiscard]] double applySlippage(double price, bool isBuy) const; + [[nodiscard]] double applyCommission(double tradeValue) const; + [[nodiscard]] double calculateShares(double capital, double price) const; +}; + +} // namespace barcola diff --git a/src/backtest/performance.cpp b/src/backtest/performance.cpp new file mode 100644 index 0000000..03c2c40 --- /dev/null +++ b/src/backtest/performance.cpp @@ -0,0 +1,194 @@ +#include "backtest/performance.h" + +#include +#include +#include +#include + +#include "core/time_utils.h" + +namespace barcola { + +double calculateSharpeRatio(const std::vector& equityCurve, + double riskFreeRate) { + if (equityCurve.size() < 2) { + return 0.0; + } + + // Compute daily returns + std::vector dailyReturns; + dailyReturns.reserve(equityCurve.size() - 1); + for (size_t i = 1; i < equityCurve.size(); ++i) { + if (equityCurve[i - 1] <= 0.0) { + continue; + } + dailyReturns.push_back(equityCurve[i] / equityCurve[i - 1] - 1.0); + } + + if (dailyReturns.empty()) { + return 0.0; + } + + const double dailyRiskFree = riskFreeRate / TRADING_DAYS_PER_YEAR; + + // Mean excess return + double sum = 0.0; + for (double r : dailyReturns) { + sum += (r - dailyRiskFree); + } + const double meanExcess = sum / static_cast(dailyReturns.size()); + + // Std dev of daily returns + double sumSqDiff = 0.0; + const double meanReturn = std::accumulate(dailyReturns.begin(), + dailyReturns.end(), 0.0) / + static_cast(dailyReturns.size()); + for (double r : dailyReturns) { + const double diff = r - meanReturn; + sumSqDiff += diff * diff; + } + const double stdDev = std::sqrt(sumSqDiff / + static_cast(dailyReturns.size())); + + if (stdDev < 1e-12) { + return 0.0; + } + + return (meanExcess / stdDev) * std::sqrt(TRADING_DAYS_PER_YEAR); +} + +double calculateMaxDrawdown(const std::vector& equityCurve) { + if (equityCurve.size() < 2) { + return 0.0; + } + + double peak = equityCurve[0]; + double maxDD = 0.0; + + for (size_t i = 1; i < equityCurve.size(); ++i) { + if (equityCurve[i] > peak) { + peak = equityCurve[i]; + } + if (peak > 0.0) { + const double dd = (peak - equityCurve[i]) / peak; + maxDD = std::max(maxDD, dd); + } + } + + return maxDD; +} + +double calculateSortinoRatio(const std::vector& equityCurve, + double riskFreeRate) { + if (equityCurve.size() < 2) { + return 0.0; + } + + std::vector dailyReturns; + dailyReturns.reserve(equityCurve.size() - 1); + for (size_t i = 1; i < equityCurve.size(); ++i) { + if (equityCurve[i - 1] <= 0.0) { + continue; + } + dailyReturns.push_back(equityCurve[i] / equityCurve[i - 1] - 1.0); + } + + if (dailyReturns.empty()) { + return 0.0; + } + + const double dailyRiskFree = riskFreeRate / TRADING_DAYS_PER_YEAR; + + double sumExcess = 0.0; + for (double r : dailyReturns) { + sumExcess += (r - dailyRiskFree); + } + const double meanExcess = sumExcess / static_cast(dailyReturns.size()); + + // Downside deviation: only negative returns + double sumDownsideSq = 0.0; + for (double r : dailyReturns) { + if (r < dailyRiskFree) { + const double diff = r - dailyRiskFree; + sumDownsideSq += diff * diff; + } + } + const double downsideDev = std::sqrt(sumDownsideSq / + static_cast(dailyReturns.size())); + + if (downsideDev < 1e-12) { + return 0.0; + } + + return (meanExcess / downsideDev) * std::sqrt(TRADING_DAYS_PER_YEAR); +} + +double calculateCalmarRatio(const std::vector& equityCurve) { + if (equityCurve.size() < 2) { + return 0.0; + } + + const double totalReturn = equityCurve.back() / equityCurve.front() - 1.0; + const double years = static_cast(equityCurve.size() - 1) / + TRADING_DAYS_PER_YEAR; + + if (years < 1e-12) { + return 0.0; + } + + const double annualizedReturn = std::pow(1.0 + totalReturn, 1.0 / years) - 1.0; + const double maxDD = calculateMaxDrawdown(equityCurve); + + if (maxDD < 1e-12) { + return 0.0; + } + + return annualizedReturn / maxDD; +} + +TradeStatistics analyzeTradeHistory(const std::vector& trades) { + TradeStatistics stats; + stats.totalTrades = trades.size(); + + if (trades.empty()) { + return stats; + } + + double totalWin = 0.0; + double totalLoss = 0.0; + double totalHoldDays = 0.0; + + for (const auto& trade : trades) { + if (trade.pnl > 0.0) { + stats.winningTrades++; + totalWin += trade.pnl; + } else if (trade.pnl < 0.0) { + stats.losingTrades++; + totalLoss += std::abs(trade.pnl); + } + + // Estimate hold days from dates + if (!trade.entryDate.empty() && !trade.exitDate.empty()) { + const time_t entry = convertDateToEpoch(trade.entryDate); + const time_t exit = convertDateToEpoch(trade.exitDate); + totalHoldDays += static_cast(exit - entry) / 86400.0; + } + } + + stats.winRate = static_cast(stats.winningTrades) / + static_cast(stats.totalTrades); + + if (stats.winningTrades > 0) { + stats.avgWin = totalWin / static_cast(stats.winningTrades); + } + if (stats.losingTrades > 0) { + stats.avgLoss = totalLoss / static_cast(stats.losingTrades); + } + + stats.profitFactor = (totalLoss > 1e-12) ? (totalWin / totalLoss) : 0.0; + stats.avgHoldDays = totalHoldDays / static_cast(stats.totalTrades); + + return stats; +} + +} // namespace barcola diff --git a/src/backtest/performance.h b/src/backtest/performance.h new file mode 100644 index 0000000..0ba2bfd --- /dev/null +++ b/src/backtest/performance.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include "backtest/strategy.h" + +namespace barcola { + +constexpr int TRADING_DAYS_PER_YEAR = 252; +constexpr double DEFAULT_RISK_FREE_RATE = 0.05; + +struct TradeStatistics { + size_t totalTrades = 0; + size_t winningTrades = 0; + size_t losingTrades = 0; + double winRate = 0.0; + double avgWin = 0.0; + double avgLoss = 0.0; + double profitFactor = 0.0; + double avgHoldDays = 0.0; +}; + +[[nodiscard]] double calculateSharpeRatio( + const std::vector& equityCurve, + double riskFreeRate = DEFAULT_RISK_FREE_RATE); + +[[nodiscard]] double calculateMaxDrawdown( + const std::vector& equityCurve); + +[[nodiscard]] double calculateSortinoRatio( + const std::vector& equityCurve, + double riskFreeRate = DEFAULT_RISK_FREE_RATE); + +[[nodiscard]] double calculateCalmarRatio( + const std::vector& equityCurve); + +[[nodiscard]] TradeStatistics analyzeTradeHistory( + const std::vector& trades); + +} // namespace barcola diff --git a/src/backtest/strategy.h b/src/backtest/strategy.h new file mode 100644 index 0000000..fc4cbaf --- /dev/null +++ b/src/backtest/strategy.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include "core/price_point.h" + +namespace barcola { + +enum class Signal { BUY, SELL, HOLD }; + +struct Trade { + std::string symbol; + double entryPrice = 0.0; + double exitPrice = 0.0; + std::string entryDate; + std::string exitDate; + double shares = 0.0; + double pnl = 0.0; + double returnPct = 0.0; +}; + +class Strategy { +public: + virtual ~Strategy() = default; + + virtual Signal evaluate(const std::vector& history, + size_t currentIndex) = 0; + + [[nodiscard]] virtual std::string name() const = 0; +}; + +} // namespace barcola diff --git a/strategies/CMakeLists.txt b/strategies/CMakeLists.txt new file mode 100644 index 0000000..dd94030 --- /dev/null +++ b/strategies/CMakeLists.txt @@ -0,0 +1,6 @@ +add_library(barcola_strategies STATIC + mean_reversion_strategy.cpp +) + +target_include_directories(barcola_strategies PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(barcola_strategies PUBLIC barcola) diff --git a/strategies/mean_reversion_strategy.cpp b/strategies/mean_reversion_strategy.cpp new file mode 100644 index 0000000..6a3f0a4 --- /dev/null +++ b/strategies/mean_reversion_strategy.cpp @@ -0,0 +1,72 @@ +#include "mean_reversion_strategy.h" + +#include +#include + +namespace barcola { + +MeanReversionStrategy::MeanReversionStrategy(size_t lookbackPeriod, + double entryZScore, + double exitZScore) + : lookback_(lookbackPeriod), entryZ_(entryZScore), exitZ_(exitZScore) {} + +Signal MeanReversionStrategy::evaluate(const std::vector& history, + size_t currentIndex) { + // Need enough history for lookback window + if (currentIndex < lookback_) { + return Signal::HOLD; + } + + const double z = computeZScore(history, currentIndex); + + if (!inPosition_ && z < -entryZ_) { + // Price is abnormally low — buy + inPosition_ = true; + return Signal::BUY; + } + + if (inPosition_ && z > exitZ_) { + // Price has reverted to (or above) mean — sell + inPosition_ = false; + return Signal::SELL; + } + + return Signal::HOLD; +} + +std::string MeanReversionStrategy::name() const { + std::ostringstream oss; + oss << "MeanReversion(lookback=" << lookback_ + << ", entryZ=" << entryZ_ + << ", exitZ=" << exitZ_ << ")"; + return oss.str(); +} + +double MeanReversionStrategy::computeZScore( + const std::vector& history, + size_t currentIndex) const { + + // Compute mean over the lookback window + double sum = 0.0; + const size_t start = currentIndex - lookback_ + 1; + for (size_t i = start; i <= currentIndex; ++i) { + sum += history[i].getClosing(); + } + const double mean = sum / static_cast(lookback_); + + // Compute standard deviation + double sumSqDiff = 0.0; + for (size_t i = start; i <= currentIndex; ++i) { + const double diff = history[i].getClosing() - mean; + sumSqDiff += diff * diff; + } + const double stdDev = std::sqrt(sumSqDiff / static_cast(lookback_)); + + if (stdDev < 1e-12) { + return 0.0; // No variance — price is flat + } + + return (history[currentIndex].getClosing() - mean) / stdDev; +} + +} // namespace barcola diff --git a/strategies/mean_reversion_strategy.h b/strategies/mean_reversion_strategy.h new file mode 100644 index 0000000..647470e --- /dev/null +++ b/strategies/mean_reversion_strategy.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include "backtest/strategy.h" + +namespace barcola { + +class MeanReversionStrategy : public Strategy { +public: + MeanReversionStrategy(size_t lookbackPeriod, + double entryZScore, + double exitZScore); + + Signal evaluate(const std::vector& history, + size_t currentIndex) override; + + [[nodiscard]] std::string name() const override; + +private: + size_t lookback_; + double entryZ_; + double exitZ_; + bool inPosition_ = false; + + // Compute z-score of current price vs its lookback-period mean and stddev + [[nodiscard]] double computeZScore(const std::vector& history, + size_t currentIndex) const; +}; + +} // namespace barcola diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2f7af1a..e1119ce 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -29,3 +29,19 @@ barcola_add_test(test_slippage) barcola_add_test(test_dynamic_hedging) barcola_add_test(test_monte_carlo) barcola_add_test(test_csv_io) + +# Phase 2: backtest tests need strategies library too +function(barcola_add_backtest_test name) + add_executable(${name} ${name}.cpp) + target_link_libraries(${name} PRIVATE barcola barcola_strategies GTest::gtest_main) + target_include_directories(${name} PRIVATE + ${PROJECT_SOURCE_DIR}/src + ${PROJECT_SOURCE_DIR}/strategies) + target_compile_definitions(${name} PRIVATE + BARCOLA_TEST_DATA_DIR="${PROJECT_SOURCE_DIR}/data") + add_test(NAME ${name} COMMAND ${name}) +endfunction() + +barcola_add_test(test_performance) +barcola_add_backtest_test(test_backtest_engine) +barcola_add_backtest_test(test_mean_reversion_strategy) diff --git a/tests/test_backtest_engine.cpp b/tests/test_backtest_engine.cpp new file mode 100644 index 0000000..85a9705 --- /dev/null +++ b/tests/test_backtest_engine.cpp @@ -0,0 +1,155 @@ +#include +#include +#include "backtest/backtest_engine.h" +#include "mean_reversion_strategy.h" + +using namespace barcola; + +// A trivial strategy that always holds — never trades +class AlwaysHoldStrategy : public Strategy { +public: + Signal evaluate(const std::vector&, size_t) override { + return Signal::HOLD; + } + [[nodiscard]] std::string name() const override { return "AlwaysHold"; } +}; + +// A strategy that buys on bar 1 and sells on bar 3 +class BuySellFixedStrategy : public Strategy { +public: + Signal evaluate(const std::vector&, size_t idx) override { + if (idx == 1) return Signal::BUY; + if (idx == 3) return Signal::SELL; + return Signal::HOLD; + } + [[nodiscard]] std::string name() const override { return "BuySellFixed"; } +}; + +TEST(BacktestEngineTest, NoTrades_EquityFlat) { + PriceHistory data("TEST"); + data.loadFromCsv(std::string(BARCOLA_TEST_DATA_DIR) + "/AAPL_2023_sample.csv"); + + BacktestConfig config; + config.initialCapital = 100000.0; + BacktestEngine engine(config); + + AlwaysHoldStrategy strategy; + const BacktestResult result = engine.run(strategy, data); + + EXPECT_EQ(result.totalTrades, 0u); + EXPECT_NEAR(result.totalReturn, 0.0, 1e-10); + EXPECT_EQ(result.equityCurve.size(), data.dataPointsCount()); + // All equity curve values should be initial capital + for (double eq : result.equityCurve) { + EXPECT_DOUBLE_EQ(eq, 100000.0); + } +} + +TEST(BacktestEngineTest, SingleRoundTrip_ProducesOneTrade) { + PriceHistory data("TEST"); + data.loadFromCsv(std::string(BARCOLA_TEST_DATA_DIR) + "/monotonic_up.csv"); + + BacktestConfig config; + config.initialCapital = 100000.0; + config.commissionRate = 0.0; + config.slippageBps = 0.0; + config.riskPerTrade = 1.0; // use full capital + BacktestEngine engine(config); + + BuySellFixedStrategy strategy; + const BacktestResult result = engine.run(strategy, data); + + EXPECT_EQ(result.totalTrades, 1u); + EXPECT_EQ(result.trades[0].symbol, "TEST"); + // Bought at bar 1, sold at bar 3 — prices are monotonically up, so profit + EXPECT_GT(result.trades[0].pnl, 0.0); + EXPECT_GT(result.trades[0].returnPct, 0.0); +} + +TEST(BacktestEngineTest, Commission_ReducesReturns) { + PriceHistory data("TEST"); + data.loadFromCsv(std::string(BARCOLA_TEST_DATA_DIR) + "/monotonic_up.csv"); + + // Run with zero commission + BacktestConfig configZero; + configZero.initialCapital = 100000.0; + configZero.commissionRate = 0.0; + configZero.slippageBps = 0.0; + configZero.riskPerTrade = 0.5; + BacktestEngine engineZero(configZero); + + // Run with commission + BacktestConfig configComm; + configComm.initialCapital = 100000.0; + configComm.commissionRate = 0.01; // 1% + configComm.slippageBps = 0.0; + configComm.riskPerTrade = 0.5; + BacktestEngine engineComm(configComm); + + BuySellFixedStrategy s1; + BuySellFixedStrategy s2; + const auto resultZero = engineZero.run(s1, data); + const auto resultComm = engineComm.run(s2, data); + + // Commission should reduce final equity + EXPECT_GT(resultZero.equityCurve.back(), resultComm.equityCurve.back()); +} + +TEST(BacktestEngineTest, Slippage_ReducesReturns) { + PriceHistory data("TEST"); + data.loadFromCsv(std::string(BARCOLA_TEST_DATA_DIR) + "/monotonic_up.csv"); + + BacktestConfig configZero; + configZero.initialCapital = 100000.0; + configZero.commissionRate = 0.0; + configZero.slippageBps = 0.0; + configZero.riskPerTrade = 0.5; + BacktestEngine engineZero(configZero); + + BacktestConfig configSlip; + configSlip.initialCapital = 100000.0; + configSlip.commissionRate = 0.0; + configSlip.slippageBps = 50.0; // 50 bps + configSlip.riskPerTrade = 0.5; + BacktestEngine engineSlip(configSlip); + + BuySellFixedStrategy s1; + BuySellFixedStrategy s2; + const auto resultZero = engineZero.run(s1, data); + const auto resultSlip = engineSlip.run(s2, data); + + EXPECT_GT(resultZero.equityCurve.back(), resultSlip.equityCurve.back()); +} + +TEST(BacktestEngineTest, EmptyData_Throws) { + PriceHistory data("EMPTY"); + data.loadFromCsv(std::string(BARCOLA_TEST_DATA_DIR) + "/empty.csv"); + + BacktestEngine engine(BacktestConfig{}); + AlwaysHoldStrategy strategy; + + EXPECT_THROW(engine.run(strategy, data), std::invalid_argument); +} + +TEST(BacktestEngineTest, MetricsArePopulated) { + PriceHistory data("AAPL"); + data.loadFromCsv(std::string(BARCOLA_TEST_DATA_DIR) + "/AAPL_2023_sample.csv"); + + BacktestConfig config; + config.initialCapital = 100000.0; + BacktestEngine engine(config); + + // Use mean reversion on the sample data — may or may not trade, + // but metrics should be computed without crashing + MeanReversionStrategy strategy(5, 1.5, 0.0); + const BacktestResult result = engine.run(strategy, data); + + // Equity curve should match data length + EXPECT_EQ(result.equityCurve.size(), data.dataPointsCount()); + // Metrics should be finite + EXPECT_FALSE(std::isnan(result.sharpeRatio)); + EXPECT_FALSE(std::isnan(result.maxDrawdown)); + EXPECT_FALSE(std::isnan(result.sortinoRatio)); + EXPECT_GE(result.maxDrawdown, 0.0); + EXPECT_LE(result.maxDrawdown, 1.0); +} diff --git a/tests/test_mean_reversion_strategy.cpp b/tests/test_mean_reversion_strategy.cpp new file mode 100644 index 0000000..6d57ca9 --- /dev/null +++ b/tests/test_mean_reversion_strategy.cpp @@ -0,0 +1,99 @@ +#include +#include "backtest/strategy.h" +#include "mean_reversion_strategy.h" +#include "core/price_point.h" + +using namespace barcola; + +// Helper: build a vector of PricePoints from closing prices +static std::vector makePrices(const std::vector& closes) { + std::vector pts; + pts.reserve(closes.size()); + for (size_t i = 0; i < closes.size(); ++i) { + // Date doesn't matter for signal logic; use sequential dates + std::string date = std::string("2023-01-") + + (i + 1 < 10 ? "0" : "") + + std::to_string(i + 1); + pts.emplace_back(date, closes[i]); + } + return pts; +} + +TEST(MeanReversionStrategyTest, Name_ContainsParameters) { + MeanReversionStrategy strategy(20, 2.0, 0.0); + const std::string n = strategy.name(); + EXPECT_NE(n.find("20"), std::string::npos); + EXPECT_NE(n.find("2"), std::string::npos); +} + +TEST(MeanReversionStrategyTest, InsufficientHistory_ReturnsHold) { + MeanReversionStrategy strategy(10, 2.0, 0.0); + auto prices = makePrices({100, 101, 102, 103, 104}); + + // All indices < lookback(10) should return HOLD + for (size_t i = 0; i < prices.size(); ++i) { + EXPECT_EQ(strategy.evaluate(prices, i), Signal::HOLD); + } +} + +TEST(MeanReversionStrategyTest, FlatPrices_NeverTrades) { + MeanReversionStrategy strategy(5, 2.0, 0.0); + // 20 bars of constant price => z-score is always 0 + std::vector closes(20, 100.0); + auto prices = makePrices(closes); + + for (size_t i = 0; i < prices.size(); ++i) { + EXPECT_EQ(strategy.evaluate(prices, i), Signal::HOLD); + } +} + +TEST(MeanReversionStrategyTest, SharpDrop_TriggersBuy) { + MeanReversionStrategy strategy(5, 1.5, 0.0); + + // 5 bars of steady price, then a sharp drop + std::vector closes = {100, 100, 100, 100, 100, 90}; + auto prices = makePrices(closes); + + // At index 5 (enough lookback), price 90 is well below the 5-bar mean ~98.3 + // z-score should be negative and large + Signal sig = strategy.evaluate(prices, 5); + EXPECT_EQ(sig, Signal::BUY); +} + +TEST(MeanReversionStrategyTest, BuyThenRevert_TriggersSell) { + MeanReversionStrategy strategy(5, 1.5, 0.0); + + // Steady, then sharp drop (triggers BUY), then recovery (triggers SELL) + std::vector closes = {100, 100, 100, 100, 100, 85, 88, 92, 96, 100}; + auto prices = makePrices(closes); + + // Walk through the strategy + bool boughtSomewhere = false; + bool soldSomewhere = false; + for (size_t i = 0; i < prices.size(); ++i) { + Signal sig = strategy.evaluate(prices, i); + if (sig == Signal::BUY) boughtSomewhere = true; + if (sig == Signal::SELL) soldSomewhere = true; + } + + EXPECT_TRUE(boughtSomewhere); + EXPECT_TRUE(soldSomewhere); +} + +TEST(MeanReversionStrategyTest, NoDuplicateBuys) { + // Strategy should not issue BUY twice without a SELL in between + MeanReversionStrategy strategy(5, 1.0, 0.5); + + // Drop that stays low + std::vector closes = {100, 100, 100, 100, 100, 85, 84, 83, 82, 81}; + auto prices = makePrices(closes); + + int buyCount = 0; + for (size_t i = 0; i < prices.size(); ++i) { + if (strategy.evaluate(prices, i) == Signal::BUY) { + buyCount++; + } + } + + EXPECT_EQ(buyCount, 1); +} diff --git a/tests/test_performance.cpp b/tests/test_performance.cpp new file mode 100644 index 0000000..fb5ef0d --- /dev/null +++ b/tests/test_performance.cpp @@ -0,0 +1,130 @@ +#include +#include +#include "backtest/performance.h" + +using namespace barcola; + +// --- Sharpe Ratio --- + +TEST(PerformanceTest, SharpeRatio_FlatEquity_ReturnsZero) { + // No returns => zero Sharpe + std::vector equity = {100000, 100000, 100000, 100000, 100000}; + EXPECT_DOUBLE_EQ(calculateSharpeRatio(equity), 0.0); +} + +TEST(PerformanceTest, SharpeRatio_GrowthWithVariance_Positive) { + // Alternating growth rates to create non-zero variance + std::vector equity; + double val = 100000.0; + for (int i = 0; i < 252; ++i) { + equity.push_back(val); + val *= (i % 2 == 0) ? 1.002 : 1.0005; + } + const double sharpe = calculateSharpeRatio(equity); + EXPECT_GT(sharpe, 0.0); +} + +TEST(PerformanceTest, SharpeRatio_TooFewPoints_ReturnsZero) { + std::vector equity = {100000}; + EXPECT_DOUBLE_EQ(calculateSharpeRatio(equity), 0.0); +} + +// --- Max Drawdown --- + +TEST(PerformanceTest, MaxDrawdown_NoDecline_Zero) { + std::vector equity = {100, 110, 120, 130, 140}; + EXPECT_DOUBLE_EQ(calculateMaxDrawdown(equity), 0.0); +} + +TEST(PerformanceTest, MaxDrawdown_KnownValue) { + // Peak at 200, drops to 150 => 25% drawdown + std::vector equity = {100, 150, 200, 180, 150, 190}; + EXPECT_NEAR(calculateMaxDrawdown(equity), 0.25, 1e-10); +} + +TEST(PerformanceTest, MaxDrawdown_FullDecline) { + // Drops from 100 to 50 => 50% drawdown + std::vector equity = {100, 80, 60, 50, 70}; + EXPECT_NEAR(calculateMaxDrawdown(equity), 0.50, 1e-10); +} + +// --- Sortino Ratio --- + +TEST(PerformanceTest, SortinoRatio_MixedReturns_Positive) { + // Growth with some down days to create downside deviation + std::vector equity; + double val = 100000.0; + for (int i = 0; i < 100; ++i) { + equity.push_back(val); + val *= (i % 5 == 0) ? 0.998 : 1.003; // mostly up, occasional down + } + const double sortino = calculateSortinoRatio(equity); + EXPECT_GT(sortino, 0.0); +} + +TEST(PerformanceTest, SortinoRatio_TooFewPoints_ReturnsZero) { + std::vector equity = {100000}; + EXPECT_DOUBLE_EQ(calculateSortinoRatio(equity), 0.0); +} + +// --- Calmar Ratio --- + +TEST(PerformanceTest, CalmarRatio_NoDrawdown_ReturnsZero) { + // No drawdown => division by zero guard returns 0 + std::vector equity = {100, 110, 120, 130, 140}; + EXPECT_DOUBLE_EQ(calculateCalmarRatio(equity), 0.0); +} + +TEST(PerformanceTest, CalmarRatio_WithDrawdown_Positive) { + // Generate a year of data with some drawdown + std::vector equity; + double val = 100000.0; + for (int i = 0; i < 252; ++i) { + equity.push_back(val); + if (i < 50) val *= 1.002; // growth + else if (i < 100) val *= 0.998; // decline + else val *= 1.001; // recovery + } + const double calmar = calculateCalmarRatio(equity); + EXPECT_GT(calmar, 0.0); +} + +// --- Trade Statistics --- + +TEST(PerformanceTest, TradeStats_Empty) { + std::vector trades; + const auto stats = analyzeTradeHistory(trades); + EXPECT_EQ(stats.totalTrades, 0u); + EXPECT_DOUBLE_EQ(stats.winRate, 0.0); +} + +TEST(PerformanceTest, TradeStats_MixedTrades) { + std::vector trades; + + Trade win; + win.pnl = 500.0; + win.entryDate = "2023-01-03"; + win.exitDate = "2023-01-10"; + trades.push_back(win); + + Trade loss; + loss.pnl = -200.0; + loss.entryDate = "2023-01-11"; + loss.exitDate = "2023-01-15"; + trades.push_back(loss); + + Trade win2; + win2.pnl = 300.0; + win2.entryDate = "2023-01-16"; + win2.exitDate = "2023-01-20"; + trades.push_back(win2); + + const auto stats = analyzeTradeHistory(trades); + EXPECT_EQ(stats.totalTrades, 3u); + EXPECT_EQ(stats.winningTrades, 2u); + EXPECT_EQ(stats.losingTrades, 1u); + EXPECT_NEAR(stats.winRate, 2.0 / 3.0, 1e-10); + EXPECT_NEAR(stats.avgWin, 400.0, 1e-10); // (500+300)/2 + EXPECT_NEAR(stats.avgLoss, 200.0, 1e-10); + EXPECT_NEAR(stats.profitFactor, 800.0 / 200.0, 1e-10); // 4.0 +}