Files
zszq-trs/UnitTestProject/Modules/SwapModule/DealInterestsGoldenReplayTest.cs
T
hjhan 7b007bddfa refactor(swap-test): 抽取 TestableSwapEodPositionService 公共基类收敛重复 Stub
- 新建 TestableSwapEodPositionService 收敛 8/8 Stub 重复的高频 override
  (PersistEodSwapPosition/SaveAllChanges/GetCurrencyRate/AddClientCash)
  + 统一 OptUserInfo 构造 + PersistedPositions/SaveChangesCount 输出捕获
- SwapEodPositionService.DealInterests 改 protected virtual(行为零变化)
- 8 个 ScenarioTest 改为继承基类,删除重复 override
- 消灭 DealInterestsScenarioTest/DealInterestsGoldenReplayTest 的反射调用
  (typeof().GetMethod().Invoke → 直接调用 DealInterests)

验证:dotnet build 0 错误;dotnet test SwapModule 284通过/6跳过/0失败
2026-07-23 11:16:11 +08:00

339 lines
16 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 : TestableSwapEodPositionService
{
public StubEodService() : base(nameof(DealInterestsGoldenReplayTest))
{
}
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);
}
// public 包装:直接调用 protected virtual DealInterests(录制场景2用)
public void ExecuteDealInterestsForRecord(
List<swap_position> interestList, List<eod_swap_position> eodPositions,
DateTime settleDate, trade td,
decimal posiLongNational, decimal grossPrice, decimal orginPv)
{
DealInterests(interestList, eodPositions, new List<eod_swap_position>(),
settleDate, td, new List<swap_flow_event>(), new List<swap_flow_event>(), null,
posiLongNational, 0m, 0m, grossPrice, orginPv);
}
}
#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();
// 直接调用 protected virtual DealInterestscopy 分支需要 CalcSwapInterests
service.ExecuteDealInterestsForRecord(
new List<swap_position> { position },
new List<eod_swap_position> { preEod },
settleDate, td, Principal, 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
}
}