using System; using System.Collections.Generic; using Xunit; using QuantEngine.Core.Domain; namespace QuantEngine.Core.Tests { public class ExitDecisionsTests { [Fact] public void ComputeStopPriceCore_AtrBased_ReturnsCorrectPrice() { // ATR 2.0배 기반 계산 검증 var res = ExitDecisions.ComputeStopPriceCore( entryPrice: 100000, atr20: 3000, currentPrice: 100000, atrMultiplier: 2.0 ); Assert.Equal("PASS", res.StopPriceStatus); Assert.Equal(94000, res.StopPrice); // 100000 - 3000 * 2.0 = 94000 } [Fact] public void ComputeStopPriceCore_FallbackPrice_Returns8PercentDown() { // 결측인 경우 8% 하락 폴백 가격으로 설정 검증 var res = ExitDecisions.ComputeStopPriceCore( entryPrice: 100000, atr20: null, currentPrice: null, atrMultiplier: null ); Assert.Contains("DATA_MISSING", res.StopPriceStatus); Assert.Equal(92000, res.StopPrice); // 100000 * 0.92 = 92000 } [Fact] public void ComputeStopPriceCore_AtrPercentBased_SetsCorrectMultiplier() { // ATR 비율에 따른 동적 승수 선택 검증 (atr20=10000, current=100000 -> atr20Pct = 10% >= 8% -> multiplier = 2.0) var res = ExitDecisions.ComputeStopPriceCore( entryPrice: 100000, atr20: 10000, currentPrice: 100000, atrMultiplier: null ); Assert.Equal("PASS", res.StopPriceStatus); Assert.Equal(2.0, res.AtrMultiplier); Assert.Equal(92000, res.StopPrice); // Max(92000, 100000 - 10000 * 2.0) = Max(92000, 80000) = 92000 } [Theory] [InlineData("STOP_OR_TIME_EXIT_READY", 4, "EXIT_100", "STOP_OR_TIME_EXIT_READY")] [InlineData("NORMAL_TRADING", 4, "EXIT_100", "RW_EXIT_STRONG")] [InlineData("NORMAL_TRADING", 1, "REGIME_TRIM_50", "REGIME_RISK_OFF")] // REGIME_PRELIM="RISK_OFF" [InlineData("NORMAL_TRADING", 1, "TRIM_70", "TIMING_EXIT_SCORE")] // timingExitScore = 75 [InlineData("NORMAL_TRADING", 1, "TRIM_50", "TRAILING_STOP_BREACH")] // trailingStopBreach = true [InlineData("NORMAL_TRADING", 0, "TIME_EXIT_100", "TIME_STOP_EXPIRED")] // daysToTimeStop = 0 public void ComputeStopActionLadder_Scenarios_ReturnExpectedAction( string timingAction, int rwPartial, string expectedAction, string expectedReason) { var ctx = new Dictionary { { "timingAction", timingAction }, { "rw_partial", rwPartial }, { "REGIME_PRELIM", expectedReason == "REGIME_RISK_OFF" ? "RISK_OFF" : "RISK_ON" }, { "timingExitScore", expectedReason == "TIMING_EXIT_SCORE" ? 75.0 : 0.0 }, { "trailingStopBreach", expectedReason == "TRAILING_STOP_BREACH" }, { "daysToTimeStop", expectedReason == "TIME_STOP_EXPIRED" ? 0 : 9999 } }; var res = ExitDecisions.ComputeStopActionLadder(ctx); Assert.Equal(expectedAction, res.Action); Assert.Equal(expectedReason, res.Reason); } [Theory] [InlineData("EVENT_SHOCK", 5.0, 3.5)] [InlineData("RISK_OFF", 7.0, 5.0)] [InlineData("SECULAR_LEADER_RISK_ON", 13.0, 9.0)] [InlineData("RISK_ON", 12.0, 8.5)] [InlineData("NEUTRAL", 10.0, 7.0)] public void ComputeDynamicHeatThresholds_Regimes_ReturnCorrectThresholds( string regime, double expectedHard, double expectedHalve) { var res = ExitDecisions.ComputeDynamicHeatThresholds(regime); Assert.Equal(expectedHard, res.HardBlock); Assert.Equal(expectedHalve, res.Halve); } } }