Files
zszq-trs/UnitTestProject/Modules/SwapModule/GLMS20260805ClosePercentDiffDiagnoseTest.cs
T
hjhan 75b033dd7b test(swap): 添加 GLMS-JIATT-20260805 复利部分平仓利息诊断测试
连 96 测试库的诊断工具,用于定位'100%平仓 vs 部分平仓利息差异异常'。
对同一笔交易分别调 GetUnwindInterests(100%) 和 (40%),逐腿打印本金/利息并对比,
输出比值以判断是否线性。复现并确认了 consumedInterest 未按 closePercent 缩放的根因
(已由 a0be0eb0 修复,本测试作回归诊断保留)。

模式同 GLMS20260701DbDiagnoseTest:TestCategory(DbDiagnose),连不上库自动 Inconclusive。
2026-08-07 19:00:31 +08:00

283 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.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()
{
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 2) 诊断:100% vs 40% 利息差异根因定位(连库跑)
[TestMethod]
[TestCategory("DbDiagnose")]
public void Diagnose_100vs40_InterestDiff()
{
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;
var interests100 = new SwapDealService(user).GetUnwindInterests(valueDate, unwindDate, td.id, cp100_B, (int)SwapEventTypeEnum.平仓);
var interests40 = new SwapDealService(user).GetUnwindInterests(valueDate, unwindDate, td.id, cp40_B, (int)SwapEventTypeEnum.平仓);
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
}
}