using System.Diagnostics; 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.Strategies.Base; using Managing.Domain.Trades; using Managing.Domain.Users; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Orleans.Streams; using static Managing.Common.Enums; namespace Managing.Application.Bots; public abstract class TradingBotBase : ITradingBot { public readonly ILogger Logger; protected readonly IServiceScopeFactory _scopeFactory; protected readonly IStreamProvider? _streamProvider; protected const int NEW_POSITION_GRACE_SECONDS = 45; // grace window before evaluating missing orders protected const int CLOSE_POSITION_GRACE_MS = 20000; // grace window before closing position to allow broker processing (20 seconds) 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; } private decimal _currentBalance; 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; } // OPTIMIZATION 2: Cache open position state to avoid expensive Positions.Any() calls private bool _hasOpenPosition = false; public TradingBotBase( ILogger logger, IServiceScopeFactory scopeFactory, TradingBotConfig config, IStreamProvider? streamProvider = null ) { _scopeFactory = scopeFactory; _streamProvider = streamProvider; Logger = logger; Config = config; Signals = new Dictionary(); Positions = new Dictionary(); WalletBalances = new Dictionary(); _currentBalance = config.BotTradingBalance; PreloadSince = CandleHelpers.GetBotPreloadSinceFromTimeframe(config.Timeframe); } public virtual async Task Start(BotStatus previousStatus) { if (TradingBox.IsLiveTrading(Config.TradingType)) { // Start async initialization in the background without blocking try { await LoadAccountAsync(); await LoadLastCandle(); if (Account == null) { await LogWarningAsync($"Account {Config.AccountName} not found. Bot cannot start."); throw new ArgumentException("Account not found"); } switch (previousStatus) { case BotStatus.Saved: var indicatorNames = Config.Scenario.Indicators.Select(i => i.Type.ToString()).ToList(); var modeText = Config.IsForWatchingOnly ? "Watch Only" : Config.IsForCopyTrading ? "Copy Trading" : "Live Trading"; var startupMessage = $"šŸš€ Strategy Started Successfully\n\n" + $"šŸ“Š Trading Setup:\n" + $"šŸŽÆ Ticker: `{Config.Ticker}`\n" + $"ā° Timeframe: `{Config.Timeframe}`\n" + $"šŸ’° Balance: `${Config.BotTradingBalance:F2}`\n" + $"šŸ‘€ Market type: `{Config.TradingType.ToString()}`\n\n" + (Config.IsForCopyTrading ? "" : $"šŸ“ˆ Active Indicators: `{string.Join(", ", indicatorNames)}`\n\n") + $"āœ… Ready to monitor signals and execute trades\n" + $"šŸ“¢ Notifications will be sent when positions are triggered"; await LogInformationAsync(startupMessage); break; case BotStatus.Running: case BotStatus.Stopped: return; default: return; } } 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 (TradingBox.IsBacktestTrading(Config.TradingType)) return; await ServiceScopeHelpers.WithScopedService(_scopeFactory, async accountService => { var account = await accountService.GetAccountByAccountName(Config.AccountName, false, false); Account = account; }); } /// /// Verifies the actual USDC balance and updates the config if the actual balance is less than the configured balance. /// This prevents bots from trying to trade with more funds than are actually available. /// public async Task VerifyAndUpdateBalance() { if (TradingBox.IsBacktestTrading(Config.TradingType)) return; if (Account == null) { Logger.LogWarning("Cannot verify balance: Account is null"); return; } try { // Fetch actual USDC balance var actualBalance = await ServiceScopeHelpers.WithScopedService(_scopeFactory, async exchangeService => { var balances = await exchangeService.GetBalances(Account); var usdcBalance = balances.FirstOrDefault(b => b.TokenName?.ToUpper() == "USDC"); return usdcBalance?.Amount ?? 0; }); // Check if actual balance is less than configured balance if (actualBalance < Config.BotTradingBalance) { Logger.LogWarning( "Actual USDC balance ({ActualBalance:F2}) is less than configured balance ({ConfiguredBalance:F2}). Updating configuration.", actualBalance, Config.BotTradingBalance); // Create new config with updated balance var newConfig = Config; newConfig.BotTradingBalance = actualBalance; // Use UpdateConfiguration to notify and log the change await UpdateConfiguration(newConfig); } else { Logger.LogDebug( "Balance verification passed. Actual: {ActualBalance:F2}, Configured: {ConfiguredBalance:F2}", actualBalance, Config.BotTradingBalance); } } catch (Exception ex) { Logger.LogError(ex, "Error verifying and updating balance"); } } public virtual async Task Run() { // Signal updates are handled by subclasses via UpdateSignals() override if (!Config.IsForWatchingOnly) await ManagePositions(); UpdateWalletBalances(); if (TradingBox.IsLiveTrading(Config.TradingType)) { ExecutionCount++; Logger.LogInformation( "[{CopyTrading}][{AgentName}] Bot Status {Name} - ServerDate: {ServerDate}, LastCandleDate: {LastCandleDate}, Signals: {SignalCount}, Executions: {ExecutionCount}, Positions: {PositionCount}", Config.IsForCopyTrading ? "CopyTrading" : "LiveTrading", Account.User.AgentName, Config.Name, DateTime.UtcNow, LastCandle?.Date, Signals.Count, ExecutionCount, Positions.Count); Logger.LogInformation("[{AgentName}] Internal Positions : {Position}", Account.User.AgentName, string.Join(", ", Positions.Values.Select(p => $"{p.SignalIdentifier} - Status: {p.Status}"))); } } public async Task UpdateSignals(IReadOnlyList candles = null) { await UpdateSignals(candles, null); } public async Task UpdateSignals(IReadOnlyList candles, Dictionary preCalculatedIndicatorValues = null) { await UpdateSignalsCore(candles, preCalculatedIndicatorValues); } protected virtual async Task UpdateSignalsCore(IReadOnlyList candles, Dictionary preCalculatedIndicatorValues = null) { // OPTIMIZATION 2: Use cached open position state instead of expensive Positions.Any() call // Skip indicator checking if flipping is disabled and there's an open position // This prevents unnecessary indicator calculations when we can't act on signals anyway if (!Config.FlipPosition && _hasOpenPosition) { Logger.LogDebug( $"Skipping signal update: Position open and flip disabled."); return; } // Check if we're in cooldown period for any direction if (await IsInCooldownPeriodAsync()) { // Still in cooldown period, skip signal generation return; } // Default implementation: do nothing (subclasses should override with signal generation logic) } 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; } } protected async Task ManagePositions() { // OPTIMIZATION 6: Combine early exit checks and collect unfinished positions in one pass // Collect unfinished positions in first iteration to avoid LINQ Where() later var unfinishedPositions = new List(); foreach (var position in Positions.Values) { if (!position.IsFinished()) { unfinishedPositions.Add(position); } } bool hasWaitingSignals = false; if (unfinishedPositions.Count == 0) // Only check signals if no open positions { foreach (var signal in Signals.Values) { if (signal.Status == SignalStatus.WaitingForPosition) { hasWaitingSignals = true; break; } } } if (unfinishedPositions.Count == 0 && !hasWaitingSignals) return; // First, process all existing positions that are not finished foreach (var position in unfinishedPositions) { // OPTIMIZATION 3: Use TryGetValue instead of direct dictionary access if (!Signals.TryGetValue(position.SignalIdentifier, out var signalForPosition)) { 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); // Position is now added to Positions collection inside OpenPosition method // No need to add it here again } } protected void UpdateWalletBalances() { var date = TradingBox.IsBacktestTrading(Config.TradingType) ? LastCandle?.Date ?? DateTime.UtcNow : DateTime.UtcNow; if (WalletBalances.Count == 0) { WalletBalances[date] = _currentBalance; return; } if (!WalletBalances.ContainsKey(date)) { WalletBalances[date] = _currentBalance; } } protected 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) { await LogDebugAsync( $"Skipping update for position {positionForSignal.Identifier} - status is {positionForSignal.Status} (never filled)"); return; } Position internalPosition = await GetInternalPositionForUpdate(positionForSignal); // Handle broker position synchronization (futures-specific logic) await SynchronizeWithBrokerPositions(internalPosition, positionForSignal); // Handle order management and position status (futures-specific logic) await HandleOrderManagementAndPositionStatus(signal, internalPosition, positionForSignal); // Common position status handling 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 = TradingBox.IsBacktestTrading(Config.TradingType) ? LastCandle : await exchangeService.GetCandle(Account, positionForSignal.Ticker, DateTime.UtcNow); }); var currentTime = TradingBox.IsBacktestTrading(Config.TradingType) ? lastCandle.Date : DateTime.UtcNow; var currentPnl = positionForSignal.ProfitAndLoss?.Net ?? 0; var pnlPercentage = TradingBox.CalculatePnLPercentage(currentPnl, positionForSignal.Open.Price, positionForSignal.Open.Quantity); var isPositionInProfit = TradingBox.IsPositionInProfit(positionForSignal.Open.Price, lastCandle.Close, positionForSignal.OriginDirection); var hasExceededTimeLimit = TradingBox.HasPositionExceededTimeLimit(positionForSignal.Open.Date, currentTime, Config.MaxPositionTimeHours); 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}%`)"); // Force a market close: compute PnL based on current price instead of SL/TP await CloseTrade(signal, positionForSignal, positionForSignal.Open, lastCandle.Close, true, 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) { positionForSignal.StopLoss.SetDate(lastCandle.Date); positionForSignal.StopLoss.SetStatus(TradeStatus.Filled); 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: `${positionForSignal.StopLoss.Price:F2}`"); await CloseTrade(signal, positionForSignal, positionForSignal.StopLoss, positionForSignal.StopLoss.Price, true); } else if (positionForSignal.TakeProfit1.Price <= lastCandle.High && positionForSignal.TakeProfit1.Status != TradeStatus.Filled) { 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: `${positionForSignal.TakeProfit1.Price:F2}`"); await CloseTrade(signal, positionForSignal, positionForSignal.TakeProfit1, positionForSignal.TakeProfit1.Price, positionForSignal.TakeProfit2 == null); } else if (positionForSignal.TakeProfit2?.Price <= lastCandle.High) { 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: `${positionForSignal.TakeProfit2.Price:F2}`"); await CloseTrade(signal, positionForSignal, positionForSignal.TakeProfit2, positionForSignal.TakeProfit2.Price, true); } } else if (positionForSignal.OriginDirection == TradeDirection.Short) { if (positionForSignal.StopLoss.Price <= lastCandle.High) { 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: `${positionForSignal.StopLoss.Price:F2}`"); await CloseTrade(signal, positionForSignal, positionForSignal.StopLoss, positionForSignal.StopLoss.Price, true); } else if (positionForSignal.TakeProfit1.Price >= lastCandle.Low && positionForSignal.TakeProfit1.Status != TradeStatus.Filled) { // Use actual execution price (lastCandle.Low for TP hit on SHORT) 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: `${positionForSignal.TakeProfit1.Price:F2}` (was `${positionForSignal.TakeProfit1.Price:F2}`)"); await CloseTrade(signal, positionForSignal, positionForSignal.TakeProfit1, positionForSignal.TakeProfit1.Price, positionForSignal.TakeProfit2 == null); } else if (positionForSignal.TakeProfit2?.Price >= lastCandle.Low) { // Use actual execution price (lastCandle.Low for TP hit on SHORT) 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: `${positionForSignal.TakeProfit2.Price:F2}` (was `${positionForSignal.TakeProfit2.Price:F2}`)"); await CloseTrade(signal, positionForSignal, positionForSignal.TakeProfit2, positionForSignal.TakeProfit2.Price, true); } } } // Synth risk monitoring (only for live trading) if (Config.UseSynthApi && TradingBox.IsLiveTrading(Config.TradingType) && positionForSignal.Status == PositionStatus.Filled) { await MonitorSynthRisk(signal, positionForSignal); } } catch (Exception ex) { await LogWarningAsync( $"Cannot update position {positionForSignal.Identifier}: {ex.Message}, {ex.StackTrace}"); SentrySdk.CaptureException(ex); return; } } // Virtual methods for trading mode-specific behavior protected virtual async Task SynchronizeWithBrokerPositions(Position internalPosition, Position positionForSignal) { // Default implementation: do nothing (for backtest) } protected virtual async Task HandleOrderManagementAndPositionStatus(LightSignal signal, Position internalPosition, Position positionForSignal) { // Default implementation: do nothing (for backtest) } protected virtual async Task MonitorSynthRisk(LightSignal signal, Position position) { // Default implementation: do nothing (for backtest) } protected virtual async Task RecoverOpenPositionFromBroker(LightSignal signal, Position position) { // Default implementation: no recovery for backtest return false; } protected virtual async Task CheckBrokerPositions() { // Default implementation: no broker checks for backtest, always allow return true; } protected virtual async Task ReconcileWithBrokerHistory(Position position, Candle currentCandle) { // Default implementation: no broker history reconciliation for backtest return false; // Return false to continue with candle-based calculation } protected virtual async Task<(decimal closingPrice, bool pnlCalculated)> CalculatePositionClosingFromCandles( Position position, Candle currentCandle, bool forceMarketClose, decimal? forcedClosingPrice) { // Used in Futures and Spot bots decimal closingPrice = 0; bool pnlCalculated = false; if (forceMarketClose && forcedClosingPrice.HasValue) { closingPrice = forcedClosingPrice.Value; bool isManualCloseProfitable = position.OriginDirection == TradeDirection.Long ? closingPrice > position.Open.Price : closingPrice < position.Open.Price; if (isManualCloseProfitable) { if (position.TakeProfit1 != null) { position.TakeProfit1.Price = closingPrice; position.TakeProfit1.SetDate(currentCandle?.Date ?? DateTime.UtcNow); position.TakeProfit1.SetStatus(TradeStatus.Filled); } if (position.StopLoss != null) { position.StopLoss.SetStatus(TradeStatus.Cancelled); } } else { if (position.StopLoss != null) { position.StopLoss.Price = closingPrice; position.StopLoss.SetDate(currentCandle?.Date ?? DateTime.UtcNow); position.StopLoss.SetStatus(TradeStatus.Filled); } if (position.TakeProfit1 != null) { position.TakeProfit1.SetStatus(TradeStatus.Cancelled); } if (position.TakeProfit2 != null) { position.TakeProfit2.SetStatus(TradeStatus.Cancelled); } } pnlCalculated = true; } else if (currentCandle != null) { // For backtest: use configured SL/TP prices to ensure consistent PnL if (position.OriginDirection == TradeDirection.Long) { if (position.StopLoss.Price >= currentCandle.Low) { closingPrice = position.StopLoss.Price; position.StopLoss.SetDate(currentCandle.Date); position.StopLoss.SetStatus(TradeStatus.Filled); if (position.TakeProfit1 != null) { position.TakeProfit1.SetStatus(TradeStatus.Cancelled); } if (position.TakeProfit2 != null) { position.TakeProfit2.SetStatus(TradeStatus.Cancelled); } } else if (position.TakeProfit1.Price <= currentCandle.High && position.TakeProfit1.Status != TradeStatus.Filled) { closingPrice = position.TakeProfit1.Price; position.TakeProfit1.SetDate(currentCandle.Date); position.TakeProfit1.SetStatus(TradeStatus.Filled); if (position.StopLoss != null) { position.StopLoss.SetStatus(TradeStatus.Cancelled); } } } else if (position.OriginDirection == TradeDirection.Short) { if (position.StopLoss.Price <= currentCandle.High) { closingPrice = position.StopLoss.Price; position.StopLoss.SetDate(currentCandle.Date); position.StopLoss.SetStatus(TradeStatus.Filled); if (position.TakeProfit1 != null) { position.TakeProfit1.SetStatus(TradeStatus.Cancelled); } if (position.TakeProfit2 != null) { position.TakeProfit2.SetStatus(TradeStatus.Cancelled); } } else if (position.TakeProfit1.Price >= currentCandle.Low && position.TakeProfit1.Status != TradeStatus.Filled) { closingPrice = position.TakeProfit1.Price; position.TakeProfit1.SetDate(currentCandle.Date); position.TakeProfit1.SetStatus(TradeStatus.Filled); if (position.StopLoss != null) { position.StopLoss.SetStatus(TradeStatus.Cancelled); } } } if (closingPrice == 0) { // Manual/exchange close - use current candle close closingPrice = currentCandle.Close; bool isManualCloseProfitable = position.OriginDirection == TradeDirection.Long ? closingPrice > position.Open.Price : closingPrice < position.Open.Price; if (isManualCloseProfitable) { position.TakeProfit1.SetPrice(closingPrice, 2); position.TakeProfit1.SetDate(currentCandle.Date); position.TakeProfit1.SetStatus(TradeStatus.Filled); if (position.StopLoss != null) { position.StopLoss.SetStatus(TradeStatus.Cancelled); } } else { position.StopLoss.SetPrice(closingPrice, 2); position.StopLoss.SetDate(currentCandle.Date); position.StopLoss.SetStatus(TradeStatus.Filled); if (position.TakeProfit1 != null) { position.TakeProfit1.SetStatus(TradeStatus.Cancelled); } if (position.TakeProfit2 != null) { position.TakeProfit2.SetStatus(TradeStatus.Cancelled); } } } pnlCalculated = true; } return (closingPrice, pnlCalculated); } protected async Task UpdatePositionDatabase(Position position) { await ServiceScopeHelpers.WithScopedService(_scopeFactory, async tradingService => { await tradingService.UpdatePositionAsync(position); }); } protected async Task GetLastPriceForPositionOpeningAsync() { if (TradingBox.IsLiveTrading(Config.TradingType)) { return await ServiceScopeHelpers.WithScopedService(_scopeFactory, async exchangeService => await exchangeService.GetCurrentPrice(Account, Config.Ticker)); } return LastCandle?.Close ?? 0; } protected async Task OpenPosition(LightSignal signal) { await LogDebugAsync($"šŸ”“ Opening position for signal: `{signal.Identifier}`"); // Check for any existing open position (not finished) for this ticker var openedPosition = Positions.Values.FirstOrDefault(p => p.IsOpen() && p.SignalIdentifier != signal.Identifier); decimal lastPrice = await GetLastPriceForPositionOpeningAsync(); if (openedPosition != null) { // OPTIMIZATION 3: Use TryGetValue instead of direct dictionary access if (!Signals.TryGetValue(openedPosition.SignalIdentifier, out var previousSignal)) { // Signal not found, expire new signal and return SetSignalStatus(signal.Identifier, SignalStatus.Expired); return null; } 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 { // Handle flip position - trading type specific logic var flippedPosition = await HandleFlipPosition(signal, openedPosition, previousSignal, lastPrice); return flippedPosition; } } else { bool canOpen = await CanOpenPosition(signal); if (!canOpen) { SetSignalStatus(signal.Identifier, SignalStatus.Expired); return null; } try { // Execute position opening - trading type specific logic var position = await ExecuteOpenPosition(signal, lastPrice); // Common logic: Handle position result if (position != null) { // Add position to internal collection before any status updates Positions[position.Identifier] = position; // OPTIMIZATION 2: Update cached open position state _hasOpenPosition = true; if (position.Open.Status != TradeStatus.Cancelled && position.Status != PositionStatus.Rejected) { SetSignalStatus(signal.Identifier, SignalStatus.PositionOpen); await SendPositionToCopyTradingStream(position); await LogDebugAsync($"āœ… Position requested successfully for signal: `{signal.Identifier}`"); return position; } else { SentrySdk.CaptureMessage("Position rejected", SentryLevel.Error); await SetPositionStatus(signal.Identifier, PositionStatus.Rejected); position.Status = PositionStatus.Rejected; await UpdatePositionDatabase(position); SetSignalStatus(signal.Identifier, SignalStatus.Expired); return position; } } 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); SentrySdk.CaptureException(ex); return null; } } } /// /// Handles position flipping logic when an opposite direction signal is received. /// This method is trading-type specific and should be overridden in derived classes. /// protected virtual async Task HandleFlipPosition(LightSignal signal, Position openedPosition, LightSignal previousSignal, decimal lastPrice) { // Default implementation - subclasses should override 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; } } /// /// Executes the actual position opening logic. /// This method is trading-type specific and should be overridden in derived classes. /// protected virtual async Task ExecuteOpenPosition(LightSignal signal, decimal lastPrice) { // Default implementation - subclasses should override // Verify actual balance before opening position await VerifyAndUpdateBalanceAsync(); var command = new OpenPositionRequest( Config.AccountName, Config.MoneyManagement, signal.Direction, Config.Ticker, PositionInitiator.Bot, signal.Date, Account.User, Config.BotTradingBalance, TradingBox.IsBacktestTrading(Config.TradingType), lastPrice, signalIdentifier: signal.Identifier, initiatorIdentifier: Identifier, tradingType: Config.TradingType); var position = await ServiceScopeHelpers .WithScopedServices( _scopeFactory, async (exchangeService, accountService, tradingService) => { return await new OpenPositionCommandHandler(exchangeService, accountService, tradingService) .Handle(command); }); return position; } private async Task SendPositionToCopyTrading(Position position) { try { // Only send to copy trading stream if this is not a copy trading bot itself if (Config.IsForCopyTrading || _streamProvider == null) { return; } // Create stream keyed by this bot's identifier for copy trading bots to subscribe to var streamId = StreamId.Create("CopyTrading", Identifier); var stream = _streamProvider.GetStream(streamId); // Publish the position to the stream await stream.OnNextAsync(position); await LogDebugAsync($"šŸ“” Position {position.Identifier} sent to copy trading stream for bot {Identifier}"); } catch (Exception ex) { Logger.LogError(ex, "Failed to send position {PositionId} to copy trading stream for bot {BotId}", position.Identifier, Identifier); } } /// /// Creates a copy of a position from a master bot for copy trading /// public async Task CopyPositionFromMasterAsync(Position masterPosition) { try { // Create a copy signal based on the master position using the proper constructor var copySignal = new LightSignal( ticker: Config.Ticker, direction: masterPosition.OriginDirection, confidence: Confidence.Medium, // Default confidence for copy trading candle: LastCandle ?? new Candle { Ticker = Config.Ticker, Timeframe = Config.Timeframe, Date = DateTime.UtcNow, Open = masterPosition.Open.Price, Close = masterPosition.Open.Price, High = masterPosition.Open.Price, Low = masterPosition.Open.Price, Volume = 0 }, date: masterPosition.Open.Date, exchange: TradingExchanges.GmxV2, // Default exchange indicatorType: IndicatorType.Composite, signalType: SignalType.Signal, indicatorName: "CopyTrading" ); // Override the identifier to include master position info copySignal.Identifier = $"copy-{masterPosition.SignalIdentifier}-{Guid.NewGuid()}"; // Store the signal Signals[copySignal.Identifier] = copySignal; await LogInformation( $"šŸ“‹ Copy trading: Created copy signal {copySignal.Identifier} for master position {masterPosition.Identifier}"); // Attempt to open the position using the existing OpenPosition method // This will handle all the position creation logic properly await OpenPosition(copySignal); } catch (Exception ex) { Logger.LogError(ex, "Failed to copy position {MasterPositionId} for bot {BotId}", masterPosition.Identifier, Identifier); throw; } } protected virtual async Task CanOpenPosition(LightSignal signal) { // Default implementation for live trading // Early return if bot hasn't executed first cycle yet if (ExecutionCount == 0) { await LogInformationAsync("ā³ Bot Not Ready\nCannot open position\nBot hasn't executed first cycle yet"); return false; } // Check broker positions for live trading var canOpenPosition = await CanOpenPositionWithBrokerChecks(signal); if (!canOpenPosition) { return false; } // Check cooldown period and loss streak return !await IsInCooldownPeriodAsync() && await CheckLossStreak(signal); } protected 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(); var canOpen = TradingBox.CheckLossStreak(recentPositions, Config.MaxLossStreak, signal.Direction); if (!canOpen) { var lastPosition = recentPositions.First(); 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 canOpen; } public abstract Task CloseTrade(LightSignal signal, Position position, Trade tradeToClose, decimal lastPrice, bool tradeClosingPosition = false, bool forceMarketClose = false); protected async Task HandleClosedPosition(Position position, decimal? forcedClosingPrice = null, bool forceMarketClose = false) { if (Positions.ContainsKey(position.Identifier)) { Candle currentCandle = await GetCurrentCandleForPositionClose(Account, position.Ticker.ToString()); // Try broker history reconciliation first var brokerHistoryReconciled = await ReconcileWithBrokerHistory(position, currentCandle); if (brokerHistoryReconciled && !forceMarketClose) { goto SkipCandleBasedCalculation; } // Calculate position closing details using subclass-specific logic var (closingPrice, pnlCalculated) = await CalculatePositionClosingFromCandles( position, currentCandle, forceMarketClose, forcedClosingPrice); // Calculate P&L if we have a closing price if (pnlCalculated && closingPrice > 0) { var entryPrice = position.Open.Price; var positionSize = TradingBox.CalculatePositionSize(position.Open.Quantity, position.Open.Leverage); decimal pnl = TradingBox.CalculatePnL(entryPrice, closingPrice, position.Open.Quantity, position.Open.Leverage, position.OriginDirection); 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 || position.ProfitAndLoss.Net == 0) { var totalFees = position.GasFees + position.UiFees; var netPnl = pnl - totalFees; position.ProfitAndLoss.Realized = pnl; position.ProfitAndLoss.Net = netPnl; } // Enhanced logging for backtest debugging var logMessage = $"šŸ’° P&L Calculated for Position {position.Identifier}\n" + $"Direction: `{position.OriginDirection}`\n" + $"Entry Price: `${entryPrice:F2}` | Exit Price: `${closingPrice:F2}`\n" + $"Position Size: `{position.Open.Quantity:F8}` | Leverage: `{position.Open.Leverage}x`\n" + $"Position Value: `${positionSize:F8}`\n" + $"Price Difference: `${TradingBox.CalculatePriceDifference(entryPrice, closingPrice, position.OriginDirection):F2}`\n" + $"Realized P&L: `${pnl:F2}`\n" + $"Gas Fees: `${position.GasFees:F2}` | UI Fees: `${position.UiFees:F2}`\n" + $"Total Fees: `${position.GasFees + position.UiFees:F2}`\n" + $"Net P&L (after fees): `${position.ProfitAndLoss.Net:F2}`"; if (TradingBox.IsLiveTrading(Config.TradingType)) { await LogDebugAsync(logMessage); } } SkipCandleBasedCalculation: await SetPositionStatus(position.SignalIdentifier, PositionStatus.Finished); // OPTIMIZATION 2: Update cached open position state after closing position _hasOpenPosition = Positions.Values.Any(p => p.IsOpen()); // Update position in database with all trade changes if (TradingBox.IsLiveTrading(Config.TradingType)) { 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 NotifyAgentAndPlatformAsync(NotificationEventType.PositionClosed, position); // Update the last position closing time for cooldown period tracking // Only update if position was actually filled LastPositionClosingTime = TradingBox.IsBacktestTrading(Config.TradingType) ? currentCandle.Date : DateTime.UtcNow; } else { await LogDebugAsync( $"Skipping PositionClosed notification for position {position.Identifier} - position was never filled (Open trade status: {position.Open?.Status})"); } } // Only update balance and log success if position was actually filled if (position.Open?.Status == TradeStatus.Filled) { await LogDebugAsync( $"āœ… Position Closed Successfully\nPosition: `{position.SignalIdentifier}`\nPnL: `${position.ProfitAndLoss?.Net:F2}`"); if (position.ProfitAndLoss != null) { // Update the current balance when position closes _currentBalance += position.ProfitAndLoss.Net; Config.BotTradingBalance += position.ProfitAndLoss.Net; await LogDebugAsync( string.Format("šŸ’° Balance Updated\nNew bot trading balance: `${0:F2}`", Config.BotTradingBalance)); } } else { await LogDebugAsync( $"āœ… 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"); } await SendClosedPositionToMessenger(position, Account.User); await CancelAllOrdersAsync(); } private async Task CancelAllOrders() { if (TradingBox.IsLiveTrading(Config.TradingType) && !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) { await LogDebugAsync($"Position still open, cancel close orders"); } else { await LogDebugAsync($"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); await LogDebugAsync( $"Closing all {Config.Ticker} orders status : {closePendingOrderStatus}"); }); } } else { await LogDebugAsync($"No need to cancel orders for {Config.Ticker}"); } } catch (Exception ex) { Logger.LogError(ex, "Error during cancelOrders"); SentrySdk.CaptureException(ex); } } } protected async Task SetPositionStatus(string signalIdentifier, PositionStatus positionStatus) { try { var position = Positions.Values.First(p => p.SignalIdentifier == signalIdentifier); if (positionStatus.Equals(PositionStatus.Canceled | PositionStatus.Rejected)) { var stackTrace = new StackTrace(true); var callingMethod = stackTrace.GetFrame(1)?.GetMethod(); var callingMethodName = callingMethod?.DeclaringType?.Name + "." + callingMethod?.Name; var exception = new InvalidOperationException( $"Position {signalIdentifier} is already canceled for User {Account.User.Name}"); exception.Data["SignalIdentifier"] = signalIdentifier; exception.Data["PositionId"] = position.Identifier; exception.Data["CurrentStatus"] = position.Status.ToString(); exception.Data["RequestedStatus"] = positionStatus.ToString(); exception.Data["AccountName"] = Account.Name; exception.Data["BotName"] = Config.Name; exception.Data["CallingMethod"] = callingMethodName; exception.Data["CallStack"] = Environment.StackTrace; SentrySdk.CaptureException(exception); } if (!position.Status.Equals(positionStatus)) { Positions.Values.First(p => p.SignalIdentifier == signalIdentifier).Status = positionStatus; await LogInformation( $"šŸ“Š Position Status Change\nPosition: {position.OriginDirection} {position.Ticker}\nNew Status: `{positionStatus}`"); // Update Open trade status when position becomes Filled if (positionStatus == PositionStatus.Filled) { 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} {ex.StackTrace}"); SentrySdk.CaptureException(ex); } } protected 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; } } protected void SetSignalStatus(string signalIdentifier, SignalStatus signalStatus) { // OPTIMIZATION 4: Use TryGetValue instead of ContainsKey + direct access (single lookup) if (Signals.TryGetValue(signalIdentifier, out var signal) && signal.Status != signalStatus) { signal.Status = signalStatus; Logger.LogDebug($"Signal {signalIdentifier} is now {signalStatus}"); } } 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(string reason = null) { await LogInformation( $"šŸ›‘ Bot Stopped\nBot: `{Config.Name}`\nTicker: `{Config.Ticker}`\nReason: `{reason ?? "No reason provided"}`"); } /// /// 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 CreateManualSignal(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); await ManagePositions(); return signal; } public async Task AddSignal(LightSignal signal) { try { // OPTIMIZATION 1: Early return for backtest - skip all logging and validation if (TradingBox.IsBacktestTrading(Config.TradingType)) { Signals.Add(signal.Identifier, signal); return; } // Set signal status based on configuration if (Config.IsForWatchingOnly || (ExecutionCount < 1 && TradingBox.IsLiveTrading(Config.TradingType))) { 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 && TradingBox.IsLiveTrading(Config.TradingType) && 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, TradingBox.IsBacktestTrading(Config.TradingType)); if (signalValidationResult.Confidence == Confidence.None || signalValidationResult.Confidence == Confidence.Low || signalValidationResult.IsBlocked) { signal.Status = SignalStatus.Expired; await LogDebugAsync($"Signal {signal.Identifier} blocked by Synth risk assessment"); } else { signal.Confidence = signalValidationResult.Confidence; await LogDebugAsync( $"Signal {signal.Identifier} passed Synth risk assessment with confidence {signalValidationResult.Confidence}"); } }); } Signals.Add(signal.Identifier, signal); await LogInformation(signalText); if (Config.IsForWatchingOnly && TradingBox.IsLiveTrading(Config.TradingType) && ExecutionCount > 0) { await ServiceScopeHelpers.WithScopedService(_scopeFactory, async messengerService => { await messengerService.SendSignal(signalText, Account.Exchange, Config.Ticker, signal.Direction, Config.Timeframe); }); } await LogDebugAsync( $"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 /// /// 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}"); } // Check for changes in individual MoneyManagement properties if (Config.MoneyManagement?.StopLoss != newConfig.MoneyManagement?.StopLoss) { var oldStopLoss = Config.MoneyManagement?.StopLoss.ToString("P2") ?? "None"; var newStopLoss = newConfig.MoneyManagement?.StopLoss.ToString("P2") ?? "None"; changes.Add($"šŸ›‘ Stop Loss: {oldStopLoss} → {newStopLoss}"); } if (Config.MoneyManagement?.TakeProfit != newConfig.MoneyManagement?.TakeProfit) { var oldTakeProfit = Config.MoneyManagement?.TakeProfit.ToString("P2") ?? "None"; var newTakeProfit = newConfig.MoneyManagement?.TakeProfit.ToString("P2") ?? "None"; changes.Add($"šŸŽÆ Take Profit: {oldTakeProfit} → {newTakeProfit}"); } if (Config.MoneyManagement?.Leverage != newConfig.MoneyManagement?.Leverage) { var oldLeverage = Config.MoneyManagement?.Leverage.ToString("F1") + "x" ?? "None"; var newLeverage = newConfig.MoneyManagement?.Leverage.ToString("F1") + "x" ?? "None"; changes.Add($"⚔ Leverage: {oldLeverage} → {newLeverage}"); } 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 protectedTradingType = Config.TradingType; newConfig.AccountName = Config.AccountName; // Update the configuration Config = newConfig; // Restore protected properties Config.TradingType = protectedTradingType; // 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, TradingType = Config.TradingType, 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, IsForCopyTrading = Config.IsForCopyTrading, MasterBotIdentifier = Config.MasterBotIdentifier, MasterBotUserId = Config.MasterBotUserId, }; } /// /// Checks if the bot is currently in a cooldown period for any direction. /// /// True if in cooldown period for any direction, false otherwise protected 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 cooldownEndTime = TradingBox.CalculateCooldownEndTime(LastPositionClosingTime.Value, Config.Timeframe, Config.CooldownPeriod); var isInCooldown = (TradingBox.IsBacktestTrading(Config.TradingType) ? LastCandle.Date : DateTime.UtcNow) < cooldownEndTime; if (isInCooldown) { var remainingTime = cooldownEndTime - LastCandle.Date; Logger.LogWarning( $"ā³ [{Account.User.AgentName}-{Config.Name}] 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) { await LogDebugAsync($"Successfully refreshed last candle for {Config.Ticker} at {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); } } /// /// 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 (TradingBox.IsBacktestTrading(Config.TradingType)) { 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); await LogDebugAsync( $"Sent position opened event to both grains for position {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); await LogDebugAsync( $"Sent position closed event to both grains for position {position.Identifier}"); break; case NotificationEventType.PositionUpdated: var positionUpdatedEvent = new PositionUpdatedEvent { PositionIdentifier = position.Identifier, }; await agentGrain.OnPositionUpdatedAsync(positionUpdatedEvent); break; } }); } catch (Exception ex) { Logger.LogError(ex, "Failed to send notifications: {EventType} for bot {BotId}", eventType, Identifier); } } // Virtual methods for mode-specific behavior protected virtual async Task LoadAccountAsync() { await LoadAccount(); } protected virtual async Task VerifyAndUpdateBalanceAsync() { await VerifyAndUpdateBalance(); } protected virtual async Task GetInternalPositionForUpdate(Position position) { return position; // Default implementation for backtest } protected virtual async Task UpdatePositionWithBrokerData(Position position, List brokerPositions) { // Default: do nothing for backtest } protected virtual async Task GetCurrentCandleForPositionClose(Account account, string ticker) { return LastCandle; // Default for backtest } protected virtual async Task CanOpenPositionWithBrokerChecks(LightSignal signal) { // Check broker positions for live trading var canOpenPosition = await CheckBrokerPositions(); if (!canOpenPosition) { return false; } return true; } protected virtual async Task SendPositionToCopyTradingStream(Position position) { await SendPositionToCopyTrading(position); } protected virtual async Task NotifyAgentAndPlatformAsync(NotificationEventType eventType, Position position) { await NotifyAgentAndPlatformGrainAsync(eventType, position); } protected virtual async Task UpdatePositionInDatabaseAsync(Position position) { await UpdatePositionDatabase(position); } protected virtual async Task SendClosedPositionToMessenger(Position position, User user) { await ServiceScopeHelpers.WithScopedService(_scopeFactory, async messengerService => { await messengerService.SendClosedPosition(position, user); }); } protected virtual async Task CancelAllOrdersAsync() { await CancelAllOrders(); } // Interface implementation public async Task LogInformation(string message) { await LogInformationAsync(message); } public async Task LogWarning(string message) { await LogWarningAsync(message); } protected virtual async Task LogInformationAsync(string message) { if (TradingBox.IsBacktestTrading(Config.TradingType)) return; Logger.LogInformation(message); try { await SendTradeMessageAsync(message); } catch (Exception e) { Console.WriteLine(e); } } protected virtual async Task LogWarningAsync(string message) { if (TradingBox.IsBacktestTrading(Config.TradingType)) return; message = $"[{Config.Name}] {message}"; try { await SendTradeMessageAsync(message, true); } catch (Exception e) { Console.WriteLine(e); } } protected virtual async Task LogDebugAsync(string message) { if (TradingBox.IsBacktestTrading(Config.TradingType)) return; Logger.LogDebug(message); try { await ServiceScopeHelpers.WithScopedService(_scopeFactory, async messengerService => { await messengerService.SendDebugMessage($"šŸ¤– {Account.User.AgentName} - {Config.Name}\n{message}"); }); } catch (Exception e) { Console.WriteLine(e); } } protected virtual async Task SendTradeMessageAsync(string message, bool isBadBehavior = false) { if (TradingBox.IsLiveTrading(Config.TradingType)) { var user = Account.User; var messageWithBotName = $"šŸ¤– {user.AgentName} - {Config.Name}\n{message}"; await ServiceScopeHelpers.WithScopedService(_scopeFactory, async messengerService => { await messengerService.SendTradeMessage(messageWithBotName, isBadBehavior, user); }); } } }