138 lines
4.8 KiB
C#
138 lines
4.8 KiB
C#
using System.Text.Json;
|
|
using QuantEngine.Core.Interfaces;
|
|
|
|
namespace QuantEngine.Application.Services;
|
|
|
|
/// <summary>
|
|
/// Canonical application path for recording factor evidence, decisions, and
|
|
/// realized outcomes in the normalized PostgreSQL learning store.
|
|
/// </summary>
|
|
public sealed class DecisionLearningService
|
|
{
|
|
private readonly INormalizedLearningStore _store;
|
|
|
|
public DecisionLearningService(INormalizedLearningStore store) => _store = store;
|
|
|
|
public async Task<Guid> RecordDecisionAsync(
|
|
string decisionKey,
|
|
DateTimeOffset decidedAt,
|
|
string instrumentId,
|
|
string action,
|
|
string gate,
|
|
decimal? score,
|
|
string sourceVersion,
|
|
IEnumerable<FactorEvidenceInput> 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);
|