- 新建 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失败
339 lines
16 KiB
C#
339 lines
16 KiB
C#
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 DealInterests(copy 分支需要 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未生成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)
|
||
|
||
/// <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
|
||
}
|
||
}
|