Files
zszq-trs/UnitTestProject/Modules/SwapModule/GLMS20260701DbDiagnoseTest.cs
T
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

365 lines
22 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-20260701-0008 多次部分平仓后,预付金返还显示仍为原始值
/// ============================================================================
/// 直连测试库,录制真实数据快照并定位根因(DB 端还是计算端)。
/// 测试结构:
/// 1) RecordSnapshot - 录 trade/position/eod_swap_position/eod_swap/flow_event
/// 2) Diagnose - 把每次部分平仓前后 InterestPrincipalFix 实际值序列打印,
/// 验证是否双重扣减;并调用 GetUnwindInterests 1.0 看后端返还值
/// 3) 期望对比 - 多次部分平仓后,1.0 closePercent 应返"剩余本金"=已扣减后),
/// 若仍返原始值 ⇒ 后端 EOD 路径 bug (SaveAutoEodWithCloseInterestPosition 双重扣减)
/// </summary>
[TestClass]
public class GLMS20260701DbDiagnoseTest
{
private const string TradeNumber_0008 = "GLMS-20260701-0008";
private const string TradeNumber_0013 = "GLMS-20260701-0013";
#region 1) 录真实数据快照(手动跑)
[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_0008);
Assert.IsNotNull(td, $"测试库无交易 {TradeNumber_0008},请确认环境");
var snapshot = new JObject
{
["TradeNumber"] = td.TradeNumber,
["TradeId"] = td.id,
["StockEqvNotional"] = td.StockEqvNotional,
["Notional"] = td.Notional,
["OriginalStockEqvNotional"] = td.OriginalStockEqvNotional,
["TradeDate"] = td.TradeDate,
["StartDate"] = td.StartDate,
["ExerciseDate"] = td.ExerciseDate
};
// 1.1 当前所有仓位(含 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
}));
// 1.2 EOD 持仓序列(关键:观察 InterestPrincipalFix 逐日变化)
var eodPositions = db.eod_swap_position
.Where(e => e.SwapTradeId == td.id && !e.Invalid && e.InterestMode == 5 || e.InterestMode == 6)
.OrderBy(e => e.ValueDate).ThenBy(e => e.PositionId)
.ToList();
snapshot["EodPositions_MarginLegOnly"] = JArray.FromObject(eodPositions, JsonSerializer.Create(new JsonSerializerSettings
{
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
DateFormatHandling = DateFormatHandling.IsoDateFormat
}));
// 1.3 EOD 交易级(eod_swap.NotionalValue 应该是初始值不变)
var eodSwaps = db.eod_swap.Where(e => e.SwapTradeId == td.id).OrderBy(e => e.ValueDate).ToList();
snapshot["EodSwaps"] = JArray.FromObject(eodSwaps, JsonSerializer.Create(new JsonSerializerSettings
{
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
DateFormatHandling = DateFormatHandling.IsoDateFormat
}));
// 1.4 所有 flow_event(看平仓/互换事件序列,及 InterestPrincipal 实际写入值)
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", "GLMS20260701");
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) 诊断:打印"预付金腿"逐日本金变化 + 后端 API 1.0 全平应返值
[TestMethod]
[TestCategory("DbDiagnose")]
public void Diagnose_InterestPrincipalFix_Progression_And_UnwindResult()
{
DiagnoseTrade(TradeNumber_0008);
}
[TestMethod]
[TestCategory("DbDiagnose")]
public void Diagnose_0013_InterestPrincipalFix_Progression_And_UnwindResult()
{
DiagnoseTrade(TradeNumber_0013);
}
private void DiagnoseTrade(string tradeNumber)
{
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; }
// 2.1 预付金腿 position.InterestPrincipalFix 当前值(多次平仓后应该已被扣减)
var marginPositions = db.swap_position
.Where(p => p.SwapTradeId == td.id && !p.Invalid
&& (p.InterestMode == (int)InterestModeEnum.初始预付金
|| p.InterestMode == (int)InterestModeEnum.追加预付金))
.ToList();
Console.WriteLine("============== 预付金腿 position 当前值(多次平仓后) ==============");
foreach (var p in marginPositions)
{
Console.WriteLine($"PositionId={p.id} Mode={p.InterestMode} Fix={p.InterestPrincipalFix} Rate={p.InterestRateDefault} Dir={p.InterestDirection} IsInitial={p.IsInitial}");
}
// 2.1b 所有 position 全景(含浮动腿),对比 IsInitial vs !IsInitial 的 PosiNotionalValue / Fix
var allPositions = db.swap_position
.Where(p => p.SwapTradeId == td.id && !p.Invalid)
.OrderBy(p => p.IsInitial).ThenBy(p => p.id)
.ToList();
Console.WriteLine("\n============== 全部 position 全景(对比 IsInitial 原始 vs !IsInitial 剩余) ==============");
Console.WriteLine($" {"Id",-8}{"Mode",-6}{"IntDir",-7}{"PosiDir",-8}{"IsInit",-8}{"Fix",-18}{"PosiNotional",-18}{"PosiQty",-12}{"UnderlyingCode",-15}");
foreach (var p in allPositions)
{
var ul = p.UnderlyingCode ?? "";
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}{ul,-15}");
}
// 2.1c 关键诊断:GetUnwindInterests 内部 origPositions vs realPostitions 差异
var origPositions = allPositions.Where(x => x.IsInitial).ToList();
var realPostitions = allPositions.Where(x => !x.IsInitial).ToList();
Console.WriteLine("\n============== GetUnwindInterests 关键源数据对比 ==============");
Console.WriteLine($" origPositions(IsInitial=True) 浮动腿 PosiNotionalValue 总和: {origPositions.Where(x => x.PosiDirection > 0).Sum(s => s.PosiNotionalValue)}");
Console.WriteLine($" realPostitions(IsInitial=False) 浮动腿 PosiNotionalValue 总和: {realPostitions.Where(x => x.PosiDirection > 0).Sum(s => s.PosiNotionalValue)} ← 应为剩余值");
Console.WriteLine($" origPositions(IsInitial=True) 预付金腿 Fix: {string.Join(",", origPositions.Where(x => x.InterestMode == 5 || x.InterestMode == 6).Select(x => x.InterestPrincipalFix))}");
Console.WriteLine($" realPostitions(IsInitial=False) 预付金腿 Fix: {string.Join(",", realPostitions.Where(x => x.InterestMode == 5 || x.InterestMode == 6).Select(x => x.InterestPrincipalFix))} ← 应为剩余值");
// 2.1d 关键诊断:realLeg.PositionId == origPos.id 匹配校验(修复后端 Clone 是否会触发)
Console.WriteLine("\n============== realLeg.PositionId ↔ origPos.id 匹配校验(决定 Clone 是否生效)==============");
foreach (var origPos in origPositions.Where(p => p.InterestMode == 5 || p.InterestMode == 6))
{
var realLeg = realPostitions.FirstOrDefault(r => r.PositionId == origPos.id);
Console.WriteLine($" origPos.id={origPos.id} Fix={origPos.InterestPrincipalFix} | realLeg found={(realLeg != null)} | realLeg.id={realLeg?.id} realLeg.PositionId={realLeg?.PositionId} realLeg.Fix={realLeg?.InterestPrincipalFix} | 需Clone={(realLeg != null && realLeg.InterestPrincipalFix != origPos.InterestPrincipalFix)}");
}
// 2.2 EOD 持仓 InterestPrincipalFix 逐日序列
var eodMarginSeq = db.eod_swap_position
.Where(e => e.SwapTradeId == td.id && !e.Invalid
&& (e.InterestMode == (int)InterestModeEnum.初始预付金
|| e.InterestMode == (int)InterestModeEnum.追加预付金))
.OrderBy(e => e.ValueDate).ThenBy(e => e.PositionId)
.ToList();
Console.WriteLine("============== EOD 预付金腿 InterestPrincipalFix 逐日变化 ==============");
foreach (var e in eodMarginSeq)
{
Console.WriteLine($" ValueDate={e.ValueDate:yyyy-MM-dd} PositionId={e.PositionId} Fix={e.InterestPrincipalFix} TdInterestPrincipal={e.TdInterestPrincipal} PosiStatus={e.PosiStatus} Invalid={e.Invalid}");
}
// 2.3 平仓事件序列(看 InterestPrincipal 实际入库值)
var closeFlows = db.swap_flow_event
.Where(f => f.SwapTradeId == td.id && f.EventType == (int)SwapEventTypeEnum.平仓
&& f.DataState == (int)SwapFlowDateStateEnum.完成
&& (f.InterestMode == (int)InterestModeEnum.初始预付金
|| f.InterestMode == (int)InterestModeEnum.追加预付金))
.OrderBy(f => f.EventDate).ToList();
Console.WriteLine("============== 历史平仓事件-预付金腿 实际 InterestPrincipal 序列 ==============");
foreach (var f in closeFlows)
{
Console.WriteLine($" EventDate={f.EventDate:yyyy-MM-dd} PositionId={f.PositionId} InterestPrincipal={f.InterestPrincipal} InterestAmount={f.InterestAmount} Quantity={f.Quantity} TradingAmount={f.TradingAmount}");
}
// 2.3b 直接调 ResolveInterestLegPositions,验证 Clone 是否真的把 Fix 覆盖成 realLeg 值
var resolved = SwapDealService.ResolveInterestLegPositions(origPositions, realPostitions);
Console.WriteLine("\n============== ResolveInterestLegPositions 直接调用结果 ==============");
foreach (var rp in resolved.Where(x => x.InterestMode == 5 || x.InterestMode == 6))
{
Console.WriteLine($" resolved: id={rp.id} PositionId={rp.PositionId} Mode={rp.InterestMode} Fix={rp.InterestPrincipalFix} (期望=realLeg.Fix)");
}
// 2.3c 模拟前端调用 controller 完整流程:前端传 closePercent=0.7(占期初) + notionalValue/posiNotionalValue
// controller 调 ToRemainingClosePercent 转为占剩余,再调 GetUnwindInterests
// 等价于 HTTP POST /swaptrade2/GetUnwindInterestList
Console.WriteLine("\n============== 模拟 HTTP API 调用(前端 closePercent=0.7 占期初)==============");
decimal frontClosePercent = 0.7m;
decimal frontNotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0d); // 期初名义本金
decimal frontPosiNotionalValue = Convert.ToDecimal(td.StockEqvNotional); // 剩余名义本金
Console.WriteLine($" 前端参数: closePercent={frontClosePercent} notionalValue={frontNotionalValue} posiNotionalValue={frontPosiNotionalValue}");
decimal convertedClosePercent = SwapDealService.ToRemainingClosePercent(frontClosePercent, frontNotionalValue, frontPosiNotionalValue);
Console.WriteLine($" ToRemainingClosePercent 转换后: closePercent={convertedClosePercent}(占剩余)");
var svc = new SwapDealService(new OptUserInfo(1, "UnitTest", OptUserFrom.UnitTest));
var apiInterests = svc.GetUnwindInterests(DateTime.Today, DateTime.Today, td.id, convertedClosePercent, (int)SwapEventTypeEnum.平仓);
Console.WriteLine($" GetUnwindInterests 返回 {apiInterests.Count} 条,预付金腿:");
foreach (var ai in apiInterests.Where(x => x.InterestMode == (int)InterestModeEnum.初始预付金 || x.InterestMode == (int)InterestModeEnum.追加预付金))
{
Console.WriteLine($" PositionId={ai.PositionId} Mode={ai.InterestMode} InterestPrincipal={ai.InterestPrincipal} InterestAmount={ai.InterestAmount}");
}
// 2.4 直调后端 GetUnwindInterests(closePercent=1.0) 看"按全部平仓应返"的预付金值
try
{
var user = new OptUserInfo(0, nameof(GLMS20260701DbDiagnoseTest), OptUserFrom.UnitTest);
var svcFull = new SwapDealService(user);
var interests = svcFull.GetUnwindInterests(DateTime.Today, DateTime.Today, td.id, 1.0m, (int)SwapEventTypeEnum.平仓);
Console.WriteLine("============== 后端 GetUnwindInterests(1.0) 实际返回值-预付金腿 ==============");
foreach (var it in interests.Where(i => i.InterestMode == 5 || i.InterestMode == 6))
{
Console.WriteLine($" PositionId={it.PositionId} Mode={it.InterestMode} InterestPrincipal={it.InterestPrincipal} InterestAmount={it.InterestAmount} InterestRate={it.InterestRate}");
}
// 诊断断言:1.0 全平应返 = realPostitions(剩余持仓)的 InterestPrincipalFix
// 后端为保持 eod_swap_position.PositionId 日终归档对齐,返回的 PositionId 仍是 origPositions.id
// 但 InterestPrincipal 应等于 realPostitions[real.PositionId == orig.id].Fix(剩余值)。
// 因此对比口径:apiRet.InterestPrincipal vs realLeg.Fix(剩余值),不是 vs origPos.Fix(原始值)。
Console.WriteLine("============== 修复验证(apiRet.InterestPrincipal vs realLeg.Fix 剩余值)==============");
int okCount = 0, badCount = 0;
foreach (var origPos in marginPositions.Where(p => p.IsInitial))
{
var apiRet = interests.FirstOrDefault(i => i.PositionId == origPos.id);
if (apiRet == null) { Console.WriteLine($" ⚠ PositionId={origPos.id} 后端未返回"); continue; }
var realLeg = marginPositions.FirstOrDefault(p => !p.IsInitial && p.PositionId == origPos.id);
decimal expectedFix = realLeg?.InterestPrincipalFix ?? origPos.InterestPrincipalFix;
var diff = Math.Abs((double)(apiRet.InterestPrincipal - expectedFix));
bool ok = diff < 0.01;
if (ok) okCount++; else badCount++;
Console.WriteLine($" {(ok ? "" : "")} PositionId={origPos.id}(origFix={origPos.InterestPrincipalFix}) → realLeg.Fix={expectedFix} 后端返={apiRet.InterestPrincipal} 差={diff:F4}");
}
Console.WriteLine($"\n 结论:通过 {okCount} 条 / 失败 {badCount} 条");
Assert.IsTrue(badCount == 0, $"修复未生效:{badCount} 条预付金腿后端返还值 ≠ realLeg.Fix 剩余值");
}
catch (Exception ex)
{
Console.WriteLine($"⚠ GetUnwindInterests 调用失败:{ex.Message}");
}
}
#endregion
#region 3) 诊断 GLMS-20260701-0006:部分平仓比例显示 32.50% 而非 50%
[TestMethod]
[TestCategory("DbDiagnose")]
public void Diagnose_0006_UnwindPercentRate_Display()
{
const string tradeNumber = "GLMS-20260701-0006";
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($" TradeType: {td.TradeType}");
Console.WriteLine($" TradeStatus: {td.TradeStatus}");
Console.WriteLine($" StockEqvNotional (剩余): {td.StockEqvNotional}");
Console.WriteLine($" OriginalStockEqvNotional (期初): {td.OriginalStockEqvNotional}");
Console.WriteLine($" Notional: {td.Notional}");
Console.WriteLine($" OriginalNotional: {td.OriginalNotional}");
Console.WriteLine($" TradeAmount: {td.TradeAmount}");
Console.WriteLine($" HasPartialUnWind: {td.HasPartialUnWind}");
Console.WriteLine($" 剩余比例 = StockEqvNotional/Original = {td.StockEqvNotional / td.OriginalStockEqvNotional}");
Console.WriteLine();
Console.WriteLine($"===== trade_cash 记录 =====");
var tradeCashList = db.trade_cash
.Where(t => t.TradeId == td.id && !t.IsDeleted &&
(t.Action == "系统操作-平仓费" || t.Action == "系统操作-行权费"))
.OrderBy(t => t.ValueDate).ThenBy(t => t.id)
.ToList();
foreach (var tc in tradeCashList)
{
Console.WriteLine($" [id={tc.id}] ValueDate={tc.ValueDate:yyyy-MM-dd} Action={tc.Action}");
Console.WriteLine($" UnwindType: {tc.UnwindType}");
Console.WriteLine($" UnwindPercentRate: {tc.UnwindPercentRate} (=> {tc.UnwindPercentRate * 100}%)");
Console.WriteLine($" UnwindStockEqvNotional: {tc.UnwindStockEqvNotional}");
Console.WriteLine($" UnwindNotional: {tc.UnwindNotional}");
Console.WriteLine($" UnwindTradeAmount: {tc.UnwindTradeAmount}");
Console.WriteLine($" UnwindMethod: {tc.UnwindMethod}");
Console.WriteLine($" ValidState: {tc.ValidState}");
Console.WriteLine($" IsLastAction: {tc.IsLastAction}");
Console.WriteLine($" ExerciseWay: {tc.ExerciseWay}");
Console.WriteLine();
}
Console.WriteLine($"===== swap_event 记录 =====");
var swapEvents = db.swap_event
.Where(e => e.SwapTradeId == td.id && !e.Invalid)
.OrderBy(e => e.ValueDate).ThenBy(e => e.id)
.ToList();
foreach (var se in swapEvents)
{
Console.WriteLine($" [id={se.id}] ValueDate={se.ValueDate:yyyy-MM-dd} EventType={se.EventType}");
Console.WriteLine($" EventReason: {se.EventReason}");
Console.WriteLine($" ClientCashId: {se.ClientCashId}");
if (!string.IsNullOrEmpty(se.EventData))
{
try
{
var ud = JsonConvert.DeserializeObject<JObject>(se.EventData);
Console.WriteLine($" EventData.ClosePercent: {ud["ClosePercent"]}");
Console.WriteLine($" EventData.CloseNotionalValue: {ud["CloseNotionalValue"]}");
Console.WriteLine($" EventData.CloseQty: {ud["CloseQty"]}");
Console.WriteLine($" EventData.NotionalValue: {ud["NotionalValue"]}");
Console.WriteLine($" EventData.PosiNotionalValue: {ud["PosiNotionalValue"]}");
Console.WriteLine($" EventData.PositionQty: {ud["PositionQty"]}");
Console.WriteLine($" EventData.CloseMethod: {ud["CloseMethod"]}");
}
catch (Exception ex)
{
Console.WriteLine($" EventData parse error: {ex.Message}");
}
}
Console.WriteLine();
}
// 查询 swap_flow_event 记录
Console.WriteLine($"===== swap_flow_event 记录 =====");
var flowEvents = db.swap_flow_event
.Where(f => f.SwapTradeId == td.id)
.OrderBy(f => f.EventDate).ThenBy(f => f.id)
.ToList();
foreach (var fe in flowEvents)
{
Console.WriteLine($" [id={fe.id}] EventDate={fe.EventDate:yyyy-MM-dd} EventType={fe.EventType}");
Console.WriteLine($" PositionId: {fe.PositionId}");
Console.WriteLine($" Quantity: {fe.Quantity}");
Console.WriteLine($" PositionQty: {fe.PositionQty}");
Console.WriteLine();
}
}
#endregion
}
}