diff --git a/src/Managing.Application/Bots/Grains/LiveTradingBotGrain.cs b/src/Managing.Application/Bots/Grains/LiveTradingBotGrain.cs index 6a1915ab..a58b4f51 100644 --- a/src/Managing.Application/Bots/Grains/LiveTradingBotGrain.cs +++ b/src/Managing.Application/Bots/Grains/LiveTradingBotGrain.cs @@ -531,7 +531,14 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable using var scope = _scopeFactory.CreateScope(); var logger = scope.ServiceProvider.GetRequiredService>(); var streamProvider = this.GetStreamProvider("ManagingStreamProvider"); - var tradingBot = new FuturesBot(logger, _scopeFactory, config, streamProvider); + + // Create the trading bot instance based on TradingType + TradingBotBase tradingBot = config.TradingType switch + { + TradingType.Futures => new FuturesBot(logger, _scopeFactory, config, streamProvider), + TradingType.Spot => new SpotBot(logger, _scopeFactory, config, streamProvider), + _ => throw new InvalidOperationException($"Unsupported TradingType for live trading: {config.TradingType}") + }; // Load state into the trading bot instance LoadStateIntoTradingBot(tradingBot); diff --git a/src/Managing.Application/Bots/SpotBot.cs b/src/Managing.Application/Bots/SpotBot.cs new file mode 100644 index 00000000..54a5c1db --- /dev/null +++ b/src/Managing.Application/Bots/SpotBot.cs @@ -0,0 +1,710 @@ +using Managing.Application.Abstractions; +using Managing.Application.Abstractions.Grains; +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 Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Orleans.Streams; +using static Managing.Common.Enums; + +namespace Managing.Application.Bots; + +public class SpotBot : TradingBotBase, ITradingBot +{ + public SpotBot( + ILogger logger, + IServiceScopeFactory scopeFactory, + TradingBotConfig config, + IStreamProvider? streamProvider = null + ) : base(logger, scopeFactory, config, streamProvider) + { + // Spot trading mode - ensure it's not backtest + Config.TradingType = TradingType.Spot; + } + + // SpotBot uses the base implementation for Start() which includes + // account loading, balance verification, and live trading startup messages + + public override async Task Run() + { + // Live trading signal update logic + if (!Config.IsForCopyTrading) + { + await UpdateSignals(); + } + + await LoadLastCandle(); + + if (!Config.IsForWatchingOnly) + { + await ManagePositions(); + } + + UpdateWalletBalances(); + + // Live trading execution logging + ExecutionCount++; + + Logger.LogInformation( + "[Spot][{CopyTrading}][{AgentName}] Bot Status {Name} - ServerDate: {ServerDate}, LastCandleDate: {LastCandleDate}, Signals: {SignalCount}, Executions: {ExecutionCount}, Positions: {PositionCount}", + Config.IsForCopyTrading ? "CopyTrading" : "LiveTrading", Account.User.AgentName, Config.Name, + DateTime.UtcNow, LastCandle?.Date, Signals.Count, ExecutionCount, Positions.Count); + + Logger.LogInformation("[{AgentName}] Internal Positions : {Position}", Account.User.AgentName, + string.Join(", ", + Positions.Values.Select(p => $"{p.SignalIdentifier} - Status: {p.Status}"))); + } + + protected override async Task GetInternalPositionForUpdate(Position position) + { + // For live trading, get position from database via trading service + return await ServiceScopeHelpers.WithScopedService( + _scopeFactory, + async tradingService => { return await tradingService.GetPositionByIdentifierAsync(position.Identifier); }); + } + + protected override async Task> GetBrokerPositionsForUpdate(Account account) + { + // For live spot trading, we don't have broker positions like futures + // Positions are verified via token balances in UpdatePositionWithBrokerData and SynchronizeWithBrokerPositions + // Return empty list - actual verification happens in those methods + return new List(); + } + + protected override async Task UpdatePositionWithBrokerData(Position position, List brokerPositions) + { + // For spot trading, fetch token balance directly and update PnL based on current price + try + { + var balances = await ServiceScopeHelpers.WithScopedService>( + _scopeFactory, + async exchangeService => { return await exchangeService.GetBalances(Account); }); + + // Find the token balance for the ticker + var tickerString = Config.Ticker.ToString(); + var tokenBalance = balances.FirstOrDefault(b => + b.TokenName?.Equals(tickerString, StringComparison.OrdinalIgnoreCase) == true); + + if (tokenBalance == null || tokenBalance.Amount <= 0) + { + // No token balance found - position might be closed + return; + } + + // Get current price from LastCandle + var currentPrice = LastCandle?.Close ?? 0; + if (currentPrice == 0) + { + // Try to get current price from exchange + currentPrice = await ServiceScopeHelpers.WithScopedService( + _scopeFactory, + async exchangeService => { return await exchangeService.GetCurrentPrice(Account, Config.Ticker); }); + } + + if (currentPrice == 0) + { + Logger.LogWarning("Cannot update PnL: current price is 0"); + return; + } + + // Calculate PnL based on current token balance and current price + // For LONG spot position: PnL = (currentPrice - openPrice) * tokenBalance + var openPrice = position.Open.Price; + var pnlBeforeFees = TradingBox.CalculatePnL(openPrice, currentPrice, tokenBalance.Amount, 1, TradeDirection.Long); + + // Update position PnL + UpdatePositionPnl(position.Identifier, pnlBeforeFees); + + var totalFees = position.GasFees + position.UiFees; + var netPnl = pnlBeforeFees - totalFees; + + if (position.ProfitAndLoss == null) + { + position.ProfitAndLoss = new ProfitAndLoss { Realized = pnlBeforeFees, Net = netPnl }; + } + else + { + position.ProfitAndLoss.Realized = pnlBeforeFees; + position.ProfitAndLoss.Net = netPnl; + } + + await LogDebugAsync( + $"šŸ“Š Spot Position PnL Updated\n" + + $"Position: `{position.Identifier}`\n" + + $"Token Balance: `{tokenBalance.Amount:F5}`\n" + + $"Open Price: `${openPrice:F2}`\n" + + $"Current Price: `${currentPrice:F2}`\n" + + $"PnL (before fees): `${pnlBeforeFees:F2}`\n" + + $"Net PnL: `${netPnl:F2}`"); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error updating position PnL from token balance"); + } + } + + protected override async Task GetCurrentCandleForPositionClose(Account account, string ticker) + { + // For live trading, get real-time candle from exchange + return await ServiceScopeHelpers.WithScopedService(_scopeFactory, + async exchangeService => + { + return await exchangeService.GetCandle(Account, Config.Ticker, DateTime.UtcNow); + }); + } + + protected override async Task CheckBrokerPositions() + { + // For spot trading, check token balances to verify position status + try + { + var balances = await ServiceScopeHelpers.WithScopedService>( + _scopeFactory, + async exchangeService => { return await exchangeService.GetBalances(Account); }); + + var tickerString = Config.Ticker.ToString(); + var tokenBalance = balances.FirstOrDefault(b => + b.TokenName?.Equals(tickerString, StringComparison.OrdinalIgnoreCase) == true); + + var hasOpenPosition = Positions.Values.Any(p => p.IsOpen()); + + if (hasOpenPosition) + { + // We have an internal position - verify it matches broker balance + if (tokenBalance != null && tokenBalance.Amount > 0) + { + await LogDebugAsync( + $"āœ… Spot Position Verified\n" + + $"Ticker: {Config.Ticker}\n" + + $"Internal position: Open\n" + + $"Token balance: `{tokenBalance.Amount:F5}`\n" + + $"Position matches broker balance"); + return false; // Position already open, cannot open new one + } + else + { + await LogWarningAsync( + $"āš ļø Position Mismatch\n" + + $"Ticker: {Config.Ticker}\n" + + $"Internal position exists but no token balance found\n" + + $"Position may need synchronization"); + return false; // Don't allow opening new position until resolved + } + } + else if (tokenBalance != null && tokenBalance.Amount > 0) + { + // We have a token balance but no internal position - orphaned position + await LogWarningAsync( + $"āš ļø Orphaned Token Balance Detected\n" + + $"Ticker: {Config.Ticker}\n" + + $"Token balance: `{tokenBalance.Amount:F5}`\n" + + $"But no internal position tracked\n" + + $"This may require manual cleanup"); + return false; // Don't allow opening new position until resolved + } + + // No position and no balance - safe to open + return true; + } + catch (Exception ex) + { + await LogWarningAsync($"āŒ Broker Position Check Failed\nError checking token balances\n{ex.Message}"); + return false; + } + } + + protected override async Task LoadAccountAsync() + { + // Live trading: load real account from database + if (Config.TradingType == TradingType.BacktestSpot) return; + await ServiceScopeHelpers.WithScopedService(_scopeFactory, async accountService => + { + var account = await accountService.GetAccountByAccountName(Config.AccountName, false, false); + Account = account; + }); + } + + protected override async Task VerifyAndUpdateBalanceAsync() + { + // Live trading: verify real USDC balance for spot trading + if (Config.TradingType == TradingType.BacktestSpot) return; + + try + { + var actualBalance = await ServiceScopeHelpers.WithScopedService(_scopeFactory, + async exchangeService => + { + var balances = await exchangeService.GetBalances(Account); + var usdcBalance = balances.FirstOrDefault(b => b.TokenName?.ToUpper() == "USDC"); + return usdcBalance?.Amount ?? 0; + }); + + if (actualBalance < Config.BotTradingBalance) + { + Logger.LogWarning( + "Actual USDC balance ({ActualBalance:F2}) is less than configured balance ({ConfiguredBalance:F2}). Updating configuration.", + actualBalance, Config.BotTradingBalance); + + var newConfig = Config; + newConfig.BotTradingBalance = actualBalance; + await UpdateConfiguration(newConfig); + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Error verifying and updating balance"); + } + } + + protected override async Task SynchronizeWithBrokerPositions(Position internalPosition, Position positionForSignal, + List brokerPositions) + { + // For spot trading, fetch token balance directly and verify/match with internal position + try + { + var balances = await ServiceScopeHelpers.WithScopedService>( + _scopeFactory, + async exchangeService => { return await exchangeService.GetBalances(Account); }); + + // Find the token balance for the ticker + var tickerString = Config.Ticker.ToString(); + var tokenBalance = balances.FirstOrDefault(b => + b.TokenName?.Equals(tickerString, StringComparison.OrdinalIgnoreCase) == true); + + if (tokenBalance != null && tokenBalance.Amount > 0) + { + // Token balance exists - verify position is filled + var previousPositionStatus = internalPosition.Status; + + // Position found on broker (token balance exists), means the position is filled + // Update position status + internalPosition.Status = PositionStatus.Filled; + await SetPositionStatus(internalPosition.SignalIdentifier, PositionStatus.Filled); + + // Update Open trade status + internalPosition.Open.SetStatus(TradeStatus.Filled); + positionForSignal.Open.SetStatus(TradeStatus.Filled); + + // Update quantity to match actual token balance + var actualTokenBalance = tokenBalance.Amount; + if (Math.Abs(internalPosition.Open.Quantity - actualTokenBalance) > 0.0001m) + { + await LogDebugAsync( + $"šŸ”„ Token Balance Mismatch\n" + + $"Internal Quantity: `{internalPosition.Open.Quantity:F5}`\n" + + $"Broker Balance: `{actualTokenBalance:F5}`\n" + + $"Updating to match broker balance"); + internalPosition.Open.Quantity = actualTokenBalance; + positionForSignal.Open.Quantity = actualTokenBalance; + } + + // Calculate and update PnL based on current price + var currentPrice = LastCandle?.Close ?? 0; + if (currentPrice == 0) + { + currentPrice = await ServiceScopeHelpers.WithScopedService( + _scopeFactory, + async exchangeService => { return await exchangeService.GetCurrentPrice(Account, Config.Ticker); }); + } + + if (currentPrice > 0) + { + var openPrice = internalPosition.Open.Price; + var pnlBeforeFees = TradingBox.CalculatePnL(openPrice, currentPrice, actualTokenBalance, 1, TradeDirection.Long); + UpdatePositionPnl(positionForSignal.Identifier, pnlBeforeFees); + + var totalFees = internalPosition.GasFees + internalPosition.UiFees; + var netPnl = pnlBeforeFees - totalFees; + internalPosition.ProfitAndLoss = new ProfitAndLoss { Realized = pnlBeforeFees, Net = netPnl }; + } + + await UpdatePositionInDatabaseAsync(internalPosition); + + if (previousPositionStatus != PositionStatus.Filled && + internalPosition.Status == PositionStatus.Filled) + { + await NotifyAgentAndPlatformAsync(NotificationEventType.PositionOpened, internalPosition); + } + else + { + await NotifyAgentAndPlatformAsync(NotificationEventType.PositionUpdated, internalPosition); + } + } + else + { + // No token balance found - check if position was closed + if (internalPosition.Status == PositionStatus.Filled) + { + await LogDebugAsync( + $"āš ļø Position Status Check\n" + + $"Internal position `{internalPosition.Identifier}` shows Filled\n" + + $"But no token balance found on broker\n" + + $"Position may have been closed"); + } + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Error synchronizing position with token balance"); + } + } + + protected override async Task HandleOrderManagementAndPositionStatus(LightSignal signal, Position internalPosition, + Position positionForSignal) + { + // Spot trading doesn't use orders like futures - positions are opened via swaps + // Just check if the swap was successful + if (internalPosition.Status == PositionStatus.New) + { + // Check if swap was successful by verifying position status + // For spot, if Open trade is Filled, the position is filled + if (positionForSignal.Open?.Status == TradeStatus.Filled) + { + internalPosition.Status = PositionStatus.Filled; + await SetPositionStatus(signal.Identifier, PositionStatus.Filled); + await UpdatePositionInDatabaseAsync(internalPosition); + await NotifyAgentAndPlatformAsync(NotificationEventType.PositionOpened, internalPosition); + } + } + } + + protected override async Task MonitorSynthRisk(LightSignal signal, Position position) + { + // Spot trading doesn't use Synth risk monitoring (futures-specific feature) + return; + } + + protected override async Task RecoverOpenPositionFromBroker(LightSignal signal, Position positionForSignal) + { + // Spot trading doesn't have broker positions to recover + // Positions are token balances, not tracked positions + return false; + } + + protected override async Task ReconcileWithBrokerHistory(Position position, Candle currentCandle) + { + // Spot trading doesn't have broker position history like futures + // Return false to continue with candle-based calculation + return false; + } + + protected override async Task<(decimal closingPrice, bool pnlCalculated)> CalculatePositionClosingFromCandles( + Position position, Candle currentCandle, bool forceMarketClose, decimal? forcedClosingPrice) + { + decimal closingPrice = 0; + bool pnlCalculated = false; + + if (forceMarketClose && forcedClosingPrice.HasValue) + { + closingPrice = forcedClosingPrice.Value; + + bool isManualCloseProfitable = position.OriginDirection == TradeDirection.Long + ? closingPrice > position.Open.Price + : closingPrice < position.Open.Price; + + if (isManualCloseProfitable) + { + if (position.TakeProfit1 != null) + { + position.TakeProfit1.Price = closingPrice; + position.TakeProfit1.SetDate(currentCandle?.Date ?? DateTime.UtcNow); + position.TakeProfit1.SetStatus(TradeStatus.Filled); + } + + if (position.StopLoss != null) + { + position.StopLoss.SetStatus(TradeStatus.Cancelled); + } + } + else + { + if (position.StopLoss != null) + { + position.StopLoss.Price = closingPrice; + position.StopLoss.SetDate(currentCandle?.Date ?? DateTime.UtcNow); + position.StopLoss.SetStatus(TradeStatus.Filled); + } + + if (position.TakeProfit1 != null) + { + position.TakeProfit1.SetStatus(TradeStatus.Cancelled); + } + + if (position.TakeProfit2 != null) + { + position.TakeProfit2.SetStatus(TradeStatus.Cancelled); + } + } + + pnlCalculated = true; + } + else if (currentCandle != null) + { + // For spot trading, check if SL/TP was hit using candle data + if (position.OriginDirection == TradeDirection.Long) + { + if (position.StopLoss.Price >= currentCandle.Low) + { + closingPrice = position.StopLoss.Price; + position.StopLoss.SetDate(currentCandle.Date); + position.StopLoss.SetStatus(TradeStatus.Filled); + + if (position.TakeProfit1 != null) + { + position.TakeProfit1.SetStatus(TradeStatus.Cancelled); + } + + if (position.TakeProfit2 != null) + { + position.TakeProfit2.SetStatus(TradeStatus.Cancelled); + } + } + else if (position.TakeProfit1.Price <= currentCandle.High && + position.TakeProfit1.Status != TradeStatus.Filled) + { + closingPrice = position.TakeProfit1.Price; + position.TakeProfit1.SetDate(currentCandle.Date); + position.TakeProfit1.SetStatus(TradeStatus.Filled); + + if (position.StopLoss != null) + { + position.StopLoss.SetStatus(TradeStatus.Cancelled); + } + } + } + + if (closingPrice == 0) + { + // Manual/exchange close - use current candle close + closingPrice = currentCandle.Close; + + bool isManualCloseProfitable = position.OriginDirection == TradeDirection.Long + ? closingPrice > position.Open.Price + : closingPrice < position.Open.Price; + + if (isManualCloseProfitable) + { + position.TakeProfit1.SetPrice(closingPrice, 2); + position.TakeProfit1.SetDate(currentCandle.Date); + position.TakeProfit1.SetStatus(TradeStatus.Filled); + + if (position.StopLoss != null) + { + position.StopLoss.SetStatus(TradeStatus.Cancelled); + } + } + else + { + position.StopLoss.SetPrice(closingPrice, 2); + position.StopLoss.SetDate(currentCandle.Date); + position.StopLoss.SetStatus(TradeStatus.Filled); + + if (position.TakeProfit1 != null) + { + position.TakeProfit1.SetStatus(TradeStatus.Cancelled); + } + + if (position.TakeProfit2 != null) + { + position.TakeProfit2.SetStatus(TradeStatus.Cancelled); + } + } + } + + pnlCalculated = true; + } + + return (closingPrice, pnlCalculated); + } + + protected override async Task GetLastPriceForPositionOpeningAsync() + { + // For live trading, get current price from exchange + return await ServiceScopeHelpers.WithScopedService(_scopeFactory, + async exchangeService => { return await exchangeService.GetCurrentPrice(Account, Config.Ticker); }); + } + + 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; + } + + // Live trading: use ScenarioRunnerGrain to get signals + await ServiceScopeHelpers.WithScopedService(_scopeFactory, async grainFactory => + { + var scenarioRunnerGrain = grainFactory.GetGrain(Guid.NewGuid()); + var signal = await scenarioRunnerGrain.GetSignals(Config, Signals, Account.Exchange, LastCandle); + if (signal == null) return; + await AddSignal(signal); + }); + } + + 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; + } + + // Early return if bot hasn't executed first cycle yet + if (ExecutionCount == 0) + { + await LogInformationAsync("ā³ Bot Not Ready\nCannot open position\nBot hasn't executed first cycle yet"); + return false; + } + + // Check broker positions for live trading + var canOpenPosition = await CanOpenPositionWithBrokerChecks(signal); + if (!canOpenPosition) + { + return false; + } + + // Check cooldown period and loss streak + 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) + { + // Spot-specific position opening: includes balance verification and live exchange calls + 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"); + } + + // Verify actual balance before opening position + await VerifyAndUpdateBalanceAsync(); + + var command = new OpenSpotPositionRequest( + Config.AccountName, + Config.MoneyManagement, + signal.Direction, + Config.Ticker, + PositionInitiator.Bot, + signal.Date, + Account.User, + Config.BotTradingBalance, + isForPaperTrading: false, // Spot is live 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}`"); + + // Live spot trading: close position via swap + 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.Common/Enums.cs b/src/Managing.Common/Enums.cs index 498dc798..e5a0fb76 100644 --- a/src/Managing.Common/Enums.cs +++ b/src/Managing.Common/Enums.cs @@ -625,6 +625,11 @@ public static class Enums /// /// Backtest spot trading mode /// - BacktestSpot + BacktestSpot, + + /// + /// Live spot trading mode + /// + Spot } } \ No newline at end of file