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; } // Ensure account is loaded before accessing Account.Exchange if (Account == null) { Logger.LogWarning("Cannot update signals: Account is null. Loading account..."); await LoadAccountAsync(); if (Account == null) { Logger.LogError("Cannot update signals: Account failed to load"); 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); } } } }