using System; using System.Collections.Generic; using System.IO; using System.Text.Json; using Xunit; using QuantEngine.Core.Domain; namespace QuantEngine.Core.Tests.ParityTests { public class ParityFixture : IDisposable { public int TotalTests = 0; public int PassedTests = 0; private readonly object _lock = new object(); public void RegisterResult(bool passed) { lock (_lock) { TotalTests++; if (passed) PassedTests++; } } public void Dispose() { string? tempDir = null; var current = new DirectoryInfo(AppContext.BaseDirectory); while (current != null) { if (Directory.Exists(Path.Combine(current.FullName, ".git"))) { tempDir = Path.Combine(current.FullName, "Temp"); break; } current = current.Parent; } if (tempDir == null) { tempDir = Path.Combine(Directory.GetCurrentDirectory(), "Temp"); } if (!Directory.Exists(tempDir)) { Directory.CreateDirectory(tempDir); } var outputPath = Path.Combine(tempDir, "dotnet_domain_parity_v1.json"); var result = new { gate = PassedTests == TotalTests && TotalTests >= 40 ? "PASS" : "FAIL", total = TotalTests, passed = PassedTests }; File.WriteAllText(outputPath, JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true })); } } public class DomainParityTests : IClassFixture { private readonly ParityFixture _fixture; public DomainParityTests(ParityFixture fixture) { _fixture = fixture; } [Theory] [InlineData(100000.0, 3000.0, 100000.0, 2.0, 94000.0)] [InlineData(100000.0, 3000.0, 100000.0, 1.5, 95500.0)] [InlineData(50000.0, 1500.0, 50000.0, 2.0, 47000.0)] [InlineData(50000.0, null, null, null, 46000.0)] [InlineData(10000.0, 500.0, 10000.0, null, 9250.0)] // Fix expected value to 9250.0 based on 1.5x ATR multiplier (ATR 5.0% < 8.0%) [InlineData(80000.0, 2000.0, 80000.0, 2.0, 76000.0)] [InlineData(200000.0, 5000.0, 200000.0, 1.5, 192500.0)] [InlineData(150000.0, 4000.0, 150000.0, 2.0, 142000.0)] [InlineData(300000.0, 8000.0, 300000.0, 1.5, 288000.0)] [InlineData(120000.0, 3000.0, 120000.0, 2.0, 114000.0)] public void StopPriceParity_MatchesPython(double entry, double? atr, double? current, double? mult, double expectedStop) { bool success = false; try { var res = ExitDecisions.ComputeStopPriceCore(entry, atr, current, mult); Assert.NotNull(res.StopPrice); Assert.InRange(res.StopPrice.Value, expectedStop * 0.9999, expectedStop * 1.0001); success = true; } finally { _fixture.RegisterResult(success); } } [Fact] public void StopPriceParity_HandlesMissingEntryPrice() { bool success = false; try { var res = ExitDecisions.ComputeStopPriceCore(null, 3000.0, 100000.0, 2.0); Assert.Null(res.StopPrice); Assert.Equal("NO_STOP_PRICE", res.StopPriceStatus); Assert.Contains("entry_price", res.DataMissing); success = true; } finally { _fixture.RegisterResult(success); } } [Fact] public void StopPriceParity_HandlesMissingAtrAndMultiplier() { bool success = false; try { var res = ExitDecisions.ComputeStopPriceCore(100000.0, null, null, null); Assert.Equal(92000.0, res.StopPrice); Assert.Equal("DATA_MISSING — 하네스 업데이트 필요", res.StopPriceStatus); Assert.Contains("atr20", res.DataMissing); success = true; } finally { _fixture.RegisterResult(success); } } [Theory] [InlineData("STOP_OR_TIME_EXIT_READY", 0, "RISK_ON", 0.0, false, 9999, "EXIT_100")] [InlineData("NORMAL", 4, "RISK_ON", 0.0, false, 9999, "EXIT_100")] [InlineData("NORMAL", 1, "RISK_OFF", 0.0, false, 9999, "REGIME_TRIM_50")] [InlineData("NORMAL", 1, "RISK_OFF_CANDIDATE", 0.0, false, 9999, "REGIME_TRIM_50")] [InlineData("NORMAL", 1, "RISK_ON", 75.0, false, 9999, "TRIM_70")] [InlineData("NORMAL", 3, "RISK_ON", 0.0, false, 9999, "TRIM_70")] [InlineData("NORMAL", 1, "RISK_ON", 0.0, true, 9999, "TRIM_50")] [InlineData("NORMAL", 2, "RISK_ON", 0.0, false, 9999, "TRIM_50")] [InlineData("NORMAL", 1, "RISK_ON", 50.0, false, 9999, "TRIM_50")] [InlineData("NORMAL", 0, "RISK_ON", 15.0, false, 9999, "TAKE_PROFIT_TIER1")] [InlineData("NORMAL", 0, "RISK_ON", 0.0, false, 0, "TIME_EXIT_100")] [InlineData("NORMAL", 0, "RISK_ON", 0.0, false, 9999, "REVIEW_HUMAN")] public void StopActionLadderParity_MatchesPython( string timingAction, int rwPartial, string regime, double param1, bool trailingStop, int daysToTimeStop, string expectedAction) { bool success = false; try { var ctx = new Dictionary { { "timingAction", timingAction }, { "rw_partial", rwPartial }, { "REGIME_PRELIM", regime }, { "trailingStopBreach", trailingStop }, { "daysToTimeStop", daysToTimeStop } }; if (expectedAction == "TAKE_PROFIT_TIER1") { ctx["profitPct"] = param1; } else { ctx["timingExitScore"] = param1; } var res = ExitDecisions.ComputeStopActionLadder(ctx); Assert.Equal(expectedAction, res.Action); success = true; } finally { _fixture.RegisterResult(success); } } [Theory] [InlineData("EVENT_SHOCK", 5.0, 3.5)] [InlineData("RISK_OFF", 7.0, 5.0)] [InlineData("SECULAR_LEADER_RISK_ON", 13.0, 9.0)] public void HeatThresholdParity_MatchesPython(string regime, double expectedHard, double expectedHalve) { bool success = false; try { var res = ExitDecisions.ComputeDynamicHeatThresholds(regime); Assert.Equal(expectedHard, res.HardBlock); Assert.Equal(expectedHalve, res.Halve); success = true; } finally { _fixture.RegisterResult(success); } } [Fact] public void HeatThresholdParity_DefaultsToBaseThreshold() { bool success = false; try { var res = ExitDecisions.ComputeDynamicHeatThresholds(""); Assert.Equal(10.0, res.HardBlock); Assert.Equal(7.0, res.Halve); success = true; } finally { _fixture.RegisterResult(success); } } [Theory] [InlineData(-5.0, "NORMAL")] [InlineData(5.0, "BREAKEVEN_RATCHET")] [InlineData(15.0, "PROFIT_LOCK_10")] [InlineData(25.0, "PROFIT_LOCK_20")] [InlineData(35.0, "PROFIT_LOCK_30")] [InlineData(45.0, "APEX_TRAILING")] [InlineData(65.0, "APEX_SUPER")] public void ProfitLockParity_MatchesPython(double profitPct, string expectedStage) { bool success = false; try { var stage = ProfitLockCalculator.ClassifyProfitLockStage(profitPct); Assert.Equal(expectedStage, stage); success = true; } finally { _fixture.RegisterResult(success); } } [Theory] [InlineData(1500.0, 1)] [InlineData(4500.0, 5)] [InlineData(15000.0, 10)] [InlineData(45000.0, 50)] [InlineData(150000.0, 100)] [InlineData(450000.0, 500)] [InlineData(1000000.0, 1000)] [InlineData(3000000.0, 1000)] public void KrxTickParity_MatchesPython(double price, int expectedTick) { bool success = false; try { int tick = KrxTickNormalizer.GetTickUnit(price); Assert.Equal(expectedTick, tick); success = true; } finally { _fixture.RegisterResult(success); } } [Fact] public void TimingDecisionParity_RejectsInvalidMarketData() { bool success = false; try { var ctx = new Dictionary { { "priceStatus", "PRICE_MISSING" } }; var res = FormulaEngine.ComputeTimingDecision(ctx); Assert.Equal("OBSERVE_DATA_MISSING", res.Action); success = true; } finally { _fixture.RegisterResult(success); } } } }