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.Common; using Managing.Core; using Managing.Core.Exceptions; using Managing.Domain.Accounts; using Managing.Domain.Bots; using Managing.Domain.Candles; using Managing.Domain.Indicators; using Managing.Domain.Scenarios; using Managing.Domain.Shared.Helpers; using Managing.Domain.Strategies; using Managing.Domain.Synth.Models; using Managing.Domain.Trades; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using static Managing.Common.Enums; namespace Managing.Application.Bots; public class TradingBotBase : ITradingBot { public readonly ILogger Logger; private readonly IServiceScopeFactory _scopeFactory; public TradingBotConfig Config { get; set; } public Account Account { get; set; } public Dictionary Signals { get; set; } public Dictionary Positions { get; set; } public Dictionary WalletBalances { get; set; } public DateTime PreloadSince { get; set; } public int PreloadedCandlesCount { get; set; } public long ExecutionCount { get; set; } = 0; public Guid Identifier { get; set; } = Guid.Empty; public Candle LastCandle { get; set; } public DateTime? LastPositionClosingTime { get; set; } public TradingBotBase( ILogger logger, IServiceScopeFactory scopeFactory, TradingBotConfig config ) { _scopeFactory = scopeFactory; Logger = logger; Config = config; Signals = new Dictionary(); Positions = new Dictionary(); WalletBalances = new Dictionary(); PreloadSince = CandleHelpers.GetBotPreloadSinceFromTimeframe(config.Timeframe); } public async Task Start(BotStatus previousStatus) { if (!Config.IsForBacktest) { // Start async initialization in the background without blocking try { // Load account asynchronously await LoadAccount(); // Load last candle asynchronously await LoadLastCandle(); if (Account == null) { await LogWarning($"Account {Config.AccountName} not found. Bot cannot start."); throw new ArgumentException("Account not found"); } // Cancel orders // await CancelAllOrders(); // Send startup message only for fresh starts (not reboots) switch (previousStatus) { case BotStatus.Saved: var indicatorNames = Config.Scenario.Indicators.Select(i => i.Type.ToString()).ToList(); var startupMessage = $"🚀 **Bot Started Successfully!**\n\n" + $"📊 **Trading Setup:**\n" + $"🎯 Ticker: `{Config.Ticker}`\n" + $"⏰ Timeframe: `{Config.Timeframe}`\n" + $"🎮 Scenario: `{Config.Scenario?.Name ?? "Unknown"}`\n" + $"💰 Balance: `${Config.BotTradingBalance:F2}`\n" + $"👀 Mode: `{(Config.IsForWatchingOnly ? "Watch Only" : "Live Trading")}`\n\n" + $"📈 **Active Indicators:** `{string.Join(", ", indicatorNames)}`\n\n" + $"✅ Ready to monitor signals and execute trades!\n" + $"📢 I'll notify you when signals are triggered."; await LogInformation(startupMessage); break; case BotStatus.Running: return; case BotStatus.Stopped: // If status was Stopped we log a message to inform the user that the bot is restarting await LogInformation($"🔄 **Bot Restarted**\n" + $"📊 Resuming operations with {Signals.Count} signals and {Positions.Count} positions\n" + $"✅ Ready to continue trading"); break; default: // Handle any other status if needed break; } } catch (Exception ex) { Logger.LogError(ex, "Error during bot startup: {Message}", ex.Message); } } } public async Task LoadLastCandle() { await ServiceScopeHelpers.WithScopedService(_scopeFactory, async grainFactory => { var grainKey = CandleHelpers.GetCandleStoreGrainKey(Account.Exchange, Config.Ticker, Config.Timeframe); var grain = grainFactory.GetGrain(grainKey); try { // Add a small delay to ensure grain is fully activated await Task.Delay(100); var lastCandles = await grain.GetLastCandle(1); LastCandle = lastCandles.FirstOrDefault(); } catch (InvalidOperationException ex) when (ex.Message.Contains("invalid activation")) { Logger.LogWarning("Grain activation failed for {GrainKey}, retrying in 1 second...", grainKey); // Wait a bit longer and retry once await Task.Delay(1000); try { var lastCandles = await grain.GetLastCandle(1); LastCandle = lastCandles.FirstOrDefault(); } catch (Exception retryEx) { Logger.LogError(retryEx, "Failed to load last candle for {GrainKey} after retry", grainKey); LastCandle = null; } } catch (Exception ex) { Logger.LogError(ex, "Error loading last candle for {GrainKey}", grainKey); LastCandle = null; } }); } public async Task LoadAccount() { if (Config.IsForBacktest) return; await ServiceScopeHelpers.WithScopedService(_scopeFactory, async accountService => { var account = await accountService.GetAccountByAccountName(Config.AccountName, false, false); Account = account; }); } public async Task Run() { // Update signals for live trading only if (!Config.IsForBacktest) { await UpdateSignals(); await LoadLastCandle(); } if (!Config.IsForWatchingOnly) await ManagePositions(); UpdateWalletBalances(); if (!Config.IsForBacktest) { ExecutionCount++; Logger.LogInformation( "Bot Status {Identifier} - ServerDate: {ServerDate}, LastCandleDate: {LastCandleDate}, Signals: {SignalCount}, Executions: {ExecutionCount}, Positions: {PositionCount}", Identifier, DateTime.UtcNow, LastCandle?.Date, Signals.Count, ExecutionCount, Positions.Count); } } public async Task UpdateSignals(HashSet candles = null) { // If position open and not flipped, do not update signals if (!Config.FlipPosition && Positions.Any(p => !p.Value.IsFinished())) return; // Check if we're in cooldown period for any direction if (await IsInCooldownPeriodAsync()) { // Still in cooldown period, skip signal generation return; } if (Config.IsForBacktest && candles != null) { var backtestSignal = TradingBox.GetSignal(candles, Config.Scenario, Signals, Config.Scenario.LoopbackPeriod); if (backtestSignal == null) return; await AddSignal(backtestSignal); } else { 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); }); } } private async Task RecreateSignalFromPosition(Position position) { try { // Create a dummy candle for the position opening time var positionCandle = new Candle { Date = position.Open.Date, OpenTime = position.Open.Date, Open = position.Open.Price, Close = position.Open.Price, High = position.Open.Price, Low = position.Open.Price, Volume = 0, Exchange = TradingExchanges.Evm, Ticker = Config.Ticker, Timeframe = Config.Timeframe }; // Create a new signal based on position information var recreatedSignal = new LightSignal( ticker: Config.Ticker, direction: position.OriginDirection, confidence: Confidence.Medium, // Default confidence for recreated signals candle: positionCandle, date: position.Open.Date, exchange: TradingExchanges.Evm, indicatorType: IndicatorType.Stc, // Use a valid strategy type for recreated signals signalType: SignalType.Signal, indicatorName: "RecreatedSignal" ); // Since Signal identifier is auto-generated, we need to update our position // to use the new signal identifier, or find another approach // For now, let's update the position's SignalIdentifier to match the recreated signal position.SignalIdentifier = recreatedSignal.Identifier; recreatedSignal.Status = SignalStatus.PositionOpen; // Add the recreated signal to our collection Signals.Add(recreatedSignal.Identifier, recreatedSignal); await LogInformation( $"🔍 **Signal Recovery Success**\nRecreated signal: `{recreatedSignal.Identifier}`\nFor position: `{position.Identifier}`"); return recreatedSignal; } catch (Exception ex) { await LogWarning($"Error recreating signal for position {position.Identifier}: {ex.Message}"); return null; } } private async Task ManagePositions() { // First, process all existing positions that are not finished foreach (var position in Positions.Values.Where(p => !p.IsFinished())) { var signalForPosition = Signals[position.SignalIdentifier]; if (signalForPosition == null) { await LogInformation( $"🔍 **Signal Recovery**\nSignal not found for position `{position.Identifier}`\nRecreating signal from position data..."); // Recreate the signal based on position information signalForPosition = await RecreateSignalFromPosition(position); if (signalForPosition == null) { await LogWarning($"Failed to recreate signal for position {position.Identifier}"); continue; } } // Ensure signal status is correctly set to PositionOpen if position is not finished if (signalForPosition.Status != SignalStatus.PositionOpen && position.Status != PositionStatus.Finished) { await LogInformation( $"🔄 **Signal Status Update**\nSignal: `{signalForPosition.Identifier}`\nStatus: `{signalForPosition.Status}` → `PositionOpen`"); SetSignalStatus(signalForPosition.Identifier, SignalStatus.PositionOpen); } await UpdatePosition(signalForPosition, position); } // Then, open positions for signals waiting for a position open // But first, check if we already have a position for any of these signals var signalsWaitingForPosition = Signals.Values.Where(s => s.Status == SignalStatus.WaitingForPosition); foreach (var signal in signalsWaitingForPosition) { if (LastCandle != null && signal.Date < LastCandle.Date) { await LogWarning( $"❌ **Signal Expired**\nSignal `{signal.Identifier}` is older than last candle `{LastCandle.Date}`\nStatus: `Expired`"); SetSignalStatus(signal.Identifier, SignalStatus.Expired); continue; } // Check if we already have a position for this signal (in case it was added but not processed yet) var existingPosition = Positions.Values.FirstOrDefault(p => p.SignalIdentifier == signal.Identifier); if (existingPosition != null) { // Position already exists for this signal, update signal status await LogInformation( $"🔄 **Signal Status Update**\nSignal: `{signal.Identifier}`\nStatus: `{signal.Status}` → `PositionOpen`\nPosition already exists: `{existingPosition.Identifier}`"); SetSignalStatus(signal.Identifier, SignalStatus.PositionOpen); continue; } // No existing position found, proceed to open a new one var newlyCreatedPosition = await OpenPosition(signal); if (newlyCreatedPosition != null) { Positions[newlyCreatedPosition.Identifier] = newlyCreatedPosition; } else { await LogWarning( $"⚠️ **Position Creation Failed**\nSignal: `{signal.Identifier}`\nPosition creation returned null"); } } } private void UpdateWalletBalances() { var date = DateTime.UtcNow; if (WalletBalances.Count == 0) { WalletBalances[date] = Config.BotTradingBalance; return; } if (!WalletBalances.ContainsKey(date)) { var previousBalance = WalletBalances.First().Value; WalletBalances[date] = previousBalance + GetProfitAndLoss(); } } private async Task UpdatePosition(LightSignal signal, Position positionForSignal) { try { // Skip processing if position is already canceled or rejected (never filled) if (positionForSignal.Status == PositionStatus.Canceled || positionForSignal.Status == PositionStatus.Rejected) { Logger.LogDebug( "Skipping update for position {PositionId} - status is {Status} (never filled)", positionForSignal.Identifier, positionForSignal.Status); return; } Position internalPosition = null; List brokerPositions = null; await ServiceScopeHelpers.WithScopedService(_scopeFactory, async tradingService => { internalPosition = Config.IsForBacktest ? positionForSignal : await tradingService.GetPositionByIdentifierAsync(positionForSignal.Identifier); if (Config.IsForBacktest) { brokerPositions = new List { internalPosition }; } else { brokerPositions = await ServiceScopeHelpers.WithScopedService>( _scopeFactory, async exchangeService => { return [.. await exchangeService.GetBrokerPositions(Account)]; }); } }); if (!Config.IsForBacktest) { var brokerPosition = brokerPositions.FirstOrDefault(p => p.Ticker == Config.Ticker); if (brokerPosition != null) { var previousPositionStatus = internalPosition.Status; // Position found on the broker, means the position is filled var brokerNetPnL = brokerPosition.GetNetPnL(); UpdatePositionPnl(positionForSignal.Identifier, brokerNetPnL); var totalFees = internalPosition.GasFees + internalPosition.UiFees; var netPnl = brokerNetPnL - totalFees; internalPosition.ProfitAndLoss = new ProfitAndLoss { Realized = brokerNetPnL, Net = netPnl }; internalPosition.Status = PositionStatus.Filled; await SetPositionStatus(internalPosition.SignalIdentifier, PositionStatus.Filled); internalPosition.Open.SetStatus(TradeStatus.Filled); positionForSignal.Open.SetStatus(TradeStatus.Filled); // Update Open trade ExchangeOrderId if broker position has one if (brokerPosition.Open?.ExchangeOrderId != null && internalPosition.Open != null) { internalPosition.Open.SetExchangeOrderId(brokerPosition.Open.ExchangeOrderId); positionForSignal.Open.SetExchangeOrderId(brokerPosition.Open.ExchangeOrderId); } // Update Stop Loss and Take Profit trades with correct ExchangeOrderId from broker if (brokerPosition.StopLoss != null && internalPosition.StopLoss != null) { internalPosition.StopLoss.SetExchangeOrderId(brokerPosition.StopLoss.ExchangeOrderId); positionForSignal.StopLoss.SetExchangeOrderId(brokerPosition.StopLoss.ExchangeOrderId); } if (brokerPosition.TakeProfit1 != null && internalPosition.TakeProfit1 != null) { internalPosition.TakeProfit1.SetExchangeOrderId(brokerPosition.TakeProfit1.ExchangeOrderId); positionForSignal.TakeProfit1.SetExchangeOrderId(brokerPosition.TakeProfit1.ExchangeOrderId); } if (brokerPosition.TakeProfit2 != null && internalPosition.TakeProfit2 != null) { internalPosition.TakeProfit2.SetExchangeOrderId(brokerPosition.TakeProfit2.ExchangeOrderId); positionForSignal.TakeProfit2.SetExchangeOrderId(brokerPosition.TakeProfit2.ExchangeOrderId); } await UpdatePositionDatabase(internalPosition); if (previousPositionStatus != PositionStatus.Filled && internalPosition.Status == PositionStatus.Filled) { await NotifyAgentAndPlatformGrainAsync(NotificationEventType.PositionOpened, internalPosition); } else { await NotifyAgentAndPlatformGrainAsync(NotificationEventType.PositionUpdated, internalPosition); } } else { // No position on the broker, the position have been closed by the exchange if (internalPosition.Status.Equals(PositionStatus.Filled)) { internalPosition.Status = PositionStatus.Finished; // Call HandleClosedPosition to ensure trade dates are properly updated await HandleClosedPosition(internalPosition); } } } if (internalPosition.Status == PositionStatus.New) { var orders = await ServiceScopeHelpers.WithScopedService>(_scopeFactory, async exchangeService => { return [.. await exchangeService.GetOpenOrders(Account, Config.Ticker)]; }); if (orders.Any()) { var ordersCount = orders.Count(); if (ordersCount >= 3) { var currentTime = Config.IsForBacktest ? LastCandle?.Date ?? DateTime.UtcNow : DateTime.UtcNow; var timeSinceRequest = currentTime - positionForSignal.Open.Date; var waitTimeMinutes = 10; if (timeSinceRequest.TotalMinutes >= waitTimeMinutes) { await LogWarning( $"⚠️ **Order Cleanup**\nToo many open orders: `{orders.Count()}`\nPosition: `{positionForSignal.Identifier}`\nTime elapsed: `{waitTimeMinutes}min`\nCanceling all orders..."); try { await ServiceScopeHelpers.WithScopedService(_scopeFactory, async exchangeService => { await exchangeService.CancelOrder(Account, Config.Ticker); }); await LogInformation( $"✅ **Orders Canceled**\nSuccessfully canceled all orders for: `{Config.Ticker}`"); } catch (Exception ex) { await LogWarning($"Failed to cancel orders: {ex.Message}"); } await SetPositionStatus(signal.Identifier, PositionStatus.Canceled); SetSignalStatus(signal.Identifier, SignalStatus.Expired); return; } else { var remainingMinutes = waitTimeMinutes - timeSinceRequest.TotalMinutes; await LogInformation( $"⏳ **Waiting for Orders**\nPosition has `{orders.Count()}` open orders\nElapsed: `{timeSinceRequest.TotalMinutes:F1}min`\nWaiting `{remainingMinutes:F1}min` more before canceling"); } } else if (ordersCount == 2) { // TODO: This should never happen, but just in case // Check if position is already open on broker with 2 orders await LogInformation( $"🔍 **Checking Broker Position**\nPosition has exactly `{orders.Count()}` open orders\nChecking if position is already open on broker..."); Position brokerPosition = null; await ServiceScopeHelpers.WithScopedService(_scopeFactory, async exchangeService => { var brokerPositions = await exchangeService.GetBrokerPositions(Account); brokerPosition = brokerPositions.FirstOrDefault(p => p.Ticker == Config.Ticker); }); if (brokerPosition != null) { await LogInformation( $"✅ **Position Found on Broker**\nPosition is already open on broker\nUpdating position status to Filled"); // Calculate net PnL after fees for broker position var brokerNetPnL = brokerPosition.GetNetPnL(); UpdatePositionPnl(positionForSignal.Identifier, brokerNetPnL); // Update Open trade status when position is found on broker with 2 orders if (internalPosition.Open != null) { internalPosition.Open.SetStatus(TradeStatus.Filled); // Update Open trade ExchangeOrderId if broker position has one if (brokerPosition.Open?.ExchangeOrderId != null) { internalPosition.Open.SetExchangeOrderId(brokerPosition.Open.ExchangeOrderId); } } // Also update the position in the bot's positions dictionary if (positionForSignal.Open != null) { positionForSignal.Open.SetStatus(TradeStatus.Filled); // Update Open trade ExchangeOrderId if broker position has one if (brokerPosition.Open?.ExchangeOrderId != null) { positionForSignal.Open.SetExchangeOrderId(brokerPosition.Open.ExchangeOrderId); } } await SetPositionStatus(signal.Identifier, PositionStatus.Filled); } else { await LogInformation( $"⏸️ **Position Pending**\nPosition still waiting to open\n`{orders.Count()}` open orders remaining"); } } else { await LogInformation( $"⏸️ **Position Pending**\nPosition still waiting to open\n`{orders.Count()}` open orders remaining"); } } else { await LogWarning( $"❌ **Position Never Filled**\nNo position on exchange and no orders\nSignal: `{signal.Identifier}`\nPosition was never filled and will be marked as canceled."); // Position was never filled (still in New status), so just mark it as canceled // Don't call HandleClosedPosition as that would incorrectly add volume/PnL await SetPositionStatus(signal.Identifier, PositionStatus.Canceled); SetSignalStatus(signal.Identifier, SignalStatus.Expired); } } else if (internalPosition.Status == PositionStatus.Finished || internalPosition.Status == PositionStatus.Flipped) { await HandleClosedPosition(positionForSignal); } else if (internalPosition.Status == PositionStatus.Filled) { Candle lastCandle = null; await ServiceScopeHelpers.WithScopedService(_scopeFactory, async exchangeService => { lastCandle = Config.IsForBacktest ? LastCandle : await exchangeService.GetCandle(Account, Config.Ticker, DateTime.UtcNow); }); var currentTime = Config.IsForBacktest ? lastCandle.Date : DateTime.UtcNow; var currentPnl = positionForSignal.ProfitAndLoss?.Realized ?? 0; var pnlPercentage = positionForSignal.Open.Price * positionForSignal.Open.Quantity != 0 ? Math.Round((currentPnl / (positionForSignal.Open.Price * positionForSignal.Open.Quantity)) * 100, 2) : 0; var isPositionInProfit = positionForSignal.OriginDirection == TradeDirection.Long ? lastCandle.Close > positionForSignal.Open.Price : lastCandle.Close < positionForSignal.Open.Price; var hasExceededTimeLimit = Config.MaxPositionTimeHours.HasValue && HasPositionExceededTimeLimit(positionForSignal, currentTime); if (hasExceededTimeLimit) { var shouldCloseOnTimeLimit = !Config.CloseEarlyWhenProfitable || isPositionInProfit; if (shouldCloseOnTimeLimit) { var profitStatus = isPositionInProfit ? "in profit" : "at a loss"; await LogInformation( $"⏰ **Time Limit Close**\nClosing position due to time limit: `{Config.MaxPositionTimeHours}h` exceeded\n📈 Position Status: **{profitStatus}**\n💰 Entry: `${positionForSignal.Open.Price}` → Current: `${lastCandle.Close}`\n📊 Realized PNL: `${currentPnl:F2}` (`{pnlPercentage:F2}%`)"); await CloseTrade(signal, positionForSignal, positionForSignal.Open, lastCandle.Close, true); return; } } // For backtest and to make sure position is closed based on SL and TP if (positionForSignal.OriginDirection == TradeDirection.Long) { if (positionForSignal.StopLoss.Price >= lastCandle.Low) { // Use actual execution price (lastCandle.Low for SL hit) var executionPrice = lastCandle.Low; positionForSignal.StopLoss.SetPrice(executionPrice, 2); positionForSignal.StopLoss.SetDate(lastCandle.Date); positionForSignal.StopLoss.SetStatus(TradeStatus.Filled); // Cancel TP trades when SL is hit if (positionForSignal.TakeProfit1 != null) { positionForSignal.TakeProfit1.SetStatus(TradeStatus.Cancelled); } if (positionForSignal.TakeProfit2 != null) { positionForSignal.TakeProfit2.SetStatus(TradeStatus.Cancelled); } await LogInformation( $"🛑 **Stop Loss Hit**\nClosing LONG position\nPrice: `${executionPrice:F2}` (was `${positionForSignal.StopLoss.Price:F2}`)"); await CloseTrade(signal, positionForSignal, positionForSignal.StopLoss, executionPrice, true); } else if (positionForSignal.TakeProfit1.Price <= lastCandle.High && positionForSignal.TakeProfit1.Status != TradeStatus.Filled) { // Use actual execution price (lastCandle.High for TP hit) var executionPrice = lastCandle.High; positionForSignal.TakeProfit1.SetPrice(executionPrice, 2); positionForSignal.TakeProfit1.SetDate(lastCandle.Date); positionForSignal.TakeProfit1.SetStatus(TradeStatus.Filled); // Cancel SL trade when TP is hit if (positionForSignal.StopLoss != null) { positionForSignal.StopLoss.SetStatus(TradeStatus.Cancelled); } await LogInformation( $"🎯 **Take Profit 1 Hit**\nClosing LONG position\nPrice: `${executionPrice:F2}` (was `${positionForSignal.TakeProfit1.Price:F2}`)"); await CloseTrade(signal, positionForSignal, positionForSignal.TakeProfit1, executionPrice, positionForSignal.TakeProfit2 == null); } else if (positionForSignal.TakeProfit2?.Price <= lastCandle.High) { // Use actual execution price (lastCandle.High for TP hit) var executionPrice = lastCandle.High; positionForSignal.TakeProfit2.SetPrice(executionPrice, 2); positionForSignal.TakeProfit2.SetDate(lastCandle.Date); positionForSignal.TakeProfit2.SetStatus(TradeStatus.Filled); // Cancel SL trade when TP is hit if (positionForSignal.StopLoss != null) { positionForSignal.StopLoss.SetStatus(TradeStatus.Cancelled); } await LogInformation( $"🎯 **Take Profit 2 Hit**\nClosing LONG position\nPrice: `${executionPrice:F2}` (was `${positionForSignal.TakeProfit2.Price:F2}`)"); await CloseTrade(signal, positionForSignal, positionForSignal.TakeProfit2, executionPrice, true); } } else if (positionForSignal.OriginDirection == TradeDirection.Short) { if (positionForSignal.StopLoss.Price <= lastCandle.High) { // Use actual execution price (lastCandle.High for SL hit on SHORT) var executionPrice = lastCandle.High; positionForSignal.StopLoss.SetPrice(executionPrice, 2); positionForSignal.StopLoss.SetDate(lastCandle.Date); positionForSignal.StopLoss.SetStatus(TradeStatus.Filled); // Cancel TP trades when SL is hit if (positionForSignal.TakeProfit1 != null) { positionForSignal.TakeProfit1.SetStatus(TradeStatus.Cancelled); } if (positionForSignal.TakeProfit2 != null) { positionForSignal.TakeProfit2.SetStatus(TradeStatus.Cancelled); } await LogInformation( $"🛑 **Stop Loss Hit**\nClosing SHORT position\nPrice: `${executionPrice:F2}` (was `${positionForSignal.StopLoss.Price:F2}`)"); await CloseTrade(signal, positionForSignal, positionForSignal.StopLoss, executionPrice, true); } else if (positionForSignal.TakeProfit1.Price >= lastCandle.Low && positionForSignal.TakeProfit1.Status != TradeStatus.Filled) { // Use actual execution price (lastCandle.Low for TP hit on SHORT) var executionPrice = lastCandle.Low; positionForSignal.TakeProfit1.SetPrice(executionPrice, 2); positionForSignal.TakeProfit1.SetDate(lastCandle.Date); positionForSignal.TakeProfit1.SetStatus(TradeStatus.Filled); // Cancel SL trade when TP is hit if (positionForSignal.StopLoss != null) { positionForSignal.StopLoss.SetStatus(TradeStatus.Cancelled); } await LogInformation( $"🎯 **Take Profit 1 Hit**\nClosing SHORT position\nPrice: `${executionPrice:F2}` (was `${positionForSignal.TakeProfit1.Price:F2}`)"); await CloseTrade(signal, positionForSignal, positionForSignal.TakeProfit1, executionPrice, positionForSignal.TakeProfit2 == null); } else if (positionForSignal.TakeProfit2?.Price >= lastCandle.Low) { // Use actual execution price (lastCandle.Low for TP hit on SHORT) var executionPrice = lastCandle.Low; positionForSignal.TakeProfit2.SetPrice(executionPrice, 2); positionForSignal.TakeProfit2.SetDate(lastCandle.Date); positionForSignal.TakeProfit2.SetStatus(TradeStatus.Filled); // Cancel SL trade when TP is hit if (positionForSignal.StopLoss != null) { positionForSignal.StopLoss.SetStatus(TradeStatus.Cancelled); } await LogInformation( $"🎯 **Take Profit 2 Hit**\nClosing SHORT position\nPrice: `${executionPrice:F2}` (was `${positionForSignal.TakeProfit2.Price:F2}`)"); await CloseTrade(signal, positionForSignal, positionForSignal.TakeProfit2, executionPrice, true); } } } else if (internalPosition.Status == PositionStatus.Rejected || internalPosition.Status == PositionStatus.Canceled) { await LogWarning($"Open position trade is rejected for signal {signal.Identifier}"); if (signal.Status == SignalStatus.PositionOpen) { Logger.LogInformation($"Try to re-open position"); await OpenPosition(signal); } } if (Config.UseSynthApi && !Config.IsForBacktest && positionForSignal.Status == PositionStatus.Filled) { decimal currentPrice = 0; await ServiceScopeHelpers.WithScopedService(_scopeFactory, async exchangeService => { currentPrice = await exchangeService.GetCurrentPrice(Account, Config.Ticker); }); var riskResult = default(SynthRiskResult); await ServiceScopeHelpers.WithScopedService(_scopeFactory, async tradingService => { riskResult = await tradingService.MonitorSynthPositionRiskAsync( Config.Ticker, positionForSignal.OriginDirection, currentPrice, positionForSignal.StopLoss.Price, positionForSignal.Identifier, Config); }); if (riskResult.ShouldWarn && !string.IsNullOrEmpty(riskResult.WarningMessage)) { await LogWarning(riskResult.WarningMessage); } if (riskResult.ShouldAutoClose && !string.IsNullOrEmpty(riskResult.EmergencyMessage)) { await LogWarning(riskResult.EmergencyMessage); await CloseTrade(Signals[positionForSignal.SignalIdentifier], positionForSignal, positionForSignal.StopLoss, currentPrice, true); } } } catch (Exception ex) { await LogWarning($"Cannot update position {positionForSignal.Identifier}: {ex.Message}, {ex.StackTrace}"); SentrySdk.CaptureException(ex); return; } } private async Task UpdatePositionDatabase(Position position) { await ServiceScopeHelpers.WithScopedService(_scopeFactory, async tradingService => { await tradingService.UpdatePositionAsync(position); }); } private async Task OpenPosition(LightSignal signal) { Logger.LogInformation($"Opening position for {signal.Identifier}"); // Check for any existing open position (not finished) for this ticker var openedPosition = Positions.Values.FirstOrDefault(p => !p.IsFinished() && p.SignalIdentifier != signal.Identifier); decimal lastPrice = await ServiceScopeHelpers.WithScopedService(_scopeFactory, async exchangeService => { return Config.IsForBacktest ? LastCandle?.Close ?? 0 : await exchangeService.GetCurrentPrice(Account, Config.Ticker); }); if (openedPosition != null) { var previousSignal = Signals[openedPosition.SignalIdentifier]; if (openedPosition.OriginDirection == signal.Direction) { await LogInformation( $"📍 **Same Direction Signal**\nSignal `{signal.Identifier}` tried to open position\nBut `{previousSignal.Identifier}` already open for same direction"); SetSignalStatus(signal.Identifier, SignalStatus.Expired); return null; } else { if (Config.FlipPosition) { var isPositionInProfit = (openedPosition.ProfitAndLoss?.Realized ?? 0) > 0; var shouldFlip = !Config.FlipOnlyWhenInProfit || isPositionInProfit; if (shouldFlip) { var flipReason = Config.FlipOnlyWhenInProfit ? "current position is in profit" : "FlipOnlyWhenInProfit is disabled"; await LogInformation( $"🔄 **Position Flip Initiated**\nFlipping position due to opposite signal\nReason: {flipReason}"); await CloseTrade(previousSignal, openedPosition, openedPosition.Open, lastPrice, true); await SetPositionStatus(previousSignal.Identifier, PositionStatus.Flipped); var newPosition = await OpenPosition(signal); await LogInformation( $"✅ **Position Flipped**\nPosition: `{previousSignal.Identifier}` → `{signal.Identifier}`\nPrice: `${lastPrice}`"); return newPosition; } else { var currentPnl = openedPosition.ProfitAndLoss?.Realized ?? 0; await LogInformation( $"💸 **Flip Blocked - Not Profitable**\nPosition `{previousSignal.Identifier}` PnL: `${currentPnl:F2}`\nSignal `{signal.Identifier}` will wait for profitability"); SetSignalStatus(signal.Identifier, SignalStatus.Expired); return null; } } else { await LogInformation( $"🚫 **Flip Disabled**\nPosition already open for: `{previousSignal.Identifier}`\nFlipping disabled, new signal expired"); SetSignalStatus(signal.Identifier, SignalStatus.Expired); return null; } } } else { bool canOpen = await CanOpenPosition(signal); if (!canOpen) { SetSignalStatus(signal.Identifier, SignalStatus.Expired); return null; } await LogInformation( $"🚀 **Opening Position**\nTime: `{signal.Date:HH:mm:ss}`\nSignal: `{signal.Identifier}`"); try { var command = new OpenPositionRequest( Config.AccountName, Config.MoneyManagement, signal.Direction, Config.Ticker, PositionInitiator.Bot, signal.Date, Account.User, Config.BotTradingBalance, Config.IsForBacktest, lastPrice, signalIdentifier: signal.Identifier, initiatorIdentifier: Identifier); var position = await ServiceScopeHelpers .WithScopedServices( _scopeFactory, async (exchangeService, accountService, tradingService) => { return await new OpenPositionCommandHandler(exchangeService, accountService, tradingService) .Handle(command); }); if (position != null) { if (position.Open.Status != TradeStatus.Cancelled) { SetSignalStatus(signal.Identifier, SignalStatus.PositionOpen); if (!Config.IsForBacktest) { await ServiceScopeHelpers.WithScopedService(_scopeFactory, async messengerService => { await messengerService.SendPosition(position); }); } Logger.LogInformation($"Position requested"); return position; // Return the created position without adding to list } else { await SetPositionStatus(signal.Identifier, PositionStatus.Rejected); SetSignalStatus(signal.Identifier, SignalStatus.Expired); return null; } } return null; } catch (InsufficientFundsException ex) { // Handle insufficient funds errors with user-friendly messaging SetSignalStatus(signal.Identifier, SignalStatus.Expired); await LogWarning(ex.UserMessage); // Log the technical details for debugging Logger.LogError(ex, "Insufficient funds error for signal {SignalId}: {ErrorMessage}", signal.Identifier, ex.Message); return null; } catch (Exception ex) { SetSignalStatus(signal.Identifier, SignalStatus.Expired); await LogWarning($"Cannot open trade : {ex.Message}, stackTrace : {ex.StackTrace}"); return null; } } } private async Task CanOpenPosition(LightSignal signal) { // Early return if we're in backtest mode and haven't executed yet // TODO : check if its a startup cycle if (!Config.IsForBacktest && ExecutionCount == 0) { await LogInformation("⏳ **Bot Not Ready**\nCannot open position\nBot hasn't executed first cycle yet"); return false; } // Check if we're in backtest mode if (Config.IsForBacktest) { return !await IsInCooldownPeriodAsync() && await CheckLossStreak(signal); } // Check broker positions for live trading var canOpenPosition = await CheckBrokerPositions(); if (!canOpenPosition) { return false; } // Synth-based pre-trade risk assessment if (Config.UseSynthApi) { decimal currentPrice = 0; await ServiceScopeHelpers.WithScopedService(_scopeFactory, async exchangeService => { currentPrice = Config.IsForBacktest ? LastCandle?.Close ?? 0 : await exchangeService.GetCurrentPrice(Account, Config.Ticker); }); bool synthRisk = false; await ServiceScopeHelpers.WithScopedService(_scopeFactory, async tradingService => { synthRisk = await tradingService.AssessSynthPositionRiskAsync(Config.Ticker, signal.Direction, currentPrice, Config, Config.IsForBacktest); }); if (!synthRisk) { return false; } } // Check cooldown period and loss streak return !await IsInCooldownPeriodAsync() && await CheckLossStreak(signal); } private async Task CheckLossStreak(LightSignal signal) { // If MaxLossStreak is 0, there's no limit if (Config.MaxLossStreak <= 0) { return true; } // Get the last N finished positions regardless of direction var recentPositions = Positions .Values .Where(p => p.IsFinished()) .OrderByDescending(p => p.Open.Date) .Take(Config.MaxLossStreak) .ToList(); // If we don't have enough positions to form a streak, we can open if (recentPositions.Count < Config.MaxLossStreak) { return true; } // Check if all recent positions were losses var allLosses = recentPositions.All(p => p.ProfitAndLoss?.Realized < 0); if (!allLosses) { return true; } // If we have a loss streak, check if the last position was in the same direction as the signal var lastPosition = recentPositions.First(); if (lastPosition.OriginDirection == signal.Direction) { await LogWarning( $"🔥 **Loss Streak Limit**\nCannot open position\nMax loss streak: `{Config.MaxLossStreak}` reached\n📉 Last `{recentPositions.Count}` trades were losses\n🎯 Last position: `{lastPosition.OriginDirection}`\nWaiting for opposite direction signal"); return false; } return true; } private async Task CheckBrokerPositions() { try { List positions = null; await ServiceScopeHelpers.WithScopedService(_scopeFactory, async exchangeService => { positions = [.. await exchangeService.GetBrokerPositions(Account)]; }); if (!positions.Any(p => p.Ticker == Config.Ticker)) { return true; } // Handle existing position on broker var previousPosition = Positions.Values.LastOrDefault(); List orders = null; await ServiceScopeHelpers.WithScopedService(_scopeFactory, async exchangeService => { orders = [.. await exchangeService.GetOpenOrders(Account, Config.Ticker)]; }); var reason = $"Cannot open position. There is already a position open for {Config.Ticker} on the broker."; if (previousPosition != null && orders.Count >= 2) { await SetPositionStatus(previousPosition.SignalIdentifier, PositionStatus.Filled); } else { reason += " Position open on broker but not enough orders or no previous position internally saved by the bot"; } await LogWarning(reason); return false; } catch (Exception ex) { await LogWarning($"❌ **Broker Position Check Failed**\nError checking broker positions\n{ex.Message}"); return false; } } public async Task CloseTrade(LightSignal signal, Position position, Trade tradeToClose, decimal lastPrice, bool tradeClosingPosition = false) { if (position.TakeProfit2 != null && position.TakeProfit1.Status == TradeStatus.Filled && tradeToClose.TradeType == TradeType.StopMarket) { // If trade is the 2nd Take profit tradeToClose.Quantity = position.TakeProfit2.Quantity; } await LogInformation( $"🔧 **Closing Trade**\nTicker: `{Config.Ticker}`\nPrice: `${lastPrice}`\n📋 Type: `{tradeToClose.TradeType}`\n📊 Quantity: `{tradeToClose.Quantity}`\n🎯 Closing Position: `{(tradeClosingPosition ? "Yes" : "No")}`"); decimal quantity = 0; if (!Config.IsForBacktest) { await ServiceScopeHelpers.WithScopedService(_scopeFactory, async exchangeService => { quantity = await exchangeService.GetQuantityInPosition(Account, Config.Ticker); }); } // Get status of position before closing it. The position might be already close by the exchange if (!Config.IsForBacktest && quantity == 0) { Logger.LogInformation($"Trade already close on exchange"); await HandleClosedPosition(position); } else { var command = new ClosePositionCommand(position, position.AccountId, lastPrice, isForBacktest: Config.IsForBacktest); try { Position closedPosition = null; await ServiceScopeHelpers.WithScopedServices( _scopeFactory, async (exchangeService, accountService, tradingService) => { closedPosition = await new ClosePositionCommandHandler(exchangeService, accountService, tradingService, _scopeFactory) .Handle(command); }); if (closedPosition.Status == PositionStatus.Finished || closedPosition.Status == PositionStatus.Flipped) { if (tradeClosingPosition) { await SetPositionStatus(signal.Identifier, PositionStatus.Finished); } await HandleClosedPosition(closedPosition); } else { throw new Exception($"Wrong position status : {closedPosition.Status}"); } } catch (Exception ex) { await LogWarning($"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); } } } } private async Task HandleClosedPosition(Position position) { if (Positions.ContainsKey(position.Identifier)) { Candle currentCandle = null; await ServiceScopeHelpers.WithScopedService(_scopeFactory, async exchangeService => { currentCandle = Config.IsForBacktest ? LastCandle : await exchangeService.GetCandle(Account, Config.Ticker, DateTime.UtcNow); }); if (currentCandle != null) { List recentCandles = null; if (Config.IsForBacktest) { recentCandles = LastCandle != null ? new List() { LastCandle } : new List(); } else { // Use CandleStoreGrain to get recent candles instead of calling exchange service directly await ServiceScopeHelpers.WithScopedService(_scopeFactory, async grainFactory => { var grainKey = CandleHelpers.GetCandleStoreGrainKey(Account.Exchange, Config.Ticker, Config.Timeframe); var grain = grainFactory.GetGrain(grainKey); try { recentCandles = await grain.GetLastCandle(5); } catch (Exception ex) { Logger.LogError(ex, "Error retrieving recent candles from CandleStoreGrain for {GrainKey}", grainKey); recentCandles = new List(); } }); } // Check if we have any candles before proceeding if (recentCandles == null || !recentCandles.Any()) { await LogWarning( $"No recent candles available for position {position.Identifier}. Using current candle data instead."); // Fallback to current candle if available if (currentCandle != null) { recentCandles = new List { currentCandle }; } else { await LogWarning( $"No candle data available for position {position.Identifier}. Cannot determine stop loss/take profit hit."); Logger.LogError( "No candle data available for position {PositionId}. Cannot determine stop loss/take profit hit.", position.Identifier); return; } } var minPriceRecent = recentCandles.Min(c => c.Low); var maxPriceRecent = recentCandles.Max(c => c.High); bool wasStopLossHit = false; bool wasTakeProfitHit = false; if (position.OriginDirection == TradeDirection.Long) { wasStopLossHit = minPriceRecent <= position.StopLoss.Price; wasTakeProfitHit = maxPriceRecent >= position.TakeProfit1.Price; } else { wasStopLossHit = maxPriceRecent >= position.StopLoss.Price; wasTakeProfitHit = minPriceRecent <= position.TakeProfit1.Price; } decimal closingPrice; if (wasStopLossHit) { // Use actual execution price based on direction closingPrice = position.OriginDirection == TradeDirection.Long ? minPriceRecent // For LONG, SL hits at the low : maxPriceRecent; // For SHORT, SL hits at the high position.StopLoss.SetPrice(closingPrice, 2); position.StopLoss.SetDate(currentCandle.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); } Logger.LogInformation( $"🛑 **Stop Loss Execution Confirmed**\n" + $"Position: `{position.Identifier}`\n" + $"SL Price: `${closingPrice:F2}` was hit (was `${position.StopLoss.Price:F2}`)\n" + $"Recent Low: `${minPriceRecent:F2}` | Recent High: `${maxPriceRecent:F2}`"); } else if (wasTakeProfitHit) { // Use actual execution price based on direction closingPrice = position.OriginDirection == TradeDirection.Long ? maxPriceRecent // For LONG, TP hits at the high : minPriceRecent; // For SHORT, TP hits at the low position.TakeProfit1.SetPrice(closingPrice, 2); position.TakeProfit1.SetDate(currentCandle.Date); position.TakeProfit1.SetStatus(TradeStatus.Filled); // Cancel SL trade when TP is hit if (position.StopLoss != null) { position.StopLoss.SetStatus(TradeStatus.Cancelled); } Logger.LogInformation( $"🎯 **Take Profit Execution Confirmed**\n" + $"Position: `{position.Identifier}`\n" + $"TP Price: `${closingPrice:F2}` was hit (was `${position.TakeProfit1.Price:F2}`)\n" + $"Recent Low: `${minPriceRecent:F2}` | Recent High: `${maxPriceRecent:F2}`"); } else { closingPrice = Config.IsForBacktest ? currentCandle.Close : 0; await ServiceScopeHelpers.WithScopedService(_scopeFactory, async exchangeService => { closingPrice = await exchangeService.GetCurrentPrice(Account, Config.Ticker); }); 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); // Cancel SL trade when TP is used for manual close 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); // Cancel TP trades when SL is used for manual close if (position.TakeProfit1 != null) { position.TakeProfit1.SetStatus(TradeStatus.Cancelled); } if (position.TakeProfit2 != null) { position.TakeProfit2.SetStatus(TradeStatus.Cancelled); } } Logger.LogInformation( $"✋ **Manual/Exchange Close Detected**\n" + $"Position: `{position.Identifier}`\n" + $"SL: `${position.StopLoss.Price:F2}` | TP: `${position.TakeProfit1.Price:F2}`\n" + $"Recent Low: `${minPriceRecent:F2}` | Recent High: `${maxPriceRecent:F2}`\n" + $"Closing at market price: `${closingPrice:F2}`"); } var entryPrice = position.Open.Price; var positionSize = position.Open.Quantity * position.Open.Leverage; decimal pnl; if (position.OriginDirection == TradeDirection.Long) { pnl = (closingPrice - entryPrice) * positionSize; } else { pnl = (entryPrice - closingPrice) * positionSize; } if (position.ProfitAndLoss == null) { var totalFees = position.GasFees + position.UiFees; var netPnl = pnl - totalFees; position.ProfitAndLoss = new ProfitAndLoss { Realized = pnl, Net = netPnl }; } else if (position.ProfitAndLoss.Realized == 0) { var totalFees = position.GasFees + position.UiFees; var netPnl = pnl - totalFees; position.ProfitAndLoss.Realized = pnl; position.ProfitAndLoss.Net = netPnl; } // Fees are now tracked separately in UiFees and GasFees properties // No need to subtract fees from PnL as they're tracked separately } await SetPositionStatus(position.SignalIdentifier, PositionStatus.Finished); // Update position in database with all trade changes if (!Config.IsForBacktest) { position.Status = PositionStatus.Finished; await UpdatePositionDatabase(position); // Only send PositionClosed notification if the position was actually filled // Check if Open trade was filled (means position was opened on the broker) if (position.Open?.Status == TradeStatus.Filled) { await NotifyAgentAndPlatformGrainAsync(NotificationEventType.PositionClosed, position); // Update the last position closing time for cooldown period tracking // Only update if position was actually filled LastPositionClosingTime = Config.IsForBacktest ? currentCandle.Date : DateTime.UtcNow; } else { Logger.LogDebug( "Skipping PositionClosed notification for position {PositionId} - position was never filled (Open trade status: {OpenStatus})", position.Identifier, position.Open?.Status); } } // Only update balance and log success if position was actually filled if (position.Open?.Status == TradeStatus.Filled) { Logger.LogInformation( $"✅ **Position Closed Successfully**\nPosition: `{position.SignalIdentifier}`\nPnL: `${position.ProfitAndLoss?.Realized:F2}`"); if (position.ProfitAndLoss != null) { Config.BotTradingBalance += position.ProfitAndLoss.Realized; Logger.LogInformation( string.Format("💰 **Balance Updated**\nNew bot trading balance: `${0:F2}`", Config.BotTradingBalance)); } } else { Logger.LogInformation( $"✅ **Position Cleanup**\nPosition: `{position.SignalIdentifier}` was never filled - no balance or PnL changes"); } } else { await LogWarning("Weird things happen - Trying to update position status, but no position found"); } if (!Config.IsForBacktest) { await ServiceScopeHelpers.WithScopedService(_scopeFactory, messengerService => { messengerService.SendClosingPosition(position); return Task.CompletedTask; }); } await CancelAllOrders(); } private async Task CancelAllOrders() { if (!Config.IsForBacktest && !Config.IsForWatchingOnly) { try { List openOrders = null; await ServiceScopeHelpers.WithScopedService(_scopeFactory, async exchangeService => { openOrders = (await exchangeService.GetOpenOrders(Account, Config.Ticker)).ToList(); }); if (openOrders.Any()) { List openPositions = null; await ServiceScopeHelpers.WithScopedService(_scopeFactory, async exchangeService => { openPositions = (await exchangeService.GetBrokerPositions(Account)) .Where(p => p.Ticker == Config.Ticker).ToList(); }); var cancelClose = openPositions.Any(); if (cancelClose) { Logger.LogInformation($"Position still open, cancel close orders"); } else { Logger.LogInformation($"Canceling all orders for {Config.Ticker}"); await ServiceScopeHelpers.WithScopedService(_scopeFactory, async exchangeService => { await exchangeService.CancelOrder(Account, Config.Ticker); var closePendingOrderStatus = await exchangeService.CancelOrder(Account, Config.Ticker); Logger.LogInformation( $"Closing all {Config.Ticker} orders status : {closePendingOrderStatus}"); }); } } else { Logger.LogInformation($"No need to cancel orders for {Config.Ticker}"); } } catch (Exception ex) { Logger.LogError(ex, "Error during cancelOrders"); SentrySdk.CaptureException(ex); } } } private async Task SetPositionStatus(string signalIdentifier, PositionStatus positionStatus) { try { var position = Positions.Values.First(p => p.SignalIdentifier == signalIdentifier); if (!position.Status.Equals(positionStatus)) { Positions.Values.First(p => p.SignalIdentifier == signalIdentifier).Status = positionStatus; await LogInformation( $"📊 **Position Status Change**\nPosition: `{signalIdentifier}`\nStatus: `{position.Status}` → `{positionStatus}`"); // Update Open trade status when position becomes Filled if (positionStatus == PositionStatus.Filled && position.Open != null) { position.Open.SetStatus(TradeStatus.Filled); } } SetSignalStatus(signalIdentifier, positionStatus == PositionStatus.Filled ? SignalStatus.PositionOpen : SignalStatus.Expired); } catch (Exception ex) { await LogWarning($"Failed to update position status for signal {signalIdentifier}: {ex.Message}"); } } private void UpdatePositionPnl(Guid identifier, decimal realized) { var position = Positions[identifier]; var totalFees = position.GasFees + position.UiFees; var netPnl = realized - totalFees; if (position.ProfitAndLoss == null) { position.ProfitAndLoss = new ProfitAndLoss() { Realized = realized, Net = netPnl }; } else { position.ProfitAndLoss.Realized = realized; position.ProfitAndLoss.Net = netPnl; } } private void SetSignalStatus(string signalIdentifier, SignalStatus signalStatus) { if (Signals.ContainsKey(signalIdentifier) && Signals[signalIdentifier].Status != signalStatus) { Signals[signalIdentifier].Status = signalStatus; Logger.LogInformation($"Signal {signalIdentifier} is now {signalStatus}"); } } public int GetWinRate() { var succeededPositions = Positions.Values.Where(p => p.IsFinished()).Count(p => p.ProfitAndLoss?.Realized > 0); var total = Positions.Values.Where(p => p.IsFinished()).Count(); if (total == 0) return 0; return (succeededPositions * 100) / total; } public decimal GetProfitAndLoss() { // Calculate net PnL after deducting fees for each position var netPnl = Positions.Values.Where(p => p.ProfitAndLoss != null) .Sum(p => p.GetNetPnL()); return netPnl; } /// /// Calculates the total fees paid by the trading bot for each position. /// Includes UI fees (0.1% of position size) and network fees ($0.15 for opening). /// Closing fees are handled by oracle, so no network fee for closing. /// /// Returns the total fees paid as a decimal value. public decimal GetTotalFees() { decimal totalFees = 0; foreach (var position in Positions.Values.Where(p => p.Open.Price > 0 && p.Open.Quantity > 0)) { totalFees += TradingHelpers.CalculatePositionFees(position); } return totalFees; } public async Task ToggleIsForWatchOnly() { Config.IsForWatchingOnly = !Config.IsForWatchingOnly; await LogInformation( $"🔄 **Watch Mode Toggle**\nBot: `{Config.Name}`\nWatch Only: `{(Config.IsForWatchingOnly ? "ON" : "OFF")}`"); } /// /// Handles bot stopping and notifies platform summary /// public async Task StopBot() { await LogInformation($"🛑 **Bot Stopped**\nBot: `{Config.Name}`\nTicker: `{Config.Ticker}`"); } public async Task LogInformation(string message) { Logger.LogInformation(message); if (Config.IsForBacktest) return; try { await SendTradeMessage(message); } catch (Exception e) { Console.WriteLine(e); } } public async Task LogWarning(string message) { message = $"[{Config.Name}] {message}"; SentrySdk.CaptureException(new Exception(message)); try { await SendTradeMessage(message, true); } catch (Exception e) { Console.WriteLine(e); } } private async Task SendTradeMessage(string message, bool isBadBehavior = false) { if (!Config.IsForBacktest) { var user = Account.User; var messageWithBotName = $"🤖 **{user.AgentName} - {Config.Name}**\n{message}"; await ServiceScopeHelpers.WithScopedService(_scopeFactory, async messengerService => { await messengerService.SendTradeMessage(messageWithBotName, isBadBehavior, user); }); } } /// /// Manually opens a position using the bot's settings and a generated signal. /// Relies on the bot's MoneyManagement for Stop Loss and Take Profit placement. /// /// The direction of the trade (Long/Short). /// The created Position object. /// Throws if no candles are available or position opening fails. public async Task OpenPositionManually(TradeDirection direction) { if (LastCandle == null) { throw new Exception("No candles available to open position"); } // Create a fake signal for manual position opening var signal = new LightSignal(Config.Ticker, direction, Confidence.Low, LastCandle, LastCandle.Date, TradingExchanges.GmxV2, IndicatorType.Stc, SignalType.Signal, "Manual Signal"); signal.Status = SignalStatus.WaitingForPosition; // Ensure status is correct signal.Identifier = signal.Identifier + "-manual" + Guid.NewGuid(); // Ensure unique identifier for manual signals // Add the signal to our collection await AddSignal(signal); // Open the position using the generated signal (SL/TP handled by MoneyManagement) var position = await OpenPosition(signal); if (position == null) { // Clean up the signal if position creation failed SetSignalStatus(signal.Identifier, SignalStatus.Expired); throw new Exception("Failed to open position"); } // Add the position to the list after successful creation Positions[position.Identifier] = position; Logger.LogInformation( $"👤 **Manual Position Opened**\nPosition: `{position.Identifier}`\nSignal: `{signal.Identifier}`\nAdded to positions list"); return position; } public async Task AddSignal(LightSignal signal) { try { // Set signal status based on configuration if (Config.IsForWatchingOnly || (ExecutionCount < 1 && !Config.IsForBacktest)) { signal.Status = SignalStatus.Expired; } var indicatorNames = Config.Scenario.Indicators.Select(i => i.Type.ToString()).ToList(); var signalText = $"🎯 **New Trading Signal**\n\n" + $"📊 **Signal Details:**\n" + $"📈 Action: `{signal.Direction}` {Config.Ticker}\n" + $"⏰ Timeframe: `{Config.Timeframe}`\n" + $"🎯 Confidence: `{signal.Confidence}`\n" + $"🔍 Indicators: `{string.Join(", ", indicatorNames)}`\n" + $"🆔 Signal ID: `{signal.Identifier}`"; // Apply Synth-based signal filtering if enabled if ((Config.UseSynthApi || !Config.IsForBacktest) && ExecutionCount > 0) { await ServiceScopeHelpers.WithScopedServices(_scopeFactory, async (tradingService, exchangeService) => { var currentPrice = await exchangeService.GetCurrentPrice(Account, Config.Ticker); var signalValidationResult = await tradingService.ValidateSynthSignalAsync( signal, currentPrice, Config, Config.IsForBacktest); if (signalValidationResult.Confidence == Confidence.None || signalValidationResult.Confidence == Confidence.Low || signalValidationResult.IsBlocked) { signal.Status = SignalStatus.Expired; Logger.LogInformation($"Signal {signal.Identifier} blocked by Synth risk assessment"); } else { signal.Confidence = signalValidationResult.Confidence; Logger.LogInformation( $"Signal {signal.Identifier} passed Synth risk assessment with confidence {signalValidationResult.Confidence}"); } }); } Signals.Add(signal.Identifier, signal); Logger.LogInformation(signalText); if (Config.IsForWatchingOnly && !Config.IsForBacktest && ExecutionCount > 0) { await ServiceScopeHelpers.WithScopedService(_scopeFactory, async messengerService => { await messengerService.SendSignal(signalText, Account.Exchange, Config.Ticker, signal.Direction, Config.Timeframe); }); } Logger.LogInformation( $"Processed signal for {Config.Ticker}: {signal.Direction} with status {signal.Status}"); } catch (Exception ex) { Logger.LogError(ex, "Failed to add signal for {Ticker}", Config.Ticker); throw; } } /// /// Checks if a position has exceeded the maximum time limit for being open. /// /// The position to check /// The current time to compare against /// True if the position has exceeded the time limit, false otherwise private bool HasPositionExceededTimeLimit(Position position, DateTime currentTime) { if (!Config.MaxPositionTimeHours.HasValue || Config.MaxPositionTimeHours.Value <= 0) { return false; // Time-based closure is disabled } var timeOpen = currentTime - position.Open.Date; var maxTimeAllowed = TimeSpan.FromHours((double)Config.MaxPositionTimeHours.Value); return timeOpen >= maxTimeAllowed; } /// /// Updates the trading bot configuration with new settings. /// This method validates the new configuration and applies it to the running bot. /// /// The new configuration to apply /// True if the configuration was successfully updated, false otherwise /// Thrown when the new configuration is invalid public async Task UpdateConfiguration(TradingBotConfig newConfig) { try { // Validate the new configuration if (newConfig == null) { throw new ArgumentException("Configuration cannot be null"); } if (newConfig.BotTradingBalance <= Constants.GMX.Config.MinimumPositionAmount) { throw new ArgumentException( $"Bot trading balance must be greater than {Constants.GMX.Config.MinimumPositionAmount}"); } if (string.IsNullOrEmpty(newConfig.AccountName)) { throw new ArgumentException("Account name cannot be null or empty"); } if (newConfig.Scenario == null) { throw new ArgumentException("Scenario object must be provided in configuration"); } // Track changes for logging var changes = new List(); // Check for changes and build change list if (Config.BotTradingBalance != newConfig.BotTradingBalance) { changes.Add($"💰 Balance: ${Config.BotTradingBalance:F2} → ${newConfig.BotTradingBalance:F2}"); } if (Config.MaxPositionTimeHours != newConfig.MaxPositionTimeHours) { var oldTime = Config.MaxPositionTimeHours?.ToString() + "h" ?? "Disabled"; var newTime = newConfig.MaxPositionTimeHours?.ToString() + "h" ?? "Disabled"; changes.Add($"⏱️ Max Time: {oldTime} → {newTime}"); } if (Config.FlipOnlyWhenInProfit != newConfig.FlipOnlyWhenInProfit) { var oldFlip = Config.FlipOnlyWhenInProfit ? "✅" : "❌"; var newFlip = newConfig.FlipOnlyWhenInProfit ? "✅" : "❌"; changes.Add($"📈 Flip Only in Profit: {oldFlip} → {newFlip}"); } if (Config.CooldownPeriod != newConfig.CooldownPeriod) { changes.Add($"⏳ Cooldown: {Config.CooldownPeriod} → {newConfig.CooldownPeriod} candles"); } if (Config.MaxLossStreak != newConfig.MaxLossStreak) { changes.Add($"📉 Max Loss Streak: {Config.MaxLossStreak} → {newConfig.MaxLossStreak}"); } if (Config.FlipPosition != newConfig.FlipPosition) { var oldFlipPos = Config.FlipPosition ? "✅" : "❌"; var newFlipPos = newConfig.FlipPosition ? "✅" : "❌"; changes.Add($"🔄 Flip Position: {oldFlipPos} → {newFlipPos}"); } if (Config.CloseEarlyWhenProfitable != newConfig.CloseEarlyWhenProfitable) { var oldCloseEarly = Config.CloseEarlyWhenProfitable ? "✅" : "❌"; var newCloseEarly = newConfig.CloseEarlyWhenProfitable ? "✅" : "❌"; changes.Add($"⏰ Close Early When Profitable: {oldCloseEarly} → {newCloseEarly}"); } if (Config.UseSynthApi != newConfig.UseSynthApi) { var oldSynth = Config.UseSynthApi ? "✅" : "❌"; var newSynth = newConfig.UseSynthApi ? "✅" : "❌"; changes.Add($"🔗 Use Synth API: {oldSynth} → {newSynth}"); } if (Config.UseForPositionSizing != newConfig.UseForPositionSizing) { var oldPositionSizing = Config.UseForPositionSizing ? "✅" : "❌"; var newPositionSizing = newConfig.UseForPositionSizing ? "✅" : "❌"; changes.Add($"📏 Use Synth for Position Sizing: {oldPositionSizing} → {newPositionSizing}"); } if (Config.UseForSignalFiltering != newConfig.UseForSignalFiltering) { var oldSignalFiltering = Config.UseForSignalFiltering ? "✅" : "❌"; var newSignalFiltering = newConfig.UseForSignalFiltering ? "✅" : "❌"; changes.Add($"🔍 Use Synth for Signal Filtering: {oldSignalFiltering} → {newSignalFiltering}"); } if (Config.UseForDynamicStopLoss != newConfig.UseForDynamicStopLoss) { var oldDynamicStopLoss = Config.UseForDynamicStopLoss ? "✅" : "❌"; var newDynamicStopLoss = newConfig.UseForDynamicStopLoss ? "✅" : "❌"; changes.Add($"🎯 Use Synth for Dynamic Stop Loss: {oldDynamicStopLoss} → {newDynamicStopLoss}"); } if (Config.IsForWatchingOnly != newConfig.IsForWatchingOnly) { var oldWatch = Config.IsForWatchingOnly ? "✅" : "❌"; var newWatch = newConfig.IsForWatchingOnly ? "✅" : "❌"; changes.Add($"👀 Watch Only: {oldWatch} → {newWatch}"); } if (Config.MoneyManagement?.GetType().Name != newConfig.MoneyManagement?.GetType().Name) { var oldMM = Config.MoneyManagement?.GetType().Name ?? "None"; var newMM = newConfig.MoneyManagement?.GetType().Name ?? "None"; changes.Add($"💰 Money Management: {oldMM} → {newMM}"); } if (Config.RiskManagement != newConfig.RiskManagement) { // Compare risk management by serializing (complex object comparison) var oldRiskSerialized = JsonConvert.SerializeObject(Config.RiskManagement, Formatting.None); var newRiskSerialized = JsonConvert.SerializeObject(newConfig.RiskManagement, Formatting.None); if (oldRiskSerialized != newRiskSerialized) { changes.Add($"⚠️ Risk Management: Configuration Updated"); } } if (Config.ScenarioName != newConfig.ScenarioName) { changes.Add($"📋 Scenario Name: {Config.ScenarioName ?? "None"} → {newConfig.ScenarioName ?? "None"}"); } if (Config.Name != newConfig.Name) { changes.Add($"🏷️ Name: {Config.Name} → {newConfig.Name}"); } if (Config.AccountName != newConfig.AccountName) { changes.Add($"👤 Account: {Config.AccountName} → {newConfig.AccountName}"); } if (Config.Ticker != newConfig.Ticker) { changes.Add($"📊 Ticker: {Config.Ticker} → {newConfig.Ticker}"); } if (Config.Timeframe != newConfig.Timeframe) { changes.Add($"📈 Timeframe: {Config.Timeframe} → {newConfig.Timeframe}"); } // Check if the actual Scenario object changed (not just the name) var scenarioChanged = false; if (Config.Scenario != newConfig.Scenario) { var oldScenarioSerialized = JsonConvert.SerializeObject(Config.Scenario, Formatting.None); var newScenarioSerialized = JsonConvert.SerializeObject(newConfig.Scenario, Formatting.None); if (oldScenarioSerialized != newScenarioSerialized) { scenarioChanged = true; changes.Add( $"🎯 Scenario: {Config.Scenario?.Name ?? "None"} → {newConfig.Scenario?.Name ?? "None"}"); } } // Protect critical properties that shouldn't change for running bots var protectedIsForBacktest = Config.IsForBacktest; // Update the configuration Config = newConfig; // Restore protected properties Config.IsForBacktest = protectedIsForBacktest; // Update bot name and identifier if allowed if (!string.IsNullOrEmpty(newConfig.Name)) { Config.Name = newConfig.Name; } // If account changed, reload it if (Config.AccountName != Account?.Name) { await LoadAccount(); } // If scenario changed, reload it and track indicator changes if (scenarioChanged) { if (newConfig.Scenario != null) { // Compare indicators after scenario change var newIndicators = newConfig.Scenario.Indicators?.ToList() ?? new List(); var indicatorChanges = ScenarioHelpers.CompareIndicators(Config.Scenario.Indicators, newIndicators); if (indicatorChanges.Any()) { changes.AddRange(indicatorChanges); } } else { throw new ArgumentException("New scenario object must be provided when updating configuration."); } } // Only log if there are actual changes if (changes.Any()) { var changeMessage = "⚙️ **Configuration Updated**\n" + string.Join("\n", changes); await LogInformation(changeMessage); } else { await LogInformation( "⚙️ **Configuration Update**\n✅ No changes detected - configuration already up to date"); } return true; } catch (Exception ex) { await LogWarning($"Failed to update bot configuration: {ex.Message}"); return false; } } /// /// Gets the current trading bot configuration. /// /// A copy of the current configuration public TradingBotConfig GetConfiguration() { return new TradingBotConfig { AccountName = Config.AccountName, MoneyManagement = Config.MoneyManagement, Ticker = Config.Ticker, ScenarioName = Config.ScenarioName, Scenario = Config.Scenario, Timeframe = Config.Timeframe, IsForWatchingOnly = Config.IsForWatchingOnly, BotTradingBalance = Config.BotTradingBalance, IsForBacktest = Config.IsForBacktest, CooldownPeriod = Config.CooldownPeriod, MaxLossStreak = Config.MaxLossStreak, MaxPositionTimeHours = Config.MaxPositionTimeHours, FlipOnlyWhenInProfit = Config.FlipOnlyWhenInProfit, FlipPosition = Config.FlipPosition, Name = Config.Name, CloseEarlyWhenProfitable = Config.CloseEarlyWhenProfitable, UseSynthApi = Config.UseSynthApi, UseForPositionSizing = Config.UseForPositionSizing, UseForSignalFiltering = Config.UseForSignalFiltering, UseForDynamicStopLoss = Config.UseForDynamicStopLoss, RiskManagement = Config.RiskManagement, }; } /// /// Checks if the bot is currently in a cooldown period for any direction. /// /// True if in cooldown period for any direction, false otherwise private async Task IsInCooldownPeriodAsync() { if (LastPositionClosingTime == null) { return false; // No previous position closing time, no cooldown } // Force refresh last candle if it's null if (LastCandle == null) { await ForceRefreshLastCandleAsync(); if (LastCandle == null) { Logger.LogWarning("Unable to refresh last candle, skipping cooldown check"); return false; // No last candle available, no cooldown check possible } } // Calculate cooldown end time based on last position closing time var baseIntervalSeconds = CandleHelpers.GetBaseIntervalInSeconds(Config.Timeframe); var cooldownEndTime = LastPositionClosingTime.Value.AddSeconds(baseIntervalSeconds * Config.CooldownPeriod); var isInCooldown = LastCandle.Date < cooldownEndTime; if (isInCooldown) { var remainingTime = cooldownEndTime - LastCandle.Date; Logger.LogWarning( $"⏳ **Cooldown Period Active**\n" + $"Cannot open new positions\n" + $"Last position closed: `{LastPositionClosingTime:HH:mm:ss}`\n" + $"Cooldown period: `{Config.CooldownPeriod}` candles\n" + $"Cooldown ends: `{cooldownEndTime:HH:mm:ss}`\n" + $"Remaining time: `{remainingTime.TotalMinutes:F1} minutes`"); } return isInCooldown; } /// /// Forces a refresh of the last candle by calling the CandleStoreGrain /// private async Task ForceRefreshLastCandleAsync() { try { await ServiceScopeHelpers.WithScopedService(_scopeFactory, async grainFactory => { var grainKey = CandleHelpers.GetCandleStoreGrainKey(Account.Exchange, Config.Ticker, Config.Timeframe); var grain = grainFactory.GetGrain(grainKey); var lastCandles = await grain.GetLastCandle(1); LastCandle = lastCandles.FirstOrDefault(); if (LastCandle != null) { Logger.LogDebug("Successfully refreshed last candle for {Ticker} at {Date}", Config.Ticker, LastCandle.Date); } else { Logger.LogWarning("No candles available from CandleStoreGrain for {Ticker}", Config.Ticker); } }); } catch (Exception ex) { Logger.LogError(ex, "Error refreshing last candle for {Ticker}", Config.Ticker); } } /// /// Gets the trade that was used to close the position /// /// The position to check /// The closing trade, or null if none found private Trade GetClosingTrade(Position position) { // Check which trade was used to close the position if (position.StopLoss?.Status == TradeStatus.Filled) { return position.StopLoss; } else if (position.TakeProfit1?.Status == TradeStatus.Filled) { return position.TakeProfit1; } else if (position.TakeProfit2?.Status == TradeStatus.Filled) { return position.TakeProfit2; } // If no specific closing trade is found, create a synthetic one based on the position // This handles cases where the position was closed manually or by the exchange if (position.ProfitAndLoss?.Realized != null) { var closeDirection = position.OriginDirection == TradeDirection.Long ? TradeDirection.Short : TradeDirection.Long; return new Trade( DateTime.UtcNow, closeDirection, TradeStatus.Filled, TradeType.StopMarket, position.Ticker, position.Open.Quantity, position.Open.Price, // Use open price as approximation position.Open.Leverage, "synthetic-close", "Position closed" ); } return null; } /// /// Notifies both AgentGrain and PlatformSummaryGrain about bot events using unified event data /// /// The type of event (e.g., PositionOpened, PositionClosed, PositionUpdated) /// Optional position data for platform summary events private async Task NotifyAgentAndPlatformGrainAsync(NotificationEventType eventType, Position position) { if (Config.IsForBacktest) { return; // Skip notifications for backtest } try { await ServiceScopeHelpers.WithScopedService(_scopeFactory, async grainFactory => { var agentGrain = grainFactory.GetGrain(Account.User.Id); var platformGrain = grainFactory.GetGrain("platform-summary"); // Create unified event objects based on event type switch (eventType) { case NotificationEventType.PositionOpened: var positionOpenEvent = new PositionOpenEvent { PositionIdentifier = position.Identifier, Ticker = position.Ticker, Volume = position.Open.Price * position.Open.Quantity * position.Open.Leverage, Fee = position.GasFees + position.UiFees, Direction = position.OriginDirection }; await agentGrain.OnPositionOpenedAsync(positionOpenEvent); await platformGrain.OnPositionOpenAsync(positionOpenEvent); Logger.LogDebug("Sent position opened event to both grains for position {PositionId}", position.Identifier); break; case NotificationEventType.PositionClosed: var positionClosedEvent = new PositionClosedEvent { PositionIdentifier = position.Identifier, Ticker = position.Ticker, RealizedPnL = position.ProfitAndLoss?.Realized ?? 0, Volume = position.Open.Price * position.Open.Quantity * position.Open.Leverage, }; await agentGrain.OnPositionClosedAsync(positionClosedEvent); await platformGrain.OnPositionClosedAsync(positionClosedEvent); Logger.LogDebug("Sent position closed event to both grains for position {PositionId}", position.Identifier); break; case NotificationEventType.PositionUpdated: var positionUpdatedEvent = new PositionUpdatedEvent { PositionIdentifier = position.Identifier, }; await agentGrain.OnPositionUpdatedAsync(positionUpdatedEvent); // No need to notify platform grain, it will be notified when position is closed or opened only Logger.LogDebug("Sent position updated event to both grains for position {PositionId}", position.Identifier); break; } }); } catch (Exception ex) { Logger.LogError(ex, "Failed to send notifications: {EventType} for bot {BotId}", eventType, Identifier); } } }