Files
zszq-trs/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs
T
hjhan bfcfc74717 test: 平仓预付金bug回归测试贴合生产铁证(精确notional + 不计息断言)
- 客户截图级用例 notional 由反推的 306,191,860.22 更正为生产精确值
  306,191,860.26,使 2*Fix-Notional = -287,820,348.64 与生产 event15997 精确 0 误差。
- 生产该预付金腿三条 swap_flow_event 的 InterestAmount 全=0(债券类预付金腿不计息),
  仅 InterestPrincipal 中招;故客户级/Trade1813 用例改断言 InterestAmount==0、用 rate=0 贴合生产。
- 显式带息加固用例(rate=0.01,验证计息基数也基于保证金本金自身)保留,但注释标明为
  合成场景、非该笔生产的真实症状,避免与生产现象混淆。
- 固化生产双平仓记录佐证:同腿"盘中错(event15997)/EOD对(event15998)"两条平仓,
  恰好印证修复方向(盘中 orginPv 对齐 EOD=Fix)正确。
2026-07-14 14:46:42 +08:00

235 lines
14 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 YLErp.DBModels;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 预付金(保证金)腿 平仓"应返还本金" bug 的回归测试(根因修复后应为全绿)。
/// ---------------------------------------------------------------
/// 业务预期:平仓"应返还本金"(swap_flow_event.InterestPrincipal) 应等于该预付金腿的
/// 保证金本金(InterestPrincipalFix * closePercent),且与逐日利息计算无关;
/// 同时预付金腿的逐日利息计息基数也应基于"保证金本金"自身,而非整笔交易的名义本金。
///
/// 根因:GetUnwindInterests 对全部腿统一用 orginPv = lastEod.NotionalValue ?? stockEqvNotional(整笔交易名义本金),
/// 缺了"预付金腿用自身保证金"的分支;公式 dynomicPrincipal = TdInterestPrincipal + posiPrincipal - orginPv
/// 把交易名义本金(千万~亿级)当减项扣掉,使 InterestPrincipal 与计息基数变成巨负值。
///
/// 根因修复(SwapDealService.InitSwapDealInterest):对预付金腿(初始/追加)在利息计算前把
/// orginPv 对齐为 position.InterestPrincipalFix,与日终路径(SwapEodPositionService)一致。
/// 仅作用于 InterestMode 5/6;债券本金腿(标的期初全价=9)等仍用交易名义本金,不受影响。
///
/// 设计:标的名义本金 100万、预付金(保证金)本金 10万(维度不同,放大错配);
/// 另含客户截图级 / 真实库 Trade1813 的精确复现用例。
/// </summary>
[TestClass]
public class SwapUnwindPrepayPrincipalBugTdd
{
private sealed class StubSwapDealService : SwapDealService
{
public StubSwapDealService(OptUserInfo optUser) : base(optUser) { }
protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate)
{
rate = 0;
return false; // 预付金腿无浮动标的,不查库
}
}
private const decimal UnderlyingNotional = 1_000_000m; // 标的名义本金(股票维度)
private const decimal PrepayPrincipal = 100_000m; // 预付金/保证金本金(预付金维度)
private const int AnnualDays = 365;
private static readonly DateTime StartDate = new(2026, 4, 27);
private static readonly DateTime ExerciseDate = new(2027, 4, 27);
private static readonly DateTime UnwindDate = new(2026, 4, 28);
private SwapDealService _svc;
[TestInitialize]
public void Init() => _svc = new StubSwapDealService(new OptUserInfo(0, nameof(SwapUnwindPrepayPrincipalBugTdd), OptUserFrom.UnitTest));
private static trade MakeTrade(decimal notional = UnderlyingNotional)
{
var extend = new trade_extend
{
TradeId = 1,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "10", // 算头不算尾
SettlementRules = 0
})
};
return new trade
{
id = 1, TradeNumber = "UT-PREPAY-TDD", ClientId = 999998,
TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate,
ExerciseDate = ExerciseDate, TradeStatus = "确认成交", ValidState = "Valid",
StockEqvNotional = (double)notional, Notional = (double)notional,
trade_extend = extend
};
}
private static swap_position MakePrepayPosition(decimal fix = PrepayPrincipal, decimal rate = 0.01m)
{
return new swap_position
{
id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum.收取,
InterestMode = (int)InterestModeEnum.初始预付金,
InterestRateDefault = rate, InterestPrincipalFix = fix,
PosiStartDate = StartDate, PosiMatuirityDate = ExerciseDate,
IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.单利,
IsAnnualized = true, interest_rest_days = 1,
interest_rule = 0, FloatRateUnderlyingCode = null,
InterestSwapInterval = "[]"
};
}
private swap_flow_event CalcUnwind(decimal closePercent, List<eod_swap_position> eodPositions)
{
eodPositions ??= new List<eod_swap_position>();
var td = MakeTrade();
var position = MakePrepayPosition();
var interests = _svc.GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
eodPositions, new List<swap_position> { position },
UnderlyingNotional, UnderlyingNotional, UnderlyingNotional, UnderlyingNotional, closePercent,
(int)SwapEventTypeEnum.平仓,
false, false, 0, UnderlyingNotional, false, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, interests.Count, "预付金腿应生成 1 条 flow_event");
return interests[0];
}
/// <summary>
/// 客户/真实库场景:自定义 标的名义本金(notional) 与 保证金本金(fix)。
/// orginPv 用 notional(与 GetUnwindInterests 行为一致:lastEod.NotionalValue ?? stockEqvNotional)。
/// </summary>
private swap_flow_event CalcUnwindWith(decimal closePercent, List<eod_swap_position> eodPositions, decimal notional, decimal fix, decimal rate = 0.01m)
{
eodPositions ??= new List<eod_swap_position>();
var td = MakeTrade(notional);
var position = MakePrepayPosition(fix, rate);
var interests = _svc.GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
eodPositions, new List<swap_position> { position },
notional, notional, notional, notional, closePercent,
(int)SwapEventTypeEnum.平仓,
false, false, 0, notional, false, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, interests.Count, "预付金腿应生成 1 条 flow_event");
return interests[0];
}
[TestMethod]
public void 无历史归档_全平_应返还本金应等于保证金本金()
{
var fe = CalcUnwind(1m, null); // 无 eod 归档 → preEod.id==0
Console.WriteLine($"[TDD] 无归档 实测 InterestPrincipal={fe.InterestPrincipal} (期望={PrepayPrincipal})");
Assert.AreEqual(PrepayPrincipal, fe.InterestPrincipal,
"无归档全平: InterestPrincipal(应返还本金) 应=保证金本金(预付金本金),不应被利息公式改写为含 -orginPv 与 double 的怪值");
}
[TestMethod]
public void 有历史归档_全平_应返还本金应等于保证金本金()
{
var eod = new List<eod_swap_position>
{
new eod_swap_position
{
id = 1, SwapTradeId = 1, PositionId = 1001,
ValueDate = new DateTime(2026, 4, 27),
TdInterestPrincipal = PrepayPrincipal,
PosiNotionalValue = PrepayPrincipal,
InterestProfitSum = 0m
}
};
var fe = CalcUnwind(1m, eod);
Console.WriteLine($"[TDD] 有归档 实测 InterestPrincipal={fe.InterestPrincipal} (期望={PrepayPrincipal})");
Assert.AreEqual(PrepayPrincipal, fe.InterestPrincipal,
"有归档全平: 计息区间被跳过,InterestPrincipal 应保持初始正确值=保证金本金");
}
// ---- 客户截图级 / 真实库场景(验证"前后是否真 Fix"----
[TestMethod]
public void 客户截图级_全平_应返还本金应等于保证金本金()
{
// 生产铁证(用户提供真实交易):TradeAmount=3亿,StockEqvNotional=306,191,860.26
// StructureType=普通债券类收益互换;预付金腿 swap_position id=34009 InterestMode=5
// InterestPrincipalFix=9,185,755.81。
// swap_flow_event(该腿, mode5) 三条:
// 9202 EventId=null dir2 IP=9,185,755.81 (建仓支付预付金 ✓)
// 9489 EventId=15997 dir1 IP=-287,820,348.64 (平仓, 盘中路径 BUG ✗)
// 9492 EventId=15998 dir1 IP=9,185,755.81 (平仓, EOD正确路径 ✓)
// 同一腿出现"盘中错 / EOD对"两条平仓记录,恰好佐证修复方向(盘中 orginPv 对齐 EOD=Fix)正确。
// 根因复现:2*Fix - Notional = 2*9,185,755.81 - 306,191,860.26 = -287,820,348.64(与生产 15997 精确 0 误差)。
// 该预付金腿三条 event 的 InterestAmount 全=0(债券类预付金腿不计息),
// 故本笔生产仅 InterestPrincipal 中招、计息基数未受影响 → rate=0 贴合生产。
const decimal notional = 306_191_860.26m;
const decimal fix = 9_185_755.81m;
var fe = CalcUnwindWith(1m, null, notional, fix, rate: 0m);
Console.WriteLine($"[TDD][客户] 实测 InterestPrincipal={fe.InterestPrincipal} InterestAmount={fe.InterestAmount} (期望Principal={fix})");
Assert.AreEqual(fix, fe.InterestPrincipal,
"客户级: 应返还本金应=保证金本金 9,185,755.81,不应被算成 -287,820,348.64");
Assert.AreEqual(0m, fe.InterestAmount,
"客户级: 该预付金腿不计息,InterestAmount 应=0(与生产三条 event 全为 0 一致);仅 InterestPrincipal 中招");
}
[TestMethod]
public void 真实库Trade1813_全平_应返还本金应等于保证金本金()
{
// 测试库 Trade=1813 / Pos=34204Fix=35,140Notional=12,100,000
// 实际存储 InterestPrincipal=-12,029,720.00=2*35,140-12,100,000,公式精确 0 误差)。
// 同属债券类预付金腿(与生产同模式,不计息),rate=0 贴合生产,仅验证 InterestPrincipal 修复。
const decimal notional = 12_100_000m;
const decimal fix = 35_140m;
var fe = CalcUnwindWith(1m, null, notional, fix, rate: 0m);
Console.WriteLine($"[TDD][Trade1813] 实测 InterestPrincipal={fe.InterestPrincipal} InterestAmount={fe.InterestAmount} (期望Principal={fix})");
Assert.AreEqual(fix, fe.InterestPrincipal,
"Trade1813: 应返还本金应=保证金本金 35,140,不应被算成 -12,029,720.00");
Assert.AreEqual(0m, fe.InterestAmount,
"Trade1813: 同属债券类预付金腿不计息,InterestAmount 应=0;仅 InterestPrincipal 中招");
}
// ---- 多次部分平仓(验证最小修复是否覆盖"多次部分成交"----
[TestMethod]
public void 多次部分平仓_显示值每次返回比例份额且总计等于保证金()
{
// 模拟分 3 次平仓:0.3 / 0.5 / 1.0(剩余)。每次传入的 fix = 该次剩余保证金本金
// (真实系统中每次部分平仓后 position.InterestPrincipalFix 会被扣减,下一笔用剩余值)。
// 根因修复后:InterestPrincipal 由利息公式基于 Fix 正确得出 = fix * closePercent。
decimal total = 0;
var r1 = CalcUnwindWith(0.3m, null, 306_191_860.26m, 100_000m);
total += r1.InterestPrincipal;
var r2 = CalcUnwindWith(0.5m, null, 306_191_860.26m, 70_000m); // 剩余 7万
total += r2.InterestPrincipal;
var r3 = CalcUnwindWith(1.0m, null, 306_191_860.26m, 35_000m); // 剩余 3.5万
total += r3.InterestPrincipal;
Console.WriteLine($"[TDD][多次部分] r1={r1.InterestPrincipal} r2={r2.InterestPrincipal} r3={r3.InterestPrincipal} 合计={total}");
Assert.AreEqual(30_000m, r1.InterestPrincipal, "第1次(30%)应返还 3万");
Assert.AreEqual(35_000m, r2.InterestPrincipal, "第2次(50% of 剩余7万)应返还 3.5万");
Assert.AreEqual(35_000m, r3.InterestPrincipal, "第3次(剩余全平)应返还 3.5万");
Assert.AreEqual(100_000m, total, "多次部分平仓合计应=保证金本金 10万");
}
[TestMethod]
public void 多次部分平仓_计息基数也被根因修复_利息基于保证金本金()
{
// 显式带息加固用例(合成,非用户那笔生产的真实症状):
// 用户那笔生产(3亿债券类TRS)预付金腿不计息(InterestAmount 全=0),仅 InterestPrincipal 中招;
// 本例用 rate=0.01 构造"若该腿计息"的场景,验证根因修复后计息基数也基于保证金本金自身
// (而非交易名义本金)InterestAmount 为小额正、且 < fix。
const decimal notional = 306_191_860.26m;
const decimal fix = 9_185_755.81m;
var fe = CalcUnwindWith(1m, null, notional, fix, rate: 0.01m);
Assert.AreEqual(fix, fe.InterestPrincipal, "显示值(应返还本金)已=保证金本金");
Console.WriteLine($"[TDD][计息基数] InterestPrincipal={fe.InterestPrincipal} InterestAmount={fe.InterestAmount}");
Assert.IsTrue(fe.InterestAmount > 0,
"根因修复后(显式带息): 预付金腿 InterestAmount 应基于保证金本金算出小额正值(约 fix*rate),不再是巨负");
Assert.IsTrue(fe.InterestAmount < fix,
"利息基数必须为保证金维度(远小于 fix),证明 orginPv 已用预付金自身 Fix,而非交易名义本金 notional");
}
}
}