diff --git a/UnitTestProject/Modules/SwapModule/DealInterestsGoldenReplayTest.cs b/UnitTestProject/Modules/SwapModule/DealInterestsGoldenReplayTest.cs new file mode 100644 index 00000000..0542fb5b --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/DealInterestsGoldenReplayTest.cs @@ -0,0 +1,344 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// DealInterests Golden 回放测试 + /// ============================================================================ + /// 用 golden JSON 存"输入数据 + 期望输出的精确字段值", + /// 回放时从 JSON 重跑,逐字段精确对比。 + /// + /// 两类方法: + /// - Record*: 连库录制/生成 golden(标 Ignore,手动跑) + /// - Replay*: 读 golden 重跑对比(进 CI) + /// + /// 价值:重构时如果任何一步的输出变了(哪怕第8位小数),立刻失败。 + /// 守恒测试验证"大方向对",golden 验证"精确值对"。 + /// ============================================================================ + [TestClass] + public class DealInterestsGoldenReplayTest + { + private static readonly string GoldenDir = Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, "Resources", "GoldenFiles", "DealInterestsGolden"); + + #region Stub(复用 DealInterestsScenarioTest 的模式) + + private sealed class StubEodService : SwapEodPositionService + { + public List PersistedPositions { get; } = new(); + private int _nextId = 1; + + public StubEodService() : base(new OptUserInfo(0, nameof(DealInterestsGoldenReplayTest), OptUserFrom.UnitTest)) + { + } + + protected override void PersistEodSwapPosition(eod_swap_position position) + { + if (position.id == 0) position.id = _nextId++; + PersistedPositions.Add(position); + } + protected override void SaveAllChanges() { } + protected override double GetCurrencyRate(string q, string s, DateTime d, bool p, CurrencyRateType t) => 1.0; + + public void ExecuteSaveEodInterestPosition( + eod_swap_position eodPayPosition, swap_position position, trade td, + DateTime valueDate, List flowEvents) + { + SaveEodInterestPosition(eodPayPosition, null, position, td, valueDate, flowEvents); + } + } + + #endregion + + #region 录制:生成 golden JSON(标 Ignore,手动跑) + + /// + /// 生成所有 golden JSON 文件。 + /// 手动取消 [Ignore] 运行,会覆盖 bin 目录下的 golden 文件。 + /// 生成后复制到 UnitTestProject/Resources/GoldenFiles/ 持久化。 + /// + [TestMethod] + [Ignore] + [TestCategory("GoldenRecord")] + public void Record_AllGoldenScenarios() + { + Directory.CreateDirectory(GoldenDir); + Record_SwapSettleZeroInterestIncomeSum(); + Record_NormalDayIncrement(); + Console.WriteLine($"\n录制完成,输出目录: {GoldenDir}"); + } + + /// 场景1:互换结清后 InterestIncomeSum≈当天新计 + private void Record_SwapSettleZeroInterestIncomeSum() + { + const decimal Principal = 10000m; + const decimal Rate = 0.03m; + const int AnnualDays = 365; + var startDate = new DateTime(2026, 4, 27); + + var td = new trade + { + id = 1, TradeNumber = "GOLDEN-001", ClientId = 999998, + TradeType = "收益互换", TradeDate = startDate, StartDate = startDate, + ExerciseDate = new DateTime(2027, 4, 27), TradeStatus = "确认成交", + ValidState = "Valid", StructureType = "单标的", + QuoteCurrency = "CNY", SettlementCurrency = "CNY", + trade_extend = new trade_extend { TradeId = 1, ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson { AnnualDays = AnnualDays, InterestCalcMode = "10", SettlementRules = 0 }) } + }; + var position = new swap_position + { + id = 1001, SwapTradeId = 1, InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.标的期初全价, InterestRateDefault = Rate, + InterestPrincipalFix = Principal, PosiStartDate = startDate, + PosiMatuirityDate = new DateTime(2027, 4, 27), IsInitial = true, + InterestType = (int)InterestTypeEnum.单利, IsAnnualized = true, + interest_rest_days = 1, interest_rule = 0, FloatRateUnderlyingCode = null + }; + var settleDate = startDate.AddDays(10); + int days = (settleDate - startDate).Days; + decimal accumulated = Math.Round(Principal * Rate * days / AnnualDays, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); + + var preEod = new eod_swap_position + { + id = 100, PositionId = 1001, ValueDate = settleDate.AddDays(-1), + InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)InterestModeEnum.标的期初全价, + InterestIncomeSum = accumulated, InterestProfitSum = accumulated, + InterestRateDefault = Rate, TdInterestPrincipal = Principal, + InterestType = (int)InterestTypeEnum.单利, IsAnnualized = true, interest_rest_days = 1 + }; + var swapEvent = new swap_flow_event + { + EventType = (int)SwapFlowEventTypeEnum.互换, PositionId = 1001, + InterestAmount = accumulated, InterestClosePnL = accumulated, + InterestRate = Rate, InterestMode = (int)InterestModeEnum.标的期初全价, + InterestPrincipal = Principal, FloatRate = 0m, + DataState = (int)SwapFlowDateStateEnum.完成 + }; + + var service = new StubEodService(); + service.ExecuteSaveEodInterestPosition(preEod, position, td, settleDate, new List { swapEvent }); + + var result = service.PersistedPositions[0]; + var golden = new GoldenScenarioModel + { + Scenario = "互换结清后待实现归零", + Description = $"攒{days}天后互换,InterestIncomeSum应≈当天新计", + Input = new GoldenInput + { + SettleDate = settleDate, + PosiLongNotional = Principal, + OrginPv = Principal + }, + Expected = new GoldenExpected + { + PositionCount = 1, + EodPositions = new JArray { GoldenAssert.EodPositionToJson(result) } + } + }; + + string json = JsonConvert.SerializeObject(golden, Formatting.Indented); + string path = Path.Combine(GoldenDir, "golden_互换结清后待实现归零.json"); + File.WriteAllText(path, json); + Console.WriteLine($"✅ 录制: {Path.GetFileName(path)}"); + Console.WriteLine($" InterestIncomeSum={result.InterestIncomeSum:F11}"); + Console.WriteLine($" TdCloseInterest={result.TdCloseInterest:F11}"); + Console.WriteLine($" RealizedInterest={result.RealizedInterest:F11}"); + } + + /// 场景2:普通日 InterestIncomeSum 递增 + private void Record_NormalDayIncrement() + { + const decimal Principal = 10000m; + const decimal Rate = 0.03m; + const int AnnualDays = 365; + var startDate = new DateTime(2026, 4, 27); + decimal dailyInc = Math.Round(Principal * Rate / AnnualDays, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); + + var td = new trade + { + id = 1, TradeNumber = "GOLDEN-002", ClientId = 999998, + TradeType = "收益互换", TradeDate = startDate, StartDate = startDate, + ExerciseDate = new DateTime(2027, 4, 27), TradeStatus = "确认成交", + ValidState = "Valid", StructureType = "单标的", + QuoteCurrency = "CNY", SettlementCurrency = "CNY", + trade_extend = new trade_extend { TradeId = 1, ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson { AnnualDays = AnnualDays, InterestCalcMode = "10", SettlementRules = 0 }) } + }; + var position = new swap_position + { + id = 1001, SwapTradeId = 1, InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.标的期初全价, InterestRateDefault = Rate, + InterestPrincipalFix = Principal, PosiStartDate = startDate, + PosiMatuirityDate = new DateTime(2027, 4, 27), IsInitial = true, + InterestType = (int)InterestTypeEnum.单利, IsAnnualized = true, + interest_rest_days = 1, interest_rule = 0, FloatRateUnderlyingCode = null, + InterestSwapInterval = null + }; + + // 用 DealInterests 走 copy 分支 + var settleDate = startDate.AddDays(2); // 第3天 + var preEod = new eod_swap_position + { + id = 100, PositionId = 1001, ValueDate = settleDate.AddDays(-1), + InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)InterestModeEnum.标的期初全价, + InterestIncomeSum = dailyInc, InterestProfitSum = dailyInc, + InterestRateDefault = Rate, TdInterestPrincipal = Principal, + InterestType = (int)InterestTypeEnum.单利, IsAnnualized = true, interest_rest_days = 1 + }; + + var service = new StubEodService(); + // 通过反射调 DealInterests(copy 分支需要 CalcSwapInterests) + var method = typeof(SwapEodPositionService).GetMethod("DealInterests", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + method.Invoke(service, new object[] + { + new List { position }, + new List { preEod }, + new List(), + settleDate, td, new List(), new List(), null, + Principal, 0m, 0m, 1m, Principal + }); + + if (service.PersistedPositions.Count == 0) + { + Console.WriteLine("⚠ 场景2未生成eod(CalcSwapInterests可能需要接缝),跳过"); + return; + } + + var result = service.PersistedPositions[0]; + var golden = new GoldenScenarioModel + { + Scenario = "普通日归档递增", + Description = "第3天收盘,InterestIncomeSum应=2天+1天=3天利息", + Expected = new GoldenExpected + { + PositionCount = 1, + EodPositions = new JArray { GoldenAssert.EodPositionToJson(result) } + } + }; + + string json = JsonConvert.SerializeObject(golden, Formatting.Indented); + string path = Path.Combine(GoldenDir, "golden_普通日归档递增.json"); + File.WriteAllText(path, json); + Console.WriteLine($"✅ 录制: {Path.GetFileName(path)}"); + Console.WriteLine($" InterestIncomeSum={result.InterestIncomeSum:F11}"); + } + + #endregion + + #region 回放:读 golden 重跑+精确对比(进 CI) + + /// + /// 回放所有 golden 文件,逐字段精确对比。 + /// 如果任何字段变了(哪怕是第8位小数),测试失败。 + /// + [TestMethod] + public void Replay_AllGoldenFiles() + { + if (!Directory.Exists(GoldenDir)) + { + Assert.Inconclusive($"golden 目录不存在: {GoldenDir}(请先跑 Record_AllGoldenScenarios)"); + return; + } + + var files = Directory.GetFiles(GoldenDir, "*.json").OrderBy(f => f).ToArray(); + Assert.IsTrue(files.Length > 0, "应至少有1个golden文件"); + + int passed = 0, failed = 0; + foreach (var file in files) + { + try + { + var golden = JsonConvert.DeserializeObject(File.ReadAllText(file)); + Console.WriteLine($"\n回放: {Path.GetFileName(file)} - {golden.Scenario}"); + + // 回放互换场景(场景1的模式) + if (golden.Scenario?.Contains("互换结清") == true) + { + ReplaySwapSettle(golden); + } + else + { + Console.WriteLine($" (场景类型'{golden.Scenario}'暂不支持自动回放,跳过)"); + continue; + } + + passed++; + Console.WriteLine($" ✅ 通过"); + } + catch (Exception ex) + { + failed++; + Console.WriteLine($" ❌ 失败: {ex.Message}"); + } + } + + Console.WriteLine($"\n回放结果: {passed}通过 {failed}失败 / {files.Length}总"); + Assert.AreEqual(0, failed, $"{failed}个golden文件回放失败"); + } + + private void ReplaySwapSettle(GoldenScenarioModel golden) + { + const decimal Principal = 10000m; + const decimal Rate = 0.03m; + const int AnnualDays = 365; + var startDate = new DateTime(2026, 4, 27); + var settleDate = golden.Input.SettleDate ?? startDate.AddDays(10); + int days = (settleDate - startDate).Days; + decimal accumulated = Math.Round(Principal * Rate * days / AnnualDays, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); + + var td = new trade + { + id = 1, TradeNumber = "GOLDEN-REPLAY", ClientId = 999998, + TradeType = "收益互换", TradeDate = startDate, StartDate = startDate, + ExerciseDate = new DateTime(2027, 4, 27), TradeStatus = "确认成交", + ValidState = "Valid", StructureType = "单标的", + QuoteCurrency = "CNY", SettlementCurrency = "CNY", + trade_extend = new trade_extend { TradeId = 1, ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson { AnnualDays = AnnualDays, InterestCalcMode = "10", SettlementRules = 0 }) } + }; + var position = new swap_position + { + id = 1001, SwapTradeId = 1, InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.标的期初全价, InterestRateDefault = Rate, + InterestPrincipalFix = Principal, PosiStartDate = startDate, + PosiMatuirityDate = new DateTime(2027, 4, 27), IsInitial = true, + InterestType = (int)InterestTypeEnum.单利, IsAnnualized = true, + interest_rest_days = 1, interest_rule = 0 + }; + var preEod = new eod_swap_position + { + id = 100, PositionId = 1001, ValueDate = settleDate.AddDays(-1), + InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)InterestModeEnum.标的期初全价, + InterestIncomeSum = accumulated, InterestProfitSum = accumulated, + InterestRateDefault = Rate, TdInterestPrincipal = Principal, + InterestType = (int)InterestTypeEnum.单利, IsAnnualized = true, interest_rest_days = 1 + }; + var swapEvent = new swap_flow_event + { + EventType = (int)SwapFlowEventTypeEnum.互换, PositionId = 1001, + InterestAmount = accumulated, InterestClosePnL = accumulated, + InterestRate = Rate, InterestMode = (int)InterestModeEnum.标的期初全价, + InterestPrincipal = Principal, DataState = (int)SwapFlowDateStateEnum.完成 + }; + + var service = new StubEodService(); + service.ExecuteSaveEodInterestPosition(preEod, position, td, settleDate, new List { swapEvent }); + + // 对比 golden 期望 + Assert.AreEqual(golden.Expected.PositionCount ?? 1, service.PersistedPositions.Count, "持仓数量"); + + var expectedEods = golden.Expected.EodPositions?.ToObject>() ?? new List(); + foreach (var expected in expectedEods) + { + var pid = expected["PositionId"]?.Value() ?? 1001; + var actual = service.PersistedPositions.FirstOrDefault(x => x.PositionId == pid); + Assert.IsNotNull(actual, $"未找到PositionId={pid}"); + GoldenAssert.AssertEodPosition(expected, actual); + } + } + + #endregion + } +} diff --git a/UnitTestProject/Modules/SwapModule/GoldenReplayFramework.cs b/UnitTestProject/Modules/SwapModule/GoldenReplayFramework.cs new file mode 100644 index 00000000..f3a4b18d --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/GoldenReplayFramework.cs @@ -0,0 +1,160 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + #region Golden 数据模型 + + /// + /// Golden 文件的通用数据模型。 + /// 每个场景序列化为一个 JSON 文件,包含:输入数据 + 期望输出。 + /// + /// JSON 结构: + /// { + /// "Scenario": "互换结清后待实现归零", + /// "Description": "攒10天后互换,验证InterestIncomeSum≈当天新计", + /// "Input": { + /// "Trade": { ... }, + /// "Positions": [ ... ], + /// "PreEodPositions": [ ... ], + /// "FlowEvents": [ ... ] + /// }, + /// "Expected": { + /// "EodPositions": [ + /// { "PositionId": 1001, "InterestIncomeSum": 0.0274, "TdCloseInterest": 2.74, ... } + /// ] + /// } + /// } + /// + public class GoldenScenarioModel + { + /// 场景名称 + public string Scenario { get; set; } + + /// 场景描述 + public string Description { get; set; } + + /// 输入数据 + public GoldenInput Input { get; set; } + + /// 期望输出(精确到小数点后N位的字段值) + public GoldenExpected Expected { get; set; } + + /// 数据来源:synthetic(合成) / recorded(真实库录制) + public string Source { get; set; } = "synthetic"; + + /// 录制时间(如果是 recorded) + public DateTime? RecordedAt { get; set; } + } + + public class GoldenInput + { + public JObject Trade { get; set; } + public JArray Positions { get; set; } + public JArray PreEodPositions { get; set; } + public JArray FlowEvents { get; set; } + + // 可选的配置参数 + public decimal? PosiLongNotional { get; set; } + public decimal? PosiShortNotional { get; set; } + public decimal? CloseNational { get; set; } + public decimal? GrossPrice { get; set; } + public decimal? OrginPv { get; set; } + public DateTime? SettleDate { get; set; } + } + + public class GoldenExpected + { + /// 期望生成的 eod 持仓数量 + public int? PositionCount { get; set; } + + /// 期望的 eod 持仓精确字段(每个 PositionId 一条) + public JArray EodPositions { get; set; } + } + + #endregion + + #region Golden 回放辅助 + + /// + /// Golden 回放的通用辅助方法。 + /// 提供精确字段对比(容许指定位数的误差)。 + /// + public static class GoldenAssert + { + /// 默认精度容差(小数点后9-2=7位) + public static decimal DefaultTolerance => 1m / (decimal)Math.Pow(10, ConsGlobal.PriceRound - 2); + + /// 对比 decimal 字段,容许指定位数误差 + public static void AssertField(decimal? expected, decimal actual, string fieldName, long positionId, decimal? tolerance = null) + { + if (expected == null) return; // golden 里没存这个字段就跳过 + var tol = tolerance ?? DefaultTolerance; + Assert.IsTrue(Math.Abs(expected.Value - actual) <= tol, + $"PositionId={positionId} {fieldName} 不匹配: expected={expected.Value}, actual={actual}, diff={expected.Value - actual}"); + } + + /// 对比 int 字段 + public static void AssertField(int? expected, int actual, string fieldName, long positionId) + { + if (expected == null) return; + Assert.AreEqual(expected.Value, actual, + $"PositionId={positionId} {fieldName} 不匹配: expected={expected.Value}, actual={actual}"); + } + + /// 对比 long 字段 + public static void AssertField(long? expected, long actual, string fieldName, long positionId) + { + if (expected == null) return; + Assert.AreEqual(expected.Value, actual, + $"PositionId={positionId} {fieldName} 不匹配: expected={expected.Value}, actual={actual}"); + } + + /// + /// 对比一个 eod_swap_position 的所有 golden 字段。 + /// golden JSON 里只存了需要验证的字段,未存的跳过。 + /// + public static void AssertEodPosition(JObject expected, eod_swap_position actual) + { + var positionId = expected["PositionId"]?.Value() ?? actual.PositionId; + + AssertField(expected["InterestIncomeSum"]?.Value(), actual.InterestIncomeSum, "InterestIncomeSum", positionId); + AssertField(expected["InterestProfitSum"]?.Value(), actual.InterestProfitSum, "InterestProfitSum", positionId); + AssertField(expected["TdInterestIncome"]?.Value(), actual.TdInterestIncome, "TdInterestIncome", positionId); + AssertField(expected["TdCloseInterest"]?.Value(), actual.TdCloseInterest, "TdCloseInterest", positionId); + AssertField(expected["TdInterestPrincipal"]?.Value(), actual.TdInterestPrincipal, "TdInterestPrincipal", positionId); + AssertField(expected["RealizedInterest"]?.Value(), actual.RealizedInterest, "RealizedInterest", positionId); + AssertField(expected["RealizedInterestFee"]?.Value(), actual.RealizedInterestFee, "RealizedInterestFee", positionId); + AssertField(expected["RealizedPnl"]?.Value(), actual.RealizedPnl, "RealizedPnl", positionId); + AssertField(expected["SwapPositionValue"]?.Value(), actual.SwapPositionValue, "SwapPositionValue", positionId); + AssertField(expected["InterestFeeSum"]?.Value(), actual.InterestFeeSum, "InterestFeeSum", positionId); + AssertField(expected["TdInterestFee"]?.Value(), actual.TdInterestFee, "TdInterestFee", positionId); + AssertField(expected["TdCloseInterestFee"]?.Value(), actual.TdCloseInterestFee, "TdCloseInterestFee", positionId); + } + + /// + /// 序列化一个 eod_swap_position 到 JObject(用于生成 golden 文件)。 + /// 只存关键字段,避免 JSON 过大。 + /// + public static JObject EodPositionToJson(eod_swap_position eod) + { + return new JObject + { + ["PositionId"] = eod.PositionId, + ["InterestIncomeSum"] = eod.InterestIncomeSum, + ["InterestProfitSum"] = eod.InterestProfitSum, + ["TdInterestIncome"] = eod.TdInterestIncome, + ["TdCloseInterest"] = eod.TdCloseInterest, + ["TdInterestPrincipal"] = eod.TdInterestPrincipal, + ["RealizedInterest"] = eod.RealizedInterest, + ["RealizedPnl"] = eod.RealizedPnl, + ["SwapPositionValue"] = eod.SwapPositionValue, + ["InterestFeeSum"] = eod.InterestFeeSum + }; + } + } + + #endregion +} diff --git a/UnitTestProject/Resources/GoldenFiles/DealInterestsGolden/golden_互换结清后待实现归零.json b/UnitTestProject/Resources/GoldenFiles/DealInterestsGolden/golden_互换结清后待实现归零.json new file mode 100644 index 00000000..87cf38ba --- /dev/null +++ b/UnitTestProject/Resources/GoldenFiles/DealInterestsGolden/golden_互换结清后待实现归零.json @@ -0,0 +1,35 @@ +{ + "Scenario": "互换结清后待实现归零", + "Description": "攒10天后互换,InterestIncomeSum应≈当天新计", + "Input": { + "Trade": null, + "Positions": null, + "PreEodPositions": null, + "FlowEvents": null, + "PosiLongNotional": 10000.0, + "PosiShortNotional": null, + "CloseNational": null, + "GrossPrice": null, + "OrginPv": 10000.0, + "SettleDate": "2026-05-07T00:00:00" + }, + "Expected": { + "PositionCount": 1, + "EodPositions": [ + { + "PositionId": 1001, + "InterestIncomeSum": 0.821917808219178082191780822, + "InterestProfitSum": 0.821917808219178082191780822, + "TdInterestIncome": 0.8219178082191780821917808219, + "TdCloseInterest": 8.21917808219, + "TdInterestPrincipal": 10000.0, + "RealizedInterest": 8.21917808219, + "RealizedPnl": 0.0, + "SwapPositionValue": 0.821917808219178082191780822, + "InterestFeeSum": 0.0 + } + ] + }, + "Source": "synthetic", + "RecordedAt": null +} \ No newline at end of file diff --git a/UnitTestProject/Resources/GoldenFiles/DealInterestsGolden/golden_普通日归档递增.json b/UnitTestProject/Resources/GoldenFiles/DealInterestsGolden/golden_普通日归档递增.json new file mode 100644 index 00000000..15fcbc77 --- /dev/null +++ b/UnitTestProject/Resources/GoldenFiles/DealInterestsGolden/golden_普通日归档递增.json @@ -0,0 +1,24 @@ +{ + "Scenario": "普通日归档递增", + "Description": "第3天收盘,InterestIncomeSum应=2天+1天=3天利息", + "Input": null, + "Expected": { + "PositionCount": 1, + "EodPositions": [ + { + "PositionId": 1001, + "InterestIncomeSum": 1.64383561644, + "InterestProfitSum": 1.64383561644, + "TdInterestIncome": 0.82191780822, + "TdCloseInterest": 0.0, + "TdInterestPrincipal": 10000.0, + "RealizedInterest": 0.0, + "RealizedPnl": 0.0, + "SwapPositionValue": 1.64383561644, + "InterestFeeSum": 0.0 + } + ] + }, + "Source": "synthetic", + "RecordedAt": null +} \ No newline at end of file