#nullable enable 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 { 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 => await tradingService.GetPositionByIdentifierAsync(position.Identifier)); } 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 tokenBalance = await ServiceScopeHelpers.WithScopedService( _scopeFactory, async exchangeService => await exchangeService.GetBalance(Account, Config.Ticker)); if (tokenBalance is not { 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 => 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 => 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 tokenBalance = await ServiceScopeHelpers.WithScopedService( _scopeFactory, async exchangeService => await exchangeService.GetBalance(Account, Config.Ticker)); var hasOpenPosition = Positions.Values.Any(p => p.IsOpen()); if (hasOpenPosition) { // We have an internal position - verify it matches broker balance if (tokenBalance is { 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 } 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 } if (tokenBalance is { Amount: > 0 }) { // Check if this is a meaningful balance or just gas reserves / dust // Minimum threshold: $10 USD value to be considered an orphaned position const decimal minOrphanedBalanceValue = 10m; if (tokenBalance.Value < minOrphanedBalanceValue) { await LogDebugAsync( $"ā„¹ļø Small Token Balance Detected (Likely Gas Reserve or Dust)\n" + $"Ticker: {Config.Ticker}\n" + $"Token balance: `{tokenBalance.Amount:F8}`\n" + $"USD Value: `${tokenBalance.Value:F2}`\n" + $"Below orphaned threshold of `${minOrphanedBalanceValue:F2}`\n" + $"Ignoring - safe to open new position"); return true; // Safe to open new position - this is just dust/gas reserve } // We have a significant token balance but no internal position - attempt to recover orphaned position await LogWarningAsync( $"āš ļø Orphaned Token Balance Detected\n" + $"Ticker: {Config.Ticker}\n" + $"Token balance: `{tokenBalance.Amount:F5}` (Value: ${tokenBalance.Value:F2})\n" + $"Above orphaned threshold of `${minOrphanedBalanceValue:F2}`\n" + $"But no internal position tracked\n" + $"Attempting to recover position from database..."); var recovered = await TryRecoverOrphanedPosition(tokenBalance.Amount); if (!recovered) { await LogWarningAsync( $"āŒ Position Recovery Failed\n" + $"Could not recover orphaned position\n" + $"Manual cleanup may be required"); } return false; // Don't allow opening new position until next cycle } // 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; } } private async Task TryRecoverOrphanedPosition(decimal tokenBalance) { try { // Get all positions for this bot from database var allPositions = await ServiceScopeHelpers.WithScopedService>( _scopeFactory, async tradingService => await tradingService.GetPositionsByInitiatorIdentifierAsync(Identifier)); // Calculate the maximum age for recovery (2 candles ago) // Only recover positions that are recent enough to be legitimate orphaned positions var candleIntervalSeconds = Managing.Domain.Candles.CandleHelpers.GetBaseIntervalInSeconds(Config.Timeframe); var maxAgeSeconds = candleIntervalSeconds * 2; // 2 candles var minDate = DateTime.UtcNow.AddSeconds(-maxAgeSeconds); // Find the last position for this account and ticker that is: // 1. Not Finished (Finished positions should not be recovered) // 2. Less than 2 candles old // 3. In a recoverable state (New, Cancelled, or Filled but not tracked) var lastPosition = allPositions .Where(p => p.AccountId == Account.Id && p.Ticker == Config.Ticker && p.Status != PositionStatus.Finished && // Never recover Finished positions p.Date >= minDate) // Only check recent positions (less than 2 candles old) .OrderByDescending(p => p.Date) .FirstOrDefault(); if (lastPosition == null) { await LogWarningAsync( $"šŸ” No Recoverable Position Found\n" + $"No recent unfinished position found in database for this ticker\n" + $"Token balance: `{tokenBalance:F5}` may be from external source or old position\n" + $"Only checking positions less than 2 candles old (max age: {maxAgeSeconds}s)"); return false; } // Verify that the Open trade exists if (lastPosition.Open == null) { await LogWarningAsync( $"āš ļø Invalid Position Data\n" + $"Position `{lastPosition.Identifier}` has no Open trade\n" + $"Cannot recover position"); return false; } // Verify that the token balance matches the position quantity with tolerance var positionQuantity = lastPosition.Open.Quantity; if (positionQuantity == 0) { await LogWarningAsync( $"āš ļø Invalid Position Data\n" + $"Position `{lastPosition.Identifier}` has zero quantity\n" + $"Cannot recover position"); return false; } var tolerance = positionQuantity * 0.006m; // 0.6% tolerance for slippage var difference = Math.Abs(tokenBalance - positionQuantity); if (difference > tolerance) { await LogWarningAsync( $"āš ļø Token Balance Mismatch\n" + $"Position: `{lastPosition.Identifier}`\n" + $"Position Quantity: `{positionQuantity:F5}`\n" + $"Token Balance: `{tokenBalance:F5}`\n" + $"Difference: `{difference:F5}` exceeds tolerance `{tolerance:F5}`\n" + $"Cannot recover - amounts don't match"); return false; } // Recover the position by setting status to Filled var previousStatus = lastPosition.Status; lastPosition.Status = PositionStatus.Filled; lastPosition.Open.SetStatus(TradeStatus.Filled); // Update quantity to match actual token balance lastPosition.Open.Quantity = tokenBalance; // Calculate current PnL var currentPrice = await ServiceScopeHelpers.WithScopedService( _scopeFactory, async exchangeService => await exchangeService.GetCurrentPrice(Account, Config.Ticker)); if (currentPrice > 0) { var openPrice = lastPosition.Open.Price; var pnlBeforeFees = TradingBox.CalculatePnL(openPrice, currentPrice, tokenBalance, 1, TradeDirection.Long); var totalFees = lastPosition.GasFees + lastPosition.UiFees; var netPnl = pnlBeforeFees - totalFees; lastPosition.ProfitAndLoss = new ProfitAndLoss { Realized = pnlBeforeFees, Net = netPnl }; } // Update position in database await UpdatePositionInDatabaseAsync(lastPosition); // Add to internal positions collection Positions[lastPosition.Identifier] = lastPosition; // Update signal status if signal already exists if (Signals.ContainsKey(lastPosition.SignalIdentifier)) { SetSignalStatus(lastPosition.SignalIdentifier, SignalStatus.PositionOpen); } await LogWarningAsync( $"āœ… Orphaned Position Recovered\n" + $"Position: `{lastPosition.Identifier}`\n" + $"Signal: `{lastPosition.SignalIdentifier}`\n" + $"Ticker: {Config.Ticker}\n" + $"Previous Status: `{previousStatus}` → `Filled`\n" + $"Token Balance: `{tokenBalance:F5}`\n" + $"Open Price: `${lastPosition.Open.Price:F2}`\n" + $"Open Date: `{lastPosition.Open.Date}`\n" + $"Current Price: `${currentPrice:F2}`\n" + $"Current PnL: `${lastPosition.ProfitAndLoss?.Realized ?? 0:F2}`\n" + $"Position is now being tracked and will be managed on next cycle"); // Send notification await NotifyAgentAndPlatformAsync(NotificationEventType.PositionOpened, lastPosition); return true; } catch (Exception ex) { Logger.LogError(ex, "Error recovering orphaned position"); await LogWarningAsync($"āŒ Position Recovery Error\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); Account = account; }); } protected override async Task SynchronizeWithBrokerPositions(Position internalPosition, Position positionForSignal) { // For spot trading, fetch token balance directly and verify/match with internal position try { var tokenBalance = await ServiceScopeHelpers.WithScopedService( _scopeFactory, async exchangeService => await exchangeService.GetBalance(Account, Config.Ticker)); if (tokenBalance is { Amount: > 0 }) { // Verify that the token balance matches the position amount var positionQuantity = internalPosition.Open.Quantity; var tokenBalanceAmount = tokenBalance.Amount; if (positionQuantity > 0) { // Only check tolerance if token balance is LESS than position quantity // If balance is greater, it could be orphaned tokens from previous positions if (tokenBalanceAmount < positionQuantity) { var tolerance = positionQuantity * 0.006m; // 0.6% tolerance to account for slippage var difference = positionQuantity - tokenBalanceAmount; if (difference > tolerance) { await LogWarningAsync( $"āš ļø Token Balance Below Position Quantity\n" + $"Position: `{internalPosition.Identifier}`\n" + $"Position Quantity: `{positionQuantity:F5}`\n" + $"Token Balance: `{tokenBalanceAmount:F5}`\n" + $"Difference: `{difference:F5}`\n" + $"Tolerance (0.6%): `{tolerance:F5}`\n" + $"Token balance is significantly lower than expected\n" + $"Skipping position synchronization"); return; // Skip processing if balance is too low } } else if (tokenBalanceAmount > positionQuantity) { // Token balance is higher than position - likely orphaned tokens // Log but continue with synchronization var excess = tokenBalanceAmount - positionQuantity; await LogDebugAsync( $"ā„¹ļø Token Balance Exceeds Position Quantity\n" + $"Position: `{internalPosition.Identifier}`\n" + $"Position Quantity: `{positionQuantity:F5}`\n" + $"Token Balance: `{tokenBalanceAmount:F5}`\n" + $"Excess: `{excess:F5}`\n" + $"Proceeding with position synchronization"); } } // Token balance exists and matches position - 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; var quantityTolerance = internalPosition.Open.Quantity * 0.006m; // 0.6% tolerance for slippage var quantityDifference = Math.Abs(internalPosition.Open.Quantity - actualTokenBalance); if (quantityDifference > quantityTolerance) { await LogDebugAsync( $"šŸ”„ Token Balance Mismatch\n" + $"Internal Quantity: `{internalPosition.Open.Quantity:F5}`\n" + $"Broker Balance: `{actualTokenBalance:F5}`\n" + $"Difference: `{quantityDifference:F5}`\n" + $"Tolerance (0.6%): `{quantityTolerance: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 = await ServiceScopeHelpers.WithScopedService( _scopeFactory, async exchangeService => 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) { var (positionFoundInHistory, hadWeb3ProxyError) = await CheckSpotPositionInExchangeHistory(internalPosition); if (hadWeb3ProxyError) { await LogWarningAsync( $"ā³ Web3Proxy Error During Spot Position Verification\n" + $"Position: `{internalPosition.Identifier}`\n" + $"Cannot verify if position is closed\n" + $"Will retry on next execution cycle"); return; } if (positionFoundInHistory) { internalPosition.Status = PositionStatus.Finished; await HandleClosedPosition(internalPosition); return; } // Position is Filled but no token balance and not found in history // This could be a zombie position - check if token balance exists via direct exchange call await LogWarningAsync( $"āš ļø Potential Zombie Position Detected\n" + $"Position: `{internalPosition.Identifier}`\n" + $"Status: Filled\n" + $"Token balance: 0\n" + $"Not found in exchange history\n" + $"This position may have been closed externally or data is delayed\n" + $"Will retry verification on next cycle"); } } } catch (Exception ex) { Logger.LogError(ex, "Error synchronizing position with token balance"); } } private async Task<(bool found, bool hadError)> CheckSpotPositionInExchangeHistory(Position position) { try { await LogDebugAsync( $"šŸ” Checking Spot Position History for Position: `{position.Identifier}`\nTicker: `{Config.Ticker}`"); var positionHistory = await ServiceScopeHelpers.WithScopedService>( _scopeFactory, async exchangeService => { var fromDate = DateTime.UtcNow.AddHours(-24); var toDate = DateTime.UtcNow; return await exchangeService.GetSpotPositionHistory(Account, Config.Ticker, fromDate, toDate); }); if (positionHistory != null && positionHistory.Count != 0) { // Find a SELL/SHORT position that occurred AFTER our open position was created // This indicates our LONG position was closed var closingPosition = positionHistory .Where(p => p.OriginDirection == TradeDirection.Short && p.Date >= position.Date) .OrderByDescending(p => p.Date) .FirstOrDefault(); if (closingPosition != null) { await LogDebugAsync( $"āœ… Closing Position Found in History\n" + $"Position: `{position.Identifier}`\n" + $"Ticker: `{closingPosition.Ticker}`\n" + $"Direction: `{closingPosition.OriginDirection}` (SHORT = Sold/Closed)\n" + $"Date: `{closingPosition.Date}`\n" + $"Our Position Open Date: `{position.Date}`"); return (true, false); } await LogDebugAsync( $"ā„¹ļø No Closing Position Found in History\n" + $"Position: `{position.Identifier}`\n" + $"Found {positionHistory.Count} history entries but none indicate position was closed\n" + $"Position is likely still open"); } else { await LogDebugAsync( $"ā„¹ļø No Position History Available\n" + $"Position: `{position.Identifier}`\n" + $"Position is likely still open"); } return (false, false); } catch (Exception ex) { Logger.LogError(ex, "Error checking spot position history for position {PositionId}", position.Identifier); await LogWarningAsync( $"āš ļø Web3Proxy Error During Spot Position History Check\n" + $"Position: `{position.Identifier}`\n" + $"Error: {ex.Message}\n" + $"Will retry on next execution cycle"); return (false, true); } } 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 Task MonitorSynthRisk(LightSignal signal, Position position) { // Spot trading doesn't use Synth risk monitoring (futures-specific feature) return Task.CompletedTask; } protected override Task RecoverOpenPositionFromBroker(LightSignal signal, Position positionForSignal) { // Spot trading doesn't have broker positions to recover // Positions are token balances, not tracked positions return Task.FromResult(false); } protected override async Task ReconcileWithBrokerHistory(Position position, Candle currentCandle) { // Spot-specific: reconcile with spot position history try { await LogDebugAsync( $"šŸ” Fetching Spot Position History\nPosition: `{position.Identifier}`\nTicker: `{Config.Ticker}`"); var positionHistory = await ServiceScopeHelpers.WithScopedService>( _scopeFactory, async exchangeService => { // Get position history from the last 24 hours for better coverage var fromDate = DateTime.UtcNow.AddHours(-24); var toDate = DateTime.UtcNow; return await exchangeService.GetSpotPositionHistory(Account, Config.Ticker, fromDate, toDate); }); // Find the most recent position in history if (positionHistory != null && positionHistory.Any()) { // Get the most recent position from spot history (ordered by date) var brokerPosition = positionHistory .OrderByDescending(p => p.Open?.Date ?? p.Date) .FirstOrDefault(); // For spot trading, SHORT direction means the spot was sold/closed // We need to verify the last position is SHORT to confirm the position was correctly closed if (brokerPosition != null && brokerPosition.OriginDirection == TradeDirection.Short) { if (brokerPosition.ProfitAndLoss != null) { await LogDebugAsync( $"āœ… Spot Position History Found\n" + $"Position: `{position.Identifier}`\n" + $"Last Position Direction: `{brokerPosition.OriginDirection}` (SHORT = Sold/Closed) āœ…\n" + $"Realized PnL (after fees): `${brokerPosition.ProfitAndLoss.Realized:F2}`\n" + $"Bot's UI Fees: `${position.UiFees:F2}`\n" + $"Bot's Gas Fees: `${position.GasFees:F2}`"); // Use the actual spot PnL data from broker history // For spot, fees are simpler (no leverage, no closing UI fees) var totalBotFees = position.GasFees + position.UiFees; var brokerRealizedPnl = brokerPosition.ProfitAndLoss.Realized; position.ProfitAndLoss = new ProfitAndLoss { Realized = brokerRealizedPnl, Net = brokerRealizedPnl - totalBotFees }; // Update the closing trade price if available if (brokerPosition.Open != null) { var brokerClosingPrice = brokerPosition.Open.Price; var isProfitable = position.OriginDirection == TradeDirection.Long ? position.Open.Price < brokerClosingPrice : position.Open.Price > brokerClosingPrice; if (isProfitable) { if (position.TakeProfit1 != null) { position.TakeProfit1.Price = brokerClosingPrice; position.TakeProfit1.SetDate(brokerPosition.Open.Date); position.TakeProfit1.SetStatus(TradeStatus.Filled); } // Cancel SL trade when TP is hit if (position.StopLoss != null) { position.StopLoss.SetStatus(TradeStatus.Cancelled); } } else { if (position.StopLoss != null) { position.StopLoss.Price = brokerClosingPrice; position.StopLoss.SetDate(brokerPosition.Open.Date); position.StopLoss.SetStatus(TradeStatus.Filled); } // Cancel TP trades when SL is hit if (position.TakeProfit1 != null) { position.TakeProfit1.SetStatus(TradeStatus.Cancelled); } if (position.TakeProfit2 != null) { position.TakeProfit2.SetStatus(TradeStatus.Cancelled); } } await LogDebugAsync( $"šŸ“Š Spot Position Reconciliation Complete\n" + $"Position: `{position.Identifier}`\n" + $"Closing Price: `${brokerClosingPrice:F2}`\n" + $"Used: `{(isProfitable ? "Take Profit" : "Stop Loss")}`\n" + $"PnL from broker: `${position.ProfitAndLoss.Realized:F2}`"); } return true; // Successfully reconciled, skip candle-based calculation } } else if (brokerPosition != null) { await LogDebugAsync( $"āš ļø Last Position Not SHORT\n" + $"Position: `{position.Identifier}`\n" + $"Last Position Direction: `{brokerPosition.OriginDirection}`\n" + $"Expected SHORT to confirm spot was sold/closed\n" + $"Will continue with candle-based calculation"); } } return false; // No matching position found or not reconciled, continue with candle-based calculation } catch (Exception ex) { Logger.LogError(ex, "Error reconciling spot position with broker history for position {PositionId}", position.Identifier); await LogWarningAsync( $"āš ļø Error During Spot Position History Reconciliation\n" + $"Position: `{position.Identifier}`\n" + $"Error: {ex.Message}\n" + $"Will continue with candle-based calculation"); return false; // On error, continue with candle-based calculation } } protected override 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 != null) { 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 Task.FromResult((closingPrice, pnlCalculated)); } 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()); // Create indicator combo config from user settings var indicatorComboConfig = TradingBox.CreateConfigFromUserSettings(Account.User); var signal = await scenarioRunnerGrain.GetSignals(Config, Signals, Account.Exchange, LastCandle, indicatorComboConfig); 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) => 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 = await ServiceScopeHelpers .WithScopedServices( _scopeFactory, async (exchangeService, accountService, tradingService) => await new CloseSpotPositionCommandHandler(exchangeService, accountService, tradingService) .Handle(command)); if (closedPosition.Status == PositionStatus.Finished || closedPosition.Status == PositionStatus.Flipped) { // Verify that token balance is cleared after closing (for live trading only) if (!Config.IsForWatchingOnly && Config.TradingType != TradingType.BacktestSpot) { await VerifyTokenBalanceCleared(closedPosition); } if (tradeClosingPosition) { await SetPositionStatus(signal.Identifier, PositionStatus.Finished); } await HandleClosedPosition(closedPosition, forceMarketClose ? lastPrice : 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 : null, forceMarketClose); } } } private async Task VerifyTokenBalanceCleared(Position closedPosition) { try { // Wait a short moment for the swap to complete on-chain await Task.Delay(2000); // 2 seconds delay var tokenBalance = await ServiceScopeHelpers.WithScopedService( _scopeFactory, async exchangeService => await exchangeService.GetBalance(Account, Config.Ticker)); // Token balance should be zero or very small (dust) after closing var maxDustAmount = 0.0001m; // Consider amounts less than this as cleared if (tokenBalance is { Amount: > 0 } && tokenBalance.Amount > maxDustAmount) { await LogWarningAsync( $"āš ļø Token Balance Not Fully Cleared After Closing\n" + $"Position: `{closedPosition.Identifier}`\n" + $"Ticker: {Config.Ticker}\n" + $"Remaining Token Balance: `{tokenBalance.Amount:F5}`\n" + $"Expected: `0` or less than `{maxDustAmount:F5}` (dust)\n" + $"Attempting to force close remaining balance..."); // Attempt to force close the remaining balance await ForceCloseRemainingBalance(closedPosition, tokenBalance.Amount); } else { await LogDebugAsync( $"āœ… Token Balance Verified Cleared\n" + $"Position: `{closedPosition.Identifier}`\n" + $"Ticker: {Config.Ticker}\n" + $"Token Balance: `{tokenBalance?.Amount ?? 0:F5}`\n" + $"Position successfully closed on exchange"); } } catch (Exception ex) { Logger.LogError(ex, "Error verifying token balance cleared for position {PositionId}", closedPosition.Identifier); // Don't throw - this is just a verification step } } private async Task ForceCloseRemainingBalance(Position position, decimal remainingBalance) { try { // Prevent infinite retry loop - only attempt force close once // The next bot cycle will handle verification await LogInformationAsync( $"šŸ”„ Force Closing Remaining Balance (One-Time Attempt)\n" + $"Position: `{position.Identifier}`\n" + $"Ticker: {Config.Ticker}\n" + $"Remaining Balance: `{remainingBalance:F5}`"); // Get current price for closing var currentPrice = await ServiceScopeHelpers.WithScopedService( _scopeFactory, async exchangeService => await exchangeService.GetCurrentPrice(Account, Config.Ticker)); if (currentPrice <= 0) { await LogWarningAsync( $"āŒ Cannot Force Close\n" + $"Current price is invalid: `{currentPrice}`\n" + $"Will retry on next cycle"); return; } // Create a new command to close the remaining balance var retryCommand = new CloseSpotPositionCommand(position, position.AccountId, currentPrice); Position retryClosedPosition = await ServiceScopeHelpers .WithScopedServices( _scopeFactory, async (exchangeService, accountService, tradingService) => await new CloseSpotPositionCommandHandler(exchangeService, accountService, tradingService) .Handle(retryCommand)); if (retryClosedPosition != null && (retryClosedPosition.Status == PositionStatus.Finished || retryClosedPosition.Status == PositionStatus.Flipped)) { await LogInformationAsync( $"āœ… Remaining Balance Force Closed Successfully\n" + $"Position: `{position.Identifier}`\n" + $"Cleared Balance: `{remainingBalance:F5}`\n" + $"Close Price: `${currentPrice:F2}`"); // Verify one more time that balance is now cleared (without triggering another force close) await Task.Delay(2000); // Wait for swap to complete var finalBalance = await ServiceScopeHelpers.WithScopedService( _scopeFactory, async exchangeService => await exchangeService.GetBalance(Account, Config.Ticker)); if (finalBalance is { Amount: > 0.0001m }) { await LogWarningAsync( $"āš ļø Balance Still Remaining After Force Close Attempt\n" + $"Position: `{position.Identifier}`\n" + $"Remaining: `{finalBalance.Amount:F5}`\n" + $"This will be handled on the next bot cycle\n" + $"Manual intervention may be required if issue persists"); } else { await LogInformationAsync( $"āœ… Balance Fully Cleared After Force Close\n" + $"Position: `{position.Identifier}`\n" + $"Final Balance: `{finalBalance?.Amount ?? 0:F5}`"); } } } catch (Exception ex) { Logger.LogError(ex, "Error force closing remaining balance for position {PositionId}", position.Identifier); await LogWarningAsync( $"āŒ Force Close Failed\n" + $"Position: `{position.Identifier}`\n" + $"Error: {ex.Message}\n" + $"Manual intervention may be required"); } } }