diff --git a/core/utils/JsonLogger.cpp b/core/utils/JsonLogger.cpp index e335f9b..fdcd2d7 100644 --- a/core/utils/JsonLogger.cpp +++ b/core/utils/JsonLogger.cpp @@ -132,6 +132,12 @@ void JsonLogger::logConnectionEvent(const std::string& eventType, writeLogEntry(entry); } +void JsonLogger::log(const nlohmann::json& entry) { + if (!m_enabled) + return; + writeLogEntry(entry); +} + void JsonLogger::flush() { if (m_fileStream && m_fileStream->is_open()) { m_fileStream->flush(); diff --git a/core/utils/JsonLogger.h b/core/utils/JsonLogger.h index 41b8d4e..4fc120f 100644 --- a/core/utils/JsonLogger.h +++ b/core/utils/JsonLogger.h @@ -96,6 +96,16 @@ class JsonLogger { const std::string& exchange, const std::string& message = ""); + /** + * @brief Write a raw JSON object as a single line. + * + * Use this when the consumer expects a specific top-level shape that + * doesn't match the wrappers emitted by logTradingEvent/etc. + * + * @param entry JSON entry to write + */ + void log(const nlohmann::json& entry); + /** * @brief Flush all pending writes to disk */ diff --git a/main.cpp b/main.cpp index 005be86..c38061d 100644 --- a/main.cpp +++ b/main.cpp @@ -614,6 +614,10 @@ int main(int argc, char* argv[]) { return 1; } + if (jsonLogger) { + engine.setJsonLogger(jsonLogger); + } + // BacktestEngine requires MLEnhancedMarketMaker; create one with ML // disabled when --enable-ml is not set std::shared_ptr btStrategy; diff --git a/strategies/backtesting/BacktestEngine.cpp b/strategies/backtesting/BacktestEngine.cpp index 03b74d8..e50407d 100644 --- a/strategies/backtesting/BacktestEngine.cpp +++ b/strategies/backtesting/BacktestEngine.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -472,6 +473,41 @@ void BacktestEngine::setStrategy( m_strategy = std::move(strategy); } +void BacktestEngine::setJsonLogger( + std::shared_ptr jsonLogger) { + m_jsonLogger = std::move(jsonLogger); +} + +void BacktestEngine::emitFinalStrategyMetrics() { + if (!m_jsonLogger || !m_jsonLogger->isEnabled()) { + return; + } + + const auto stats = m_analyzer->calculateStatistics(); + + // nlohmann::json serializes NaN/Inf as `null` without warning, but the Go + // runner rejects that — and profitFactor naturally goes to +Inf when there + // are no losing trades. Replace non-finite values with null explicitly so + // the JSON is at least well-defined. + auto finite = [](double v) -> nlohmann::json { + return std::isfinite(v) ? nlohmann::json(v) : nlohmann::json(nullptr); + }; + + nlohmann::json entry = {{"type", "strategy_metrics"}, + {"sharpe_ratio", finite(stats.sharpeRatio)}, + {"max_drawdown", finite(stats.maxDrawdown)}, + {"win_rate", finite(stats.winRate)}, + {"total_trades", stats.totalTrades}, + {"total_pnl", finite(stats.totalPnL)}, + {"total_volume", finite(stats.totalVolume)}, + {"profit_factor", finite(stats.profitFactor)}, + {"var_95", finite(stats.valueAtRisk95)}, + {"var_99", finite(stats.valueAtRisk99)}, + {"timestamp", m_currentTime}}; + m_jsonLogger->log(entry); + m_jsonLogger->flush(); +} + bool BacktestEngine::initialize() { spdlog::info("Initializing BacktestEngine"); @@ -491,10 +527,10 @@ bool BacktestEngine::initialize() { bool BacktestEngine::runBacktest(const std::string& symbol) { if (!m_strategy) { - spdlog::error("No strategy set for backtesting"); - return false; + // Data-replay-only mode: produce zero-trade metrics. Useful for + // validating data ingestion and for tests that don't need a strategy. + spdlog::warn("Running backtest without a strategy (data replay only)"); } - return runBacktest(symbol, m_strategy); } @@ -537,6 +573,8 @@ bool BacktestEngine::runBacktest( m_position = 0.0; m_unrealizedPnL = 0.0; m_realizedPnL = 0.0; + m_avgCostBasis = 0.0; + m_lastData = MarketDataPoint{}; size_t totalDataPoints = m_dataManager->getDataPointCount(); size_t processedPoints = 0; @@ -549,12 +587,22 @@ bool BacktestEngine::runBacktest( m_currentTime = dataPoint.timestamp; - // Process market data - processMarketData(dataPoint); + // Update the market-data snapshot the fill logic reads from. We do this + // before processStrategyOrders so that any quotes carried over from the + // previous tick are matched against the new bid/ask (i.e., they fill if + // the market walked through them between ticks). + m_lastData = dataPoint; + m_analyzer->recordMarketData(dataPoint); - // Process strategy orders + // Match any previously-queued strategy orders against the new market. processStrategyOrders(); + // Feed the new tick to the strategy so it can regenerate quotes. Those + // quotes become resting orders that the next iteration will try to fill. + if (m_strategy) { + m_strategy->updateMarketData(dataPoint); + } + // Update portfolio updatePortfolio(dataPoint); @@ -581,8 +629,15 @@ bool BacktestEngine::runBacktest( // Final performance calculation calculatePerformance(); + // Fast-path summary for the platform runner: one JSONL line with + // pre-computed Sharpe / drawdown / win rate. + emitFinalStrategyMetrics(); + m_isRunning.store(false); - m_progress.store(1.0); + // Preserve partial progress if the run was interrupted via stop(). + if (!m_shouldStop.load()) { + m_progress.store(1.0); + } auto endTime = std::chrono::steady_clock::now(); auto duration = std::chrono::duration_cast( @@ -600,28 +655,177 @@ bool BacktestEngine::runBacktest( } void BacktestEngine::processMarketData(const MarketDataPoint& data) { - // Record market data for analysis + // Kept for compatibility / external callers. The main backtest loop does + // not call this — it inlines the equivalent steps so it can interleave + // strategy updates with fill matching. Notably, this function does NOT + // invoke processStrategyOrders, so calling it directly will record market + // data and regenerate strategy quotes without attempting any fills. m_analyzer->recordMarketData(data); - - // Update strategy with new market data (if strategy supports it) + m_lastData = data; if (m_strategy) { - // Note: I would need to add a method to update market data in the strategy - // m_strategy->updateMarketData(data); + m_strategy->updateMarketData(data); } } +double BacktestEngine::applyFillToCostBasis(OrderSide side, double qty, + double fillPrice) { + const double signedQty = (side == OrderSide::BUY) ? qty : -qty; + const double prev = m_position; + const double next = prev + signedQty; + double realized = 0.0; + + const bool sameSide = (prev == 0.0) || ((prev > 0) == (signedQty > 0)); + if (sameSide) { + // Opening or increasing: recompute weighted-average cost basis. + if (next != 0.0) { + m_avgCostBasis = (prev * m_avgCostBasis + signedQty * fillPrice) / next; + } else { + m_avgCostBasis = 0.0; + } + } else { + // Reducing (possibly flipping) position. P&L is realized on the portion + // that closes existing exposure, valued against m_avgCostBasis. + const double closeQty = std::min(std::abs(signedQty), std::abs(prev)); + if (prev > 0.0) { + realized = (fillPrice - m_avgCostBasis) * closeQty; + } else { + realized = (m_avgCostBasis - fillPrice) * closeQty; + } + + if (next == 0.0) { + m_avgCostBasis = 0.0; + } else if ((prev > 0.0) != (next > 0.0)) { + // Flipped sides: remaining qty opens a fresh position at fillPrice. + m_avgCostBasis = fillPrice; + } + // else: partial close, keep existing cost basis on the residual. + } + + m_position = next; + return realized; +} + void BacktestEngine::processStrategyOrders() { - // This is a simplified implementation - // In a real implementation, we would: - // 1. Get orders from the strategy - // 2. Validate orders against risk limits - // 3. Execute orders with simulated market impact - // 4. Update strategy with fill information - - // For now, we'll simulate some basic trading activity - if (m_strategy && m_currentTime % 5000000000ULL == 0) { // Every 5 seconds - // Simulate strategy generating orders - // This would be replaced with actual strategy integration + if (!m_strategy) { + return; + } + + auto pending = m_strategy->getPendingOrders(); + if (pending.empty()) { + return; + } + + const double currentBid = m_lastData.bid; + const double currentAsk = m_lastData.ask; + if (currentBid <= 0.0 || currentAsk <= 0.0 || currentAsk <= currentBid) { + return; + } + + for (const auto& order : pending) { + if (!order) { + continue; + } + + const OrderSide side = order->getSide(); + const double limitPrice = order->getPrice(); + const double qty = order->getQuantity(); + if (qty <= 0.0) { + continue; + } + + // Marketable limit: buy fills if its limit >= best ask; sell fills + // if its limit <= best bid. Non-marketable limits are dropped (a real + // book would queue them, but in this synchronous replay we don't + // simulate resting liquidity). + double fillPrice = 0.0; + bool filled = false; + if (side == OrderSide::BUY && limitPrice >= currentAsk) { + fillPrice = currentAsk; + filled = true; + } else if (side == OrderSide::SELL && limitPrice <= currentBid) { + fillPrice = currentBid; + filled = true; + } + + if (!filled) { + continue; + } + + // Adverse slippage: push fill price against the taker. Clamp to a + // positive minimum — pathological slippageBps on a low-priced asset + // could otherwise drive sell-fills to zero or negative. + if (m_config.enableSlippage && m_config.slippageBps > 0.0) { + const double slip = fillPrice * m_config.slippageBps * 0.0001; + fillPrice += (side == OrderSide::BUY) ? slip : -slip; + if (fillPrice <= 0.0) { + spdlog::warn("Slippage drove fill price non-positive; skipping order " + "{} (slippageBps={})", + order->getOrderId(), m_config.slippageBps); + continue; + } + } + + const double notional = qty * fillPrice; + const double fee = std::abs(notional) * m_config.tradingFee; + + // Enforce position limit: reject fills that would push |position| above + // configured maxPosition. A real venue would reject these upstream; the + // strategy's inventory skew should keep us away, but this is belt-and- + // braces. + const double signedQty = (side == OrderSide::BUY) ? qty : -qty; + const double wouldBePosition = m_position + signedQty; + if (m_config.maxPosition > 0.0 && + std::abs(wouldBePosition) > m_config.maxPosition) { + continue; + } + + // Buys can't overspend available balance (fee included). Sells always OK + // here since we model long-or-short positions uniformly. + if (side == OrderSide::BUY && m_balance < notional + fee) { + continue; + } + + double realized = 0.0; + double tradePnL = 0.0; + { + // Mutate portfolio state under the same mutex createSnapshot reads + // under, so external observers never see a torn update. + std::lock_guard stateLock(m_stateMutex); + realized = applyFillToCostBasis(side, qty, fillPrice); + tradePnL = realized - fee; + + // Cash: buying consumes balance (+fees), selling releases it (-fees). + m_balance -= (side == OrderSide::BUY) ? notional : -notional; + m_balance -= fee; + m_realizedPnL += tradePnL; + } + + BacktestTrade trade; + trade.timestamp = m_currentTime; + trade.orderId = order->getOrderId(); + trade.symbol = order->getSymbol(); + trade.side = side; + trade.quantity = qty; + trade.price = fillPrice; + trade.fee = fee; + trade.pnl = tradePnL; + trade.position = m_position; + trade.balance = m_balance; + trade.strategy = "BasicMarketMaker"; + trade.regime = ""; + m_analyzer->recordTrade(trade); + + m_strategy->onBacktestFill(side, fillPrice, qty, m_currentTime); + + if (m_jsonLogger && m_jsonLogger->isEnabled()) { + nlohmann::json entry = {{"type", "order_filled"}, + {"side", side == OrderSide::BUY ? "buy" : "sell"}, + {"price", fillPrice}, + {"quantity", qty}, + {"pnl", tradePnL}, + {"timestamp", m_currentTime}}; + m_jsonLogger->log(entry); + } } } diff --git a/strategies/backtesting/BacktestEngine.h b/strategies/backtesting/BacktestEngine.h index 1e11c4e..21421cc 100644 --- a/strategies/backtesting/BacktestEngine.h +++ b/strategies/backtesting/BacktestEngine.h @@ -1,6 +1,7 @@ #pragma once #include "../../core/orderbook/Order.h" +#include "../../core/utils/JsonLogger.h" #include "../../core/utils/TimeUtils.h" #include "../../strategies/analytics/MarketRegimeDetector.h" #include "../../strategies/basic/MLEnhancedMarketMaker.h" @@ -237,6 +238,9 @@ class BacktestEngine { void setStrategy( std::shared_ptr strategy); + // Structured JSONL logging (platform runner ingests this format). + void setJsonLogger(std::shared_ptr jsonLogger); + // Results access TradingStatistics getResults() const; std::vector getTrades() const; @@ -287,6 +291,13 @@ class BacktestEngine { double m_position; double m_unrealizedPnL; double m_realizedPnL; + double m_avgCostBasis{0.0}; + + // Latest market snapshot (used by processStrategyOrders to decide fills). + MarketDataPoint m_lastData; + + // Optional structured JSONL sink for external consumers. + std::shared_ptr m_jsonLogger; // Time management uint64_t m_currentTime; @@ -301,6 +312,13 @@ class BacktestEngine { void updatePortfolio(const MarketDataPoint& data); void calculatePerformance(); + // Realize P&L against cost basis when a fill reduces/flips position. + // Returns the realized P&L for this fill (excluding fees). + double applyFillToCostBasis(OrderSide side, double qty, double fillPrice); + + // Emit a single JSONL strategy_metrics record at the end of the run. + void emitFinalStrategyMetrics(); + // Order execution simulation bool executeOrder(const Order& order, const MarketDataPoint& marketData, BacktestTrade& trade); diff --git a/strategies/basic/BasicMarketMaker.cpp b/strategies/basic/BasicMarketMaker.cpp index fc0fca3..21b804a 100644 --- a/strategies/basic/BasicMarketMaker.cpp +++ b/strategies/basic/BasicMarketMaker.cpp @@ -624,5 +624,102 @@ void BasicMarketMaker::setJsonLogger( m_jsonLogger = jsonLogger; } +void BasicMarketMaker::updateMarketData( + const pinnacle::analytics::MarketDataPoint& data) { + if (data.bid <= 0.0 || data.ask <= 0.0 || data.ask <= data.bid) { + return; + } + m_btLastBid = data.bid; + m_btLastAsk = data.ask; + m_btLastMid = (data.bid + data.ask) * 0.5; + m_btLastTimestamp = data.timestamp; + generateBacktestQuotes(); +} + +void BasicMarketMaker::generateBacktestQuotes() { + const double mid = m_btLastMid; + if (mid <= 0.0) { + return; + } + + // Target spread from config, clamped to [min, max]. + double targetSpread = m_config.baseSpreadBps * 0.0001 * mid; + const double minSpread = m_config.minSpreadBps * 0.0001 * mid; + const double maxSpread = m_config.maxSpreadBps * 0.0001 * mid; + targetSpread = std::max(minSpread, std::min(targetSpread, maxSpread)); + + // Inventory skew: skew both sides away from an over-long/short position. + const double position = m_position.load(std::memory_order_relaxed); + const double positionRatio = + (m_config.maxPosition > 0.0) ? (position / m_config.maxPosition) : 0.0; + const double skewFactor = + static_cast(m_config.inventorySkewFactor) * positionRatio; + const double skewAdjust = skewFactor * mid * 0.0001; + + const double bidPrice = mid - (targetSpread / 2.0) - skewAdjust; + const double askPrice = mid + (targetSpread / 2.0) - skewAdjust; + + const double bidQty = calculateOrderQuantity(OrderSide::BUY); + const double askQty = calculateOrderQuantity(OrderSide::SELL); + + std::lock_guard lock(m_pendingOrdersMutex); + m_pendingOrders.clear(); + + // Order IDs are unique per (symbol, side, timestamp). The stats counter is + // NOT read here — that would be a data race against m_statsMutex. + if (bidPrice > 0.0 && bidQty >= m_config.minOrderQuantity) { + std::string orderId = + m_symbol + "-BT-BUY-" + std::to_string(m_btLastTimestamp); + m_pendingOrders.push_back(std::make_shared( + orderId, m_symbol, OrderSide::BUY, OrderType::LIMIT, bidPrice, bidQty, + m_btLastTimestamp)); + } + if (askPrice > 0.0 && askQty >= m_config.minOrderQuantity) { + std::string orderId = + m_symbol + "-BT-SELL-" + std::to_string(m_btLastTimestamp); + m_pendingOrders.push_back(std::make_shared( + orderId, m_symbol, OrderSide::SELL, OrderType::LIMIT, askPrice, askQty, + m_btLastTimestamp)); + } + + { + std::lock_guard statsLock(m_statsMutex); + m_stats.quoteUpdateCount++; + m_stats.orderPlacedCount += m_pendingOrders.size(); + } +} + +std::vector> BasicMarketMaker::getPendingOrders() { + std::lock_guard lock(m_pendingOrdersMutex); + std::vector> out; + out.swap(m_pendingOrders); + return out; +} + +void BasicMarketMaker::onBacktestFill(OrderSide side, double price, + double quantity, uint64_t /*timestamp*/) { + const double signedQty = (side == OrderSide::BUY) ? quantity : -quantity; + + // CAS loop: atomically add signedQty to m_position. In backtest mode this + // runs single-threaded, but a CAS is cheap insurance against future callers + // (and matches how fills are applied on the live path). + double prev = m_position.load(std::memory_order_relaxed); + double next; + do { + next = prev + signedQty; + } while ( + !m_position.compare_exchange_weak(prev, next, std::memory_order_relaxed)); + + // Keep a simple PnL estimate consistent with updateStatistics(): mark the + // position at fill price. BacktestEngine tracks canonical realized PnL. + m_pnl.store(next * price, std::memory_order_relaxed); + + std::lock_guard statsLock(m_statsMutex); + m_stats.orderFilledCount++; + m_stats.totalVolumeTraded += quantity; + m_stats.maxPosition = std::max(m_stats.maxPosition, next); + m_stats.minPosition = std::min(m_stats.minPosition, next); +} + } // namespace strategy } // namespace pinnacle diff --git a/strategies/basic/BasicMarketMaker.h b/strategies/basic/BasicMarketMaker.h index 76a769b..cb5b889 100644 --- a/strategies/basic/BasicMarketMaker.h +++ b/strategies/basic/BasicMarketMaker.h @@ -7,6 +7,7 @@ #include "../../core/utils/JsonLogger.h" #include "../../core/utils/LockFreeQueue.h" #include "../../exchange/simulator/MarketDataFeed.h" +#include "../analytics/MarketRegimeDetector.h" #include "../config/StrategyConfig.h" #include @@ -107,6 +108,30 @@ class BasicMarketMaker { */ void onMarketUpdate(const pinnacle::exchange::MarketUpdate& update); + /** + * @brief Feed a backtest market-data tick into the strategy. + * + * Updates the strategy's view of the market and regenerates the set of + * quotes it would like to place. Intended for synchronous driving from + * BacktestEngine; the strategy's worker thread is not required to be + * running. + */ + virtual void + updateMarketData(const pinnacle::analytics::MarketDataPoint& data); + + /** + * @brief Pull (and clear) the set of orders the strategy wants to submit + * based on the most recent updateMarketData call. + */ + std::vector> getPendingOrders(); + + /** + * @brief Notify the strategy that one of its pending orders was filled in + * backtest. Updates position and fill statistics. + */ + void onBacktestFill(OrderSide side, double price, double quantity, + uint64_t timestamp); + /** * @brief Get the current strategy statistics * @@ -231,6 +256,15 @@ class BasicMarketMaker { std::mutex m_eventMutex; std::condition_variable m_eventCondition; + // Backtest driving state: populated by updateMarketData, consumed by + // getPendingOrders. Not used in live/simulation paths. + std::vector> m_pendingOrders; + mutable std::mutex m_pendingOrdersMutex; + double m_btLastBid{0.0}; + double m_btLastAsk{0.0}; + double m_btLastMid{0.0}; + uint64_t m_btLastTimestamp{0}; + // Internal implementation methods void strategyMainLoop(); void processEvents(); @@ -240,6 +274,7 @@ class BasicMarketMaker { void updateStatistics(); double calculateOrderQuantity(OrderSide side) const; double calculateInventorySkewFactor() const; + void generateBacktestQuotes(); }; } // namespace strategy diff --git a/tests/unit/BacktestEngineTests.cpp b/tests/unit/BacktestEngineTests.cpp index 1699e4b..6af7b79 100644 --- a/tests/unit/BacktestEngineTests.cpp +++ b/tests/unit/BacktestEngineTests.cpp @@ -478,6 +478,14 @@ TEST_F(BacktestEngineTest, StrategyComparisonTest) { TEST_F(BacktestEngineTest, StopBacktestTest) { createTestDataFile("TESTCOIN", 1000); // Large dataset + // Slow the loop so 50ms of parent sleep lands mid-run. The engine sleeps + // (1000us / speedMultiplier) per tick when speedMultiplier < 1.0, so + // 0.01 gives 100ms per tick — the first tick alone outlasts the parent's + // sleep window and lets stop() observe a non-complete progress. + BacktestConfiguration slowConfig = config_; + slowConfig.speedMultiplier = 0.01; + engine_->updateConfiguration(slowConfig); + EXPECT_TRUE(engine_->initialize()); // Start backtest in background