Files
Finlytic/FinlyticEngine/Services/Trading/EvaluationHistoryService.cs
T

159 lines
6.7 KiB
C#

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;
/// <summary>
/// Serves the admin-only evaluation-history query (<c>MqttTopics.Channels.EngineGetEvaluationHistory</c>) over
/// <c>EngineEvaluationSnapshotEntity</c>. Deliberately kept as its own focused interface rather than folded
/// into <see cref="ITradeLifecycleService"/>: this is a read-only reporting/audit query with none of
/// <see cref="ITradeLifecycleService"/>'s dependencies (AI gate, derivative resolver, composite scorer) and a
/// completely different caller (the admin Web UI tab, not the trading pipeline) - mirroring how
/// <see cref="Scoring.ICompositeOpportunityScorer"/>, <see cref="Ai.IAiReasoningGateService"/> and
/// <see cref="Derivatives.IKnockOutDerivativeResolver"/> are already separate, single-purpose services instead
/// of being methods on <see cref="ITradeLifecycleService"/>.
/// </summary>
public interface IEvaluationHistoryService
{
/// <summary>
/// Returns a filtered, paginated page of evaluation-history rows plus a pre-aggregated summary over the
/// same (unpaginated) filtered set. See <see cref="GetEvaluationHistoryRequest"/> and
/// <see cref="EvaluationHistorySummaryDto"/> for the exact filter/aggregation semantics.
/// </summary>
Task<GetEvaluationHistoryResponse> GetHistoryAsync(GetEvaluationHistoryRequest request, CancellationToken cancellationToken = default);
}
public class EvaluationHistoryService : IEvaluationHistoryService
{
/// <summary>
/// Hard cap on <see cref="GetEvaluationHistoryRequest.PageSize"/> 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).
/// </summary>
private const int MaxPageSize = 200;
private const int DefaultPageSize = 50;
private readonly IServiceScopeFactory _scopeFactory;
public EvaluationHistoryService(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
/// <inheritdoc />
public async Task<GetEvaluationHistoryResponse> GetHistoryAsync(GetEvaluationHistoryRequest request, CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
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);
}
/// <summary>
/// Maps a persisted <see cref="Database.Entities.EngineEvaluationSnapshotEntity"/> row 1:1 onto its wire DTO.
/// </summary>
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
);
}
}