From a2db0bae004d8986b4a419324a1a49d849b44c73 Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Mon, 13 Jul 2026 01:22:45 +0900 Subject: [PATCH] refactor(dotnet): normalize history ingestion payloads --- .../Services/HistoryIngestionService.cs | 45 ++++++++++++---- .../HistoryIngestionServiceTests.cs | 51 +++++++++++++++++++ 2 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 src/dotnet/QuantEngine.Core.Tests/HistoryIngestionServiceTests.cs diff --git a/src/dotnet/QuantEngine.Application/Services/HistoryIngestionService.cs b/src/dotnet/QuantEngine.Application/Services/HistoryIngestionService.cs index 6a4d9275..40cea6f7 100644 --- a/src/dotnet/QuantEngine.Application/Services/HistoryIngestionService.cs +++ b/src/dotnet/QuantEngine.Application/Services/HistoryIngestionService.cs @@ -34,25 +34,32 @@ namespace QuantEngine.Application.Services string? sourceVersion = null, string? gate = null) { + ArgumentNullException.ThrowIfNull(decision); + + var normalizedInstrumentId = NormalizeOptional(instrumentId); + var normalizedSourceVersion = NormalizeOptional(sourceVersion) ?? RequireValue(decision.DecisionSource, nameof(decision.DecisionSource)); + var normalizedGate = NormalizeOptional(gate) ?? (string.IsNullOrWhiteSpace(sellDecision?.Validation) ? "PASS" : sellDecision.Validation!.Trim()); + var normalizedAction = RequireValue(decision.FinalAction, nameof(decision.FinalAction)); + var payload = new Dictionary { ["decision_id"] = Guid.NewGuid().ToString("N"), ["decided_at"] = DateTimeOffset.UtcNow, - ["instrument_id"] = instrumentId ?? string.Empty, - ["action"] = decision.FinalAction, - ["gate"] = gate ?? (string.IsNullOrWhiteSpace(sellDecision?.Validation) ? "PASS" : sellDecision.Validation), + ["instrument_id"] = normalizedInstrumentId ?? string.Empty, + ["action"] = normalizedAction, + ["gate"] = normalizedGate, ["score"] = decision.PriorityScore, - ["source_version"] = sourceVersion ?? decision.DecisionSource, + ["source_version"] = normalizedSourceVersion, ["provenance"] = new Dictionary { - ["final_action"] = decision.FinalAction, + ["final_action"] = normalizedAction, ["action_priority"] = decision.ActionPriority, ["priority_score"] = decision.PriorityScore, ["decision_source"] = decision.DecisionSource, - ["sell_action"] = sellDecision?.Action, - ["sell_validation"] = sellDecision?.Validation, - ["timing_action"] = timingDecision?.Action, - ["timing_reason"] = timingDecision?.Reason + ["sell_action"] = NormalizeOptional(sellDecision?.Action), + ["sell_validation"] = NormalizeOptional(sellDecision?.Validation), + ["timing_action"] = NormalizeOptional(timingDecision?.Action), + ["timing_reason"] = NormalizeOptional(timingDecision?.Reason) } }; @@ -67,6 +74,11 @@ namespace QuantEngine.Application.Services string? sourceVersion = null, DateTimeOffset? observedAt = null) { + factorId = RequireValue(factorId, nameof(factorId)); + factorVersion = RequireValue(factorVersion, nameof(factorVersion)); + outputGate = RequireValue(outputGate, nameof(outputGate)); + sourceVersion = NormalizeOptional(sourceVersion) ?? factorVersion; + var payload = new Dictionary { ["factor_output_id"] = Guid.NewGuid().ToString("N"), @@ -75,7 +87,7 @@ namespace QuantEngine.Application.Services ["factor_version"] = factorVersion, ["output_value"] = outputValue, ["output_gate"] = outputGate, - ["source_version"] = sourceVersion ?? factorVersion, + ["source_version"] = sourceVersion, ["provenance"] = new Dictionary { ["factor_id"] = factorId, @@ -88,5 +100,18 @@ namespace QuantEngine.Application.Services return _store.AppendAsync("factor_output_history", payload); } + + private static string RequireValue(string value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("Value is required.", parameterName); + } + + return value.Trim(); + } + + private static string? NormalizeOptional(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } } diff --git a/src/dotnet/QuantEngine.Core.Tests/HistoryIngestionServiceTests.cs b/src/dotnet/QuantEngine.Core.Tests/HistoryIngestionServiceTests.cs new file mode 100644 index 00000000..601391d1 --- /dev/null +++ b/src/dotnet/QuantEngine.Core.Tests/HistoryIngestionServiceTests.cs @@ -0,0 +1,51 @@ +using Moq; +using QuantEngine.Application.Services; +using QuantEngine.Core.Domain; +using QuantEngine.Core.Interfaces; + +namespace QuantEngine.Core.Tests; + +public class HistoryIngestionServiceTests +{ + [Fact] + public async Task AppendDecisionAsync_NormalizesTypedPayload() + { + var store = new Mock(MockBehavior.Strict); + store.Setup(s => s.AppendAsync("decision_result_history", It.Is>(payload => + payload["instrument_id"] != null && payload["instrument_id"]!.ToString() == "005930" && + payload["action"] != null && payload["action"]!.ToString() == "BUY" && + payload["gate"] != null && payload["gate"]!.ToString() == "PASS" && + payload["source_version"] != null && payload["source_version"]!.ToString() == "v1"))).ReturnsAsync(1); + + var service = new HistoryIngestionService(store.Object); + + var result = await service.AppendDecisionAsync( + new FinalDecisionResult + { + FinalAction = " BUY ", + ActionPriority = 1, + PriorityScore = 12.3, + DecisionSource = " v1 " + }, + new SellDecisionResult { Action = "SELL", Validation = " PASS " }, + null, + " 005930 ", + " ", + null); + + Assert.Equal(1, result); + store.VerifyAll(); + } + + [Fact] + public async Task AppendFactorOutputAsync_RejectsBlankCoreFields() + { + var service = new HistoryIngestionService(new Mock().Object); + + await Assert.ThrowsAsync(() => service.AppendFactorOutputAsync( + " ", + "1.0", + 1.0, + "PASS")); + } +}