From 3c9d22f60dcf810c49609fae038c42743d41ca34 Mon Sep 17 00:00:00 2001 From: hjhan Date: Thu, 16 Jul 2026 13:00:21 +0800 Subject: [PATCH] =?UTF-8?q?test(swap):=20=E6=B5=AE=E5=8A=A8=E8=85=BF=20fpo?= =?UTF-8?q?sitions=20=E7=94=A8=20orig=20=E4=B8=8D=E5=BD=B1=E5=93=8D?= =?UTF-8?q?=E9=A2=84=E4=BB=98=E9=87=91=E7=BB=93=E6=9E=9C=20=E2=80=94=20?= =?UTF-8?q?=E8=AF=8A=E6=96=AD=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 验证 GLMS-20260701-0008 的预付金 bug 修复无需触碰浮动腿(fpositions / posiLong/posiShort): - 本 deal 利息腿仅 mode 9(标的期初全价) + mode 5(初始预付金),无「多头/空头存续名义本金」腿。 - CalcNotionalByMode 中 posiLong/posiShort 仅在「多头/空头存续名义本金」分支被消费(L709-716), mode 9/5 均不依赖之;故 fpositions 用 orig 100M 不影响预付金「返回预付金」计算。 - 诊断用例用反射调 private CalcNotionalByMode 实证:mode9 closePrincipal=posiNotional×比例、 mode5 closePrincipal=实时腿 Fix×比例,均与 posiLong(orig 100M) 无关;并证明若存在 多头存续名义本金腿则 orig 会算错(10M vs 正确 6.89M)——普通收益互换的潜在同类 bug,本 deal 不触发,属修复范围外。 不改动任何生产代码。3 个诊断用例全过。 --- .../SwapUnwindFloatingLegDiagnosticTdd.cs | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 UnitTestProject/Modules/SwapModule/SwapUnwindFloatingLegDiagnosticTdd.cs diff --git a/UnitTestProject/Modules/SwapModule/SwapUnwindFloatingLegDiagnosticTdd.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindFloatingLegDiagnosticTdd.cs new file mode 100644 index 00000000..c8645fac --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapUnwindFloatingLegDiagnosticTdd.cs @@ -0,0 +1,112 @@ +using Newtonsoft.Json; +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 诊断测试:验证「浮动腿 fpositions 仍用 origPositions(orig 100M)」对本 deal 的 + /// 预付金/返回预付金结果是否产生影响。结论预期:本 deal 利息腿只有 mode 9(标的期初全价) + /// 与 mode 5(初始预付金),CalcNotionalByMode 中 posiLong/posiShort 仅在「多头/空头存续名义本金」 + /// 分支被消费(L709-716),故本 deal 即便 fpositions 用 orig 100M,预付金腿结果也不受其影响。 + /// 本测试仅做诊断/验证,不改动任何生产代码;用反射调用 private CalcNotionalByMode 以直接证明 + /// “mode 9 / mode 5 的 closePrincipal 不依赖 posiLong/posiShort”。 + /// + [TestClass] + public class SwapUnwindFloatingLegDiagnosticTdd + { + 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 OrigFix = 99_000m; // 期初预付金腿初始本金 + private const decimal RealFix = 66_813.12m; // 实时预付金腿剩余本金(4 次平仓后) + private const decimal OrigLong = 100_000_000m; // 期初标的(多头)名义本金 + private const decimal RealLong = 68_947_200m; // 实时标的(多头)剩余名义本金 + private const decimal ClosePct = 0.1m; // 本次平仓比例 10% + + private static readonly DateTime D0 = new(2026, 7, 1); + private static readonly DateTime D1 = new(2026, 7, 16); + + private SwapDealService _svc; + [TestInitialize] public void Init() => _svc = new StubSwapDealService(new OptUserInfo(0, nameof(SwapUnwindFloatingLegDiagnosticTdd), OptUserFrom.UnitTest)); + + // ---- GLMS 双轨持仓构造 ---- + private static swap_position OrigPrepay(decimal fix = OrigFix) => new swap_position + { id = 35798, SwapTradeId = 1993, PosiDirection = 0, InterestMode = (int)InterestModeEnum.初始预付金, + InterestPrincipalFix = fix, IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.单利, + interest_rest_days = 1, InterestDirection = (int)SwapDirectionEnum.收取, InterestSwapInterval = "[]" }; + private static swap_position RealPrepay(decimal fix = RealFix) => new swap_position + { id = 35871, SwapTradeId = 1993, PositionId = 35798, PosiDirection = 0, InterestMode = (int)InterestModeEnum.初始预付金, + InterestPrincipalFix = fix, IsInitial = false, Invalid = false, InterestType = (int)InterestTypeEnum.单利, + interest_rest_days = 1, InterestDirection = (int)SwapDirectionEnum.收取, InterestSwapInterval = "[]" }; + private static swap_position OrigBasePrice() => new swap_position + { id = 35797, SwapTradeId = 1993, PosiDirection = 0, InterestMode = (int)InterestModeEnum.标的期初全价, + InterestPrincipalFix = 0, IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.单利, + interest_rest_days = 1, InterestSwapInterval = "[]" }; + private static swap_position RealBasePrice() => new swap_position + { id = 35870, SwapTradeId = 1993, PositionId = 35797, PosiDirection = 0, InterestMode = (int)InterestModeEnum.标的期初全价, + InterestPrincipalFix = 0, IsInitial = false, Invalid = false, InterestType = (int)InterestTypeEnum.单利, + interest_rest_days = 1, InterestSwapInterval = "[]" }; + private static swap_position OrigLongLeg() => new swap_position + { id = 35799, SwapTradeId = 1993, PosiDirection = 2, PositionType = (int)PositionTypeFlag.Long, InterestMode = 0, + PosiNotionalValue = OrigLong, IsInitial = true, Invalid = false }; + private static swap_position RealLongLeg() => new swap_position + { id = 35872, SwapTradeId = 1993, PositionId = 35799, PosiDirection = 2, PositionType = (int)PositionTypeFlag.Long, InterestMode = 0, + PosiNotionalValue = RealLong, IsInitial = false, Invalid = false }; + + private static trade MakeTrade() + { + var extend = new trade_extend { TradeId = 1993, ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { AnnualDays = 365, InterestCalcMode = "10", SettlementRules = 0 }) }; + return new trade { id = 1993, TradeNumber = "GLMS-20260701-0008", ClientId = 999998, TradeType = "收益互换", + TradeDate = D0, StartDate = D0, ExerciseDate = D1, TradeStatus = "确认成交", ValidState = "Valid", + StockEqvNotional = (double)RealLong, Notional = (double)RealLong, trade_extend = extend }; + } + + /// 用反射调用 private CalcNotionalByMode,直接证明各 mode 的 closePrincipal 是否依赖 posiLong/posiShort。 + private (decimal close, decimal posi, decimal pct) CallCalcNotionalByMode(swap_position position, decimal closePct, decimal posiNotional, decimal posiLong, decimal posiShort) + { + var m = typeof(SwapDealService).GetMethod("CalcNotionalByMode", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + return ((decimal, decimal, decimal))m.Invoke(_svc, new object[] { position, closePct, posiNotional, posiLong, posiShort }); + } + + [TestMethod] + public void 诊断_mode9_标的期初全价_closePrincipal_不依赖posiLong_而用posiNotional() + { + // mode 9 分支:closePrincipal = posiNotional * closePercent + var baseP = OrigBasePrice(); + var (close, posi, _) = CallCalcNotionalByMode(baseP, ClosePct, RealLong * ClosePct, OrigLong, 0m); + Console.WriteLine($"[mode9] posiNotional={RealLong * ClosePct} posiLong(orig)={OrigLong} → closePrincipal={close}"); + Assert.AreEqual(RealLong * ClosePct * ClosePct, close, "mode9 应 = posiNotional(=real剩余*closePct) * closePct,与 posiLong(orig 100M) 无关"); + } + + [TestMethod] + public void 诊断_mode5_预付金_closePrincipal_用自身Fix_不依赖posiLong() + { + // mode 5 分支:closePrincipal = position.InterestPrincipalFix * closePercent(用 Clone 后的 real Fix) + var prepay = RealPrepay(); // Fix = RealFix(66,813.12) + var (close, posi, _) = CallCalcNotionalByMode(prepay, ClosePct, RealLong * ClosePct, OrigLong, 0m); + Console.WriteLine($"[mode5] Fix(cloned real)={RealFix} posiLong(orig)={OrigLong} → closePrincipal={close}"); + Assert.AreEqual(RealFix * ClosePct, close, "mode5 应 = 实时腿剩余本金(real Fix) * closePct,与 posiLong(orig 100M) 无关"); + Assert.AreNotEqual(OrigFix * ClosePct, close, "务必不是期初 99,000 * closePct(证明后端修复生效)"); + } + + [TestMethod] + public void 诊断_若将来有_多头存续名义本金_腿_posiLong用orig才出错_本deal无此腿_故不影响() + { + // 构造一个「多头存续名义本金」腿,证明此时 posiLong 取值(orig vs real)会直接决定结果—— + // 说明本 deal 没有这种腿,所以 fpositions 用 orig 100M 不影响;但普通收益互换若有此腿则会踩坑。 + var longLeg = new swap_position { id = 35799, InterestMode = (int)InterestModeEnum.多头存续名义本金 }; + var byOrig = CallCalcNotionalByMode(longLeg, ClosePct, RealLong * ClosePct, OrigLong, 0m); // 当前代码:posiLong=orig 100M + var byReal = CallCalcNotionalByMode(longLeg, ClosePct, RealLong * ClosePct, RealLong, 0m); // 若修正为 real 75.6M + Console.WriteLine($"[多头存续名义本金] orig100M→close={byOrig.close} ; real75.6M→close={byReal.close}"); + Assert.AreEqual(OrigLong * ClosePct, byOrig.close, "现状:多头存续名义本金用 orig 100M → 多次部分平仓后会偏大"); + Assert.AreEqual(RealLong * ClosePct, byReal.close, "正确应:用 real 剩余本金 75.6M"); + Assert.AreNotEqual(byOrig.close, byReal.close, "★ 潜在同类 bug:普通收益互换(含多头/空头存续名义本金腿)在多次部分平仓后,posiLong/posiShort 用 orig 会算错——本 deal 无此腿故不触发,属本轮修复范围外"); + } + } +}