diff --git a/src/Managing.Application/Backtests/BacktestExecutor.cs b/src/Managing.Application/Backtests/BacktestExecutor.cs index e76a2378..58709d4e 100644 --- a/src/Managing.Application/Backtests/BacktestExecutor.cs +++ b/src/Managing.Application/Backtests/BacktestExecutor.cs @@ -544,15 +544,22 @@ public class BacktestExecutor throw new InvalidOperationException("Bot configuration is not initialized"); } - if (config.TradingType != TradingType.BacktestFutures) + if (config.TradingType != TradingType.BacktestFutures && config.TradingType != TradingType.BacktestSpot) { - throw new InvalidOperationException("BacktestExecutor can only be used for backtesting"); + throw new InvalidOperationException($"BacktestExecutor can only be used for backtesting. TradingType must be BacktestFutures or BacktestSpot, but got {config.TradingType}"); } - // Create the trading bot instance + // Create the trading bot instance based on TradingType using var scope = _scopeFactory.CreateScope(); var logger = scope.ServiceProvider.GetRequiredService>(); - var tradingBot = new BacktestFuturesBot(logger, _scopeFactory, config); + + TradingBotBase tradingBot = config.TradingType switch + { + TradingType.BacktestFutures => new BacktestFuturesBot(logger, _scopeFactory, config), + TradingType.BacktestSpot => new BacktestSpotBot(logger, _scopeFactory, config), + _ => throw new InvalidOperationException($"Unsupported TradingType for backtest: {config.TradingType}") + }; + return tradingBot; } diff --git a/src/Managing.Application/Bots/BacktestSpotBot.cs b/src/Managing.Application/Bots/BacktestSpotBot.cs index e69de29b..d4ceea77 100644 --- a/src/Managing.Application/Bots/BacktestSpotBot.cs +++ b/src/Managing.Application/Bots/BacktestSpotBot.cs @@ -0,0 +1,340 @@ +using Managing.Application.Abstractions; +using Managing.Application.Abstractions.Services; +using Managing.Application.Trading.Commands; +using Managing.Application.Trading.Handlers; +using Managing.Core; +using Managing.Domain.Accounts; +using Managing.Domain.Bots; +using Managing.Domain.Candles; +using Managing.Domain.Indicators; +using Managing.Domain.Shared.Helpers; +using Managing.Domain.Strategies.Base; +using Managing.Domain.Trades; +using Managing.Domain.Users; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Orleans.Streams; +using static Managing.Common.Enums; + +namespace Managing.Application.Bots; + +public class BacktestSpotBot : TradingBotBase, ITradingBot +{ + public BacktestSpotBot( + ILogger logger, + IServiceScopeFactory scopeFactory, + TradingBotConfig config, + IStreamProvider? streamProvider = null + ) : base(logger, scopeFactory, config, streamProvider) + { + // Backtest-specific initialization + Config.TradingType = TradingType.BacktestSpot; + } + + public override async Task Start(BotStatus previousStatus) + { + // Backtest mode: Skip account loading and broker initialization + // Just log basic startup info + await LogInformation($"šŸ”¬ Backtest Spot Bot Started\n" + + $"šŸ“Š Testing Setup:\n" + + $"šŸŽÆ Ticker: `{Config.Ticker}`\n" + + $"ā° Timeframe: `{Config.Timeframe}`\n" + + $"šŸŽ® Scenario: `{Config.Scenario?.Name ?? "Unknown"}`\n" + + $"šŸ’° Initial Balance: `${Config.BotTradingBalance:F2}`\n" + + $"āœ… Ready to run spot backtest simulation"); + } + + public override async Task Run() + { + // Backtest signal update is handled in BacktestExecutor loop + // No need to call UpdateSignals() here + + if (!Config.IsForWatchingOnly) + await ManagePositions(); + + UpdateWalletBalances(); + + // Backtest logging - simplified, no account dependency + ExecutionCount++; + Logger.LogInformation( + "[BacktestSpot][{BotName}] Execution {ExecutionCount} - LastCandleDate: {LastCandleDate}, Signals: {SignalCount}, Positions: {PositionCount}", + Config.Name, ExecutionCount, LastCandle?.Date, Signals.Count, Positions.Count); + } + + protected override async Task GetInternalPositionForUpdate(Position position) + { + // In backtest mode, return the position as-is (no database lookup needed) + return position; + } + + protected override async Task> GetBrokerPositionsForUpdate(Account account) + { + // In backtest mode, return empty list (no broker positions to check) + return new List(); + } + + protected override async Task UpdatePositionWithBrokerData(Position position, List brokerPositions) + { + // In backtest mode, skip broker synchronization + return; + } + + protected override async Task GetCurrentCandleForPositionClose(Account account, string ticker) + { + // In backtest mode, use LastCandle + return LastCandle; + } + + protected override async Task CanOpenPositionWithBrokerChecks(LightSignal signal) + { + // In backtest mode, skip broker position checks + return await CanOpenPosition(signal); + } + + protected override async Task LoadAccountAsync() + { + // In backtest mode, skip account loading + return; + } + + protected override async Task VerifyAndUpdateBalanceAsync() + { + // In backtest mode, skip balance verification + return; + } + + protected override async Task SendPositionToCopyTradingStream(Position position) + { + // In backtest mode, skip copy trading stream + return; + } + + protected override async Task NotifyAgentAndPlatformAsync(NotificationEventType eventType, Position position) + { + // In backtest mode, skip notifications + return; + } + + protected override async Task UpdatePositionInDatabaseAsync(Position position) + { + // In backtest mode, skip database updates + return; + } + + protected override async Task SendClosedPositionToMessenger(Position position, User user) + { + // In backtest mode, skip messenger updates + return; + } + + protected override async Task CancelAllOrdersAsync() + { + // In backtest mode, no orders to cancel + return; + } + + protected override async Task LogInformationAsync(string message) + { + // In backtest mode, skip user notifications, just log to system + if (Config.TradingType == TradingType.BacktestSpot) + return; + + await base.LogInformationAsync(message); + } + + protected override async Task LogWarningAsync(string message) + { + // In backtest mode, skip user notifications, just log to system + if (Config.TradingType == TradingType.BacktestSpot) + return; + + await base.LogWarningAsync(message); + } + + protected override async Task LogDebugAsync(string message) + { + // In backtest mode, skip messenger debug logs + if (Config.TradingType == TradingType.BacktestSpot) + return; + + await base.LogDebugAsync(message); + } + + protected override async Task SendTradeMessageAsync(string message, bool isBadBehavior) + { + // In backtest mode, skip trade messages + return; + } + + protected override async Task UpdateSignalsCore(IReadOnlyList candles, + Dictionary preCalculatedIndicatorValues = null) + { + // For spot trading, always fetch signals regardless of open positions + // Check if we're in cooldown period + if (await IsInCooldownPeriodAsync()) + { + // Still in cooldown period, skip signal generation + return; + } + + // For backtest, if no candles provided (called from Run()), skip signal generation + // Signals are generated in BacktestExecutor with rolling window candles + if (candles == null || candles.Count == 0) + return; + + if (Config.Scenario == null) + throw new ArgumentNullException(nameof(Config.Scenario), "Config.Scenario cannot be null"); + + // Use TradingBox.GetSignal for backtest with pre-calculated indicators + var backtestSignal = TradingBox.GetSignal(candles, Config.Scenario, Signals, Config.Scenario.LoopbackPeriod, + preCalculatedIndicatorValues); + if (backtestSignal == null) return; + + await AddSignal(backtestSignal); + } + + protected override async Task GetLastPriceForPositionOpeningAsync() + { + // For backtest, use LastCandle close price + return LastCandle?.Close ?? 0; + } + + protected override async Task CanOpenPosition(LightSignal signal) + { + // For spot trading, only LONG signals can open positions + if (signal.Direction != TradeDirection.Long) + { + await LogInformationAsync( + $"🚫 Short Signal Ignored\nShort signals cannot open positions in spot trading\nSignal: `{signal.Identifier}` will be ignored"); + return false; + } + + // Backtest-specific logic: only check cooldown and loss streak + // No broker checks, no synth risk assessment, no startup cycle check needed + return !await IsInCooldownPeriodAsync() && await CheckLossStreak(signal); + } + + protected override async Task HandleFlipPosition(LightSignal signal, Position openedPosition, + LightSignal previousSignal, decimal lastPrice) + { + // For spot trading, SHORT signals should close the open LONG position + // LONG signals should not flip (they would be same direction) + if (signal.Direction == TradeDirection.Short && openedPosition.OriginDirection == TradeDirection.Long) + { + // SHORT signal closes the open LONG position + await LogInformationAsync( + $"šŸ”» Short Signal - Closing Long Position\nClosing position `{openedPosition.Identifier}` due to SHORT signal\nSignal: `{signal.Identifier}`"); + await CloseTrade(previousSignal, openedPosition, openedPosition.Open, lastPrice, true); + await SetPositionStatus(previousSignal.Identifier, PositionStatus.Finished); + SetSignalStatus(signal.Identifier, SignalStatus.Expired); + return null; // No new position opened for SHORT signals + } + else if (signal.Direction == TradeDirection.Long && openedPosition.OriginDirection == TradeDirection.Long) + { + // Same direction LONG signal - ignore it + await LogInformationAsync( + $"šŸ“ Same Direction Signal\nLONG signal `{signal.Identifier}` ignored\nPosition `{openedPosition.Identifier}` already open for LONG"); + SetSignalStatus(signal.Identifier, SignalStatus.Expired); + return null; + } + else + { + // This shouldn't happen in spot trading, but handle it gracefully + await LogInformationAsync( + $"āš ļø Unexpected Signal Direction\nSignal: `{signal.Identifier}` Direction: `{signal.Direction}`\nPosition: `{openedPosition.Identifier}` Direction: `{openedPosition.OriginDirection}`\nSignal ignored"); + SetSignalStatus(signal.Identifier, SignalStatus.Expired); + return null; + } + } + + protected override async Task ExecuteOpenPosition(LightSignal signal, decimal lastPrice) + { + // Backtest-specific position opening: no balance verification, no exchange calls + // Only LONG signals should reach here (SHORT signals are filtered out earlier) + if (signal.Direction != TradeDirection.Long) + { + throw new InvalidOperationException($"Only LONG signals can open positions in spot trading. Received: {signal.Direction}"); + } + + if (Account == null || Account.User == null) + { + throw new InvalidOperationException("Account and Account.User must be set before opening a position"); + } + + var command = new OpenSpotPositionRequest( + Config.AccountName, + Config.MoneyManagement, + signal.Direction, + Config.Ticker, + PositionInitiator.Bot, + signal.Date, + Account.User, + Config.BotTradingBalance, + isForPaperTrading: true, // Backtest is always paper trading + lastPrice, + signalIdentifier: signal.Identifier, + initiatorIdentifier: Identifier, + tradingType: Config.TradingType); + + var position = await ServiceScopeHelpers + .WithScopedServices( + _scopeFactory, + async (exchangeService, accountService, tradingService) => + { + return await new OpenSpotPositionCommandHandler(exchangeService, accountService, tradingService) + .Handle(command); + }); + + return position; + } + + public override async Task CloseTrade(LightSignal signal, Position position, Trade tradeToClose, decimal lastPrice, + bool tradeClosingPosition = false, bool forceMarketClose = false) + { + await LogInformationAsync( + $"šŸ”§ Closing {position.OriginDirection} Spot Trade\nTicker: `{Config.Ticker}`\nPrice: `${lastPrice}`\nšŸ“‹ Type: `{tradeToClose.TradeType}`\nšŸ“Š Quantity: `{tradeToClose.Quantity:F5}`"); + + // Backtest-specific: no exchange quantity check, no grace period, direct close + var command = new CloseSpotPositionCommand(position, position.AccountId, lastPrice); + try + { + Position closedPosition = null; + await ServiceScopeHelpers.WithScopedServices( + _scopeFactory, async (exchangeService, accountService, tradingService) => + { + closedPosition = + await new CloseSpotPositionCommandHandler(exchangeService, accountService, tradingService) + .Handle(command); + }); + + if (closedPosition.Status == PositionStatus.Finished || closedPosition.Status == PositionStatus.Flipped) + { + if (tradeClosingPosition) + { + await SetPositionStatus(signal.Identifier, PositionStatus.Finished); + } + + await HandleClosedPosition(closedPosition, forceMarketClose ? lastPrice : (decimal?)null, + forceMarketClose); + } + else + { + throw new Exception($"Wrong position status : {closedPosition.Status}"); + } + } + catch (Exception ex) + { + await LogWarningAsync($"Position {signal.Identifier} not closed : {ex.Message}"); + + if (position.Status == PositionStatus.Canceled || position.Status == PositionStatus.Rejected) + { + // Trade close on exchange => Should close trade manually + await SetPositionStatus(signal.Identifier, PositionStatus.Finished); + // Ensure trade dates are properly updated even for canceled/rejected positions + await HandleClosedPosition(position, forceMarketClose ? lastPrice : (decimal?)null, + forceMarketClose); + } + } + } +} + diff --git a/src/Managing.Application/Trading/Commands/CloseSpotPositionCommand.cs b/src/Managing.Application/Trading/Commands/CloseSpotPositionCommand.cs new file mode 100644 index 00000000..79e4e33f --- /dev/null +++ b/src/Managing.Application/Trading/Commands/CloseSpotPositionCommand.cs @@ -0,0 +1,20 @@ +using Managing.Domain.Trades; +using MediatR; + +namespace Managing.Application.Trading.Commands +{ + public class CloseSpotPositionCommand : IRequest + { + public CloseSpotPositionCommand(Position position, int accountId, decimal? executionPrice = null) + { + Position = position; + AccountId = accountId; + ExecutionPrice = executionPrice; + } + + public Position Position { get; } + public int AccountId { get; } + public decimal? ExecutionPrice { get; set; } + } +} + diff --git a/src/Managing.Application/Trading/Commands/OpenSpotPositionRequest.cs b/src/Managing.Application/Trading/Commands/OpenSpotPositionRequest.cs new file mode 100644 index 00000000..42c15b10 --- /dev/null +++ b/src/Managing.Application/Trading/Commands/OpenSpotPositionRequest.cs @@ -0,0 +1,65 @@ +using Managing.Common; +using Managing.Domain.Trades; +using Managing.Domain.Users; +using MediatR; +using static Managing.Common.Enums; + +namespace Managing.Application.Trading.Commands +{ + public class OpenSpotPositionRequest : IRequest + { + public OpenSpotPositionRequest( + string accountName, + LightMoneyManagement moneyManagement, + TradeDirection direction, + Ticker ticker, + PositionInitiator initiator, + DateTime date, + User user, + decimal amountToTrade, + bool isForPaperTrading = false, + decimal? price = null, + string signalIdentifier = null, + Guid? initiatorIdentifier = null, + TradingType tradingType = TradingType.BacktestSpot) + { + AccountName = accountName; + MoneyManagement = moneyManagement; + Direction = direction; + Ticker = ticker; + Initiator = initiator; + Date = date; + User = user; + + if (amountToTrade <= Constants.GMX.Config.MinimumPositionAmount) + { + throw new ArgumentException("Bot trading balance must be greater than : 5usdc", nameof(amountToTrade)); + } + + AmountToTrade = amountToTrade; + + IsForPaperTrading = isForPaperTrading; + Price = price; + SignalIdentifier = signalIdentifier; + InitiatorIdentifier = initiatorIdentifier ?? + throw new ArgumentNullException(nameof(initiatorIdentifier), + "InitiatorIdentifier is required"); + TradingType = tradingType; + } + + public string SignalIdentifier { get; set; } + public string AccountName { get; } + public LightMoneyManagement MoneyManagement { get; } + public TradeDirection Direction { get; } + public Ticker Ticker { get; } + public bool IsForPaperTrading { get; } + public decimal? Price { get; } + public decimal AmountToTrade { get; } + public DateTime Date { get; } + public PositionInitiator Initiator { get; } + public User User { get; } + public Guid InitiatorIdentifier { get; } + public TradingType TradingType { get; } + } +} + diff --git a/src/Managing.Application/Trading/Handlers/CloseSpotPositionCommandHandler.cs b/src/Managing.Application/Trading/Handlers/CloseSpotPositionCommandHandler.cs new file mode 100644 index 00000000..f7a885f3 --- /dev/null +++ b/src/Managing.Application/Trading/Handlers/CloseSpotPositionCommandHandler.cs @@ -0,0 +1,124 @@ +using Managing.Application.Abstractions; +using Managing.Application.Abstractions.Services; +using Managing.Application.Trading.Commands; +using Managing.Common; +using Managing.Domain.Accounts; +using Managing.Domain.Shared.Helpers; +using Managing.Domain.Trades; +using Microsoft.Extensions.Logging; +using static Managing.Common.Enums; + +namespace Managing.Application.Trading.Handlers; + +public class CloseSpotPositionCommandHandler( + IExchangeService exchangeService, + IAccountService accountService, + ITradingService tradingService, + ILogger logger = null) + : ICommandHandler +{ + public async Task Handle(CloseSpotPositionCommand request) + { + try + { + // For backtest, use execution price directly + var lastPrice = request.ExecutionPrice.GetValueOrDefault(); + + // Calculate closing direction (opposite of opening direction) + var direction = request.Position.OriginDirection == TradeDirection.Long + ? TradeDirection.Short + : TradeDirection.Long; + + // For spot trading, determine swap direction for closing + // Long position: Swap Token -> USDC (sell token for USDC) + // Short position: Swap USDC -> Token (buy token with USDC) + Ticker fromTicker; + Ticker toTicker; + double swapAmount; + + if (request.Position.OriginDirection == TradeDirection.Long) + { + fromTicker = request.Position.Ticker; + toTicker = Ticker.USDC; + swapAmount = (double)request.Position.Open.Quantity; + } + else + { + fromTicker = Ticker.USDC; + toTicker = request.Position.Ticker; + // For short, we need to calculate how much USDC to swap back + // This should be the original amount + profit/loss + var originalAmount = request.Position.Open.Price * request.Position.Open.Quantity; + swapAmount = (double)originalAmount; + } + + // For backtest/paper trading, simulate the swap without calling the exchange + SwapInfos swapResult; + if (request.Position.TradingType == TradingType.BacktestSpot) + { + // Simulate successful swap for backtest + swapResult = new SwapInfos + { + Success = true, + Hash = Guid.NewGuid().ToString(), + Message = "Backtest spot position closed successfully" + }; + } + else + { + // For live trading, call SwapGmxTokensAsync + var account = await accountService.GetAccountById(request.AccountId); + swapResult = await tradingService.SwapGmxTokensAsync( + request.Position.User, + account.Name, + fromTicker, + toTicker, + swapAmount, + "market", + null, + 0.5); + } + + if (!swapResult.Success) + { + throw new InvalidOperationException($"Failed to close spot position: {swapResult.Error ?? swapResult.Message}"); + } + + // Build the closing trade directly for backtest (no exchange call needed) + var closedTrade = exchangeService.BuildEmptyTrade( + request.Position.Open.Ticker, + lastPrice, + request.Position.Open.Quantity, + direction, + 1, // Spot trading has no leverage + TradeType.Market, + request.Position.Open.Date, + TradeStatus.Filled); + + // Update position status and calculate PnL + request.Position.Status = PositionStatus.Finished; + request.Position.ProfitAndLoss = + TradingBox.GetProfitAndLoss(request.Position, closedTrade.Quantity, lastPrice, + 1); // Spot trading has no leverage + + // Add UI fees for closing the position + var closingPositionSizeUsd = lastPrice * closedTrade.Quantity; + var closingUiFees = TradingBox.CalculateClosingUiFees(closingPositionSizeUsd); + request.Position.AddUiFees(closingUiFees); + request.Position.AddGasFees(Constants.GMX.Config.GasFeePerTransaction); + + // For backtest, skip database update + + return request.Position; + } + catch (Exception ex) + { + logger?.LogError(ex, "Error closing spot position: {Message} \n Stacktrace : {StackTrace}", ex.Message, + ex.StackTrace); + + SentrySdk.CaptureException(ex); + throw; + } + } +} + diff --git a/src/Managing.Application/Trading/Handlers/OpenSpotPositionCommandHandler.cs b/src/Managing.Application/Trading/Handlers/OpenSpotPositionCommandHandler.cs new file mode 100644 index 00000000..38ffc4af --- /dev/null +++ b/src/Managing.Application/Trading/Handlers/OpenSpotPositionCommandHandler.cs @@ -0,0 +1,184 @@ +using Managing.Application.Abstractions; +using Managing.Application.Abstractions.Services; +using Managing.Application.Trading.Commands; +using Managing.Common; +using Managing.Core.Exceptions; +using Managing.Domain.Accounts; +using Managing.Domain.Shared.Helpers; +using Managing.Domain.Trades; +using static Managing.Common.Enums; + +namespace Managing.Application.Trading.Handlers +{ + public class OpenSpotPositionCommandHandler( + IExchangeService exchangeService, + IAccountService accountService, + ITradingService tradingService) + : ICommandHandler + { + public async Task Handle(OpenSpotPositionRequest request) + { + var account = await accountService.GetAccount(request.AccountName, hideSecrets: false, getBalance: false); + + var initiator = request.IsForPaperTrading ? PositionInitiator.PaperTrading : request.Initiator; + var position = new Position(Guid.NewGuid(), account.Id, request.Direction, + request.Ticker, + request.MoneyManagement, + initiator, request.Date, request.User); + + if (!string.IsNullOrEmpty(request.SignalIdentifier)) + { + position.SignalIdentifier = request.SignalIdentifier; + } + + position.InitiatorIdentifier = request.InitiatorIdentifier; + position.TradingType = request.TradingType; + + // Always use BotTradingBalance directly as the balance to risk + // Round to 2 decimal places to prevent precision errors + decimal balanceToRisk = Math.Round(request.AmountToTrade, 0, MidpointRounding.ToZero); + + // Minimum check + if (balanceToRisk < Constants.GMX.Config.MinimumPositionAmount) + { + throw new Exception( + $"Bot trading balance of {balanceToRisk} USD is less than the minimum {Constants.GMX.Config.MinimumPositionAmount} USD required to trade"); + } + + var price = request.IsForPaperTrading && request.Price.HasValue + ? request.Price.Value + : await exchangeService.GetPrice(account, request.Ticker, DateTime.Now); + var quantity = balanceToRisk / price; + + var openPrice = request.IsForPaperTrading || request.Price.HasValue + ? request.Price.Value + : price; + + // For spot trading, determine swap direction + // Long: Swap USDC -> Token (buy token with USDC) + // Short: Swap Token -> USDC (sell token for USDC) + Ticker fromTicker; + Ticker toTicker; + double swapAmount; + + if (request.Direction == TradeDirection.Long) + { + fromTicker = Ticker.USDC; + toTicker = request.Ticker; + swapAmount = (double)balanceToRisk; + } + else + { + fromTicker = request.Ticker; + toTicker = Ticker.USDC; + swapAmount = (double)quantity; + } + + // For backtest/paper trading, simulate the swap without calling the exchange + SwapInfos swapResult; + if (request.IsForPaperTrading) + { + // Simulate successful swap for backtest + swapResult = new SwapInfos + { + Success = true, + Hash = Guid.NewGuid().ToString(), + Message = "Backtest spot position opened successfully" + }; + } + else + { + // For live trading, call SwapGmxTokensAsync + swapResult = await tradingService.SwapGmxTokensAsync( + request.User, + request.AccountName, + fromTicker, + toTicker, + swapAmount, + "market", + null, + 0.5); + } + + if (!swapResult.Success) + { + position.Status = PositionStatus.Rejected; + throw new InvalidOperationException($"Failed to open spot position: {swapResult.Error ?? swapResult.Message}"); + } + + // Build the opening trade + var trade = exchangeService.BuildEmptyTrade( + request.Ticker, + openPrice, + quantity, + request.Direction, + 1, // Spot trading has no leverage + TradeType.Market, + request.Date, + TradeStatus.Filled); + + position.Open = trade; + + // Calculate and set fees for the position + position.GasFees = TradingBox.CalculateOpeningGasFees(); + + // Set UI fees for opening + var positionSizeUsd = TradingBox.GetVolumeForPosition(position); + position.UiFees = TradingBox.CalculateOpeningUiFees(positionSizeUsd); + + var closeDirection = request.Direction == TradeDirection.Long + ? TradeDirection.Short + : TradeDirection.Long; + + // Determine SL/TP Prices + var stopLossPrice = RiskHelpers.GetStopLossPrice(request.Direction, openPrice, request.MoneyManagement); + var takeProfitPrice = RiskHelpers.GetTakeProfitPrice(request.Direction, openPrice, request.MoneyManagement); + + // Stop loss + position.StopLoss = exchangeService.BuildEmptyTrade( + request.Ticker, + stopLossPrice, + position.Open.Quantity, + closeDirection, + 1, // Spot trading has no leverage + TradeType.StopLoss, + request.Date, + TradeStatus.Requested); + + // Take profit + position.TakeProfit1 = exchangeService.BuildEmptyTrade( + request.Ticker, + takeProfitPrice, + quantity, + closeDirection, + 1, // Spot trading has no leverage + TradeType.TakeProfit, + request.Date, + TradeStatus.Requested); + + position.Status = IsOpenTradeHandled(position.Open.Status) + ? position.Status + : PositionStatus.Rejected; + + if (position.Status == PositionStatus.Rejected) + { + SentrySdk.CaptureException( + new Exception($"Position {position.Identifier} for {request.SignalIdentifier} rejected")); + } + + if (!request.IsForPaperTrading) + { + await tradingService.InsertPositionAsync(position); + } + + return position; + } + + private static bool IsOpenTradeHandled(TradeStatus tradeStatus) + { + return tradeStatus == TradeStatus.Filled + || tradeStatus == TradeStatus.Requested; + } + } +} + diff --git a/src/Managing.Bootstrap/ApiBootstrap.cs b/src/Managing.Bootstrap/ApiBootstrap.cs index 0f18c4c8..9731d878 100644 --- a/src/Managing.Bootstrap/ApiBootstrap.cs +++ b/src/Managing.Bootstrap/ApiBootstrap.cs @@ -396,7 +396,9 @@ public static class ApiBootstrap services.AddScoped(); services.AddScoped(); services.AddTransient, OpenPositionCommandHandler>(); + services.AddTransient, OpenSpotPositionCommandHandler>(); services.AddTransient, CloseBacktestFuturesPositionCommandHandler>(); + services.AddTransient, CloseSpotPositionCommandHandler>(); services.AddTransient, CloseFuturesPositionCommandHandler>(); // Keep old handler for backward compatibility services.AddTransient, ClosePositionCommandHandler>(); diff --git a/src/Managing.Common/Enums.cs b/src/Managing.Common/Enums.cs index a570df69..498dc798 100644 --- a/src/Managing.Common/Enums.cs +++ b/src/Managing.Common/Enums.cs @@ -620,6 +620,11 @@ public static class Enums /// /// Backtest futures trading mode /// - BacktestFutures + BacktestFutures, + + /// + /// Backtest spot trading mode + /// + BacktestSpot } } \ No newline at end of file diff --git a/src/Managing.Workers.Tests/BacktestExecutorTests.cs b/src/Managing.Workers.Tests/BacktestExecutorTests.cs index 528014d6..a1fae7f8 100644 --- a/src/Managing.Workers.Tests/BacktestExecutorTests.cs +++ b/src/Managing.Workers.Tests/BacktestExecutorTests.cs @@ -498,6 +498,101 @@ public class BacktestExecutorTests : BaseTests, IDisposable $"āœ… Two-scenarios performance test passed: {candlesPerSecond:F1} candles/sec with {scenario.Indicators.Count} indicators"); } + [Fact] + public async Task ExecuteBacktestSpot_With_ETH_FifteenMinutes_Data_Should_Return_LightBacktest() + { + Console.WriteLine("TEST START: ExecuteBacktestSpot_With_ETH_FifteenMinutes_Data_Should_Return_LightBacktest"); + // Arrange + var candles = FileHelpers.ReadJson>("../../../Data/ETH-FifteenMinutes-candles.json"); + Assert.NotNull(candles); + Assert.NotEmpty(candles); + + var scenario = new Scenario("ETH_Spot_BacktestScenario"); + var rsiDivIndicator = ScenarioHelpers.BuildIndicator(IndicatorType.RsiDivergence, "RsiDiv", period: 14); + scenario.Indicators = new List { (IndicatorBase)rsiDivIndicator }; + scenario.LoopbackPeriod = 15; + + var config = new TradingBotConfig + { + AccountName = _account.Name, + MoneyManagement = MoneyManagement, + Ticker = Ticker.ETH, + Scenario = LightScenario.FromScenario(scenario), + Timeframe = Timeframe.FifteenMinutes, + IsForWatchingOnly = false, + BotTradingBalance = 1000, + TradingType = TradingType.BacktestSpot, // Use BacktestSpot instead of BacktestFutures + CooldownPeriod = 1, + MaxLossStreak = 0, + FlipPosition = false, + Name = "ETH_FifteenMinutes_Spot_Test", + FlipOnlyWhenInProfit = true, + MaxPositionTimeHours = null, + CloseEarlyWhenProfitable = false + }; + + // Act + var result = await _backtestExecutor.ExecuteAsync( + config, + candles, + _testUser, + save: false, + withCandles: false, + requestId: null, + bundleRequestId: null, + metadata: null, + progressCallback: null); + + // Output the result to console for review + var json = JsonConvert.SerializeObject(new + { + result.FinalPnl, + result.WinRate, + result.GrowthPercentage, + result.HodlPercentage, + result.Fees, + result.NetPnl, + result.MaxDrawdown, + result.SharpeRatio, + result.Score, + result.InitialBalance, + StartDate = result.StartDate.ToString("yyyy-MM-dd HH:mm:ss"), + EndDate = result.EndDate.ToString("yyyy-MM-dd HH:mm:ss"), + TradingType = result.Config.TradingType + }, Formatting.Indented); + + Console.WriteLine("BacktestExecutor Spot Results:"); + Console.WriteLine(json); + + // Debug: Verify telemetry is working + Console.WriteLine($"DEBUG: Spot backtest completed successfully with {result.WinRate}% win rate"); + + // Assert - Validate specific backtest results + Assert.NotNull(result); + Assert.IsType(result); + + // Verify TradingType is BacktestSpot + Assert.Equal(TradingType.BacktestSpot, result.Config.TradingType); + + // Validate key metrics - Updated with actual backtest results + Assert.Equal(1000.0m, result.InitialBalance); + Assert.Equal(-71.63m, Math.Round(result.FinalPnl, 2)); + Assert.Equal(16, result.WinRate); + Assert.Equal(-10.86m, Math.Round(result.GrowthPercentage, 2)); + Assert.Equal(-0.67m, Math.Round(result.HodlPercentage, 2)); + Assert.Equal(32.59m, Math.Round(result.Fees, 2)); + Assert.Equal(-108.65m, Math.Round(result.NetPnl, 2)); + Assert.Equal(111.76m, Math.Round((decimal)result.MaxDrawdown, 2)); + Assert.Equal(-0.107, Math.Round((double)(result.SharpeRatio ?? 0), 3)); + Assert.True(Math.Abs(result.Score - 0.0) < 0.001, + $"Score {result.Score} should be within 0.001 of expected value 0.0"); + + // Validate dates + Assert.Equal(new DateTime(2025, 10, 14, 12, 0, 0), result.StartDate); + Assert.Equal(new DateTime(2025, 10, 24, 11, 45, 0), result.EndDate); + Assert.True(result.StartDate < result.EndDate); + } + public void Dispose() { _loggerFactory?.Dispose();