159 lines
6.7 KiB
C#
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
|
|
);
|
|
}
|
|
}
|