using Newtonsoft.Json; using YLErp.DBModels; using YLErp.DBModels.Enums; namespace YLErp.Modules.SwapModule { /// /// 预付金(保证金)腿 平仓"应返还本金" 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 的精确复现用例。 /// [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 eodPositions) { eodPositions ??= new List(); var td = MakeTrade(); var position = MakePrepayPosition(); var interests = _svc.GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, eodPositions, new List { 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]; } /// /// 客户/真实库场景:自定义 标的名义本金(notional) 与 保证金本金(fix)。 /// orginPv 用 notional(与 GetUnwindInterests 行为一致:lastEod.NotionalValue ?? stockEqvNotional)。 /// private swap_flow_event CalcUnwindWith(decimal closePercent, List eodPositions, decimal notional, decimal fix, decimal rate = 0.01m) { eodPositions ??= new List(); var td = MakeTrade(notional); var position = MakePrepayPosition(fix, rate); var interests = _svc.GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, eodPositions, new List { 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 { 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=34204:Fix=35,140,Notional=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"); } } }