using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using FinlyticCore.Dtos.Trading; using FinlyticEngine.Database; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace FinlyticEngine.Services.Trading; /// /// Serves the admin-only evaluation-history query (MqttTopics.Channels.EngineGetEvaluationHistory) over /// EngineEvaluationSnapshotEntity. Deliberately kept as its own focused interface rather than folded /// into : this is a read-only reporting/audit query with none of /// 's dependencies (AI gate, derivative resolver, composite scorer) and a /// completely different caller (the admin Web UI tab, not the trading pipeline) - mirroring how /// , and /// are already separate, single-purpose services instead /// of being methods on . /// public interface IEvaluationHistoryService { /// /// Returns a filtered, paginated page of evaluation-history rows plus a pre-aggregated summary over the /// same (unpaginated) filtered set. See and /// for the exact filter/aggregation semantics. /// Task GetHistoryAsync(GetEvaluationHistoryRequest request, CancellationToken cancellationToken = default); } public class EvaluationHistoryService : IEvaluationHistoryService { /// /// Hard cap on so a caller cannot force FinlyticEngine /// to materialize/transmit an unbounded result set in a single response (Rules.md-style defensive default, /// requested explicitly by the task brief). /// private const int MaxPageSize = 200; private const int DefaultPageSize = 50; private readonly IServiceScopeFactory _scopeFactory; public EvaluationHistoryService(IServiceScopeFactory scopeFactory) { _scopeFactory = scopeFactory; } /// public async Task GetHistoryAsync(GetEvaluationHistoryRequest request, CancellationToken cancellationToken = default) { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); int page = Math.Max(1, request.Page); int pageSize = Math.Clamp(request.PageSize <= 0 ? DefaultPageSize : request.PageSize, 1, MaxPageSize); var query = db.Snapshots.AsNoTracking().AsQueryable(); if (request.FromUtc.HasValue) { query = query.Where(s => s.EvaluatedAtUtc >= request.FromUtc.Value); } if (request.ToUtc.HasValue) { query = query.Where(s => s.EvaluatedAtUtc <= request.ToUtc.Value); } if (request.OutcomeFilter.HasValue) { query = query.Where(s => s.OutcomeReason == request.OutcomeFilter.Value); } if (request.TriggerSourceFilter.HasValue) { query = query.Where(s => s.TriggerSource == request.TriggerSourceFilter.Value); } if (!string.IsNullOrWhiteSpace(request.IsinOrSymbolSearch)) { var term = request.IsinOrSymbolSearch.Trim(); query = query.Where(s => s.Isin.Contains(term) || s.Symbol.Contains(term)); } int totalCount = await query.CountAsync(cancellationToken); var pageEntities = await query .OrderByDescending(s => s.EvaluatedAtUtc) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(cancellationToken); var entries = pageEntities.Select(MapSnapshotToDto).ToList(); // Summary is computed over the SAME filtered (but unpaginated) set as the page above - see // EvaluationHistorySummaryDto's doc comment for why, and why LastProposalCreatedAtUtc is the one // deliberate exception that ignores the From/To filters. var outcomeCounts = await query .GroupBy(s => s.OutcomeReason) .Select(g => new OutcomeReasonCountDto(g.Key, g.Count())) .ToListAsync(cancellationToken); decimal averageScore = totalCount > 0 ? Math.Round(await query.AverageAsync(s => s.CompositeOpportunityScore, cancellationToken), 2) : 0m; int proposalsCreated = outcomeCounts.FirstOrDefault(c => c.OutcomeReason == OutcomeReason.Approved)?.Count ?? 0; DateTime? lastProposalCreatedAtUtc = await db.TradeProposals.AsNoTracking() .OrderByDescending(p => p.CreatedAtUtc) .Select(p => (DateTime?)p.CreatedAtUtc) .FirstOrDefaultAsync(cancellationToken); var summary = new EvaluationHistorySummaryDto( TotalEvaluations: totalCount, CountsByOutcome: outcomeCounts, AverageCompositeScore: averageScore, ProposalsCreated: proposalsCreated, LastProposalCreatedAtUtc: lastProposalCreatedAtUtc ); return new GetEvaluationHistoryResponse(totalCount, entries, summary); } /// /// Maps a persisted row 1:1 onto its wire DTO. /// private static EvaluationHistoryEntryDto MapSnapshotToDto(Database.Entities.EngineEvaluationSnapshotEntity e) { return new EvaluationHistoryEntryDto( Id: e.Id, Isin: e.Isin, Symbol: e.Symbol, TechnicalScore: e.TechnicalScore, SentimentScore: e.SentimentScore, FundamentalScore: e.FundamentalScore, CompositeOpportunityScore: e.CompositeOpportunityScore, ReliabilityBonus: e.ReliabilityBonus, PassedEarningsLockout: e.PassedEarningsLockout, DaysToNextEarnings: e.DaysToNextEarnings, PassedDividendGate: e.PassedDividendGate, DaysToNextExDividend: e.DaysToNextExDividend, UniverseSource: e.UniverseSource, UniverseEnteredAtUtc: e.UniverseEnteredAtUtc, PassedSimulationVeto: e.PassedSimulationVeto, PassedAiValidation: e.PassedAiValidation, AiThesisSummary: e.AiThesisSummary, OutcomeReason: e.OutcomeReason, TriggerSource: e.TriggerSource, TriggeredByUserId: e.TriggeredByUserId, ProposalId: e.ProposalId, EvaluatedAtUtc: e.EvaluatedAtUtc ); } }