using System.Text.Json; using QuantEngine.Core.Interfaces; namespace QuantEngine.Application.Services; /// /// Canonical application path for recording factor evidence, decisions, and /// realized outcomes in the normalized PostgreSQL learning store. /// public sealed class DecisionLearningService { private readonly INormalizedLearningStore _store; public DecisionLearningService(INormalizedLearningStore store) => _store = store; public async Task RecordDecisionAsync( string decisionKey, DateTimeOffset decidedAt, string instrumentId, string action, string gate, decimal? score, string sourceVersion, IEnumerable factors, object? trace = null, object? provenance = null) { decisionKey = RequireValue(decisionKey, nameof(decisionKey)); instrumentId = RequireValue(instrumentId, nameof(instrumentId)); action = RequireValue(action, nameof(action)); gate = RequireValue(gate, nameof(gate)); sourceVersion = RequireValue(sourceVersion, nameof(sourceVersion)); var decisionId = await _store.AppendDecisionAsync(new DecisionEventRecord( decisionKey, decidedAt, instrumentId, action, gate, score, sourceVersion, SerializeJson(trace), SerializeJson(provenance))); foreach (var factor in factors ?? throw new ArgumentNullException(nameof(factors))) { var normalizedFactor = NormalizeFactor(factor); var observationId = await _store.AppendSourceObservationAsync(new SourceObservationRecord( normalizedFactor.ObservedAt, instrumentId, normalizedFactor.SourceName, sourceVersion, normalizedFactor.PayloadJson, normalizedFactor.ProvenanceJson)); var factorObservationId = await _store.AppendFactorObservationAsync(new FactorObservationRecord( observationId, normalizedFactor.FactorObservationId, normalizedFactor.FactorId, normalizedFactor.FactorVersion, normalizedFactor.ObservedAt, normalizedFactor.NumericValue, normalizedFactor.TextValue, normalizedFactor.Gate, normalizedFactor.ProvenanceJson)); await _store.AppendDecisionFactorEvidenceAsync(decisionId, factorObservationId, normalizedFactor.Role); } return decisionId; } public Task RecordOutcomeAsync( Guid decisionId, int horizonDays, DateTimeOffset evaluatedAt, decimal? realizedReturn, decimal? benchmarkReturn, string outcomeClass, string evaluationGate, object? provenance = null) { decimal? excessReturn = realizedReturn.HasValue && benchmarkReturn.HasValue ? realizedReturn.Value - benchmarkReturn.Value : null; return _store.AppendOutcomeAsync(new OutcomeEvaluationRecord( decisionId, horizonDays, evaluatedAt, realizedReturn, benchmarkReturn, excessReturn, outcomeClass, evaluationGate, SerializeJson(provenance))); } private static string SerializeJson(object? value) => JsonSerializer.Serialize(value ?? new { }); private static string RequireValue(string value, string parameterName) { if (string.IsNullOrWhiteSpace(value)) { throw new ArgumentException("Value is required.", parameterName); } return value.Trim(); } private static FactorEvidenceInput NormalizeFactor(FactorEvidenceInput factor) { ArgumentNullException.ThrowIfNull(factor); return factor with { FactorId = RequireValue(factor.FactorId, nameof(factor.FactorId)), FactorVersion = RequireValue(factor.FactorVersion, nameof(factor.FactorVersion)), SourceName = RequireValue(factor.SourceName, nameof(factor.SourceName)), PayloadJson = RequireValue(factor.PayloadJson, nameof(factor.PayloadJson)), ProvenanceJson = RequireValue(factor.ProvenanceJson, nameof(factor.ProvenanceJson)), Gate = RequireValue(factor.Gate, nameof(factor.Gate)), Role = RequireValue(factor.Role, nameof(factor.Role)) }; } } public sealed record FactorEvidenceInput( Guid FactorObservationId, string FactorId, string FactorVersion, DateTimeOffset ObservedAt, decimal? NumericValue, string? TextValue, string Gate, string Role, string SourceName, string PayloadJson, string ProvenanceJson);