test(accrual): 新增同日双次部分平仓字符化测试 + EOD基数快照replay诊断

- SwapUnwindSameDayDoublePartialTest: 内存Stub驱动同日两次部分平仓(30%/50%),
  验证基数线性累加(0.3N+0.35N=0.65N),推翻"基数不滚动"假设;缺口仅在
  调用方是否传陈旧notional,已文档化
- GLMS20260805ClosePercentDiffDiagnoseTest: 从Resources快照反序列化离线重跑
  EOD基数诊断,去DB化;无快照Inconclusive,保留Assert.Fail作8/5 bug护栏
This commit is contained in:
hjhan
2026-08-12 11:28:40 +08:00
parent ff937f24e7
commit 85746d7ee9
2 changed files with 243 additions and 0 deletions
@@ -96,6 +96,97 @@ namespace YLErp.Modules.SwapModule
#endregion
#region 1.5) 线 EOD
/// <summary>
/// 去 DB 化回放:从 <see cref="Record_RealSnapshot"/> 落盘的 snapshot_*.json 反序列化
/// eod_swap_position / swap_position,离线重跑「EOD 预付金基数是否=初始本金」诊断。
///
/// 目的:原 Diagnose_100vs40_InterestDiff 直接连 96 库跑 GetUnwindInterests
/// 依赖数据库可用性、且每次重跑都重新查库。本方法把「一次录制、内存多次回放」
/// 落地——录制一次(连库)后,后续诊断完全在内存完成,确定性、可重复、不依赖库。
///
/// 语义保持为 bug 护栏:若快照录制时 EOD 基数用了初始本金而非实时剩余,本测试
/// 仍会 Assert.Fail(不掩盖生产 bug)。录制一份「修复后」的快照即可转绿。
/// 无快照时 Inconclusive(须先连库跑一次 Record_RealSnapshot)。
/// </summary>
[TestMethod]
[TestCategory("DbDiagnose")]
public void Replay_100vs40_FromSnapshot()
{
var dir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Resources", "DbDiagnose", "GLMS20260805");
if (!Directory.Exists(dir))
{
Assert.Inconclusive($"未找到快照目录 {dir},请先连库跑一次 Record_RealSnapshot 录制真实数据快照");
return;
}
var files = Directory.GetFiles(dir, "snapshot_*.json").OrderByDescending(f => f).ToArray();
if (files.Length == 0)
{
Assert.Inconclusive($"目录 {dir} 下无 snapshot_*.json,请先连库跑一次 Record_RealSnapshot");
return;
}
var snapshotPath = files[0];
Console.WriteLine($"✅ 载入快照(离线回放): {snapshotPath}");
var snapshot = JObject.Parse(File.ReadAllText(snapshotPath));
var tradeNumber = snapshot.Value<string>("TradeNumber");
Console.WriteLine($"===== 离线回放 交易 {tradeNumber} =====");
var eodPrepay = JsonConvert.DeserializeObject<List<eod_swap_position>>(snapshot["EodPositions"].ToString())
.Where(e => !e.Invalid
&& (e.InterestMode == (int)InterestModeEnum.
|| e.InterestMode == (int)InterestModeEnum.))
.OrderBy(e => e.ValueDate).ThenBy(e => e.PositionId).ToList();
var origPrepay = JsonConvert.DeserializeObject<List<swap_position>>(snapshot["Positions"].ToString())
.Where(p => !p.Invalid && p.IsInitial
&& (p.InterestMode == (int)InterestModeEnum. || p.InterestMode == (int)InterestModeEnum.)).ToList();
var realPrepay = JsonConvert.DeserializeObject<List<swap_position>>(snapshot["Positions"].ToString())
.Where(p => !p.Invalid && !p.IsInitial
&& (p.InterestMode == (int)InterestModeEnum. || p.InterestMode == (int)InterestModeEnum.)).ToList();
bool bugDetected = false;
if (eodPrepay.Count == 0)
{
Console.WriteLine(" (快照无预付金腿 EOD 记录 → 无可诊断的基数 bug)");
}
else
{
Console.WriteLine($" {"ValueDate",-12}{"PosId",-8}{"Mode",-6}{"TdInterestPrincipal",-20}{"InterestProfitSum",-20}");
foreach (var e in eodPrepay)
{
Console.WriteLine($" {e.ValueDate:yyyy-MM-dd} {e.PositionId,-8}{e.InterestMode,-6}{e.TdInterestPrincipal,-20}{e.InterestProfitSum,-20}");
}
Console.WriteLine("\n ---- 预付金本金基数三方对比(离线)----");
foreach (var orig in origPrepay)
{
var real = realPrepay.FirstOrDefault(r => r.PositionId == orig.id);
var latestEod = eodPrepay.Where(e => e.PositionId == orig.id).OrderByDescending(e => e.ValueDate).FirstOrDefault();
var realFix = real?.InterestPrincipalFix ?? 0;
var eodTd = latestEod?.TdInterestPrincipal ?? 0;
Console.WriteLine($" PosId={orig.id} origFix(初始)={orig.InterestPrincipalFix} realFix(剩余)={realFix} EOD.TdInterestPrincipal(最新)={eodTd}");
bool eodMatchesOrig = Math.Abs((double)(eodTd - orig.InterestPrincipalFix)) < 0.01;
bool eodMatchesReal = Math.Abs((double)(eodTd - realFix)) < 0.01;
if (eodMatchesOrig && !eodMatchesReal && orig.InterestPrincipalFix != realFix)
{
bugDetected = true;
Console.WriteLine($" ⚠⚠ EOD 基数=初始本金(≠剩余)→ 坐实:日终用了初始预付金本金而非实时剩余,后续利息计算基数错误!");
}
}
}
// bug 护栏:快照若录制到基数 bug,离线回放仍须红,不掩盖生产事故。
// 修复生产并重新录制快照后,此断言自然转绿。
Assert.IsFalse(bugDetected,
"离线回放复现 8/5 基数 bug:EOD 预付金基数用了初始本金而非实时剩余。需先修复生产、再录制新快照让本测试转绿。");
}
#endregion
#region 2) 100% vs 40%
[TestMethod]