diff --git a/src/dotnet/QuantEngine.Application/Services/FormulaService.cs b/src/dotnet/QuantEngine.Application/Services/FormulaService.cs index 42813dbb..dbe8e276 100644 --- a/src/dotnet/QuantEngine.Application/Services/FormulaService.cs +++ b/src/dotnet/QuantEngine.Application/Services/FormulaService.cs @@ -16,14 +16,14 @@ namespace QuantEngine.Application.Services _learningService = learningService; } - public TimingDecisionResult ComputeTimingDecision(Dictionary ctx) - => FormulaEngine.ComputeTimingDecision(ctx); + public TimingDecisionResult ComputeTimingDecision(Dictionary ctx) + => FormulaEngine.ComputeTimingDecision(RequireContext(ctx)); - public SellDecisionResult ComputeSellDecision(Dictionary ctx) - => FormulaEngine.ComputeSellDecision(ctx); + public SellDecisionResult ComputeSellDecision(Dictionary ctx) + => FormulaEngine.ComputeSellDecision(RequireContext(ctx)); - public FinalDecisionResult ComputeFinalDecision(Dictionary ctx) - => FormulaEngine.ComputeFinalDecision(ctx); + public FinalDecisionResult ComputeFinalDecision(Dictionary ctx) + => FormulaEngine.ComputeFinalDecision(RequireContext(ctx)); public async Task ComputeAndRecordFinalDecisionAsync( Dictionary ctx, @@ -32,17 +32,18 @@ namespace QuantEngine.Application.Services string sourceVersion, IEnumerable factorEvidence) { - var decision = ComputeFinalDecision(ctx); + var normalizedContext = RequireContext(ctx); + var decision = ComputeFinalDecision(normalizedContext); return await _learningService.RecordDecisionAsync( - decisionKey, + RequireValue(decisionKey, nameof(decisionKey)), DateTimeOffset.UtcNow, - instrumentId, + RequireValue(instrumentId, nameof(instrumentId)), decision.FinalAction, "PASS", Convert.ToDecimal(decision.PriorityScore), - sourceVersion, + RequireValue(sourceVersion, nameof(sourceVersion)), factorEvidence, - new { context_keys = ctx.Keys.OrderBy(key => key).ToArray() }, + new { context_keys = normalizedContext.Keys.OrderBy(key => key).ToArray() }, new { formula = "FormulaEngine.ComputeFinalDecision", source_version = sourceVersion }); } @@ -59,6 +60,22 @@ namespace QuantEngine.Application.Services => FormulaEngine.ComputeCashRecoveryOptimizer(sellCandidates, cashShortfallMinKrw); public Task AppendFormulaRunAsync(string formulaName, Dictionary payload) - => _historyStore.AppendAsync($"formula_{formulaName}_history", payload); + => _historyStore.AppendAsync($"formula_{RequireValue(formulaName, nameof(formulaName))}_history", payload); + + private static Dictionary RequireContext(Dictionary ctx) + { + ArgumentNullException.ThrowIfNull(ctx); + return ctx; + } + + private static string RequireValue(string value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("Value is required.", parameterName); + } + + return value.Trim(); + } } } diff --git a/src/dotnet/QuantEngine.Core.Tests/FormulaServiceTests.cs b/src/dotnet/QuantEngine.Core.Tests/FormulaServiceTests.cs new file mode 100644 index 00000000..63594305 --- /dev/null +++ b/src/dotnet/QuantEngine.Core.Tests/FormulaServiceTests.cs @@ -0,0 +1,48 @@ +using Moq; +using QuantEngine.Application.Services; +using QuantEngine.Core.Domain; +using QuantEngine.Core.Interfaces; + +namespace QuantEngine.Core.Tests; + +public class FormulaServiceTests +{ + [Fact] + public async Task ComputeAndRecordFinalDecisionAsync_NormalizesInputs() + { + var historyStore = new Mock(MockBehavior.Strict); + var learningStore = new Mock(MockBehavior.Strict); + + learningStore.Setup(s => s.AppendDecisionAsync(It.IsAny())) + .ReturnsAsync(Guid.NewGuid()); + + var service = new FormulaService(historyStore.Object, new DecisionLearningService(learningStore.Object)); + + var result = await service.ComputeAndRecordFinalDecisionAsync( + new Dictionary + { + ["entryModeGate"] = "PASS", + ["entryMode"] = "PULLBACK", + ["leaderGate"] = "PASS", + ["acGate"] = "CLEAR", + ["priceStatus"] = "PRICE_OK", + ["atr20"] = 1.5 + }, + " decision-1 ", + " 005930 ", + " v1 ", + []); + + Assert.NotEqual(Guid.Empty, result); + learningStore.VerifyAll(); + } + + [Fact] + public async Task AppendFormulaRunAsync_RejectsBlankName() + { + var service = new FormulaService(new Mock().Object, new DecisionLearningService(new Mock().Object)); + + await Assert.ThrowsAsync(() => + service.AppendFormulaRunAsync(" ", new Dictionary())); + } +}