using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
///
/// 线上事故诊断: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 跳过。
///
[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 1.5) 离线回放:从已录快照重跑 EOD 基数诊断(不连库)
///
/// 去 DB 化回放:从 落盘的 snapshot_*.json 反序列化
/// eod_swap_position / swap_position,离线重跑「EOD 预付金基数是否=初始本金」诊断。
///
/// 目的:原 Diagnose_100vs40_InterestDiff 直接连 96 库跑 GetUnwindInterests,
/// 依赖数据库可用性、且每次重跑都重新查库。本方法把「一次录制、内存多次回放」
/// 落地——录制一次(连库)后,后续诊断完全在内存完成,确定性、可重复、不依赖库。
///
/// 语义保持为 bug 护栏:若快照录制时 EOD 基数用了初始本金而非实时剩余,本测试
/// 仍会 Assert.Fail(不掩盖生产 bug)。录制一份「修复后」的快照即可转绿。
/// 无快照时 Inconclusive(须先连库跑一次 Record_RealSnapshot)。
///
[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("TradeNumber");
Console.WriteLine($"===== 离线回放 交易 {tradeNumber} =====");
var eodPrepay = JsonConvert.DeserializeObject>(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>(snapshot["Positions"].ToString())
.Where(p => !p.Invalid && p.IsInitial
&& (p.InterestMode == (int)InterestModeEnum.初始预付金 || p.InterestMode == (int)InterestModeEnum.追加预付金)).ToList();
var realPrepay = JsonConvert.DeserializeObject>(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()
{
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 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 interests100, List 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_40:ResolveInterestLegPositions 没用实时剩余本金(看 realFix vs origFix)");
Console.WriteLine(" · 复利腿利息比异常:检查 consumedInterest 扣除(GetConsumedInterest 用 EventDate 过滤)");
Console.WriteLine(" · 单利腿利息比异常:检查 preEodPosition.InterestProfitSum 基数(EOD 是否用了初始本金)");
Console.WriteLine(" · 全部腿都偏:closePercent 双语义转换 + tdClose 导致计息区间坍缩");
}
#endregion
}
}