using Managing.Application.Abstractions; using Managing.Application.Abstractions.Services; using Managing.Application.Trading; using Managing.Application.Trading.Commands; using Managing.Common; using Managing.Core.FixedSizedQueue; using Managing.Domain.Accounts; using Managing.Domain.Bots; using Managing.Domain.Candles; using Managing.Domain.Scenarios; using Managing.Domain.Shared.Helpers; using Managing.Domain.Strategies; using Managing.Domain.Strategies.Base; using Managing.Domain.Trades; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using static Managing.Common.Enums; namespace Managing.Application.Bots; public class TradingBot : Bot, ITradingBot { public readonly ILogger Logger; public readonly IExchangeService ExchangeService; public readonly IMessengerService MessengerService; public readonly IAccountService AccountService; private readonly ITradingService TradingService; private readonly IBotService BotService; public TradingBotConfig Config { get; set; } public Account Account { get; set; } public HashSet Indicators { get; set; } public FixedSizeQueue OptimizedCandles { get; set; } public HashSet Candles { get; set; } public HashSet Signals { get; set; } public List Positions { get; set; } public Dictionary WalletBalances { get; set; } public Dictionary IndicatorsValues { get; set; } public DateTime StartupTime { get; set; } public DateTime PreloadSince { get; set; } public int PreloadedCandlesCount { get; set; } public decimal Fee { get; set; } public TradingBot( IExchangeService exchangeService, ILogger logger, ITradingService tradingService, IAccountService accountService, IMessengerService messengerService, IBotService botService, TradingBotConfig config ) : base(config.Name) { ExchangeService = exchangeService; AccountService = accountService; MessengerService = messengerService; TradingService = tradingService; BotService = botService; Logger = logger; if (config.BotTradingBalance <= Constants.GMX.Config.MinimumPositionAmount) { throw new ArgumentException( $"Initial trading balance must be greater than {Constants.GMX.Config.MinimumPositionAmount}", nameof(config.BotTradingBalance)); } Config = config; Indicators = new HashSet(); Signals = new HashSet(); OptimizedCandles = new FixedSizeQueue(600); Candles = new HashSet(); Positions = new List(); WalletBalances = new Dictionary(); IndicatorsValues = new Dictionary(); // Load indicators if scenario is provided in config if (Config.Scenario != null) { LoadIndicators(Config.Scenario); } else { throw new ArgumentException( "Scenario object must be provided in TradingBotConfig. ScenarioName alone is not sufficient."); } if (!Config.IsForBacktest) { Interval = CandleExtensions.GetIntervalFromTimeframe(Config.Timeframe); PreloadSince = CandleExtensions.GetBotPreloadSinceFromTimeframe(Config.Timeframe); } } public override void Start() { base.Start(); // Load account synchronously LoadAccount().GetAwaiter().GetResult(); if (!Config.IsForBacktest) { // Scenario and indicators should already be loaded in constructor // This is just a safety check if (Config.Scenario == null || !Indicators.Any()) { throw new InvalidOperationException( "Scenario or indicators not loaded properly in constructor. This indicates a configuration error."); } PreloadCandles().GetAwaiter().GetResult(); CancelAllOrders().GetAwaiter().GetResult(); // Send startup message only for fresh starts (not reboots) var isReboot = Signals.Any() || Positions.Any(); if (!isReboot) { try { var indicatorNames = 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."; LogInformation(startupMessage).GetAwaiter().GetResult(); } catch (Exception ex) { Logger.LogError(ex, ex.Message); } } else { try { LogInformation($"🔄 **Bot Restarted**\n" + $"📊 Resuming operations with {Signals.Count} signals and {Positions.Count} positions\n" + $"✅ Ready to continue trading").GetAwaiter().GetResult(); } catch (Exception ex) { Logger.LogError(ex, ex.Message); } } InitWorker(Run).GetAwaiter().GetResult(); } // Fee = TradingService.GetFee(Account, IsForBacktest); } public async Task LoadAccount() { var account = await AccountService.GetAccount(Config.AccountName, false, false); if (account == null) { Logger.LogWarning($"No account found for this {Config.AccountName}"); Stop(); } else { Account = account; } } public void LoadScenario(Scenario scenario) { if (scenario == null) { var errorMessage = "Null scenario provided"; Logger.LogWarning(errorMessage); // If called during construction, throw exception instead of Stop() if (Status == BotStatus.Down) { throw new ArgumentException(errorMessage); } else { Stop(); } } else { // Store the scenario in config and load indicators Config.Scenario = scenario; LoadIndicators(ScenarioHelpers.GetIndicatorsFromScenario(scenario)); Logger.LogInformation($"Loaded scenario '{scenario.Name}' with {Indicators.Count} indicators"); } } public void LoadIndicators(Scenario scenario) { LoadIndicators(ScenarioHelpers.GetIndicatorsFromScenario(scenario)); } public void LoadIndicators(IEnumerable indicators) { // Clear existing indicators to prevent duplicates Indicators.Clear(); foreach (var indicator in indicators) { Indicators.Add(indicator); } Logger.LogInformation($"Loaded {Indicators.Count} indicators for bot '{Name}'"); } public async Task Run() { if (!Config.IsForBacktest) { // Check broker balance before running var balance = await ExchangeService.GetBalance(Account, false); if (balance < Constants.GMX.Config.MinimumPositionAmount && Positions.All(p => p.IsFinished())) { await LogWarning( $"Balance on broker is below {Constants.GMX.Config.MinimumPositionAmount} USD (actual: {balance}). Stopping bot {Identifier} and saving backup."); SaveBackup(); Stop(); return; } Logger.LogInformation($"____________________{Name}____________________"); Logger.LogInformation( $"Time : {DateTime.Now} - Server time {DateTime.Now.ToUniversalTime()} - Last candle : {OptimizedCandles.Last().Date} - Bot : {Name} - Ticker : {Config.Ticker}"); } var previousLastCandle = OptimizedCandles.LastOrDefault(); if (!Config.IsForBacktest) await UpdateCandles(); var currentLastCandle = OptimizedCandles.LastOrDefault(); if (currentLastCandle != previousLastCandle || Config.IsForBacktest) await UpdateSignals(OptimizedCandles); else Logger.LogInformation($"No need to update signals for {Config.Ticker}"); if (!Config.IsForWatchingOnly) await ManagePositions(); if (!Config.IsForBacktest) { SaveBackup(); UpdateIndicatorsValues(); } UpdateWalletBalances(); if (!Config.IsForBacktest) // Log every 10th execution { Logger.LogInformation($"Candle date : {OptimizedCandles.Last().Date:u}"); Logger.LogInformation($"Signals : {Signals.Count}"); Logger.LogInformation($"ExecutionCount : {ExecutionCount}"); Logger.LogInformation($"Positions : {Positions.Count}"); Logger.LogInformation("__________________________________________________"); } } public void UpdateIndicatorsValues() { foreach (var strategy in Indicators) { IndicatorsValues[strategy.Type] = ((Indicator)strategy).GetIndicatorValues(); } } private async Task PreloadCandles() { if (OptimizedCandles.Any()) return; var haveSignal = Signals.Any(); if (haveSignal) { PreloadSince = Signals.First().Date; } var candles = await ExchangeService.GetCandlesInflux(Account.Exchange, Config.Ticker, PreloadSince, Config.Timeframe); foreach (var candle in candles.Where(c => c.Date < DateTime.Now.ToUniversalTime())) { if (!OptimizedCandles.Any(c => c.Date == candle.Date)) { OptimizedCandles.Enqueue(candle); Candles.Add(candle); if (!haveSignal) { await UpdateSignals(OptimizedCandles); } } } PreloadedCandlesCount = OptimizedCandles.Count(); } private async Task UpdateSignals(FixedSizeQueue candles) { // If position open and not flipped, do not update signals if (!Config.FlipPosition && Positions.Any(p => !p.IsFinished())) return; var signal = TradingBox.GetSignal(candles.ToHashSet(), Indicators, Signals, Config.Scenario.LoopbackPeriod); if (signal == null) return; signal.User = Account.User; await AddSignal(signal); } private async Task AddSignal(Signal signal) { if (Config.IsForWatchingOnly || (ExecutionCount < 1 && !Config.IsForBacktest)) signal.Status = SignalStatus.Expired; var signalText = $"{Config.ScenarioName} trigger a signal. Signal told you " + $"to {signal.Direction} {Config.Ticker} on {Config.Timeframe}. The confidence in this signal is {signal.Confidence}. Identifier : {signal.Identifier}"; // Apply Synth-based signal filtering if enabled if (Config.UseSynthApi) { var currentPrice = Config.IsForBacktest ? OptimizedCandles.Last().Close : ExchangeService.GetPrice(Account, Config.Ticker, DateTime.UtcNow); var signalValidationResult = TradingService.ValidateSynthSignalAsync(signal, currentPrice, Config, Config.IsForBacktest).GetAwaiter().GetResult(); if (signalValidationResult.Confidence == Confidence.None || signalValidationResult.Confidence == Confidence.Low || signalValidationResult.IsBlocked) { signal.Status = SignalStatus.Expired; await LogInformation( $"🚫 **Synth Signal Filter** - Signal {signal.Identifier} blocked by Synth risk assessment. Context : {signalValidationResult.ValidationContext}"); return; } else { signal.SetConfidence(signalValidationResult.Confidence); signalText += $" and Synth risk assessment passed. Context : {signalValidationResult.ValidationContext}"; } } Signals.Add(signal); Logger.LogInformation(signalText); if (Config.IsForWatchingOnly && !Config.IsForBacktest && ExecutionCount > 0) { await MessengerService.SendSignal(signalText, Account.Exchange, Config.Ticker, signal.Direction, Config.Timeframe); } } protected async Task UpdateCandles() { if (OptimizedCandles.Count == 0 || ExecutionCount == 0) return; var lastCandle = OptimizedCandles.Last(); var newCandle = await ExchangeService.GetCandlesInflux(Account.Exchange, Config.Ticker, lastCandle.Date, Config.Timeframe); foreach (var candle in newCandle.Where(c => c.Date < DateTime.Now.ToUniversalTime())) { OptimizedCandles.Enqueue(candle); Candles.Add(candle); } } private async Task RecreateSignalFromPosition(Position position) { try { // Get the candle that corresponds to the position opening time var positionCandle = OptimizedCandles.FirstOrDefault(c => c.Date <= position.Open.Date) ?? OptimizedCandles.LastOrDefault(); if (positionCandle == null) { await LogWarning( $"Cannot find candle for position {position.Identifier} opened at {position.Open.Date}"); return null; } // Create a new signal based on position information var recreatedSignal = new Signal( ticker: Config.Ticker, direction: position.OriginDirection, confidence: Confidence.Medium, // Default confidence for recreated signals candle: positionCandle, date: position.Open.Date, exchange: Account.Exchange, 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; recreatedSignal.User = Account.User; // Add the recreated signal to our collection Signals.Add(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() { foreach (var position in Positions.Where(p => !p.IsFinished())) { var signalForPosition = Signals.FirstOrDefault(s => s.Identifier == 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); } // Open position for signals waiting for a position open foreach (var signal in Signals.Where(s => s.Status == SignalStatus.WaitingForPosition)) { Task.Run(() => OpenPosition(signal)).GetAwaiter().GetResult(); } } private void UpdateWalletBalances() { var lastCandle = OptimizedCandles.LastOrDefault(); if (lastCandle == null) return; var date = lastCandle.Date; if (WalletBalances.Count == 0) { // WalletBalances[date] = await ExchangeService.GetBalance(Account, IsForBacktest); WalletBalances[date] = Config.BotTradingBalance; return; } if (!WalletBalances.ContainsKey(date)) { var previousBalance = WalletBalances.First().Value; WalletBalances[date] = previousBalance + GetProfitAndLoss(); } } private async Task UpdatePosition(Signal signal, Position positionForSignal) { try { var position = Config.IsForBacktest ? positionForSignal : TradingService.GetPositionByIdentifier(positionForSignal.Identifier); var positionsExchange = Config.IsForBacktest ? new List { position } : await TradingService.GetBrokerPositions(Account); if (!Config.IsForBacktest) { var brokerPosition = positionsExchange.FirstOrDefault(p => p.Ticker == Config.Ticker); if (brokerPosition != null) { UpdatePositionPnl(positionForSignal.Identifier, brokerPosition.ProfitAndLoss.Realized); if (position.Status.Equals(PositionStatus.New)) { await SetPositionStatus(position.SignalIdentifier, PositionStatus.Filled); } position = brokerPosition; } else { // No position, position close on the broker if (!position.Status.Equals(PositionStatus.New)) { // Setup the previous status of the position position.Status = PositionStatus.Filled; } } } if (position.Status == PositionStatus.New) { var orders = await ExchangeService.GetOpenOrders(Account, Config.Ticker); if (orders.Any()) { // If there are 3 or more orders and position is still not filled, check if enough time has passed if (orders.Count() >= 3) { var currentTime = Config.IsForBacktest ? OptimizedCandles.Last().Date : 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 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 { await LogInformation( $"⏸️ **Position Pending**\nPosition still waiting to open\n`{orders.Count()}` open orders remaining"); } } else { await LogWarning( $"❌ **Position Not Found**\nNo position on exchange and no orders\nSignal: `{signal.Identifier}`\nPosition might be already closed"); await HandleClosedPosition(positionForSignal); } } else if (position.Status == (PositionStatus.Finished | PositionStatus.Flipped)) { await HandleClosedPosition(positionForSignal); } else if (position.Status == (PositionStatus.Filled | PositionStatus.PartiallyFilled)) { // For backtesting or force close if not executed on exchange : // check if position is still open // Check status, if still open update the status of the position // Position might be partially filled, meaning that TPSL havent been sended yet // But the position might already been closed by the exchange so we have to check should be closed var lastCandle = Config.IsForBacktest ? OptimizedCandles.Last() : 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; // Check if position is in profit by comparing entry price with current market price var isPositionInProfit = positionForSignal.OriginDirection == TradeDirection.Long ? lastCandle.Close > positionForSignal.Open.Price : lastCandle.Close < positionForSignal.Open.Price; var hasExceededTimeLimit = Config.MaxPositionTimeHours.HasValue && HasPositionExceededTimeLimit(positionForSignal, currentTime); // 2. Time-based closure (if time limit exceeded) if (hasExceededTimeLimit) { // If CloseEarlyWhenProfitable is enabled, only close if profitable // If CloseEarlyWhenProfitable is disabled, close regardless of profit status 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; } else { await LogInformation( $"⏳ **Time Limit - Waiting**\nTime limit exceeded but position at loss\n📉 Entry: `${positionForSignal.Open.Price}` → Current: `${lastCandle.Close}`\n💰 Realized PNL: `${currentPnl:F2}` (`{pnlPercentage:F2}%`\n🎯 Waiting for profit before closing (CloseEarlyWhenProfitable enabled)"); } } // 3. Normal stop loss and take profit checks if (positionForSignal.OriginDirection == TradeDirection.Long) { if (positionForSignal.StopLoss.Price >= lastCandle.Low) { await LogInformation( $"🛑 **Stop Loss Hit**\nClosing LONG position\nPrice: `${positionForSignal.StopLoss.Price}`"); await CloseTrade(signal, positionForSignal, positionForSignal.StopLoss, positionForSignal.StopLoss.Price, true); positionForSignal.StopLoss.SetStatus(TradeStatus.Filled); } else if (positionForSignal.TakeProfit1.Price <= lastCandle.High && positionForSignal.TakeProfit1.Status != TradeStatus.Filled) { await LogInformation( $"🎯 **Take Profit 1 Hit**\nClosing LONG position\nPrice: `${positionForSignal.TakeProfit1.Price}`"); await CloseTrade(signal, positionForSignal, positionForSignal.TakeProfit1, positionForSignal.TakeProfit1.Price, positionForSignal.TakeProfit2 == null); positionForSignal.TakeProfit1.SetStatus(TradeStatus.Filled); } else if (positionForSignal.TakeProfit2?.Price <= lastCandle.High) { await LogInformation( $"🎯 **Take Profit 2 Hit**\nClosing LONG position\nPrice: `${positionForSignal.TakeProfit2.Price}`"); await CloseTrade(signal, positionForSignal, positionForSignal.TakeProfit2, positionForSignal.TakeProfit2.Price, true); positionForSignal.TakeProfit2.SetStatus(TradeStatus.Filled); } } else if (positionForSignal.OriginDirection == TradeDirection.Short) { if (positionForSignal.StopLoss.Price <= lastCandle.High) { await LogInformation( $"🛑 **Stop Loss Hit**\nClosing SHORT position\nPrice: `${positionForSignal.StopLoss.Price}`"); await CloseTrade(signal, positionForSignal, positionForSignal.StopLoss, positionForSignal.StopLoss.Price, true); positionForSignal.StopLoss.SetStatus(TradeStatus.Filled); } else if (positionForSignal.TakeProfit1.Price >= lastCandle.Low && positionForSignal.TakeProfit1.Status != TradeStatus.Filled) { await LogInformation( $"🎯 **Take Profit 1 Hit**\nClosing SHORT position\nPrice: `${positionForSignal.TakeProfit1.Price}`"); await CloseTrade(signal, positionForSignal, positionForSignal.TakeProfit1, positionForSignal.TakeProfit1.Price, positionForSignal.TakeProfit2 == null); positionForSignal.TakeProfit1.SetStatus(TradeStatus.Filled); } else if (positionForSignal.TakeProfit2?.Price >= lastCandle.Low) { await LogInformation( $"🎯 **Take Profit 2 Hit**\nClosing SHORT position\nPrice: `${positionForSignal.TakeProfit2.Price}`"); await CloseTrade(signal, positionForSignal, positionForSignal.TakeProfit2, positionForSignal.TakeProfit2.Price, true); positionForSignal.TakeProfit2.SetStatus(TradeStatus.Filled); } } } else if (position.Status == (PositionStatus.Rejected | PositionStatus.Canceled)) { await LogWarning($"Open position trade is rejected for signal {signal.Identifier}"); // if position is not open // Re-open the trade for the signal only if signal still up if (signal.Status == SignalStatus.PositionOpen) { Logger.LogInformation($"Try to re-open position"); await OpenPosition(signal); } } // Synth-based position monitoring for liquidation risk if (Config.UseSynthApi && !Config.IsForBacktest && positionForSignal.Status == PositionStatus.Filled) { var currentPrice = ExchangeService.GetPrice(Account, Config.Ticker, DateTime.UtcNow); var riskResult = await TradingService.MonitorSynthPositionRiskAsync( Config.Ticker, positionForSignal.OriginDirection, currentPrice, positionForSignal.StopLoss.Price, positionForSignal.Identifier, Config); if (riskResult != null && riskResult.ShouldWarn && !string.IsNullOrEmpty(riskResult.WarningMessage)) { await LogWarning(riskResult.WarningMessage); } if (riskResult.ShouldAutoClose && !string.IsNullOrEmpty(riskResult.EmergencyMessage)) { await LogWarning(riskResult.EmergencyMessage); var signalForAutoClose = Signals.FirstOrDefault(s => s.Identifier == positionForSignal.SignalIdentifier); if (signalForAutoClose != null) { await CloseTrade(signalForAutoClose, positionForSignal, positionForSignal.StopLoss, currentPrice, true); } } } } catch (Exception ex) { await LogWarning($"Cannot update position {positionForSignal.Identifier}: {ex.Message}"); SentrySdk.CaptureException(ex); return; } } private async Task OpenPosition(Signal signal) { // Check if a position is already open Logger.LogInformation($"Opening position for {signal.Identifier}"); var openedPosition = Positions.FirstOrDefault(p => p.Status == PositionStatus.Filled && p.SignalIdentifier != signal.Identifier); var lastPrice = Config.IsForBacktest ? OptimizedCandles.Last().Close : ExchangeService.GetPrice(Account, Config.Ticker, DateTime.UtcNow); // If position open if (openedPosition != null) { var previousSignal = Signals.First(s => s.Identifier == openedPosition.SignalIdentifier); // Check if signal is the opposite side => flip the position 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); } else { // An operation is already open for the opposite direction // ==> Flip the position if (Config.FlipPosition) { // Check if current position is in profit before flipping var isPositionInProfit = (openedPosition.ProfitAndLoss?.Realized ?? 0) > 0; // Determine if we should flip based on configuration 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); await OpenPosition(signal); await LogInformation( $"✅ **Position Flipped**\nPosition: `{previousSignal.Identifier}` → `{signal.Identifier}`\nPrice: `${lastPrice}`"); } 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; } } else { await LogInformation( $"🚫 **Flip Disabled**\nPosition already open for: `{previousSignal.Identifier}`\nFlipping disabled, new signal expired"); SetSignalStatus(signal.Identifier, SignalStatus.Expired); } } } else { if (!(await CanOpenPosition(signal))) { SetSignalStatus(signal.Identifier, SignalStatus.Expired); return; } 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, User, Config.BotTradingBalance, Config.IsForBacktest, lastPrice, signalIdentifier: signal.Identifier); var position = (new OpenPositionCommandHandler(ExchangeService, AccountService, TradingService) .Handle(command)).GetAwaiter().GetResult(); if (position != null) { Positions.Add(position); if (position.Open.Status != TradeStatus.Cancelled) { SetSignalStatus(signal.Identifier, SignalStatus.PositionOpen); if (!Config.IsForBacktest) { await MessengerService.SendPosition(position); } Logger.LogInformation($"Position requested"); } else { await SetPositionStatus(signal.Identifier, PositionStatus.Rejected); SetSignalStatus(signal.Identifier, SignalStatus.Expired); } } } catch (Exception ex) { // Keep signal open for debug purpose //SetSignalStatus(signal.Identifier, SignalStatus.Expired); SetSignalStatus(signal.Identifier, SignalStatus.Expired); await LogWarning($"Cannot open trade : {ex.Message}, stackTrace : {ex.StackTrace}"); } } } private async Task CanOpenPosition(Signal signal) { // Early return if we're in backtest mode and haven't executed yet if (!Config.IsForBacktest && ExecutionCount < 1) { 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 CheckCooldownPeriod(signal) && 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) { var currentPrice = Config.IsForBacktest ? OptimizedCandles.Last().Close : ExchangeService.GetPrice(Account, Config.Ticker, DateTime.UtcNow); if (!(await TradingService.AssessSynthPositionRiskAsync(Config.Ticker, signal.Direction, currentPrice, Config, Config.IsForBacktest))) { return false; } } // Check cooldown period and loss streak return await CheckCooldownPeriod(signal) && await CheckLossStreak(signal); } private async Task CheckLossStreak(Signal 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 .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 { var positions = await ExchangeService.GetBrokerPositions(Account); if (!positions.Any(p => p.Ticker == Config.Ticker)) { return true; } // Handle existing position on broker var previousPosition = Positions.LastOrDefault(); var 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; } } private async Task CheckCooldownPeriod(Signal signal) { var lastPosition = Positions.LastOrDefault(p => p.IsFinished() && p.SignalIdentifier != signal.Identifier && p.OriginDirection == signal.Direction); if (lastPosition == null) { return true; } var cooldownCandle = OptimizedCandles.TakeLast((int)Config.CooldownPeriod).FirstOrDefault(); if (cooldownCandle == null) { await LogWarning("📊 **Cooldown Check Failed**\nCannot check cooldown\nNot enough candles available"); return false; } // Get the actual closing date of the position instead of signal date var positionClosingDate = GetPositionClosingDate(lastPosition); if (positionClosingDate == null) { await LogWarning($"Cannot determine closing date for last position {lastPosition.Identifier}"); return false; } var canOpenPosition = positionClosingDate < cooldownCandle.Date; if (!canOpenPosition) { await LogInformation( $"⏳ **Cooldown Active**\nPosition blocked by cooldown period\n📅 Last Position Closed: `{positionClosingDate:MM/dd HH:mm}`\n🕒 Cooldown Until: `{cooldownCandle.Date:MM/dd HH:mm}`"); } return canOpenPosition; } /// /// Gets the actual closing date of a position by checking which trade (Stop Loss or Take Profit) was executed. /// /// The finished position /// The date when the position was closed, or null if cannot be determined private DateTime? GetPositionClosingDate(Position position) { if (!position.IsFinished()) { return null; } // Check which trade actually closed the position if (position.StopLoss?.Status == TradeStatus.Filled && position.StopLoss.Date != default) { return position.StopLoss.Date; } if (position.TakeProfit1?.Status == TradeStatus.Filled && position.TakeProfit1.Date != default) { return position.TakeProfit1.Date; } if (position.TakeProfit2?.Status == TradeStatus.Filled && position.TakeProfit2.Date != default) { return position.TakeProfit2.Date; } // Fallback: if we can't determine the exact closing trade, use the latest date available var availableDates = new List(); if (position.StopLoss?.Date != default) availableDates.Add(position.StopLoss.Date); if (position.TakeProfit1?.Date != default) availableDates.Add(position.TakeProfit1.Date); if (position.TakeProfit2?.Date != default) availableDates.Add(position.TakeProfit2.Date); return availableDates.Any() ? availableDates.Max() : position.Open.Date; } public async Task CloseTrade(Signal 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")}`"); // Get status of position before closing it. The position might be already close by the exchange if (!Config.IsForBacktest && await ExchangeService.GetQuantityInPosition(Account, Config.Ticker) == 0) { Logger.LogInformation($"Trade already close on exchange"); await HandleClosedPosition(position); } else { var command = new ClosePositionCommand(position, lastPrice, isForBacktest: Config.IsForBacktest); try { var closedPosition = (new ClosePositionCommandHandler(ExchangeService, AccountService, TradingService) .Handle(command)).Result; if (closedPosition.Status == (PositionStatus.Finished | 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 | PositionStatus.Rejected)) { // Trade close on exchange => Should close trade manually await SetPositionStatus(signal.Identifier, PositionStatus.Finished); } } } } private async Task HandleClosedPosition(Position position) { if (Positions.Any(p => p.Identifier == position.Identifier)) { // Update the close date for the trade that actually closed the position var currentCandle = Config.IsForBacktest ? OptimizedCandles.LastOrDefault() : ExchangeService.GetCandle(Account, Config.Ticker, DateTime.UtcNow); if (currentCandle != null && position.ProfitAndLoss != null) { // Determine which trade closed the position based on realized P&L if (position.ProfitAndLoss.Realized > 0) { // Profitable close = Take Profit position.TakeProfit1.SetDate(currentCandle.Date); } else { // Loss or breakeven close = Stop Loss position.StopLoss.SetDate(currentCandle.Date); } } await SetPositionStatus(position.SignalIdentifier, PositionStatus.Finished); Logger.LogInformation( $"✅ **Position Closed Successfully**\nPosition: `{position.SignalIdentifier}`\nPnL: `${position.ProfitAndLoss?.Realized:F2}`"); // Update the bot's trading balance after position is closed if (position.ProfitAndLoss != null) { // Add PnL (could be positive or negative) Config.BotTradingBalance += position.ProfitAndLoss.Realized; Logger.LogInformation( $"💰 **Balance Updated**\nNew bot trading balance: `${Config.BotTradingBalance:F2}`"); } } else { await LogWarning("Weird things happen - Trying to update position status, but no position found"); } if (!Config.IsForBacktest) { await MessengerService.SendClosingPosition(position); } await CancelAllOrders(); } private async Task CancelAllOrders() { if (!Config.IsForBacktest && !Config.IsForWatchingOnly) { try { var openOrders = await ExchangeService.GetOpenOrders(Account, Config.Ticker); if (openOrders.Any()) { var openPositions = (await ExchangeService.GetBrokerPositions(Account)) .Where(p => p.Ticker == Config.Ticker); var cancelClose = openPositions.Any(); if (cancelClose) { Logger.LogInformation($"Position still open, cancel close orders"); } else { Logger.LogInformation($"Canceling all orders for {Config.Ticker}"); 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.First(p => p.SignalIdentifier == signalIdentifier); if (!position.Status.Equals(positionStatus)) { Positions.First(p => p.SignalIdentifier == signalIdentifier).Status = positionStatus; await LogInformation( $"📊 **Position Status Change**\nPosition: `{signalIdentifier}`\nStatus: `{position.Status}` → `{positionStatus}`"); } 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(string identifier, decimal realized) { Positions.First(p => p.Identifier == identifier).ProfitAndLoss = new ProfitAndLoss() { Realized = realized }; } private void SetSignalStatus(string signalIdentifier, SignalStatus signalStatus) { if (Signals.Any(s => s.Identifier == signalIdentifier && s.Status != signalStatus)) { Signals.First(s => s.Identifier == signalIdentifier).Status = signalStatus; Logger.LogInformation($"Signal {signalIdentifier} is now {signalStatus}"); } } public int GetWinRate() { var succeededPositions = Positions.Where(p => p.IsFinished()).Count(p => p.ProfitAndLoss?.Realized > 0); var total = Positions.Where(p => p.IsFinished()).Count(); if (total == 0) return 0; return (succeededPositions * 100) / total; } public decimal GetProfitAndLoss() { var pnl = Positions.Where(p => p.ProfitAndLoss != null).Sum(p => p.ProfitAndLoss.Realized); return pnl - GetTotalFees(); } /// /// Calculates the total fees paid by the trading bot for each position. /// Includes UI fees (0.1% of position size) and network fees ($0.50 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.Where(p => p.Open.Price > 0 && p.Open.Quantity > 0)) { totalFees += CalculatePositionFees(position); } return totalFees; } /// /// Calculates the total fees for a specific position based on GMX V2 fee structure /// /// The position to calculate fees for /// The total fees for the position private decimal CalculatePositionFees(Position position) { decimal fees = 0; // Calculate position size in USD (leverage is already included in quantity calculation) var positionSizeUsd = position.Open.Price * position.Open.Quantity; // UI Fee: 0.1% of position size paid BOTH on opening AND closing var uiFeeRate = 0.001m; // 0.1% var uiFeeOpen = positionSizeUsd * uiFeeRate; // Fee paid on opening var uiFeeClose = positionSizeUsd * uiFeeRate; // Fee paid on closing var totalUiFees = uiFeeOpen + uiFeeClose; // Total: 0.2% of position size fees += totalUiFees; // Network Fee: $0.50 for opening position only // Closing is handled by oracle, so no network fee for closing var networkFeeForOpening = 0.50m; fees += networkFeeForOpening; return fees; } public async Task ToggleIsForWatchOnly() { Config.IsForWatchingOnly = !Config.IsForWatchingOnly; await LogInformation( $"🔄 **Watch Mode Toggle**\nBot: `{Name}`\nWatch Only: `{(Config.IsForWatchingOnly ? "ON" : "OFF")}`"); } private async Task LogInformation(string message) { Logger.LogInformation(message); try { await SendTradeMessage(message); } catch (Exception e) { Console.WriteLine(e); } } private async Task LogWarning(string message) { message = $"[{Identifier}] {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} - {Name}**\n{message}"; await MessengerService.SendTradeMessage(messageWithBotName, isBadBehavior, user); } } public override void SaveBackup() { var data = new TradingBotBackup { Config = Config, Signals = Signals, Positions = Positions, WalletBalances = WalletBalances, StartupTime = StartupTime }; BotService.SaveOrUpdateBotBackup(User, Identifier, Status, JsonConvert.SerializeObject(data)); } public override void LoadBackup(BotBackup backup) { var data = JsonConvert.DeserializeObject(backup.Data); // Load the configuration directly Config = data.Config; // Ensure IsForBacktest is always false when loading from backup Config.IsForBacktest = false; // Load runtime state Signals = data.Signals ?? new HashSet(); Positions = data.Positions ?? new List(); WalletBalances = data.WalletBalances ?? new Dictionary(); PreloadSince = data.StartupTime; Identifier = backup.Identifier; User = backup.User; Status = backup.LastStatus; } /// /// 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) { var lastCandle = OptimizedCandles.LastOrDefault(); if (lastCandle == null) { throw new Exception("No candles available to open position"); } // Create a fake signal for manual position opening var signal = new Signal(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.User = Account.User; // Assign user // Add the signal to our collection await AddSignal(signal); // Open the position using the generated signal (SL/TP handled by MoneyManagement) await OpenPosition(signal); // Get the opened position var position = Positions.FirstOrDefault(p => p.SignalIdentifier == signal.Identifier); if (position == null) { // Clean up the signal if position creation failed SetSignalStatus(signal.Identifier, SignalStatus.Expired); throw new Exception("Failed to open position"); } Logger.LogInformation( $"👤 **Manual Position Opened**\nPosition: `{position.Identifier}`\nSignal: `{signal.Identifier}`"); return position; } /// /// 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) { 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. /// /// The new configuration to apply /// True if the configuration was successfully updated, false otherwise public async Task UpdateConfiguration(TradingBotConfig newConfig) { return await UpdateConfiguration(newConfig, allowNameChange: false); } /// /// 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 /// Whether to allow changing the bot name/identifier /// True if the configuration was successfully updated, false otherwise /// Thrown when the new configuration is invalid public async Task UpdateConfiguration(TradingBotConfig newConfig, bool allowNameChange = false) { 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 (allowNameChange && 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}"); } // Capture current indicators before any changes for scenario comparison var oldIndicators = Indicators?.ToList() ?? new List(); // 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; var protectedName = allowNameChange ? newConfig.Name : Config.Name; // Update the configuration Config = newConfig; // Restore protected properties Config.IsForBacktest = protectedIsForBacktest; Config.Name = protectedName; // Update bot name and identifier if allowed if (allowNameChange && !string.IsNullOrEmpty(newConfig.Name)) { Name = newConfig.Name; Identifier = 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) { LoadScenario(newConfig.Scenario); // Compare indicators after scenario change var newIndicators = Indicators?.ToList() ?? new List(); var indicatorChanges = CompareIndicators(oldIndicators, 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"); } // Save the updated configuration as backup if (!Config.IsForBacktest) { SaveBackup(); } 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, }; } /// /// Compares two lists of indicators and returns a list of changes (added, removed, modified). /// /// The previous list of indicators /// The new list of indicators /// A list of change descriptions private List CompareIndicators(List oldIndicators, List newIndicators) { var changes = new List(); // Create dictionaries for easier comparison using Type as key var oldIndicatorDict = oldIndicators.ToDictionary(i => i.Type, i => i); var newIndicatorDict = newIndicators.ToDictionary(i => i.Type, i => i); // Find removed indicators var removedTypes = oldIndicatorDict.Keys.Except(newIndicatorDict.Keys); foreach (var removedType in removedTypes) { var indicator = oldIndicatorDict[removedType]; changes.Add($"➖ **Removed Indicator:** {removedType} ({indicator.GetType().Name})"); } // Find added indicators var addedTypes = newIndicatorDict.Keys.Except(oldIndicatorDict.Keys); foreach (var addedType in addedTypes) { var indicator = newIndicatorDict[addedType]; changes.Add($"➕ **Added Indicator:** {addedType} ({indicator.GetType().Name})"); } // Find modified indicators (same type but potentially different configuration) var commonTypes = oldIndicatorDict.Keys.Intersect(newIndicatorDict.Keys); foreach (var commonType in commonTypes) { var oldIndicator = oldIndicatorDict[commonType]; var newIndicator = newIndicatorDict[commonType]; // Compare indicators by serializing them (simple way to detect configuration changes) var oldSerialized = JsonConvert.SerializeObject(oldIndicator, Formatting.None); var newSerialized = JsonConvert.SerializeObject(newIndicator, Formatting.None); if (oldSerialized != newSerialized) { changes.Add($"🔄 **Modified Indicator:** {commonType} ({newIndicator.GetType().Name})"); } } // Add summary if there are changes if (changes.Any()) { var summary = $"📊 **Indicator Changes:** {addedTypes.Count()} added, {removedTypes.Count()} removed, {commonTypes.Count(c => JsonConvert.SerializeObject(oldIndicatorDict[c]) != JsonConvert.SerializeObject(newIndicatorDict[c]))} modified"; changes.Insert(0, summary); } return changes; } } public class TradingBotBackup { /// /// The complete trading bot configuration /// public TradingBotConfig Config { get; set; } /// /// Runtime state: Active signals for the bot /// public HashSet Signals { get; set; } /// /// Runtime state: Open and closed positions for the bot /// public List Positions { get; set; } /// /// Runtime state: Historical wallet balances over time /// public Dictionary WalletBalances { get; set; } /// /// Runtime state: When the bot was started /// public DateTime StartupTime { get; set; } }