From 6e666844f0af493987abd86a7c8c6e225499767d Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Sun, 12 Jul 2026 11:53:17 +0900 Subject: [PATCH] feat: add canonical decision learning service --- .../Services/DecisionLearningService.cs | 101 ++++++++++++++++++ src/dotnet/QuantEngine.Web/Program.cs | 1 + 2 files changed, 102 insertions(+) create mode 100644 src/dotnet/QuantEngine.Application/Services/DecisionLearningService.cs diff --git a/src/dotnet/QuantEngine.Application/Services/DecisionLearningService.cs b/src/dotnet/QuantEngine.Application/Services/DecisionLearningService.cs new file mode 100644 index 00000000..926e0526 --- /dev/null +++ b/src/dotnet/QuantEngine.Application/Services/DecisionLearningService.cs @@ -0,0 +1,101 @@ +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) + { + var decisionId = await _store.AppendDecisionAsync(new DecisionEventRecord( + decisionKey, + decidedAt, + instrumentId, + action, + gate, + score, + sourceVersion, + JsonSerializer.Serialize(trace ?? new { }), + JsonSerializer.Serialize(provenance ?? new { }))); + + foreach (var factor in factors) + { + var observationId = await _store.AppendSourceObservationAsync(new SourceObservationRecord( + factor.ObservedAt, + instrumentId, + factor.SourceName, + sourceVersion, + factor.PayloadJson, + factor.ProvenanceJson)); + var factorObservationId = await _store.AppendFactorObservationAsync(new FactorObservationRecord( + observationId, + factor.FactorObservationId, + factor.FactorId, + factor.FactorVersion, + factor.ObservedAt, + factor.NumericValue, + factor.TextValue, + factor.Gate, + factor.ProvenanceJson)); + await _store.AppendDecisionFactorEvidenceAsync(decisionId, factorObservationId, factor.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, + JsonSerializer.Serialize(provenance ?? new { }))); + } +} + +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); diff --git a/src/dotnet/QuantEngine.Web/Program.cs b/src/dotnet/QuantEngine.Web/Program.cs index 194bdd57..19de1372 100644 --- a/src/dotnet/QuantEngine.Web/Program.cs +++ b/src/dotnet/QuantEngine.Web/Program.cs @@ -97,6 +97,7 @@ try builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped();