Files
zszq-trs/UnitTestProject/Modules/SwapModule/GLMS20260701DbDiagnoseTest.cs
T
hjhan d51dd8a60b test(swap): 回归测试对齐 GLMS-20260701-0008 四次部分平仓快照
生产 deal 又平仓两次(共四次),实时预付金腿 InterestPrincipalFix 由 73,260
降至 66,813.12(4 次返还 9,900+15,840+3,663+2,783.88=32,186.88;
99,000−32,186.88=66,813.12,与 dev 库实时腿完全勾稽)。

- SwapUnwindPrepayOrigVsRealBugTdd:RemainingFix 常量 73,260→66,813.12;
  头部注释同步为四次平仓;新增 GLMS20260701_四次部分平仓_LiveSnapshot 用例,
  把 4 次平仓真实数据硬编码为忠实回归(日后该 deal 再平仓需同步更新)。
- 纳入此前遗漏提交的连库诊断测试 GLMS20260701DbDiagnoseTest.cs
  (使用 DbContextFactory 取连接串,无硬编码密码,安全)。

验证:dotnet test --filter SwapUnwindPrepay → 26 passed(19+7)失败 0;
YLErpDAL / UnitTestProject 编译通过(仅既有警告)。
2026-07-16 11:09:47 +08:00

216 lines
13 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 = "GLMS-20260701-0008";
#region 1) 录真实数据快照(手动跑)
[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},请确认环境");
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()
{
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}{"Dir",-6}{"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,-6}{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.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.4 直调后端 GetUnwindInterests(closePercent=1.0) 看"按全部平仓应返"的预付金值
try
{
var user = new OptUserInfo(0, nameof(GLMS20260701DbDiagnoseTest), OptUserFrom.UnitTest);
var svc = new SwapDealService(user);
var interests = svc.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
}
}