互换两个独立bug分析文档+诊断测试

Bug①利息默认值偏大: 互换结清后eod的InterestProfitSum(待实现)没归零
(=RealizedInterest=77.26), 导致后续平仓默认值=77.26(本该0)+增量→偏大。
6-29收盘后三天对比坐实: 6-30默认83.70, 7-1默认90.14, 均含已实现的77.26。
根因 eod公式cs:829 对互换场景扣减失效(TdInterestIncome≈TdCloseInterest抵消)。

Bug②收益结算审核后状态卡死: ApproveSwapTrade(cs:1525)漏ExerciseDate判断,
到期互换审核后状态退回'确认成交'(CloseMethod默认0≠全部平仓1),
导致EodCheckMaturityTrade一直阻止收盘。引入点083848fb(2025-05-28)。

- 新增利息偏大分析文档(根因/修复方案A扣RealizedInterest/B eod归零)
- 新增审核状态卡死分析文档(根因/修复补ExerciseDate判断)
- 新增SwapPartialUnwindInterestDefaultTest(Step0探查/Step0b诊断/Step0d三天对比)
This commit is contained in:
hjhan
2026-07-01 09:57:25 +08:00
parent 78751f0a57
commit cd03fc53fb
3 changed files with 878 additions and 0 deletions
@@ -0,0 +1,454 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using YLErp.DBModels;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 互换部分平仓后利息端/预付金默认盈亏偏大 - 录制/验证测试(TDD 红灯)
/// ============================================================================
/// 背景:
/// 昨天收益结算(互换)→收盘→今天平仓,"预付金平仓盈亏"和"利息端平仓盈亏"
/// 默认值偏大。根因:CalcDailySimpleInterest(cs:771) 从 PosiStartDate 全程重算利息,
/// 只读 InterestProfitSum(待实现),不读 RealizedInterest(已实现),导致跨天重复计入。
/// 同日去重(cs:435) 只覆盖当天、算尾跳过,跨天不生效。
///
/// TDD 红灯→绿灯:
/// 红灯(当前):找一笔多次操作的交易 → 模拟默认值计算 → 断言默认值 > 应计基数(待实现-已实现)
/// 绿灯(修复后):默认值 ≤ 应计基数
///
/// 运行方式:全部 [Ignore]+[TestCategory("DBRecording")],不进 CI。
/// ============================================================================
[TestClass]
public class SwapPartialUnwindInterestDefaultTest
{
private static readonly string GoldenDir = Path.Combine(
AppDomain.CurrentDomain.BaseDirectory, "Resources", "GoldenFiles", "SwapPartialUnwindInterest");
private static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings
{
Formatting = Formatting.Indented,
NullValueHandling = NullValueHandling.Include,
DateFormatString = "yyyy-MM-ddTHH:mm:ss",
ReferenceLoopHandling = ReferenceLoopHandling.Ignore
};
/// <summary>
/// Step0:探查测试库,列出有"多次平仓/互换操作"的互换交易,供挑选样本。
///
/// 复现条件:一笔交易 swap_flow_event 里 EventType IN(平仓,互换,自动互换) 且 DataState=完成
/// 的记录 ≥ 2 条(说明做过多次操作),且有 eod_swap_position(已收盘)。
/// </summary>
[TestMethod]
[TestCategory("DBRecording")]
public void Step0_ListMultiOperationTrades()
{
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex)
{
Assert.Inconclusive($"无法连接测试库(CI/无DB环境正常跳过):{ex.Message}");
return;
}
try
{
// 找有多次操作的交易
var multiOpTrades = db.swap_flow_event
.Where(x => (x.EventType == (int)SwapFlowEventTypeEnum.
|| x.EventType == (int)SwapFlowEventTypeEnum.
|| x.EventType == (int)SwapFlowEventTypeEnum.)
&& x.DataState == (int)SwapFlowDateStateEnum.)
.AsEnumerable()
.GroupBy(x => x.SwapTradeId)
.Where(g => g.Count() >= 2)
.Select(g => new
{
SwapTradeId = g.Key,
= g.Count(),
= g.Count(x => x.EventType == (int)SwapFlowEventTypeEnum.),
= g.Count(x => x.EventType == (int)SwapFlowEventTypeEnum. || x.EventType == (int)SwapFlowEventTypeEnum.),
= g.Min(x => x.EventDate),
= g.Max(x => x.EventDate),
= g.Sum(x => x.InterestClosePnL),
EodCount = db.eod_swap_position.Count(e => e.SwapTradeId == g.Key)
})
.Where(t => t.EodCount > 0)
.OrderByDescending(t => t.)
.Take(30)
.ToList();
Console.WriteLine($"=== 多次操作的互换交易数: {multiOpTrades.Count} ===\n");
Console.WriteLine($"{"TradeId",8} {"",6} {"",6} {"",6} {"eod",6} {"",16} {"",-24}");
foreach (var t in multiOpTrades)
{
string dateRange = $"{t.最早操作日:yyyy-MM-dd}~{t.最晚操作日:yyyy-MM-dd}";
Console.WriteLine($"{t.SwapTradeId,8} {t.操作次数,6} {t.平仓次数,6} {t.互换次数,6} {t.EodCount,6} {t.利息盈亏合计,16:F2} {dateRange,-24}");
}
if (multiOpTrades.Count == 0)
{
Assert.Inconclusive("无多次操作的样本(需有≥2次平仓/互换且有eod的交易)。");
}
Assert.IsTrue(multiOpTrades.Count > 0);
}
finally
{
db?.Dispose();
}
}
/// <summary>
/// Step0b:对单笔交易做详细诊断——对比"待实现"vs"已实现"利息,判断默认值是否重复计入。
///
/// 核心逻辑(不改数据,纯查询):
/// - 默认值计算读 InterestProfitSum(待实现),不读 RealizedInterest(已实现)
/// - 若某持仓 InterestProfitSum >> 0 且已有多次操作(RealizedInterest >> 0),
/// 说明下次平仓默认值会基于"全程待实现"重算,重复计入已实现部分
/// - 真正应计基数 = InterestProfitSum - RealizedInterest(剩余未实现)
/// </summary>
[TestMethod]
[TestCategory("DBRecording")]
public void Step0b_DiagnoseSingleTradeInterestDuplication()
{
int tradeId = SampleTradeId;
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex)
{
Assert.Inconclusive($"无法连接测试库(CI/无DB环境正常跳过):{ex.Message}");
return;
}
try
{
Console.WriteLine($"===== 诊断 SwapTradeId={tradeId} 利息端默认值重复计入 =====\n");
// 1. 该交易的利息腿(InterestDirection>0)最新 eod 快照
var latestEodDate = db.eod_swap_position
.Where(x => x.SwapTradeId == tradeId)
.Max(x => (DateTime?)x.ValueDate);
if (latestEodDate == null)
{
Assert.Inconclusive($"交易 {tradeId} 无 eod 数据");
return;
}
var interestEods = db.eod_swap_position
.Where(x => x.SwapTradeId == tradeId
&& x.ValueDate == latestEodDate
&& x.InterestDirection > 0)
.OrderBy(x => x.PositionId)
.ToList();
Console.WriteLine($"[1] 最新eod({latestEodDate:yyyy-MM-dd})利息腿持仓: {interestEods.Count} 条\n");
Console.WriteLine($"{"PositionId",12} {"InterestMode",12} {"InterestProfitSum",22} {"RealizedInterest",22} {"(-)",18} {"",10}");
int riskCount = 0;
foreach (var e in interestEods)
{
decimal base_ = e.InterestProfitSum - e.RealizedInterest;
bool risk = e.InterestProfitSum != 0 && e.RealizedInterest != 0
&& Math.Abs(e.InterestProfitSum) > Math.Abs(base_);
if (risk) riskCount++;
string modeName = ((InterestModeEnum)(e.InterestMode)).ToString();
Console.WriteLine($"{e.PositionId,12} {modeName,12} {e.InterestProfitSum,22:F4} {e.RealizedInterest,22:F4} {base_,18:F4} {(risk ? "" : ""),10}");
}
// 2. 历史操作记录(看每次利息盈亏)
var history = db.swap_flow_event
.Where(x => x.SwapTradeId == tradeId
&& x.DataState == (int)SwapFlowDateStateEnum.
&& (x.EventType == (int)SwapFlowEventTypeEnum.
|| x.EventType == (int)SwapFlowEventTypeEnum.
|| x.EventType == (int)SwapFlowEventTypeEnum.))
.OrderBy(x => x.EventDate).ThenBy(x => x.id)
.ToList();
Console.WriteLine($"\n[2] 历史操作记录: {history.Count} 条\n");
Console.WriteLine($"{"id",8} {"EventDate",12} {"EventType",10} {"PositionId",12} {"InterestClosePnL",18} {"InterestAmount",16}");
foreach (var h in history)
{
string etName = ((SwapFlowEventTypeEnum)h.EventType).ToString();
Console.WriteLine($"{h.id,8} {h.EventDate:yyyy-MM-dd} {etName,10} {h.PositionId,12} {h.InterestClosePnL,18:F4} {h.InterestAmount,16:F4}");
}
// 3. 诊断结论
Console.WriteLine($"\n[结论]");
if (riskCount > 0)
{
Console.WriteLine($"⚠ 有 {riskCount} 条利息腿存在重复计入风险:");
Console.WriteLine($" InterestProfitSum(待实现) 被用作下次平仓默认值计算基数(cs:774),");
Console.WriteLine($" 但它没有扣除 RealizedInterest(已实现)。");
Console.WriteLine($" → 部分平仓后再平仓,默认值会偏大(含已实现部分)。");
}
else
{
Console.WriteLine($" 未检测到重复计入风险(可能 InterestProfitSum 或 RealizedInterest 为0)。");
}
Assert.IsTrue(interestEods.Count > 0, "应有利息腿持仓");
}
finally
{
db?.Dispose();
}
}
/// <summary>
/// 样本交易ID。1889 = GLMS-20260616-000429号收益结算+收盘,30号平仓。
/// </summary>
private int SampleTradeId => 1889;
/// <summary>
/// Step0c:精确诊断——调用真实的 GetUnwindInterests 拿默认值,对比 eod 应计,定位偏差。
///
/// 这是最直接的验证:用平仓日的参数调 GetUnwindInterests(与前端拿默认值完全相同的路径),
/// 看返回的 InterestClosePnL 是否包含了"之前已通过互换实现的部分"。
/// </summary>
[TestMethod]
[TestCategory("DBRecording")]
public void Step0c_VerifyDefaultViaRealService()
{
int tradeId = SampleTradeId;
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex)
{
Assert.Inconclusive($"无法连接测试库(CI/无DB环境正常跳过):{ex.Message}");
return;
}
try
{
// 找最后一次平仓事件,用它的参数模拟"打开平仓页"
var lastClose = db.swap_flow_event
.Where(x => x.SwapTradeId == tradeId
&& x.EventType == (int)SwapFlowEventTypeEnum.
&& x.DataState == (int)SwapFlowDateStateEnum.)
.OrderByDescending(x => x.EventDate)
.FirstOrDefault();
if (lastClose == null)
{
Assert.Inconclusive($"交易 {tradeId} 无平仓记录");
return;
}
Console.WriteLine($"===== 调用 GetUnwindInterests 验证 SwapTradeId={tradeId} =====");
Console.WriteLine($"模拟平仓日: EventDate={lastClose.EventDate:yyyy-MM-dd} UnwindDate={lastClose.UnwindDate:yyyy-MM-dd}\n");
// 该交易平仓前的最近 eod(用于对比)
var preEodDate = db.eod_swap_position
.Where(x => x.SwapTradeId == tradeId && x.ValueDate < lastClose.UnwindDate)
.Max(x => (DateTime?)x.ValueDate);
var preEodInterests = db.eod_swap_position
.Where(x => x.SwapTradeId == tradeId && x.ValueDate == preEodDate && x.InterestDirection > 0)
.ToList();
Console.WriteLine($"[平仓前最近eod: {preEodDate:yyyy-MM-dd}]");
Console.WriteLine($"{"PositionId",12} {"InterestProfitSum()",28} {"RealizedInterest()",24}");
foreach (var e in preEodInterests)
{
Console.WriteLine($"{e.PositionId,12} {e.InterestProfitSum,28:F4} {e.RealizedInterest,24:F4}");
}
// 调用真实服务(与前端 GetUnwindInterestList 完全相同的路径)
var userInfo = new OptUserInfo(1, "UnitTest", OptUserFrom.UnitTest);
var service = new SwapDealService(userInfo);
// closePercent 取实际平仓的(从历史 flow_event 推断:InterestPrincipal / PosiNotionalValue
decimal closePercent = 1m; // 先用全平测试
var defaults = service.GetUnwindInterests(
lastClose.EventDate, lastClose.UnwindDate.Value, tradeId, closePercent,
(int)SwapEventTypeEnum.);
Console.WriteLine($"\n[GetUnwindInterests 返回的默认值] closePercent={closePercent}");
Console.WriteLine($"{"PositionId",12} {"InterestMode",12} {"InterestClosePnL",22} {"InterestAmount",20} {"InterestClosePnL",24}");
foreach (var d in defaults.Where(x => x.InterestDirection > 0))
{
var hist = db.swap_flow_event.FirstOrDefault(x => x.SwapTradeId == tradeId
&& x.PositionId == d.PositionId && x.id == lastClose.id);
string modeName = ((InterestModeEnum)d.InterestMode).ToString();
Console.WriteLine($"{d.PositionId,12} {modeName,12} {d.InterestClosePnL,22:F4} {d.InterestAmount,20:F4} {hist?.InterestClosePnL ?? 0,24:F4}");
}
// 诊断:默认值 vs 历史实际值 的差异
Console.WriteLine($"\n[诊断]");
bool hasDiscrepancy = false;
foreach (var d in defaults.Where(x => x.InterestDirection > 0))
{
var hist = db.swap_flow_event.FirstOrDefault(x => x.SwapTradeId == tradeId
&& x.PositionId == d.PositionId && x.id == lastClose.id);
if (hist != null && Math.Abs(d.InterestClosePnL - hist.InterestClosePnL) > 0.01m)
{
Console.WriteLine($" PositionId={d.PositionId}: 默认值={d.InterestClosePnL:F4} vs 历史={hist.InterestClosePnL:F4} 差异={d.InterestClosePnL - hist.InterestClosePnL:F4}");
hasDiscrepancy = true;
}
}
if (hasDiscrepancy)
{
Console.WriteLine($" ⚠ 默认值与历史实际值有差异(可能是重算口径变化或bug)");
}
else
{
Console.WriteLine($" 默认值与历史实际值一致(该样本未复现偏差)");
}
Assert.IsTrue(defaults.Count > 0, "应返回利息腿默认值");
}
finally
{
db?.Dispose();
}
}
/// <summary>
/// Step0d:针对 1889GLMS-20260616-0004)的全面诊断。
///
/// 场景:29号收益结算(互换)+收盘 → 30号平仓。
/// 测试环境会不断回退复用同一笔交易,需甄别。
///
/// 本方法一次性查清:
/// 1. swap_event 全历史(含回退 EventType=5),甄别哪些是回退后的有效操作
/// 2. swap_flow_event 全历史(含 DataState≠完成的废弃事件)
/// 3. eod_swap_position 按日期序列,看 InterestProfitSum/RealizedInterest 逐日演变
/// 4. 调 GetUnwindInterests 拿30号平仓默认值,对比29号互换已实现的部分
/// </summary>
[TestMethod]
[TestCategory("DBRecording")]
public void Step0d_DiagnoseTrade1889_FullTimeline()
{
int tradeId = SampleTradeId;
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex)
{
Assert.Inconclusive($"无法连接测试库(CI/无DB环境正常跳过):{ex.Message}");
return;
}
try
{
Console.WriteLine($"===== 全面诊断 SwapTradeId={tradeId} =====\n");
// 1. swap_event 全历史(含回退/删除)
var allEvents = db.swap_event
.Where(x => x.SwapTradeId == tradeId)
.OrderBy(x => x.id)
.ToList();
Console.WriteLine($"[1] swap_event 全历史: {allEvents.Count} 条(甄别回退)");
Console.WriteLine($" 仅显示 Invalid=False(有效)的事件:");
var validEvents = allEvents.Where(x => !x.Invalid).ToList();
Console.WriteLine($" {"id",8} {"EventType",10} {"ValueDate",12} {"ClientCashId",12} {"EventReason",-20}");
foreach (var e in validEvents)
{
string etName = ((SwapEventTypeEnum)e.EventType).ToString();
Console.WriteLine($" {e.id,8} {etName,10} {e.ValueDate:yyyy-MM-dd} {e.ClientCashId,12} {(e.EventReason ?? ""),-20}");
}
Console.WriteLine($" (另有 {allEvents.Count(x => x.Invalid)} 条 Invalid=True 的回退/历史事件,已隐藏)");
// 2. swap_flow_event 全历史(仅完成状态,过滤废弃)
var allFlowEvents = db.swap_flow_event
.Where(x => x.SwapTradeId == tradeId)
.OrderBy(x => x.id)
.ToList();
var validFlowEventsAll = allFlowEvents.Where(x => x.DataState == (int)SwapFlowDateStateEnum.).ToList();
Console.WriteLine($"\n[2] swap_flow_event 完成状态: {validFlowEventsAll.Count} 条(共{allFlowEvents.Count}条,已隐藏{allFlowEvents.Count - validFlowEventsAll.Count}条废弃)");
Console.WriteLine($" {"id",8} {"EventDate",12} {"UnwindDate",12} {"EventType",10} {"PositionId",10} {"InterestClosePnL",18} {"InterestAmount",16} {"MarkClosePnl",14}");
foreach (var f in validFlowEventsAll)
{
string etName = ((SwapFlowEventTypeEnum)f.EventType).ToString();
Console.WriteLine($" {f.id,8} {f.EventDate:yyyy-MM-dd} {f.UnwindDate?.ToString("yyyy-MM-dd") ?? "-",-12} {etName,10} {f.PositionId,10} {f.InterestClosePnL,18:F4} {f.InterestAmount,16:F4} {f.MarkClosePnl,14:F4}");
}
// 3. eod_swap_position 按日期序列(利息腿),看 InterestProfitSum/RealizedInterest 演变
var eodTimeline = db.eod_swap_position
.Where(x => x.SwapTradeId == tradeId && x.InterestDirection > 0)
.OrderBy(x => x.ValueDate).ThenBy(x => x.PositionId)
.ToList();
Console.WriteLine($"\n[3] eod_swap_position 利息腿按日序列: {eodTimeline.Count} 条");
Console.WriteLine($"{"ValueDate",12} {"PositionId",10} {"InterestProfitSum",18} {"RealizedInterest",18} {"TdCloseInterest",16} {"InterestIncomeSum",18}");
foreach (var e in eodTimeline)
{
Console.WriteLine($"{e.ValueDate:yyyy-MM-dd} {e.PositionId,10} {e.InterestProfitSum,18:F4} {e.RealizedInterest,18:F4} {e.TdCloseInterest,16:F4} {e.InterestIncomeSum,18:F4}");
}
// 4. 甄别:找出有效的 29号互换 和 30号平仓
var validFlowEvents = allFlowEvents
.Where(x => x.DataState == (int)SwapFlowDateStateEnum.)
.OrderBy(x => x.EventDate).ThenBy(x => x.id)
.ToList();
var swapOn29 = validFlowEvents.Where(x => x.EventDate == new DateTime(2026, 6, 29)
&& (x.EventType == (int)SwapFlowEventTypeEnum. || x.EventType == (int)SwapFlowEventTypeEnum.)).ToList();
var closeOn30 = validFlowEvents.Where(x => x.EventDate == new DateTime(2026, 6, 30)
&& x.EventType == (int)SwapFlowEventTypeEnum.).ToList();
Console.WriteLine($"\n[4] 关键操作甄别(DataState=完成)");
Console.WriteLine($" 29号互换/自动互换: {swapOn29.Count} 条");
foreach (var s in swapOn29)
Console.WriteLine($" id={s.id} PositionId={s.PositionId} InterestClosePnL={s.InterestClosePnL:F4} InterestAmount={s.InterestAmount:F4}");
Console.WriteLine($" 30号平仓: {closeOn30.Count} 条");
foreach (var c in closeOn30)
Console.WriteLine($" id={c.id} PositionId={c.PositionId} InterestClosePnL={c.InterestClosePnL:F4} InterestAmount={c.InterestAmount:F4}");
// 5. 模拟"打开平仓页"——分别测 6-29/6-30/7-1 三天,对比默认值变化
Console.WriteLine($"\n[5] 调 GetUnwindInterests 模拟打开平仓页(6-29/6-30/7-1 三天对比)");
var userInfo = new OptUserInfo(1, "UnitTest", OptUserFrom.UnitTest);
var service = new SwapDealService(userInfo);
var testDates = new[] {
new DateTime(2026, 6, 29),
new DateTime(2026, 6, 30),
new DateTime(2026, 7, 1),
};
Console.WriteLine($" {"",12} {"PositionId",10} {"InterestMode",14} {"InterestClosePnL",22} {"eod待实现IPS",14} {"eod已实现RI",14} {"Δ-",14}");
foreach (var testDate in testDates)
{
var defaults = service.GetUnwindInterests(
testDate, testDate, tradeId, 1m, (int)SwapEventTypeEnum.);
foreach (var d in defaults.Where(x => x.InterestDirection > 0))
{
// 找该日期前最近的 eod
var preEod = eodTimeline.Where(x => x.PositionId == d.PositionId && x.ValueDate < testDate)
.OrderByDescending(x => x.ValueDate).FirstOrDefault();
decimal ips = preEod?.InterestProfitSum ?? 0;
decimal ri = preEod?.RealizedInterest ?? 0;
decimal delta = d.InterestClosePnL - ips;
string modeName = ((InterestModeEnum)d.InterestMode).ToString();
string preEodDate = preEod?.ValueDate.ToString("MM-dd") ?? "无";
Console.WriteLine($" {testDate:yyyy-MM-dd} {d.PositionId,10} {modeName,14} {d.InterestClosePnL,22:F4} {ips,14:F4}({preEodDate}) {ri,14:F4} {delta,14:F4}");
}
}
// 6. 核心诊断
Console.WriteLine($"\n[6] 核心诊断");
Console.WriteLine($" 关键观察:29号互换已实现 77.26,看 eod 的 InterestProfitSum(待实现) 是否扣减了已实现部分");
var eod29 = eodTimeline.Where(x => x.ValueDate == new DateTime(2026, 6, 29)).ToList();
foreach (var e in eod29)
{
Console.WriteLine($" PositionId={e.PositionId} 6-29 eod:");
Console.WriteLine($" InterestProfitSum(待实现) = {e.InterestProfitSum:F4}");
Console.WriteLine($" RealizedInterest(已实现) = {e.RealizedInterest:F4}");
Console.WriteLine($" TdCloseInterest(当日实现) = {e.TdCloseInterest:F4}");
if (e.InterestProfitSum != 0 && e.RealizedInterest != 0 && Math.Abs(e.InterestProfitSum - e.RealizedInterest) < 0.1m)
{
Console.WriteLine($" ⚠ 待实现({e.InterestProfitSum:F4}) ≈ 已实现({e.RealizedInterest:F4}) → 互换结清后待实现没归零!");
Console.WriteLine($" → 导致后续平仓默认值仍基于待实现(77.26)算,偏大");
}
}
Console.WriteLine($"\n 用户反馈:6-29看平仓默认=0(正确,因为当天还没收盘/互换),6-30和7-1有问题");
Console.WriteLine($" 根因:29号收盘后 InterestProfitSum 没扣减已实现的 77.26(仍=77.26),");
Console.WriteLine($" 所以后续平仓默认值 = 77.26(应已归零的待实现) + 增量 → 偏大");
Assert.IsTrue(allEvents.Count > 0);
}
finally
{
db?.Dispose();
}
}
}
}