refactor(dotnet): normalize decision learning records
This commit is contained in:
@@ -25,6 +25,12 @@ public sealed class DecisionLearningService
|
|||||||
object? trace = null,
|
object? trace = null,
|
||||||
object? provenance = 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(
|
var decisionId = await _store.AppendDecisionAsync(new DecisionEventRecord(
|
||||||
decisionKey,
|
decisionKey,
|
||||||
decidedAt,
|
decidedAt,
|
||||||
@@ -33,29 +39,30 @@ public sealed class DecisionLearningService
|
|||||||
gate,
|
gate,
|
||||||
score,
|
score,
|
||||||
sourceVersion,
|
sourceVersion,
|
||||||
JsonSerializer.Serialize(trace ?? new { }),
|
SerializeJson(trace),
|
||||||
JsonSerializer.Serialize(provenance ?? new { })));
|
SerializeJson(provenance)));
|
||||||
|
|
||||||
foreach (var factor in factors)
|
foreach (var factor in factors ?? throw new ArgumentNullException(nameof(factors)))
|
||||||
{
|
{
|
||||||
|
var normalizedFactor = NormalizeFactor(factor);
|
||||||
var observationId = await _store.AppendSourceObservationAsync(new SourceObservationRecord(
|
var observationId = await _store.AppendSourceObservationAsync(new SourceObservationRecord(
|
||||||
factor.ObservedAt,
|
normalizedFactor.ObservedAt,
|
||||||
instrumentId,
|
instrumentId,
|
||||||
factor.SourceName,
|
normalizedFactor.SourceName,
|
||||||
sourceVersion,
|
sourceVersion,
|
||||||
factor.PayloadJson,
|
normalizedFactor.PayloadJson,
|
||||||
factor.ProvenanceJson));
|
normalizedFactor.ProvenanceJson));
|
||||||
var factorObservationId = await _store.AppendFactorObservationAsync(new FactorObservationRecord(
|
var factorObservationId = await _store.AppendFactorObservationAsync(new FactorObservationRecord(
|
||||||
observationId,
|
observationId,
|
||||||
factor.FactorObservationId,
|
normalizedFactor.FactorObservationId,
|
||||||
factor.FactorId,
|
normalizedFactor.FactorId,
|
||||||
factor.FactorVersion,
|
normalizedFactor.FactorVersion,
|
||||||
factor.ObservedAt,
|
normalizedFactor.ObservedAt,
|
||||||
factor.NumericValue,
|
normalizedFactor.NumericValue,
|
||||||
factor.TextValue,
|
normalizedFactor.TextValue,
|
||||||
factor.Gate,
|
normalizedFactor.Gate,
|
||||||
factor.ProvenanceJson));
|
normalizedFactor.ProvenanceJson));
|
||||||
await _store.AppendDecisionFactorEvidenceAsync(decisionId, factorObservationId, factor.Role);
|
await _store.AppendDecisionFactorEvidenceAsync(decisionId, factorObservationId, normalizedFactor.Role);
|
||||||
}
|
}
|
||||||
|
|
||||||
return decisionId;
|
return decisionId;
|
||||||
@@ -83,7 +90,36 @@ public sealed class DecisionLearningService
|
|||||||
excessReturn,
|
excessReturn,
|
||||||
outcomeClass,
|
outcomeClass,
|
||||||
evaluationGate,
|
evaluationGate,
|
||||||
JsonSerializer.Serialize(provenance ?? new { })));
|
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))
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using Moq;
|
||||||
|
using QuantEngine.Application.Services;
|
||||||
|
using QuantEngine.Core.Interfaces;
|
||||||
|
|
||||||
|
namespace QuantEngine.Core.Tests;
|
||||||
|
|
||||||
|
public class DecisionLearningServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task RecordDecisionAsync_TrimsAndPersistsNormalizedPayloads()
|
||||||
|
{
|
||||||
|
var store = new Mock<INormalizedLearningStore>(MockBehavior.Strict);
|
||||||
|
Guid decisionId = Guid.NewGuid();
|
||||||
|
Guid factorObservationId = Guid.NewGuid();
|
||||||
|
Guid sourceObservationId = Guid.NewGuid();
|
||||||
|
|
||||||
|
store.Setup(s => s.AppendDecisionAsync(It.Is<DecisionEventRecord>(r =>
|
||||||
|
r.DecisionKey == "decision-1" &&
|
||||||
|
r.InstrumentId == "005930" &&
|
||||||
|
r.Action == "BUY" &&
|
||||||
|
r.Gate == "PASS" &&
|
||||||
|
r.SourceVersion == "v1" &&
|
||||||
|
r.TraceJson.Contains("\"mode\":\"test\"") &&
|
||||||
|
r.ProvenanceJson.Contains("\"origin\":\"unit\"")))).ReturnsAsync(decisionId);
|
||||||
|
|
||||||
|
store.Setup(s => s.AppendSourceObservationAsync(It.Is<SourceObservationRecord>(r =>
|
||||||
|
r.InstrumentId == "005930" &&
|
||||||
|
r.SourceName == "kis" &&
|
||||||
|
r.SourceVersion == "v1" &&
|
||||||
|
r.PayloadJson == "{}" &&
|
||||||
|
r.ProvenanceJson == "{}"))).ReturnsAsync(sourceObservationId);
|
||||||
|
|
||||||
|
store.Setup(s => s.AppendFactorObservationAsync(It.Is<FactorObservationRecord>(r =>
|
||||||
|
r.ObservationId == sourceObservationId &&
|
||||||
|
r.FactorObservationId != Guid.Empty &&
|
||||||
|
r.FactorId == "factor_a" &&
|
||||||
|
r.FactorVersion == "1.0" &&
|
||||||
|
r.Gate == "PASS" &&
|
||||||
|
r.ProvenanceJson == "{}"))).ReturnsAsync(factorObservationId);
|
||||||
|
|
||||||
|
store.Setup(s => s.AppendDecisionFactorEvidenceAsync(decisionId, factorObservationId, "primary"))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var service = new DecisionLearningService(store.Object);
|
||||||
|
|
||||||
|
var result = await service.RecordDecisionAsync(
|
||||||
|
" decision-1 ",
|
||||||
|
DateTimeOffset.Parse("2026-07-13T00:00:00Z"),
|
||||||
|
" 005930 ",
|
||||||
|
" BUY ",
|
||||||
|
" PASS ",
|
||||||
|
12.5m,
|
||||||
|
" v1 ",
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new FactorEvidenceInput(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
" factor_a ",
|
||||||
|
" 1.0 ",
|
||||||
|
DateTimeOffset.Parse("2026-07-13T00:00:00Z"),
|
||||||
|
3.14m,
|
||||||
|
null,
|
||||||
|
" PASS ",
|
||||||
|
" primary ",
|
||||||
|
" kis ",
|
||||||
|
"{}",
|
||||||
|
"{}")
|
||||||
|
},
|
||||||
|
new { mode = "test" },
|
||||||
|
new { origin = "unit" });
|
||||||
|
|
||||||
|
Assert.Equal(decisionId, result);
|
||||||
|
store.VerifyAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RecordDecisionAsync_RejectsMissingFactors()
|
||||||
|
{
|
||||||
|
var service = new DecisionLearningService(new Mock<INormalizedLearningStore>().Object);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ArgumentNullException>(() => service.RecordDecisionAsync(
|
||||||
|
"decision-1",
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
"005930",
|
||||||
|
"BUY",
|
||||||
|
"PASS",
|
||||||
|
null,
|
||||||
|
"v1",
|
||||||
|
null!));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user