using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using FinlyticCore.Dtos; using FinlyticCore.Dtos.Fundamentals; using FinlyticCore.Dtos.Sentiment; using FinlyticCore.Dtos.TechnicalAnalysis; using FinlyticCore.Dtos.Trading; using FinlyticCore.Services; using FinlyticCore.Util; using FinlyticEngine.Database; using FinlyticEngine.Database.Entities; using FinlyticEngine.Services.Ai; using FinlyticEngine.Services.Derivatives; using FinlyticEngine.Services.Mqtt; using FinlyticEngine.Services.Scoring; using FinlyticEngine.Settings; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace FinlyticEngine.Services.Trading; public class TradeLifecycleService : ITradeLifecycleService { private readonly IServiceScopeFactory _scopeFactory; private readonly ICompositeOpportunityScorer _scorer; private readonly IAiReasoningGateService _aiGate; private readonly IKnockOutDerivativeResolver _derivativeResolver; private readonly IEngineRpcClient _rpcClient; private readonly ISettingsService _settingsService; private readonly IFinlyticLogger _logger; public TradeLifecycleService( IServiceScopeFactory scopeFactory, ICompositeOpportunityScorer scorer, IAiReasoningGateService aiGate, IKnockOutDerivativeResolver derivativeResolver, IEngineRpcClient rpcClient, ISettingsService settingsService, IFinlyticLogger logger) { _scopeFactory = scopeFactory; _scorer = scorer; _aiGate = aiGate; _derivativeResolver = derivativeResolver; _rpcClient = rpcClient; _settingsService = settingsService; _logger = logger; } public async Task> GetProposalsAsync(bool onlyActive = true, int limit = 50, CancellationToken cancellationToken = default) { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var query = db.TradeProposals.AsNoTracking(); if (onlyActive) { var now = DateTime.UtcNow; query = query.Where(p => p.IsActive && p.ExpiresAtUtc > now); } var list = await query .OrderByDescending(p => p.CompositeScore) .Take(limit) .ToListAsync(cancellationToken); return list.Select(MapProposalEntityToDto).ToList(); } public async Task> GetActiveTradesAsync(Guid userId, ExecutionMode? mode = null, CancellationToken cancellationToken = default) { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); // Tenant boundary: applied before any other predicate so another user's rows are never materialised. var query = db.Trades .Include(t => t.Fills) .AsNoTracking() .Where(t => t.UserId == userId) .Where(t => t.Status != TradeStatus.Closed && t.Status != TradeStatus.StoppedOut && t.Status != TradeStatus.Invalidated && t.Status != TradeStatus.Expired); if (mode.HasValue) { query = query.Where(t => t.ExecutionMode == mode.Value); } var list = await query .OrderByDescending(t => t.OpenedAtUtc) .ToListAsync(cancellationToken); return list.Select(MapTradeEntityToDto).ToList(); } /// /// Builds an honest "nothing to evaluate" for the cases where the /// pipeline could not even produce a real score (blank ISIN, or no technical setups found). All score /// fields are 0/null rather than fabricated, and is prefixed with /// the same "[Regelbasiert]" marker uses for its /// fallback, so a caller/UI never mistakes this for a real AI /// verdict (Rules.md §4). /// private static AssetEvaluationResultDto BuildNoEvaluationResult(string reason) { return new AssetEvaluationResultDto( Proposal: null, CompositeScore: 0m, TechnicalScore: 0m, SentimentScore: 0m, FundamentalScore: 0m, PassedEarningsLockout: true, DaysToNextEarnings: null, PassedDividendGate: true, DaysToNextExDividend: null, AiApproved: false, AiThesisSummary: $"[Regelbasiert] {reason}", AiIdentifiedRisks: new List() ); } /// /// Persists an row for the two early-return cases in /// (blank ISIN, no technical setups) and returns the same /// DTO the caller would have received before these rows existed. /// All score fields are recorded as 0/default - identical to 's /// own honesty guarantee - since the pipeline never reached scoring for these two cases (Rules.md §4). /// /// The (possibly blank) ISIN to record on the snapshot row. /// Human-readable reason, reused verbatim from . /// Whether this evaluation was automatic or manual. /// The manual caller's identity, or for automatic runs. /// Propagated to the snapshot insert. private async Task PersistNoEvaluationSnapshotAsync( string isinForRecord, string reason, TriggerSource triggerSource, Guid? triggeredByUserId, CancellationToken cancellationToken) { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); db.Snapshots.Add(new EngineEvaluationSnapshotEntity { Id = Guid.NewGuid(), Isin = isinForRecord, Symbol = string.Empty, TechnicalScore = 0m, SentimentScore = 0m, FundamentalScore = 0m, CompositeOpportunityScore = 0m, ReliabilityBonus = 0m, PassedEarningsLockout = true, DaysToNextEarnings = null, PassedDividendGate = true, DaysToNextExDividend = null, UniverseSource = null, UniverseEnteredAtUtc = null, PassedSimulationVeto = true, PassedAiValidation = false, AiThesisSummary = $"[Regelbasiert] {reason}", TriggerSource = triggerSource, TriggeredByUserId = triggerSource == TriggerSource.Manual ? triggeredByUserId : null, OutcomeReason = OutcomeReason.NoTechnicalSetups, ProposalId = null, EvaluatedAtUtc = DateTime.UtcNow }); await db.SaveChangesAsync(cancellationToken); return BuildNoEvaluationResult(reason); } /// /// Derives which best explains a completed evaluation (i.e. one that reached /// scoring - the earlier "no technical setup" case always short-circuits to /// and never reaches this method). Note that a result of /// from this method is provisional: /// downgrades it to immediately afterwards if an /// active, non-expired proposal already exists for the same ISIN, since no second proposal row is created /// in that case. /// /// Priority order when more than one gate failed simultaneously (first match wins): /// /// /// /// - the AI reasoning gate approved the opportunity. /// /// /// - is /// . Checked before the score threshold even though the score gate is evaluated /// later in the pipeline, because the lockout's suppression multiplier /// (CompositeOpportunityScorer's mEarnings = 0.15) is usually *why* the score ended up below /// threshold in the first place - reporting only "score too low" would hide the actual, actionable cause. /// /// /// - is /// , for the same reason as the lockout case above (its own suppression multiplier, /// mVeto = 0.20, likewise drives the score down). /// /// /// - is /// . Checked last among the three suppression gates since it is the mildest /// (mDividend = 0.5 vs. earnings' 0.15 and the simulation veto's 0.20) - a predictable, mechanical /// ex-dividend price adjustment rather than a fundamental surprise or a failed backtest. /// /// /// - none of the three hard gates above fired, but /// is , meaning the composite score never reached /// Engine.MinCompositeScore and the evaluation was not forced, so the AI reasoning gate was never /// even consulted (a synthetic rule-based rejection was recorded instead). /// /// /// - everything upstream cleared ( /// is , both hard gates passed) but the AI reasoning gate itself - whether a real AI /// call or one of its own internal rule-based fallbacks (gate disabled, webhook unreachable) - still /// declined. This is deliberately the last, most specific fallback: everything else has already been /// ruled out by the time this is reached. /// /// /// /// AiValidationResultDto.IsApproved from the (possibly rule-based) AI gate result. /// ScoringResult.PassedEarningsLockout. /// ScoringResult.PassedSimulationVeto. /// /// Whether the composite score cleared Engine.MinCompositeScore or the evaluation was forced - i.e. /// the exact condition under which the AI reasoning gate was actually consulted rather than synthetically /// rejected. /// /// The single best-matching for this evaluation. private static OutcomeReason DetermineOutcomeReason( bool aiApproved, bool passedEarningsLockout, bool passedSimulationVeto, bool passedDividendGate, bool scoreGateOpened) { if (aiApproved) return OutcomeReason.Approved; if (!passedEarningsLockout) return OutcomeReason.EarningsLockout; if (!passedSimulationVeto) return OutcomeReason.SimulationVeto; if (!passedDividendGate) return OutcomeReason.DividendGate; if (!scoreGateOpened) return OutcomeReason.BelowScoreThreshold; return OutcomeReason.AiRejected; } /// public async Task EvaluateAssetAsync( string isin, string? ticker = null, bool forceAiEvaluation = false, TriggerSource triggerSource = TriggerSource.Automatic, Guid? triggeredByUserId = null, CancellationToken cancellationToken = default) { // Automatic runs never carry a user identity, enforced here regardless of what a caller passed in, so // a programming mistake upstream can never leak a stale/wrong UserId onto an automatic snapshot row. var effectiveTriggeredByUserId = triggerSource == TriggerSource.Manual ? triggeredByUserId : null; if (string.IsNullOrWhiteSpace(isin)) { return await PersistNoEvaluationSnapshotAsync( string.Empty, "Keine gültige ISIN angegeben.", triggerSource, effectiveTriggeredByUserId, cancellationToken); } var cleanIsin = isin.Trim().ToUpperInvariant(); await _logger.LogInfoAsync(EngineSettingKeys.EngineChannel, "[TradeLifecycle] Starting on-demand evaluation for ISIN {Isin} (Ticker: {Ticker})", cleanIsin, ticker ?? "N/A"); // 1. Fetch Technical Analysis Setups from FinlyticTechnicals var taSetups = await _rpcClient.SendRpcRequestAsync, IsinRequest>( MqttTopics.Channels.TaGetSetupsForIsin, new IsinRequest(cleanIsin, ticker, ForceRefresh: false), TimeSpan.FromSeconds(5) ); if (taSetups == null || taSetups.Count == 0) { await _logger.LogWarningAsync(EngineSettingKeys.EngineChannel, "[TradeLifecycle] No technical setups returned for {Isin}", cleanIsin); return await PersistNoEvaluationSnapshotAsync( cleanIsin, $"Keine technischen Setups für {cleanIsin} verfügbar.", triggerSource, effectiveTriggeredByUserId, cancellationToken); } // Pick top technical setup var bestSetup = taSetups.OrderByDescending(s => s.QualityScore).First(); // 2. Parallel Fetch: Sentiment, Fundamentals & Simulation Matrix var sentTask = _rpcClient.SendRpcRequestAsync( MqttTopics.Channels.SentimentGetIsin, new GetSentimentByIsinRequest(cleanIsin), TimeSpan.FromSeconds(3) ); var fundTask = _rpcClient.SendRpcRequestAsync( MqttTopics.Channels.FundamentalsGet, new IsinRequest(cleanIsin, ticker, ForceRefresh: false), TimeSpan.FromSeconds(4) ); var matrixTask = _rpcClient.SendRpcRequestAsync( MqttTopics.Channels.SimGetReliability, new FinlyticCore.Dtos.Simulation.GetReliabilityRequest(cleanIsin, bestSetup.StrategyKey), TimeSpan.FromSeconds(3) ); await Task.WhenAll(sentTask, fundTask, matrixTask); var sentiment = await sentTask; var fundamentals = await fundTask; var reliability = await matrixTask; // 3. Multi-Faktor Composite Opportunity Scoring (COS) with Simulation Feedback var scoringResult = await _scorer.CalculateCompositeScoreAsync(bestSetup, sentiment, fundamentals, reliability, cancellationToken); var minScore = await _settingsService.GetSettingAsync(EngineSettingKeys.MinCompositeScore, cancellationToken); // 4. AI Reasoning Gate // Captured explicitly (rather than re-evaluating the same expression later) because // DetermineOutcomeReason needs to know precisely whether the AI gate was ever consulted, to tell // apart OutcomeReason.BelowScoreThreshold (never consulted) from OutcomeReason.AiRejected (consulted, // declined) below. bool scoreGateOpened = scoringResult.CompositeScore >= minScore || forceAiEvaluation; AiValidationResultDto aiValidation; if (scoreGateOpened) { aiValidation = await _aiGate.ValidateOpportunityAsync(bestSetup, sentiment, fundamentals, scoringResult, reliability, cancellationToken); } else { aiValidation = new AiValidationResultDto( IsApproved: false, Confidence: null, Source: ValidationSource.RuleBased, ThesisSummary: $"[Regelbasiert] Score {scoringResult.CompositeScore:F1} liegt unter Mindestwert ({minScore:F1}).", InvalidationReason: "Unzureichende Multi-Faktor Confluence.", KeyCatalysts: new List(), IdentifiedRisks: new List { "Niedriger Gesamtscore" } ); } // 5. Knock-Out Derivative Selection DerivativeSelectionDto? selectedDerivative = null; if (aiValidation.IsApproved || forceAiEvaluation) { selectedDerivative = await _derivativeResolver.ResolveOptimalTurboAsync( cleanIsin, bestSetup.Direction, bestSetup.InvalidationPrice, bestSetup.CurrentPrice, cancellationToken ); } // 6. Persist Evaluation Snapshot & Proposal using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var outcomeReason = DetermineOutcomeReason( aiValidation.IsApproved, scoringResult.PassedEarningsLockout, scoringResult.PassedSimulationVeto, scoringResult.PassedDividendGate, scoreGateOpened); var snapshot = new EngineEvaluationSnapshotEntity { Id = Guid.NewGuid(), Isin = cleanIsin, Symbol = bestSetup.Symbol, TechnicalScore = scoringResult.TechnicalScore, SentimentScore = scoringResult.SentimentScore, FundamentalScore = scoringResult.FundamentalScore, CompositeOpportunityScore = scoringResult.CompositeScore, ReliabilityBonus = scoringResult.ReliabilityBonus, PassedEarningsLockout = scoringResult.PassedEarningsLockout, DaysToNextEarnings = scoringResult.DaysToNextEarnings, PassedDividendGate = scoringResult.PassedDividendGate, DaysToNextExDividend = scoringResult.DaysToNextExDividend, UniverseSource = bestSetup.UniverseSource, UniverseEnteredAtUtc = bestSetup.UniverseEnteredAtUtc, PassedSimulationVeto = scoringResult.PassedSimulationVeto, PassedAiValidation = aiValidation.IsApproved, AiThesisSummary = aiValidation.ThesisSummary, TriggerSource = triggerSource, TriggeredByUserId = effectiveTriggeredByUserId, OutcomeReason = outcomeReason, ProposalId = null, EvaluatedAtUtc = DateTime.UtcNow }; db.Snapshots.Add(snapshot); TradeProposalDto? proposalDto = null; if (aiValidation.IsApproved) { // Dedup guard: OpportunityPollerBackgroundService re-evaluates the same technical top-picks on // every scan cycle. Without this check, an asset that stays above the approval threshold for hours // gets a brand-new, near-identical EngineTradeProposalEntity - and a fresh // finlytic/engine/proposals/created broadcast to every connected client - every single cycle. This // was confirmed in production as the root cause of a single ISIN generating 1,310 proposal rows in // roughly two hours. An active, non-expired proposal already covering the same UnderlyingIsin means // the opportunity is already on offer, so no second row/broadcast is created for it. var existingActiveProposal = await db.TradeProposals .AsNoTracking() .Where(p => p.UnderlyingIsin == cleanIsin && p.IsActive && p.ExpiresAtUtc > DateTime.UtcNow) .OrderByDescending(p => p.CreatedAtUtc) .FirstOrDefaultAsync(cancellationToken); if (existingActiveProposal != null) { // The evaluation itself genuinely cleared every gate (PassedAiValidation on this snapshot row // stays true), but OutcomeReason records the real business outcome: no new proposal was made. outcomeReason = OutcomeReason.DuplicateActiveProposal; snapshot.OutcomeReason = outcomeReason; snapshot.ProposalId = existingActiveProposal.Id; await db.SaveChangesAsync(cancellationToken); // A manual "Analyze now" call for an asset that already has an open proposal should still // surface that proposal, not falsely report "no proposal" (Rules.md §4). proposalDto = MapProposalEntityToDto(existingActiveProposal); } else { var proposalValidityHours = await _settingsService.GetSettingAsync(EngineSettingKeys.ProposalValidityHours, cancellationToken); decimal takeProfit1 = bestSetup.ExitPlan.TakeProfitStages.Count > 0 ? bestSetup.ExitPlan.TakeProfitStages[0].TargetPrice : (bestSetup.Direction == SignalDirection.Buy ? bestSetup.EntryPrice * 1.05m : bestSetup.EntryPrice * 0.95m); var proposalEntity = new EngineTradeProposalEntity { Id = Guid.NewGuid(), UnderlyingIsin = cleanIsin, Symbol = bestSetup.Symbol, StrategyKey = bestSetup.StrategyKey, Direction = bestSetup.Direction, QualityScore = bestSetup.QualityScore, CompositeScore = scoringResult.CompositeScore, CurrentPrice = bestSetup.CurrentPrice, EntryPrice = bestSetup.EntryPrice, StopLoss = bestSetup.InvalidationPrice, TakeProfit1 = takeProfit1, RiskRewardRatio = bestSetup.EstimatedRiskRewardRatio, ExitPlan = bestSetup.ExitPlan, SelectedDerivative = selectedDerivative, AiValidation = aiValidation, IsActive = true, CreatedAtUtc = DateTime.UtcNow, ExpiresAtUtc = DateTime.UtcNow.AddHours(proposalValidityHours) }; // Link the snapshot row to the proposal it produced (both are still unsaved/tracked here, so // this just needs to happen before the single SaveChangesAsync below persists both). snapshot.ProposalId = proposalEntity.Id; db.TradeProposals.Add(proposalEntity); await db.SaveChangesAsync(cancellationToken); proposalDto = MapProposalEntityToDto(proposalEntity); // Broadcast MQTT Push Event for new proposal await _rpcClient.PublishAsync("finlytic/engine/proposals/created", proposalDto); } } else { await db.SaveChangesAsync(cancellationToken); } // Whether approved or rejected, the caller always receives the real, already-computed scores and AI // reasoning — never bare silence for a rejection (Rules.md §4). return new AssetEvaluationResultDto( Proposal: proposalDto, CompositeScore: scoringResult.CompositeScore, TechnicalScore: scoringResult.TechnicalScore, SentimentScore: scoringResult.SentimentScore, FundamentalScore: scoringResult.FundamentalScore, PassedEarningsLockout: scoringResult.PassedEarningsLockout, DaysToNextEarnings: scoringResult.DaysToNextEarnings, PassedDividendGate: scoringResult.PassedDividendGate, DaysToNextExDividend: scoringResult.DaysToNextExDividend, AiApproved: aiValidation.IsApproved, AiThesisSummary: aiValidation.ThesisSummary, AiIdentifiedRisks: aiValidation.IdentifiedRisks ); } public async Task CreateTradeFromProposalAsync( Guid userId, Guid proposalId, ExecutionMode mode, decimal? initialFillPrice = null, decimal? initialQuantity = null, CancellationToken cancellationToken = default) { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); // Only a still-active, non-expired proposal may be accepted. Proposals invalidate themselves purely // via ExpiresAtUtc (see EvaluateAssetAsync) — there is no separate "reject" path that deactivates them. var now = DateTime.UtcNow; var proposal = await db.TradeProposals .FirstOrDefaultAsync(p => p.Id == proposalId && p.IsActive && p.ExpiresAtUtc > now, cancellationToken); if (proposal == null) return null; // A proposal is a system-wide opportunity, not a per-user resource: it is deliberately NOT consumed or // deactivated here so other users may still accept it independently. What must be prevented is the same // user accepting the same proposal twice, which would otherwise silently create a second, redundant trade. var alreadyAccepted = await db.Trades .AnyAsync(t => t.UserId == userId && t.ProposalId == proposalId, cancellationToken); if (alreadyAccepted) { throw new InvalidOperationException( $"User {userId} has already accepted proposal {proposalId}; a duplicate trade was not created."); } var fillPrice = initialFillPrice ?? proposal.EntryPrice; var fillQty = initialQuantity ?? 1m; var trade = new EngineTradeEntity { Id = Guid.NewGuid(), UserId = userId, ProposalId = proposal.Id, UnderlyingIsin = proposal.UnderlyingIsin, Symbol = proposal.Symbol, DerivativeIsin = proposal.SelectedDerivative?.DerivativeIsin, DerivativeWkn = proposal.SelectedDerivative?.DerivativeWkn, ExecutionMode = mode, InstrumentType = proposal.SelectedDerivative != null ? (proposal.Direction == SignalDirection.Buy ? InstrumentCategoryType.TurboLong : InstrumentCategoryType.TurboShort) : InstrumentCategoryType.Stock, Direction = proposal.Direction, Status = TradeStatus.Active, AverageBuyIn = fillPrice, TotalQuantity = fillQty, InitialStopLoss = proposal.StopLoss, CurrentStopLoss = proposal.StopLoss, CurrentPrice = fillPrice, TakeProfit1 = proposal.TakeProfit1, TakeProfit2 = proposal.ExitPlan.TakeProfitStages.Count > 1 ? proposal.ExitPlan.TakeProfitStages[1].TargetPrice : proposal.TakeProfit1 * 1.05m, ExitPlan = proposal.ExitPlan, OpenedAtUtc = DateTime.UtcNow, LastUpdatedAtUtc = DateTime.UtcNow }; var initialFill = new EngineTradeFillEntity { Id = Guid.NewGuid(), TradeId = trade.Id, Trade = trade, ExecutedAtUtc = DateTime.UtcNow, Price = fillPrice, Quantity = fillQty, Fee = 1.0m, Note = "Initial Entry Fill" }; // trade is a brand-new root here, so db.Trades.Add(trade) cascades Added through the whole graph // (including Fills) on its own — the explicit db.TradeFills.Add is redundant but keeps this call site // consistent with AddTradeFillAsync, where it is NOT redundant (see the comment there). trade.Fills.Add(initialFill); db.Trades.Add(trade); db.TradeFills.Add(initialFill); await db.SaveChangesAsync(cancellationToken); var tradeDto = MapTradeEntityToDto(trade); await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", tradeDto); return tradeDto; } public async Task AcceptProposalAsync(AcceptTradeProposalRequest request, CancellationToken cancellationToken = default) { // ExecutionMode.ManualTradeRepublic is hardcoded here (rather than taken from the request) because this // RPC channel exists specifically for the human-driven Web/App acceptance flow, where a user reviews a // proposal in Trade Republic and confirms a manual fill. The autonomous paper-trading bot never calls // this endpoint — it executes proposals itself via FinlyticBot, which uses its own dedicated code path // instead of AcceptProposalAsync. var trade = await CreateTradeFromProposalAsync( request.UserId, request.ProposalId, ExecutionMode.ManualTradeRepublic, request.ExecutedPrice, request.Quantity, cancellationToken); if (trade == null) { throw new InvalidOperationException( $"Proposal {request.ProposalId} does not exist, is no longer active, or has expired."); } return trade; } public async Task CreateManualTradeAsync(CreateManualTradeRequest request, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(request.UnderlyingIsin)) { throw new ArgumentException("UnderlyingIsin must not be blank.", nameof(request)); } if (string.IsNullOrWhiteSpace(request.Symbol)) { throw new ArgumentException("Symbol must not be blank.", nameof(request)); } if (request.EntryPrice <= 0m) { throw new ArgumentException("EntryPrice must be positive.", nameof(request)); } if (request.Quantity <= 0m) { throw new ArgumentException("Quantity must be positive.", nameof(request)); } using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var takeProfit1 = request.TakeProfit1; var takeProfit2 = request.TakeProfit2 ?? takeProfit1; var exitPlan = new ExitPlan( StrategyType: ExitStrategyType.FixedSingleTarget, InitialStopLoss: request.InitialStopLoss, TakeProfitStages: new List { new(StageNumber: 1, TargetPrice: takeProfit1, PercentToClose: 100m, RMultiple: 1m, Description: "Manuelles Kursziel (kein Proposal)") }); var trade = new EngineTradeEntity { Id = Guid.NewGuid(), UserId = request.UserId, // No backing proposal: Guid.Empty signals "manually opened" (see doc comment on // CreateManualTradeRequest / ITradeLifecycleService.CreateManualTradeAsync). ProposalId = Guid.Empty, UnderlyingIsin = request.UnderlyingIsin.Trim().ToUpperInvariant(), Symbol = request.Symbol, DerivativeIsin = request.DerivativeIsin, DerivativeWkn = request.DerivativeWkn, ExecutionMode = ExecutionMode.ManualTradeRepublic, InstrumentType = request.InstrumentType, Direction = request.Direction, Status = TradeStatus.Active, AverageBuyIn = request.EntryPrice, TotalQuantity = request.Quantity, InitialStopLoss = request.InitialStopLoss, CurrentStopLoss = request.InitialStopLoss, CurrentPrice = request.EntryPrice, TakeProfit1 = takeProfit1, TakeProfit2 = takeProfit2, TotalFeesEur = request.Fee, ExitPlan = exitPlan, OpenedAtUtc = DateTime.UtcNow, LastUpdatedAtUtc = DateTime.UtcNow }; var initialFill = new EngineTradeFillEntity { Id = Guid.NewGuid(), TradeId = trade.Id, Trade = trade, ExecutedAtUtc = DateTime.UtcNow, Price = request.EntryPrice, Quantity = request.Quantity, Fee = request.Fee, Note = "Manual Entry (no proposal)" }; // trade is a brand-new root here, so db.Trades.Add(trade) cascades Added through the whole graph // (including Fills) on its own — the explicit db.TradeFills.Add is redundant but keeps this call site // consistent with AddTradeFillAsync, where it is NOT redundant (see the comment there). trade.Fills.Add(initialFill); db.Trades.Add(trade); db.TradeFills.Add(initialFill); await db.SaveChangesAsync(cancellationToken); var dto = MapTradeEntityToDto(trade); await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", dto); return dto; } public async Task AddTradeFillAsync( Guid userId, Guid tradeId, decimal executedPrice, decimal quantity, decimal fee = 0m, string? note = null, CancellationToken cancellationToken = default) { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var trade = await db.Trades .Include(t => t.Fills) .FirstOrDefaultAsync(t => t.Id == tradeId && t.UserId == userId, cancellationToken); if (trade == null) throw new InvalidOperationException($"Trade with ID {tradeId} not found."); var fill = new EngineTradeFillEntity { Id = Guid.NewGuid(), TradeId = trade.Id, Trade = trade, ExecutedAtUtc = DateTime.UtcNow, Price = executedPrice, Quantity = quantity, Fee = fee, Note = note }; // Explicitly track the new fill as Added via the DbSet, not just via collection-navigation fixup. // A fill's Id is a client-generated Guid (set above), so if this entity only entered the change // tracker through `trade.Fills.Add(fill)` on an already-tracked trade, EF Core cannot use "default // key value => Added" as its heuristic (the key is never default) and instead discovers the object as // Unchanged, then promotes it to Modified once DetectChanges sees its properties differ from nothing — // producing an UPDATE for a row that was never inserted (DbUpdateConcurrencyException: 0 rows // affected). db.TradeFills.Add(fill) marks it Added unambiguously; trade.Fills.Add(fill) is still // needed so the in-memory graph/DTO mapping below sees the new fill. db.TradeFills.Add(fill); trade.Fills.Add(fill); // Recalculate Dynamic Average Buy-In: Sum(P * Q) / Sum(Q) decimal totalValue = trade.Fills.Sum(f => f.Price * f.Quantity); decimal totalQty = trade.Fills.Sum(f => f.Quantity); if (totalQty > 0) { trade.AverageBuyIn = Math.Round(totalValue / totalQty, 4); trade.TotalQuantity = totalQty; } trade.TotalFeesEur = trade.Fills.Sum(f => f.Fee); trade.Status = TradeStatus.Active; trade.LastUpdatedAtUtc = DateTime.UtcNow; // Recalculate Dynamic R-Levels & Take-Profits based on new AverageBuyIn decimal unitRisk = Math.Abs(trade.AverageBuyIn - trade.InitialStopLoss); if (unitRisk > 0) { if (trade.Direction == SignalDirection.Buy) { trade.TakeProfit1 = trade.AverageBuyIn + (1.0m * unitRisk); trade.TakeProfit2 = trade.AverageBuyIn + (2.0m * unitRisk); } else { trade.TakeProfit1 = trade.AverageBuyIn - (1.0m * unitRisk); trade.TakeProfit2 = trade.AverageBuyIn - (2.0m * unitRisk); } } await db.SaveChangesAsync(cancellationToken); await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel, "[TradeLifecycle] Fill added to trade {TradeId}: Qty={Qty}, Price={Price:F2}, New AverageBuyIn={BuyIn:F4}, TotalQty={TotalQty}", trade.Id, quantity, executedPrice, trade.AverageBuyIn, trade.TotalQuantity); var dto = MapTradeEntityToDto(trade); await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", dto); return dto; } public async Task UpdateStopLossAsync( Guid userId, Guid tradeId, decimal newStopLoss, string reason, CancellationToken cancellationToken = default) { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var trade = await db.Trades .Include(t => t.Fills) .FirstOrDefaultAsync(t => t.Id == tradeId && t.UserId == userId, cancellationToken); if (trade == null) throw new InvalidOperationException($"Trade with ID {tradeId} not found."); decimal oldSl = trade.CurrentStopLoss; trade.CurrentStopLoss = newStopLoss; trade.LastUpdatedAtUtc = DateTime.UtcNow; await db.SaveChangesAsync(cancellationToken); await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel, "[TradeLifecycle] Stop Loss updated for trade {TradeId} from {OldSl:F2} to {NewSl:F2}. Reason: {Reason}", trade.Id, oldSl, newStopLoss, reason); var dto = MapTradeEntityToDto(trade); await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", dto); return dto; } public async Task CloseTradeAsync( Guid userId, Guid tradeId, decimal closePrice, string reason, CancellationToken cancellationToken = default) { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var trade = await db.Trades .Include(t => t.Fills) .FirstOrDefaultAsync(t => t.Id == tradeId && t.UserId == userId, cancellationToken); if (trade == null) throw new InvalidOperationException($"Trade with ID {tradeId} not found."); trade.Status = TradeStatus.Closed; trade.ClosedAtUtc = DateTime.UtcNow; trade.CurrentPrice = closePrice; trade.LastUpdatedAtUtc = DateTime.UtcNow; // Realized PnL Calculation if (trade.Direction == SignalDirection.Buy) { trade.RealizedPnlEur = ((closePrice - trade.AverageBuyIn) * trade.TotalQuantity) - trade.TotalFeesEur; } else { trade.RealizedPnlEur = ((trade.AverageBuyIn - closePrice) * trade.TotalQuantity) - trade.TotalFeesEur; } await db.SaveChangesAsync(cancellationToken); await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel, "[TradeLifecycle] Trade {TradeId} closed at {Price:F2} (PnL: {PnL:F2} €). Reason: {Reason}", trade.Id, closePrice, trade.RealizedPnlEur, reason); var dto = MapTradeEntityToDto(trade); await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", dto); return dto; } private static TradeProposalDto MapProposalEntityToDto(EngineTradeProposalEntity e) { return new TradeProposalDto( ProposalId: e.Id, UnderlyingIsin: e.UnderlyingIsin, Symbol: e.Symbol, StrategyKey: e.StrategyKey, Direction: e.Direction, QualityScore: e.QualityScore, CompositeScore: e.CompositeScore, CurrentPrice: e.CurrentPrice, EntryPrice: e.EntryPrice, InvalidationPrice: e.StopLoss, ExitPlan: e.ExitPlan, SelectedDerivative: e.SelectedDerivative, AiValidation: e.AiValidation, CreatedAtUtc: e.CreatedAtUtc, ExpiresAtUtc: e.ExpiresAtUtc ); } private static ActiveTradeDto MapTradeEntityToDto(EngineTradeEntity e) { decimal unrealizedPnlEur = 0m; decimal unrealizedPnlPercent = 0m; if (e.AverageBuyIn > 0 && e.TotalQuantity > 0 && e.CurrentPrice > 0) { if (e.Direction == SignalDirection.Buy) { unrealizedPnlEur = (e.CurrentPrice - e.AverageBuyIn) * e.TotalQuantity; unrealizedPnlPercent = ((e.CurrentPrice - e.AverageBuyIn) / e.AverageBuyIn) * 100m; } else { unrealizedPnlEur = (e.AverageBuyIn - e.CurrentPrice) * e.TotalQuantity; unrealizedPnlPercent = ((e.AverageBuyIn - e.CurrentPrice) / e.AverageBuyIn) * 100m; } } return new ActiveTradeDto( TradeId: e.Id, ProposalId: e.ProposalId, UnderlyingIsin: e.UnderlyingIsin, Symbol: e.Symbol, DerivativeIsin: e.DerivativeIsin, DerivativeWkn: e.DerivativeWkn, ExecutionMode: e.ExecutionMode, InstrumentType: e.InstrumentType, Direction: e.Direction, Status: e.Status, AverageBuyIn: e.AverageBuyIn, TotalQuantity: e.TotalQuantity, InitialStopLoss: e.InitialStopLoss, CurrentStopLoss: e.CurrentStopLoss, CurrentPrice: e.CurrentPrice, UnrealizedPnlEur: Math.Round(unrealizedPnlEur, 2), UnrealizedPnlPercent: Math.Round(unrealizedPnlPercent, 2), RealizedPnlEur: Math.Round(e.RealizedPnlEur, 2), ExitPlan: e.ExitPlan, Fills: e.Fills.Select(f => new TradeFillDto( FillId: f.Id, ExecutedAtUtc: f.ExecutedAtUtc, Price: f.Price, Quantity: f.Quantity, Fee: f.Fee, Note: f.Note )).ToList(), OpenedAtUtc: e.OpenedAtUtc, ClosedAtUtc: e.ClosedAtUtc ); } }