Files
zszq-trs/UnitTestProject/Modules/SwapModule/GLMS20260805ClosePercentDiffDiagnoseTest.cs
hjhan 90d1d85b4a test(infra): DbDiagnose 用例库不可达自守卫——真探测一次+进程级缓存结论
原守卫 try 挂在 GetYLDbContext() 上永远打不中(EF context 构造不连库,
Connect Timeout 发生在首个查询),无库裸跑 14 个用例逐个假红且各耗 15s。
改为 DbDiagnoseGuard.RequireTestDb():CanConnect 真探测一次并缓存,
不可达全批 Inconclusive——裸跑不再有假失败噪音,探测成本 14×15s→1×15s。
连上库的机器行为不变(照常真跑)。
2026-08-18 09:58:16 +08:00

387 lines
22 KiB
C#
Raw Permalink 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.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 线上事故诊断:GLMS-JIATT-20260805-FICC-01-2180120IB 100%平仓 vs 40%平仓 利息差异异常
/// ============================================================================
/// 现象:同一笔交易,100% 平仓与 40% 平仓算出的利息差异远大于线性比例。
/// 怀疑点:8/5 创建的交易,撞上 8/6-8/7 对 SwapDealService 平仓利息计算的密集修复窗口,
/// 尤其 3a435ad8(8/7 13:46) 把 ResolveInterestLegPositionsAsOf 分桶从 UnwindDate 改回
/// EventDate、并删除 01d7f0c5 的 priorClosePositionIds 防护,可能引入回归。
///
/// 直连 96 测试库,对这笔交易:
/// 1) 录真实数据快照(trade/position/eod/flow_event
/// 2) 分别调 GetUnwindInterests(closePercent=1.0) 和 (=0.4),逐腿打印本金/利息
/// 3) 对比两者是否成线性比例;定位差异落在哪条腿、哪个字段
/// 4) 检查 EOD 快照的预付金 TdInterestPrincipal 是否用了初始本金(坐实 8/5 基数 bug)
///
/// 用法:本地连 96 库跑 Diagnose_100vs40_InterestDiff;连不上库自动 Inconclusive 跳过。
/// </summary>
[TestClass]
public class GLMS20260805ClosePercentDiffDiagnoseTest
{
private const string TradeNumber = "GLMS-JIATT-20260805-FICC-01-2180120IB";
#region 1) 录真实数据快照(手动跑,标 Ignore
[TestMethod]
[Ignore]
[TestCategory("DbDiagnose")]
public void Record_RealSnapshot()
{
DbDiagnoseGuard.RequireTestDb();
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber);
Assert.IsNotNull(td, $"测试库无交易 {TradeNumber},请确认 96 库是否有该数据");
var snapshot = new JObject
{
["TradeNumber"] = td.TradeNumber,
["TradeId"] = td.id,
["TradeDate"] = td.TradeDate,
["StartDate"] = td.StartDate,
["StockEqvNotional"] = td.StockEqvNotional,
["OriginalStockEqvNotional"] = td.OriginalStockEqvNotional,
["Notional"] = td.Notional,
["OriginalNotional"] = td.OriginalNotional,
["TradeStatus"] = td.TradeStatus,
["HasPartialUnWind"] = td.HasPartialUnWind
};
// 持仓(含 IsInitial=初始 + !IsInitial=已平后剩余)
var positions = db.swap_position
.Where(p => p.SwapTradeId == td.id && !p.Invalid)
.OrderBy(p => p.IsInitial).ThenBy(p => p.id)
.ToList();
snapshot["Positions"] = JArray.FromObject(positions, JsonSerializer.Create(new JsonSerializerSettings
{
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
DateFormatHandling = DateFormatHandling.IsoDateFormat
}));
// EOD 预付金腿逐日(关键:看 TdInterestPrincipal 是否=初始本金)
var eodPositions = db.eod_swap_position
.Where(e => e.SwapTradeId == td.id && !e.Invalid)
.OrderBy(e => e.ValueDate).ThenBy(e => e.PositionId)
.ToList();
snapshot["EodPositions"] = JArray.FromObject(eodPositions, JsonSerializer.Create(new JsonSerializerSettings
{
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
DateFormatHandling = DateFormatHandling.IsoDateFormat
}));
// 所有 flow_event(看平仓事件序列、EventDate vs UnwindDate
var flows = db.swap_flow_event
.Where(f => f.SwapTradeId == td.id)
.OrderBy(f => f.EventDate).ThenBy(f => f.id)
.ToList();
snapshot["FlowEvents"] = JArray.FromObject(flows, JsonSerializer.Create(new JsonSerializerSettings
{
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
DateFormatHandling = DateFormatHandling.IsoDateFormat
}));
var dir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Resources", "DbDiagnose", "GLMS20260805");
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, $"snapshot_{DateTime.Now:yyyyMMdd_HHmmss}.json");
File.WriteAllText(path, JsonConvert.SerializeObject(snapshot, Formatting.Indented,
new JsonSerializerSettings { DateFormatHandling = DateFormatHandling.IsoDateFormat }));
Console.WriteLine($"✅ 快照已保存: {path}");
}
#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]
[TestCategory("DbDiagnose")]
public void Diagnose_100vs40_InterestDiff()
{
DbDiagnoseGuard.RequireTestDb();
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber);
if (td == null) { Assert.Inconclusive($"测试库无 {TradeNumber}"); return; }
Console.WriteLine($"===== 交易 {TradeNumber} (id={td.id}) =====");
Console.WriteLine($" TradeDate={td.TradeDate:yyyy-MM-dd} StartDate={td.StartDate:yyyy-MM-dd}");
Console.WriteLine($" StockEqvNotional(剩余)={td.StockEqvNotional} Original(期初)={td.OriginalStockEqvNotional}");
string remainRatio = td.OriginalStockEqvNotional == 0 ? "N/A" : (td.StockEqvNotional / td.OriginalStockEqvNotional.Value).ToString("P2");
Console.WriteLine($" 剩余比例={remainRatio}");
Console.WriteLine($" HasPartialUnWind={td.HasPartialUnWind} TradeStatus={td.TradeStatus}");
Console.WriteLine();
// ---- A. 持仓全景 ----
var allPositions = db.swap_position
.Where(p => p.SwapTradeId == td.id && !p.Invalid)
.OrderBy(p => p.IsInitial).ThenBy(p => p.id)
.ToList();
PrintPositions("持仓全景(orig=IsInitial初始 vs real=!IsInitial剩余)", allPositions);
// ---- B. 历史平仓事件(确认是否之前平过仓、EventDate vs UnwindDate 是否一致)----
PrintCloseFlowEvents(db, td.id);
// ---- C. EOD 预付金腿逐日(看基数是否=初始本金 → 坐实 8/5 基数 bug)----
PrintEodPrepaySequence(db, td.id);
// ---- D. 核心对比:分别调 100% 和 40% ----
Console.WriteLine("\n\n############ 核心:100% vs 40% GetUnwindInterests 对比 ############");
var user = new OptUserInfo(0, nameof(GLMS20260805ClosePercentDiffDiagnoseTest), OptUserFrom.UnitTest);
// 前端传"占期初(A)"语义,后端转"占剩余(B)"。这里模拟前端两种选择。
decimal frontNotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0d); // 期初
decimal frontPosiNotionalValue = Convert.ToDecimal(td.StockEqvNotional); // 剩余
Console.WriteLine($"\n 前端参数:期初={frontNotionalValue} 剩余={frontPosiNotionalValue}");
// 选 100%(占期初 A=1.0
decimal cp100_A = 1.0m;
decimal cp100_B = SwapDealService.ToRemainingClosePercent(cp100_A, frontNotionalValue, frontPosiNotionalValue);
Console.WriteLine($" [100%] 前端A={cp100_A} → 后端B={cp100_B}(占剩余)");
// 选 40%(占期初 A=0.4
decimal cp40_A = 0.4m;
decimal cp40_B = SwapDealService.ToRemainingClosePercent(cp40_A, frontNotionalValue, frontPosiNotionalValue);
Console.WriteLine($" [40%] 前端A={cp40_A} → 后端B={cp40_B}(占剩余)");
Console.WriteLine($" 注:若期初≠剩余,A=1.0→B 被 cap 到 1,A=0.4→B 是另一值,二者本就非线性。\n");
// 计算日期用今天(实际前端选哪天可改)
var valueDate = DateTime.Today;
var unwindDate = DateTime.Today;
// FR007 fixing 是外部数据依赖:非交易日/数据未发布时取价会抛 Exception。
// 与"连不上库自动 Inconclusive"同语义——外部数据不可用不应判为测试失败。
List<swap_flow_event> interests100, interests40;
try
{
interests100 = new SwapDealService(user).GetUnwindInterests(valueDate, unwindDate, td.id, cp100_B, (int)SwapEventTypeEnum.平仓);
interests40 = new SwapDealService(user).GetUnwindInterests(valueDate, unwindDate, td.id, cp40_B, (int)SwapEventTypeEnum.平仓);
}
catch (Exception ex) when (ex.Message.Contains("获取不到") && ex.Message.Contains("价格"))
{
Assert.Inconclusive($"FR007 fixing 数据不可用({valueDate:yyyy-MM-dd} 非交易日或数据未发布):{ex.Message}");
return;
}
PrintInterestComparison(interests100, interests40, cp100_B, cp40_B);
}
#endregion
#region 打印辅助
private static void PrintPositions(string title, List<swap_position> positions)
{
Console.WriteLine($"===== {title} =====");
Console.WriteLine($" {"Id",-8}{"Mode",-6}{"IntDir",-7}{"PosiDir",-8}{"IsInit",-8}{"Fix",-18}{"PosiNotional",-18}{"PosiQty",-12}");
foreach (var p in positions)
{
Console.WriteLine($" {p.id,-8}{p.InterestMode,-6}{p.InterestDirection,-7}{p.PosiDirection,-8}{p.IsInitial,-8}{p.InterestPrincipalFix,-18}{p.PosiNotionalValue,-18}{p.PosiQuantity,-12}");
}
}
private static void PrintCloseFlowEvents(YLContext db, int tradeId)
{
Console.WriteLine($"\n===== 历史平仓/互换事件(EventDate vs UnwindDate=====");
var flows = db.swap_flow_event
.Where(f => f.SwapTradeId == tradeId
&& (f.EventType == (int)SwapEventTypeEnum.平仓
|| f.EventType == (int)SwapEventTypeEnum.互换
|| f.EventType == (int)SwapEventTypeEnum.自动互换)
&& f.DataState == (int)SwapFlowDateStateEnum.完成)
.OrderBy(f => f.EventDate).ThenBy(f => f.id)
.ToList();
if (flows.Count == 0) { Console.WriteLine(" (无历史平仓/互换事件 → 此前未平过仓)"); return; }
Console.WriteLine($" {"EventDate",-12}{"UnwindDate",-12}{"一致?",-8}{"Type",-6}{"PosId",-8}{"Mode",-6}{"I.Principal",-16}{"I.Amount",-14}");
foreach (var f in flows)
{
var sameDate = f.EventDate == f.UnwindDate;
var typeStr = f.EventType == (int)SwapEventTypeEnum.平仓 ? "平仓" :
f.EventType == (int)SwapEventTypeEnum.互换 ? "互换" : "自动";
Console.WriteLine($" {f.EventDate:yyyy-MM-dd} {f.UnwindDate:yyyy-MM-dd} {(sameDate ? "" : "否⚠"),-6}{typeStr,-6}{f.PositionId,-8}{f.InterestMode,-6}{f.InterestPrincipal,-16}{f.InterestAmount,-14}");
}
Console.WriteLine(" ⚠ EventDate≠UnwindDate 的历史事件:当前 3a435ad8 按 EventDate 分桶,可能与 UnwindDate 口径不一致");
}
private static void PrintEodPrepaySequence(YLContext db, int tradeId)
{
Console.WriteLine($"\n===== EOD 预付金腿逐日(看 TdInterestPrincipal 是否=初始本金)=====");
var eodPrepay = db.eod_swap_position
.Where(e => e.SwapTradeId == tradeId && !e.Invalid
&& (e.InterestMode == (int)InterestModeEnum.初始预付金
|| e.InterestMode == (int)InterestModeEnum.追加预付金))
.OrderBy(e => e.ValueDate).ThenBy(e => e.PositionId)
.ToList();
if (eodPrepay.Count == 0) { Console.WriteLine(" (无预付金腿 EOD 记录)"); return; }
Console.WriteLine($" {"ValueDate",-12}{"PosId",-8}{"Mode",-6}{"TdInterestPrincipal",-20}{"InterestProfitSum",-20}{"InterestIncomeSum",-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}{e.InterestIncomeSum,-20}");
}
// 对比初始 vs 实时剩余 vs EOD
var origPrepay = db.swap_position.Where(p => p.SwapTradeId == tradeId && !p.Invalid && p.IsInitial
&& (p.InterestMode == (int)InterestModeEnum.初始预付金 || p.InterestMode == (int)InterestModeEnum.追加预付金)).ToList();
var realPrepay = db.swap_position.Where(p => p.SwapTradeId == tradeId && !p.Invalid && !p.IsInitial
&& (p.InterestMode == (int)InterestModeEnum.初始预付金 || p.InterestMode == (int)InterestModeEnum.追加预付金)).ToList();
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;
var eodMatchesOrig = Math.Abs((double)(eodTd - orig.InterestPrincipalFix)) < 0.01;
var eodMatchesReal = Math.Abs((double)(eodTd - realFix)) < 0.01;
Console.WriteLine($" PosId={orig.id} origFix(初始)={orig.InterestPrincipalFix} realFix(剩余)={realFix} EOD.TdInterestPrincipal(最新)={eodTd}");
if (eodMatchesOrig && !eodMatchesReal && orig.InterestPrincipalFix != realFix)
{
Console.WriteLine($" ⚠⚠ EOD 基数=初始本金(≠剩余)→ 坐实:日终用了初始预付金本金而非实时剩余,后续利息计算基数错误!");
}
}
}
private static void PrintInterestComparison(List<swap_flow_event> interests100, List<swap_flow_event> interests40, decimal cp100_B, decimal cp40_B)
{
Console.WriteLine($"\n ---- GetUnwindInterests 返回(100% 共{interests100.Count}条 / 40% 共{interests40.Count}条)----");
Console.WriteLine($" {"PosId",-8}{"Mode",-6}{"IntDir",-8}{"I.Principal(100)",-18}{"I.Principal(40)",-18}{"本金比",-10}{"I.Amount(100)",-16}{"I.Amount(40)",-16}{"利息比",-10}");
decimal totalAmount100 = 0, totalAmount40 = 0;
foreach (var i100 in interests100.OrderBy(x => x.PositionId))
{
var i40 = interests40.FirstOrDefault(x => x.PositionId == i100.PositionId && x.InterestMode == i100.InterestMode);
var amt40 = i40?.InterestAmount ?? 0;
var prin40 = i40?.InterestPrincipal ?? 0;
totalAmount100 += i100.InterestAmount;
totalAmount40 += amt40;
string prinRatio = prin40 == 0 ? "-" : (i100.InterestPrincipal / prin40).ToString("F4");
string amtRatio = amt40 == 0 ? "-" : (i100.InterestAmount / amt40).ToString("F4");
Console.WriteLine($" {i100.PositionId,-8}{i100.InterestMode,-6}{i100.InterestDirection,-8}{i100.InterestPrincipal,-18}{prin40,-18}{prinRatio,-10}{i100.InterestAmount,-16}{amt40,-16}{amtRatio,-10}");
}
Console.WriteLine($"\n ===== 利息合计 =====");
Console.WriteLine($" 100% 总利息 = {totalAmount100}");
Console.WriteLine($" 40% 总利息 = {totalAmount40}");
var ratioStr = totalAmount40 == 0 ? "N/A" : (totalAmount100 / totalAmount40).ToString("F4");
Console.WriteLine($" 比值(100/40) = {ratioStr}");
Console.WriteLine($" 若为线性关系,比值应≈{cp100_B / cp40_B:F4}(即 B_100 / B_40");
Console.WriteLine($" 若实际比值远偏离此值 → 存在非线性/bug,重点看上方哪条腿的[利息比]或[本金比]异常");
Console.WriteLine($"\n ===== 诊断结论指引 =====");
Console.WriteLine(" · 本金比≠B_100/B_40ResolveInterestLegPositions 没用实时剩余本金(看 realFix vs origFix");
Console.WriteLine(" · 复利腿利息比异常:检查 consumedInterest 扣除(GetConsumedInterest 用 EventDate 过滤)");
Console.WriteLine(" · 单利腿利息比异常:检查 preEodPosition.InterestProfitSum 基数(EOD 是否用了初始本金)");
Console.WriteLine(" · 全部腿都偏:closePercent 双语义转换 + tdClose 导致计息区间坍缩");
}
#endregion
}
}