using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using FinlyticCore.Models.Analyzer; using FinlyticCore.Models.Trades; using FinlyticTrades.Database; using FinlyticTrades.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace FinlyticTrades.Services; public interface ITradeLifecycleService { Task ProcessProposedTradeAsync(TradeProposalDto proposal, CancellationToken cancellationToken = default); Task ProcessManualAnalysisResponseAsync(ManualAnalysisResponseDto response, string userId, CancellationToken cancellationToken = default); Task AcceptTradeAsync(TradeAcceptanceDto request, CancellationToken cancellationToken = default); Task AddHourlyUpdateAsync(TradeHourlyUpdateDto update, CancellationToken cancellationToken = default); Task> GetActiveTradesAsync(string? userId = null, CancellationToken cancellationToken = default); Task> GetTradesAsync(string? isin, string? status, string? userId = null, CancellationToken cancellationToken = default); Task CloseTradeAsync(string tradeId, CloseTradeRequest request, CancellationToken cancellationToken = default); Task RejectTradeAsync(string tradeId, CloseTradeRequest request, CancellationToken cancellationToken = default); void CalculatePnL(TradeEntity trade, decimal? overridePrice = null); } public class TradeLifecycleService : ITradeLifecycleService { private readonly TradesDbContext _dbContext; private readonly ILogger _logger; public TradeLifecycleService(TradesDbContext dbContext, ILogger logger) { _dbContext = dbContext; _logger = logger; } public async Task ProcessManualAnalysisResponseAsync(ManualAnalysisResponseDto response, string userId, CancellationToken cancellationToken = default) { if (response == null || !response.IsTradeProposed) { _logger.LogInformation("[{Channel}] Manual analysis response indicated NO trade proposed (AnalysisId: {AnalysisId}). Skipping.", "TradesChannel", response?.AnalysisId); return false; } if (response.Proposal != null) { response.Proposal.UserId = userId; return await ProcessProposedTradeAsync(response.Proposal, cancellationToken); } if (response.N8nResponse != null) { var n8n = response.N8nResponse; var exec = n8n.ExecutionPlan; var generatedProposal = new TradeProposalDto { TradeId = "PROP-" + Guid.NewGuid().ToString("N")[..10].ToUpperInvariant(), AnalysisId = response.AnalysisId, EventId = response.AnalysisId, UserId = userId, IsGlobalProposal = false, Status = "Proposed", SignalType = string.Equals(n8n.SuggestedDirection, "Short", StringComparison.OrdinalIgnoreCase) ? "SELL" : "BUY", RiskTolerance = n8n.SuggestedRisk, Timeframe = n8n.SuggestedTimeframe, Reasoning = n8n.AiReasoning, StopLoss = exec?.StopLoss ?? 0m, TakeProfit = exec?.TakeProfitTargets?.FirstOrDefault() ?? 0m, EntryZoneMin = exec?.EntryZone?.Min, EntryZoneMax = exec?.EntryZone?.Max, TakeProfitTargets = exec?.TakeProfitTargets, RiskRewardRatio = exec?.RiskRewardRatio, MaxLeverage = exec?.MaxLeverage, TechnicalRationale = n8n.DetailedAnalysis?.TechnicalRationale ?? string.Empty, FundamentalRationale = n8n.DetailedAnalysis?.FundamentalRationale ?? string.Empty, RiskWarning = n8n.DetailedAnalysis?.RiskWarning ?? string.Empty, CreatedAt = DateTime.UtcNow }; return await ProcessProposedTradeAsync(generatedProposal, cancellationToken); } return false; } public async Task ProcessProposedTradeAsync(TradeProposalDto proposal, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(proposal.Symbol) && string.IsNullOrWhiteSpace(proposal.Isin)) { _logger.LogWarning("[{Channel}] ProcessProposedTradeAsync: Received proposal with missing Symbol and ISIN. Skipping.", "TradesChannel"); return false; } var targetStatus = string.Equals(proposal.Status, "Rejected", StringComparison.OrdinalIgnoreCase) ? TradeStatus.Rejected : TradeStatus.Proposed; var existingTrade = await _dbContext.Trades .FirstOrDefaultAsync(t => (!string.IsNullOrWhiteSpace(proposal.TradeId) && t.TradeId == proposal.TradeId) || (!string.IsNullOrWhiteSpace(proposal.AnalysisId) && t.AnalysisId == proposal.AnalysisId) || (!string.IsNullOrWhiteSpace(proposal.Isin) && t.Isin == proposal.Isin && (t.Status == TradeStatus.Proposed || t.Status == TradeStatus.Active)), cancellationToken); if (existingTrade != null) { if (existingTrade.Status == TradeStatus.Active) { _logger.LogInformation("[{Channel}] An ACTIVE trade {TradeId} already exists for {Symbol} ({Isin}). Skipping duplicate proposed trade creation.", "TradesChannel", existingTrade.TradeId, proposal.Symbol, proposal.Isin); return true; } if (existingTrade.Status != TradeStatus.Closed) { existingTrade.Status = targetStatus; } MapProposalToEntity(proposal, existingTrade); _dbContext.Trades.Update(existingTrade); await _dbContext.SaveChangesAsync(cancellationToken); _logger.LogInformation("[{Channel}] Successfully UPDATED existing trade proposal {TradeId} for Symbol {Symbol} (ISIN: {Isin}) with status {Status}", "TradesChannel", existingTrade.TradeId, proposal.Symbol, proposal.Isin, existingTrade.Status); return true; } string tradeId = !string.IsNullOrWhiteSpace(proposal.TradeId) ? proposal.TradeId : ("TRD-" + Guid.NewGuid().ToString("N")[..10].ToUpperInvariant()); var tradeEntity = new TradeEntity { TradeId = tradeId, CreatedAt = DateTime.UtcNow }; MapProposalToEntity(proposal, tradeEntity); tradeEntity.Status = targetStatus; _dbContext.Trades.Add(tradeEntity); await _dbContext.SaveChangesAsync(cancellationToken); _logger.LogInformation("[{Channel}] Successfully ingested NEW trade proposal {TradeId} for Symbol {Symbol} (ISIN: {Isin}) with status {Status}", "TradesChannel", tradeId, proposal.Symbol, proposal.Isin, targetStatus); return true; } public async Task AcceptTradeAsync(TradeAcceptanceDto request, CancellationToken cancellationToken = default) { string targetUserId = !string.IsNullOrWhiteSpace(request.UserId) ? request.UserId : "default_user"; var existingTrade = await _dbContext.Trades .FirstOrDefaultAsync(t => (!string.IsNullOrEmpty(request.TradeId) && t.TradeId == request.TradeId) || (!string.IsNullOrEmpty(request.AnalysisId) && t.AnalysisId == request.AnalysisId), cancellationToken); if (existingTrade != null) { if (existingTrade.Status == TradeStatus.Closed) { _logger.LogWarning("[{Channel}] Refused to accept trade {TradeId} because its status is CLOSED", "TradesChannel", existingTrade.TradeId); return null; } existingTrade.Status = TradeStatus.Active; existingTrade.IsGlobalProposal = false; existingTrade.UserId = targetUserId; if (request.ActualEntryPrice > 0) existingTrade.ActualEntryPrice = request.ActualEntryPrice; if (request.EntryPrice > 0) existingTrade.EntryPrice = request.EntryPrice.Value; if (request.PositionSize > 0) existingTrade.PositionSize = request.PositionSize; if (request.LeverageUsed > 0) existingTrade.LeverageUsed = request.LeverageUsed; if (request.Quantity > 0) existingTrade.Quantity = request.Quantity; if (request.EntryFee.HasValue) existingTrade.EntryFee = request.EntryFee; if (request.ExitFee.HasValue) existingTrade.ExitFee = request.ExitFee; if (request.StopLoss > 0) existingTrade.StopLoss = request.StopLoss.Value; if (request.TakeProfit > 0) existingTrade.TakeProfit = request.TakeProfit.Value; if (request.KnockoutThreshold > 0) existingTrade.KnockoutThreshold = request.KnockoutThreshold; if (!string.IsNullOrWhiteSpace(request.Timeframe)) existingTrade.Timeframe = request.Timeframe; if (!string.IsNullOrWhiteSpace(request.DerivativeIsin)) existingTrade.DerivativeIsin = request.DerivativeIsin; if (!string.IsNullOrWhiteSpace(request.Reasoning)) existingTrade.Reasoning = request.Reasoning; existingTrade.ExecutionTimestamp = request.ExecutionTimestamp?.ToUniversalTime() ?? DateTime.UtcNow; existingTrade.PnlAbsolute = -(existingTrade.EntryFee ?? 0m) - (existingTrade.ExitFee ?? 0m); if (existingTrade.PositionSize > 0) { existingTrade.PnlPercent = (existingTrade.PnlAbsolute / existingTrade.PositionSize) * 100m; } _dbContext.Trades.Update(existingTrade); await _dbContext.SaveChangesAsync(cancellationToken); _logger.LogInformation("[{Channel}] Successfully ACCEPTED and UPDATED trade {TradeId} for ISIN {Isin}, UserId: {UserId}", "TradesChannel", existingTrade.TradeId, existingTrade.Isin, existingTrade.UserId); return existingTrade; } var proposal = await _dbContext.Trades .FirstOrDefaultAsync(t => t.IsGlobalProposal && (!string.IsNullOrEmpty(request.AnalysisId) ? t.AnalysisId == request.AnalysisId : t.Isin == request.Isin), cancellationToken); var targetTradeId = !string.IsNullOrWhiteSpace(request.TradeId) ? request.TradeId : ("TRD-" + Guid.NewGuid().ToString("N")[..10].ToUpperInvariant()); var newTrade = new TradeEntity { TradeId = targetTradeId, AnalysisId = proposal?.AnalysisId ?? (string.IsNullOrWhiteSpace(request.AnalysisId) ? Guid.NewGuid().ToString("N") : request.AnalysisId), EventId = proposal?.EventId ?? request.AnalysisId, Sector = proposal?.Sector ?? request.Sector ?? "General", Symbol = proposal?.Symbol ?? request.Symbol ?? request.Isin, Isin = proposal?.Isin ?? request.Isin, CompanyName = proposal?.CompanyName ?? request.CompanyName ?? request.Symbol ?? request.Isin, Status = TradeStatus.Active, IsGlobalProposal = false, UserId = targetUserId, EntryPrice = proposal?.EntryPrice ?? request.EntryPrice ?? request.ActualEntryPrice ?? 0m, StopLoss = request.StopLoss > 0 ? request.StopLoss.Value : (proposal?.StopLoss ?? 0m), TakeProfit = request.TakeProfit > 0 ? request.TakeProfit.Value : (proposal?.TakeProfit ?? 0m), SignalType = proposal?.SignalType ?? request.SignalType ?? "BUY", RiskTolerance = proposal?.RiskTolerance ?? "Moderate", Timeframe = proposal?.Timeframe ?? request.Timeframe ?? "1D", InstrumentType = proposal?.InstrumentType ?? request.InstrumentType ?? "Stock", DerivativeIsin = request.DerivativeIsin ?? proposal?.DerivativeIsin, WinRate = proposal?.WinRate ?? 50, VixRegime = proposal?.VixRegime ?? FinlyticCore.Models.Analyzer.VixMarketRegime.Normal, VixValue = proposal?.VixValue ?? 15, Reasoning = proposal?.Reasoning ?? request.Reasoning ?? "User Accepted Trade", EntryZoneMin = proposal?.EntryZoneMin, EntryZoneMax = proposal?.EntryZoneMax, TakeProfitTargets = proposal?.TakeProfitTargets, RiskRewardRatio = proposal?.RiskRewardRatio, MaxLeverage = proposal?.MaxLeverage, TechnicalRationale = proposal?.TechnicalRationale ?? string.Empty, FundamentalRationale = proposal?.FundamentalRationale ?? string.Empty, RiskWarning = proposal?.RiskWarning ?? string.Empty, CreatedAt = DateTime.UtcNow, ActualEntryPrice = request.ActualEntryPrice > 0 ? request.ActualEntryPrice : (proposal?.EntryPrice ?? request.EntryPrice ?? 0m), PositionSize = request.PositionSize, LeverageUsed = request.LeverageUsed > 0 ? request.LeverageUsed : 1m, EntryFee = request.EntryFee, ExitFee = request.ExitFee, ExecutionTimestamp = request.ExecutionTimestamp?.ToUniversalTime() ?? DateTime.UtcNow, Quantity = request.Quantity, KnockoutThreshold = request.KnockoutThreshold, IsRecurring = request.IsRecurring }; newTrade.PnlAbsolute = -(newTrade.EntryFee ?? 0m) - (newTrade.ExitFee ?? 0m); if (newTrade.PositionSize > 0) { newTrade.PnlPercent = (newTrade.PnlAbsolute / newTrade.PositionSize) * 100m; } _dbContext.Trades.Add(newTrade); await _dbContext.SaveChangesAsync(cancellationToken); _logger.LogInformation("[{Channel}] Successfully created active trade {TradeId} for ISIN {Isin}, UserId: {UserId}", "TradesChannel", newTrade.TradeId, request.Isin, newTrade.UserId); return newTrade; } public async Task AddHourlyUpdateAsync(TradeHourlyUpdateDto update, CancellationToken cancellationToken = default) { var trade = await _dbContext.Trades .FirstOrDefaultAsync(t => t.TradeId == update.TradeId || t.Id.ToString() == update.TradeId, cancellationToken); if (trade == null || (trade.Status != TradeStatus.Active && trade.Status != TradeStatus.Proposed)) { _logger.LogWarning("[{Channel}] Cannot add hourly update: Trade {TradeId} not found or not active/proposed.", "TradesChannel", update.TradeId); return; } var updateEntity = new TradeHourlyUpdateEntity { TradeId = trade.Id, Recommendation = update.Recommendation, CurrentPrice = update.CurrentPrice, SuggestedStopLoss = update.SuggestedStopLoss, SuggestedTakeProfit = update.SuggestedTakeProfit, VixValue = update.VixValue, Reasoning = update.Reasoning, Timestamp = update.Timestamp }; _dbContext.TradeHourlyUpdates.Add(updateEntity); if (update.SuggestedStopLoss.HasValue && update.SuggestedStopLoss > 0) trade.StopLoss = update.SuggestedStopLoss.Value; if (update.SuggestedTakeProfit.HasValue && update.SuggestedTakeProfit > 0) trade.TakeProfit = update.SuggestedTakeProfit.Value; if (string.Equals(update.Recommendation, "Close", StringComparison.OrdinalIgnoreCase)) { if (trade.IsGlobalProposal || trade.Status == TradeStatus.Proposed) { trade.Status = TradeStatus.Invalidated; trade.CloseReason = "ProposalInvalidated"; trade.ClosedAt = DateTime.UtcNow; } else { // NO AUTO CLOSE for active user trades! // Trade remains Active, alert is stored in HourlyUpdates and surfaced in UI for manual confirmation. _logger.LogInformation("[{Channel}] Active trade {TradeId} received Close recommendation ({Reasoning}). Trade kept Active for user action.", "TradesChannel", trade.TradeId, update.Reasoning); } } await _dbContext.SaveChangesAsync(cancellationToken); _logger.LogInformation("[{Channel}] Added hourly update for Trade {TradeId}. Recommendation: {Rec}, Price: {Price}", "TradesChannel", update.TradeId, update.Recommendation, update.CurrentPrice); } public async Task> GetActiveTradesAsync(string? userId = null, CancellationToken cancellationToken = default) { var query = _dbContext.Trades.AsNoTracking().Include(t => t.HourlyUpdates).AsQueryable(); if (!string.IsNullOrWhiteSpace(userId)) { query = query.Where(t => t.UserId == userId || t.IsGlobalProposal); } return await query .Where(t => t.Status == TradeStatus.Active || t.Status == TradeStatus.Proposed) .OrderByDescending(t => t.CreatedAt) .ToListAsync(cancellationToken); } public async Task> GetTradesAsync(string? isin, string? status, string? userId = null, CancellationToken cancellationToken = default) { var query = _dbContext.Trades.AsNoTracking().Include(t => t.HourlyUpdates).AsQueryable(); if (!string.IsNullOrWhiteSpace(userId)) { query = query.Where(t => t.UserId == userId || t.IsGlobalProposal); } if (!string.IsNullOrWhiteSpace(isin)) { query = query.Where(t => t.Isin == isin); } if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse(status, true, out var parsedStatus)) { query = query.Where(t => t.Status == parsedStatus); } return await query.OrderByDescending(t => t.CreatedAt).ToListAsync(cancellationToken); } public async Task CloseTradeAsync(string tradeId, CloseTradeRequest request, CancellationToken cancellationToken = default) { var trade = await _dbContext.Trades .FirstOrDefaultAsync(t => t.TradeId == tradeId || t.Id.ToString() == tradeId, cancellationToken); if (trade == null) return null; trade.Status = TradeStatus.Closed; trade.UserExitPrice = request.UserExitPrice; trade.UserExitTimestamp = request.UserExitTimestamp?.ToUniversalTime() ?? DateTime.UtcNow; if (request.ExitFee > 0m) { trade.ExitFee = request.ExitFee; } trade.CloseReason = request.CloseReason; trade.ClosedAt = DateTime.UtcNow; CalculatePnL(trade); await _dbContext.SaveChangesAsync(cancellationToken); _logger.LogInformation("[{Channel}] Trade {TradeId} manually closed at price {ExitPrice}. PnL: {PnlAbs} ({PnlPct:F2}%)", "TradesChannel", trade.TradeId, trade.UserExitPrice, trade.PnlAbsolute, trade.PnlPercent); return trade; } public async Task RejectTradeAsync(string tradeId, CloseTradeRequest request, CancellationToken cancellationToken = default) { var trade = await _dbContext.Trades .FirstOrDefaultAsync(t => t.TradeId == tradeId || t.Id.ToString() == tradeId, cancellationToken); if (trade == null) return null; trade.Status = TradeStatus.Rejected; trade.CloseReason = request.CloseReason ?? "UserRejected"; trade.ClosedAt = DateTime.UtcNow; await _dbContext.SaveChangesAsync(cancellationToken); _logger.LogInformation("[{Channel}] Trade {TradeId} rejected by user.", "TradesChannel", trade.TradeId); return trade; } private static void MapProposalToEntity(TradeProposalDto dto, TradeEntity entity) { entity.AnalysisId = dto.AnalysisId; entity.EventId = dto.EventId; entity.UserId = !string.IsNullOrWhiteSpace(dto.UserId) ? dto.UserId : (entity.UserId ?? "default_user"); entity.IsGlobalProposal = dto.IsGlobalProposal; entity.Sector = dto.Sector; entity.Symbol = dto.Symbol; entity.Isin = dto.Isin; entity.CompanyName = dto.CompanyName; entity.EntryPrice = dto.EntryPrice; entity.StopLoss = dto.StopLoss; entity.TakeProfit = dto.TakeProfit; entity.SignalType = dto.SignalType; entity.RiskTolerance = dto.RiskTolerance; entity.Timeframe = dto.Timeframe; entity.InstrumentType = dto.InstrumentType; if (!string.IsNullOrWhiteSpace(dto.AssetType)) entity.AssetType = dto.AssetType; entity.HasCfd = dto.HasCfd; if (dto.DerivativeProductCategories.Count > 0) entity.DerivativeProductCategories = dto.DerivativeProductCategories; if (!string.IsNullOrWhiteSpace(dto.DerivativeIsin)) entity.DerivativeIsin = dto.DerivativeIsin; entity.WinRate = dto.WinRate; entity.VixRegime = dto.VixRegime; entity.VixValue = dto.VixValue; entity.TtlMinutes = dto.TtlMinutes; entity.Reasoning = dto.Reasoning; entity.EntryZoneMin = dto.EntryZoneMin; entity.EntryZoneMax = dto.EntryZoneMax; entity.TakeProfitTargets = dto.TakeProfitTargets != null ? string.Join(",", dto.TakeProfitTargets) : entity.TakeProfitTargets; entity.RiskRewardRatio = dto.RiskRewardRatio; entity.MaxLeverage = dto.MaxLeverage; entity.TechnicalRationale = dto.TechnicalRationale; entity.FundamentalRationale = dto.FundamentalRationale; entity.RiskWarning = dto.RiskWarning; if (dto.ActualEntryPrice.HasValue) entity.ActualEntryPrice = dto.ActualEntryPrice; if (dto.PositionSize.HasValue) entity.PositionSize = dto.PositionSize; if (dto.LeverageUsed.HasValue) entity.LeverageUsed = dto.LeverageUsed; if (dto.EntryFee.HasValue) entity.EntryFee = dto.EntryFee; if (dto.ExitFee.HasValue) entity.ExitFee = dto.ExitFee; if (dto.ExecutionTimestamp.HasValue) entity.ExecutionTimestamp = dto.ExecutionTimestamp; if (dto.Quantity.HasValue) entity.Quantity = dto.Quantity; if (dto.KnockoutThreshold.HasValue) entity.KnockoutThreshold = dto.KnockoutThreshold; entity.IsRecurring = dto.IsRecurring; } public void CalculatePnL(TradeEntity trade, decimal? overridePrice = null) { decimal? evalPrice = overridePrice ?? trade.UserExitPrice ?? trade.HourlyUpdates?.LastOrDefault()?.CurrentPrice; if (!evalPrice.HasValue || evalPrice.Value <= 0m) return; decimal exitPrice = evalPrice.Value; decimal entryPrice = trade.ActualEntryPrice.HasValue && trade.ActualEntryPrice.Value > 0m ? trade.ActualEntryPrice.Value : trade.EntryPrice; if (entryPrice <= 0m) return; decimal positionSize = trade.PositionSize.HasValue && trade.PositionSize.Value > 0m ? trade.PositionSize.Value : ((trade.Quantity ?? 1m) * entryPrice); decimal entryFee = trade.EntryFee ?? 0m; decimal exitFee = trade.ExitFee ?? 0m; decimal totalFees = entryFee + exitFee; decimal rawMoveRatio; bool isShort = string.Equals(trade.SignalType, "SELL", StringComparison.OrdinalIgnoreCase) || string.Equals(trade.SignalType, "SHORT", StringComparison.OrdinalIgnoreCase); if (isShort) { rawMoveRatio = (entryPrice - exitPrice) / entryPrice; } else { rawMoveRatio = (exitPrice - entryPrice) / entryPrice; } decimal pnlAbs; if (string.Equals(trade.InstrumentType, "KnockOut", StringComparison.OrdinalIgnoreCase) || string.Equals(trade.InstrumentType, "Certificate", StringComparison.OrdinalIgnoreCase) || string.Equals(trade.InstrumentType, "Option", StringComparison.OrdinalIgnoreCase)) { pnlAbs = (rawMoveRatio * positionSize) - totalFees; } else { decimal leverage = trade.LeverageUsed > 0m ? trade.LeverageUsed.Value : 1m; pnlAbs = (rawMoveRatio * positionSize * leverage) - totalFees; } trade.PnlAbsolute = Math.Round(pnlAbs, 4); trade.PnlPercent = positionSize > 0m ? Math.Round((pnlAbs / positionSize) * 100.0m, 2) : 0m; trade.IsWin = pnlAbs > 0m; } }