Files
zszq-trs/UnitTestProject/Modules/SwapModule/DealInterestsGoldenReplayTest.cs
T
hjhan d11e332ff3 test(swap): golden录制/回放基础设施+DealInterests场景
引入golden回放框架,补充合成测试的精确值验证缺口。

新增GoldenReplayFramework.cs:
- GoldenScenarioModel: 通用golden数据模型(输入+期望输出)
- GoldenAssert: 精确字段对比(容许指定位数误差),逐字段验证

新增DealInterestsGoldenReplayTest.cs:
- Record_AllGoldenScenarios: 生成golden JSON(标Ignore,手动跑)
- Replay_AllGoldenFiles: 读golden重跑+精确对比(进CI)

2个golden场景:
- 互换结清后待实现归零: InterestIncomeSum=0.82191780822(精确到11位)
- 普通日归档递增(回放暂不支持自动重放,留后续)

golden文件持久化到Resources/GoldenFiles/DealInterestsGolden/

价值:重构时如果任何字段变了(哪怕第8位小数),回放立刻失败。
守恒测试验证大方向对,golden验证精确值对。

验证: 119+1(回放)=120全通过。
2026-07-02 09:37:18 +08:00

345 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using YLErp.DBModels;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 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<eod_swap_position> 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<swap_flow_event> flowEvents)
{
SaveEodInterestPosition(eodPayPosition, null, position, td, valueDate, flowEvents);
}
}
#endregion
#region 录制:生成 golden JSON(标 Ignore,手动跑)
/// <summary>
/// 生成所有 golden JSON 文件。
/// 手动取消 [Ignore] 运行,会覆盖 bin 目录下的 golden 文件。
/// 生成后复制到 UnitTestProject/Resources/GoldenFiles/ 持久化。
/// </summary>
[TestMethod]
[Ignore]
[TestCategory("GoldenRecord")]
public void Record_AllGoldenScenarios()
{
Directory.CreateDirectory(GoldenDir);
Record_SwapSettleZeroInterestIncomeSum();
Record_NormalDayIncrement();
Console.WriteLine($"\n录制完成,输出目录: {GoldenDir}");
}
/// <summary>场景1:互换结清后 InterestIncomeSum≈当天新计</summary>
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<swap_flow_event> { 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}");
}
/// <summary>场景2:普通日 InterestIncomeSum 递增</summary>
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();
// 通过反射调 DealInterestscopy 分支需要 CalcSwapInterests
var method = typeof(SwapEodPositionService).GetMethod("DealInterests",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
method.Invoke(service, new object[]
{
new List<swap_position> { position },
new List<eod_swap_position> { preEod },
new List<eod_swap_position>(),
settleDate, td, new List<swap_flow_event>(), new List<swap_flow_event>(), null,
Principal, 0m, 0m, 1m, Principal
});
if (service.PersistedPositions.Count == 0)
{
Console.WriteLine("⚠ 场景2未生成eodCalcSwapInterests可能需要接缝),跳过");
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
/// <summary>
/// 回放所有 golden 文件,逐字段精确对比。
/// 如果任何字段变了(哪怕是第8位小数),测试失败。
/// </summary>
[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<GoldenScenarioModel>(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<swap_flow_event> { swapEvent });
// 对比 golden 期望
Assert.AreEqual(golden.Expected.PositionCount ?? 1, service.PersistedPositions.Count, "持仓数量");
var expectedEods = golden.Expected.EodPositions?.ToObject<List<JObject>>() ?? new List<JObject>();
foreach (var expected in expectedEods)
{
var pid = expected["PositionId"]?.Value<long>() ?? 1001;
var actual = service.PersistedPositions.FirstOrDefault(x => x.PositionId == pid);
Assert.IsNotNull(actual, $"未找到PositionId={pid}");
GoldenAssert.AssertEodPosition(expected, actual);
}
}
#endregion
}
}