using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using FinlyticCore.Dtos.Bot; using FinlyticCore.Dtos.TechnicalAnalysis; using FinlyticCore.Dtos.Trading; using FinlyticCore.Services; using FinlyticBot.Database; using FinlyticBot.Database.Entities; using FinlyticBot.Services.Alpaca; using FinlyticBot.Services.Execution; using FinlyticBot.Services.Ledger; using FinlyticBot.Services.Mqtt; using FinlyticBot.Settings; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; namespace FinlyticBot.Services.Monitoring; public record BotGetCandlesRequest(string Isin, string Timeframe = "1m"); public class BotTradeLifecycleBackgroundService : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly IAlpacaTradingService _alpacaService; private readonly ISyntheticPaperBroker _syntheticBroker; private readonly IBotRpcClient _rpcClient; private readonly ISettingsService _settingsService; private readonly IFinlyticLogger _logger; public BotTradeLifecycleBackgroundService( IServiceScopeFactory scopeFactory, IAlpacaTradingService alpacaService, ISyntheticPaperBroker syntheticBroker, IBotRpcClient rpcClient, ISettingsService settingsService, IFinlyticLogger logger) { _scopeFactory = scopeFactory; _alpacaService = alpacaService; _syntheticBroker = syntheticBroker; _rpcClient = rpcClient; _settingsService = settingsService; _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await _logger.LogInfoAsync(BotSettingKeys.LifecycleChannel, "[BotLifecycle] Starting Bot Trade Lifecycle & Trailing Monitoring Service..."); await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); DateTime lastSnapshotUtc = DateTime.UtcNow; while (!stoppingToken.IsCancellationRequested) { try { var intervalSec = await _settingsService.GetSettingAsync(BotSettingKeys.MonitoringIntervalSeconds, stoppingToken); using (var scope = _scopeFactory.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); var openPositions = await db.Positions .Where(p => p.Status == BotPositionStatus.Active || p.Status == BotPositionStatus.BreakEvenTriggered || p.Status == BotPositionStatus.Tp1Hit) .ToListAsync(stoppingToken); if (openPositions.Count > 0) { foreach (var pos in openPositions) { if (stoppingToken.IsCancellationRequested) break; try { // 1. Fetch live candle for price var candles = await _rpcClient.SendRpcRequestAsync, BotGetCandlesRequest>( "ta_GetCandles", new BotGetCandlesRequest(pos.Isin, "1m"), TimeSpan.FromSeconds(3) ); if (candles == null || candles.Count == 0) continue; var lastCandle = candles.Last(); decimal currentPrice = lastCandle.Close; pos.CurrentPrice = currentPrice; pos.LastSyncAtUtc = DateTime.UtcNow; // 2. Check Stop-Loss Violation bool isStopped = pos.Direction == SignalDirection.Buy ? currentPrice <= pos.CurrentStopLoss : currentPrice >= pos.CurrentStopLoss; if (isStopped) { pos.Status = BotPositionStatus.StoppedOut; pos.ClosedAtUtc = DateTime.UtcNow; decimal pnl = pos.Direction == SignalDirection.Buy ? ((currentPrice - pos.AverageBuyIn) * pos.Quantity) - pos.TotalFeesEur : ((pos.AverageBuyIn - currentPrice) * pos.Quantity) - pos.TotalFeesEur; pos.RealizedPnlEur = Math.Round(pnl, 2); await _logger.LogWarningAsync(BotSettingKeys.LifecycleChannel, "[BotLifecycle] Position {Id} for {Isin} STOPPED OUT at {Price:F2} € (PnL: {PnL:F2} €)", pos.Id, pos.Isin, currentPrice, pos.RealizedPnlEur); await db.SaveChangesAsync(stoppingToken); await _rpcClient.PublishAsync("finlytic/bot/trades/stream", BotOrderExecutor.MapEntityToDto(pos)); continue; } // 3. Check Take-Profit 1 -> Move SL to Break-Even (Free-Roll) bool isTp1 = pos.Direction == SignalDirection.Buy ? currentPrice >= pos.TakeProfit1 : currentPrice <= pos.TakeProfit1; if (isTp1 && pos.Status == BotPositionStatus.Active) { decimal oldSl = pos.CurrentStopLoss; pos.CurrentStopLoss = pos.AverageBuyIn; pos.Status = BotPositionStatus.BreakEvenTriggered; if (pos.Venue == BotExecutionVenue.AlpacaPaperTrading && !string.IsNullOrWhiteSpace(pos.AlpacaOrderId)) { try { await _alpacaService.UpdateStopLossAsync(pos.AlpacaOrderId, pos.CurrentStopLoss, stoppingToken); } catch (Exception ex) { await _logger.LogWarningAsync(BotSettingKeys.AlpacaChannel, ex, "[BotLifecycle] Failed to update Alpaca bracket stop-loss for order {Id}", pos.AlpacaOrderId); } } await _logger.LogInfoAsync(BotSettingKeys.LifecycleChannel, "[BotLifecycle] Position {Id} for {Isin} reached TP1 ({TP1:F2} €). Moved SL from {OldSl:F2} to Break-Even ({BuyIn:F2} €)", pos.Id, pos.Isin, pos.TakeProfit1, oldSl, pos.AverageBuyIn); await db.SaveChangesAsync(stoppingToken); await _rpcClient.PublishAsync("finlytic/bot/trades/stream", BotOrderExecutor.MapEntityToDto(pos)); } // 4. Check Take-Profit 2 bool isTp2 = pos.Direction == SignalDirection.Buy ? currentPrice >= pos.TakeProfit2 : currentPrice <= pos.TakeProfit2; if (isTp2) { pos.Status = BotPositionStatus.Closed; pos.ClosedAtUtc = DateTime.UtcNow; decimal pnl = pos.Direction == SignalDirection.Buy ? ((currentPrice - pos.AverageBuyIn) * pos.Quantity) - pos.TotalFeesEur : ((pos.AverageBuyIn - currentPrice) * pos.Quantity) - pos.TotalFeesEur; pos.RealizedPnlEur = Math.Round(pnl, 2); await _logger.LogInfoAsync(BotSettingKeys.LifecycleChannel, "[BotLifecycle] Position {Id} for {Isin} reached TP2 ({TP2:F2} €). Closed with profit {PnL:F2} €", pos.Id, pos.Isin, pos.TakeProfit2, pos.RealizedPnlEur); await db.SaveChangesAsync(stoppingToken); await _rpcClient.PublishAsync("finlytic/bot/trades/stream", BotOrderExecutor.MapEntityToDto(pos)); continue; } // 5. Trailing Stop Rule check if (pos.Status == BotPositionStatus.BreakEvenTriggered && pos.ExitPlan?.TrailingStopRule != null) { if (pos.Direction == SignalDirection.Buy) { decimal trail = currentPrice * 0.97m; if (trail > pos.CurrentStopLoss) { pos.CurrentStopLoss = Math.Round(trail, 2); await db.SaveChangesAsync(stoppingToken); await _rpcClient.PublishAsync("finlytic/bot/trades/stream", BotOrderExecutor.MapEntityToDto(pos)); } } } await db.SaveChangesAsync(stoppingToken); await _rpcClient.PublishAsync("finlytic/bot/trades/stream", BotOrderExecutor.MapEntityToDto(pos)); } catch (Exception ex) { await _logger.LogWarningAsync(BotSettingKeys.LifecycleChannel, ex, "[BotLifecycle] Error monitoring bot position {Id}", pos.Id); } } } // Periodic Daily Snapshot if (DateTime.UtcNow - lastSnapshotUtc >= TimeSpan.FromHours(1)) { var allPositions = await db.Positions.AsNoTracking().ToListAsync(stoppingToken); decimal realized = allPositions.Sum(p => p.RealizedPnlEur); decimal unrealized = allPositions .Where(p => p.Status == BotPositionStatus.Active || p.Status == BotPositionStatus.BreakEvenTriggered) .Sum(p => (p.Direction == SignalDirection.Buy ? (p.CurrentPrice - p.AverageBuyIn) : (p.AverageBuyIn - p.CurrentPrice)) * p.Quantity); int closedCount = allPositions.Count(p => p.Status == BotPositionStatus.Closed || p.Status == BotPositionStatus.StoppedOut); int winCount = allPositions.Count(p => (p.Status == BotPositionStatus.Closed || p.Status == BotPositionStatus.StoppedOut) && p.RealizedPnlEur > 0); decimal winRate = closedCount > 0 ? ((decimal)winCount / closedCount) * 100m : 0m; // Same configured base capital as SyntheticPaperBroker.GetSummaryAsync (Rules.md §4: // no hardcoded financial constants) - this used to be a literal 50000m that could // silently drift from the actual configured Bot.SyntheticBaseCapitalEur setting. decimal baseCapital = await _settingsService.GetSettingAsync(BotSettingKeys.SyntheticBaseCapitalEur, stoppingToken); db.PortfolioSnapshots.Add(new BotPortfolioSnapshotEntity { Id = Guid.NewGuid(), SnapshotDateUtc = DateTime.UtcNow, TotalEquityEur = baseCapital + realized + unrealized, CashEur = baseCapital + realized, OpenPositionsCount = openPositions.Count, DailyRealizedPnlEur = realized, TotalUnrealizedPnlEur = unrealized, WinRatePercent = Math.Round(winRate, 2), CreatedAtUtc = DateTime.UtcNow }); await db.SaveChangesAsync(stoppingToken); lastSnapshotUtc = DateTime.UtcNow; } } await Task.Delay(TimeSpan.FromSeconds(Math.Max(5, intervalSec)), stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } catch (Exception ex) { await _logger.LogErrorAsync(BotSettingKeys.LifecycleChannel, ex, "[BotLifecycle] Unexpected error in bot lifecycle loop. Retrying in 15s."); await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken); } } await _logger.LogInfoAsync(BotSettingKeys.LifecycleChannel, "[BotLifecycle] Bot Trade Lifecycle Monitoring Service stopped."); } }