fix(swap): 多次部分平仓预付金返还默认显示初始值(GLMS-20260701-0008)

根因: GetUnwindInterests 利息腿迭代源取 origPositions(IsInitial=1),
其预付金腿 InterestPrincipalFix 恒为初始值(99000); 而"当前剩余本金"(73260)
存于实时腿 realPositions(IsInitial=0)。CalcNotionalByMode 与 InitSwapDealInterest
均读 position.InterestPrincipalFix, 导致多次部分平仓后平仓页默认按初始本金计算
返还。首次平仓 orig==real 掩盖了 bug, 故此前"只修好一次部分平仓"。

修复: 抽纯函数 ResolveInterestLegPositions(orig, real)——迭代源仍用 origPositions
(保留 orig.id -> eod_swap_position.PositionId 的日终匹配, 全库 eod 均按 orig.id 归档,
换 realPositions 会破坏 preEod 匹配导致利息重算错误), 仅对预付金腿(初始5/追加6)
Clone 覆盖 InterestPrincipalFix 为实时腿剩余本金; real.PositionId == orig.id 精确
1:1 关联; orig==real 时返回原始腿本身不克隆。

测试: 新增 SwapUnwindPrepayOrigVsRealBugTdd(6 用例)直接单测该纯函数, 覆盖既有
19 个用例(均绕过 orig/real 选择)的盲区。dotnet test --filter SwapUnwindPrepay
25 passed(19+6) 失败 0。
This commit is contained in:
hjhan
2026-07-16 10:49:12 +08:00
parent 80064b23bd
commit 24266cfad4
2 changed files with 190 additions and 1 deletions
@@ -0,0 +1,152 @@
using YLErp.DBModels;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 多次部分平仓"返回预付金"默认显示仍是初始值 bug 的回归测试(根因修复后应为全绿)。
/// ---------------------------------------------------------------
/// 生产铁证 GLMS-20260701-0008SwapTradeId=1993dev DB 192.168.2.96 / glms_yltrs_ylcms):
/// 预付金腿(InterestMode=5) 双轨记录——
/// orig 35798 (IsInitial=1, PosiDirection=0, InterestPrincipalFix=99,000) ← 期初腿,恒为初始值
/// real 35871 (IsInitial=0, PositionId=35798, InterestPrincipalFix=73,260) ← 实时腿,已扣减 2 次平仓(9,900+15,840)
///
/// 根因:GetUnwindInterests 的利息腿迭代源取 origPositions(IsInitial=1),其预付金腿
/// InterestPrincipalFix 恒=99,000;而"当前剩余本金"73,260 存在 real 腿。GetInterests 算
/// closePrincipal = Fix × closePercent 与预付金计息基数 orginPv 都读 position.InterestPrincipalFix
/// 于是多次部分平仓后打开平仓页,"返回预付金"仍按初始 99,000 计算——完全不对。
/// 首次平仓时 orig==real,掩盖了该 bug(解释"为何只修好一次部分平仓")。
///
/// 修复:SwapDealService.ResolveInterestLegPositions —— 迭代源仍用 origPositions(保留
/// orig.id → eod_swap_position.PositionId 的日终匹配,全库 25,441 行 eod 均按 orig.id 归档,
/// 换 realPositions 会破坏 preEod 匹配导致利息重算错误),仅对预付金腿(初始5/追加6) Clone 覆盖
/// InterestPrincipalFix 为实时腿剩余本金。real 与 orig 通过 real.PositionId == orig.id 精确 1:1 关联。
///
/// 覆盖盲区说明:既有 SwapUnwindPrepayPrincipalBugTdd 的 19 个用例全部直接调 GetInterests
/// 并只喂一条 IsInitial=true 的持仓,完全绕过 GetUnwindInterests 的 orig-vs-real 选择逻辑,
/// 测不到本次 bug。本类直接单测抽出的纯函数 ResolveInterestLegPositions 以锁定该契约。
/// </summary>
[TestClass]
public class SwapUnwindPrepayOrigVsRealBugTdd
{
// 生产 GLMS-20260701-0008 精确值
private const long OrigId = 35798;
private const long RealId = 35871;
private const decimal InitialFix = 99_000m; // orig 腿初始本金
private const decimal RemainingFix = 73_260m; // real 腿剩余本金(已扣减 2 次平仓)
private static swap_position OrigPrepay(decimal fix = InitialFix, int mode = (int)InterestModeEnum.)
=> new swap_position
{
id = OrigId,
SwapTradeId = 1993,
PosiDirection = 0, // 利息端(收/支)
InterestMode = mode,
InterestPrincipalFix = fix,
IsInitial = true,
Invalid = false
};
private static swap_position RealPrepay(long positionId = OrigId, decimal fix = RemainingFix, int mode = (int)InterestModeEnum.)
=> new swap_position
{
id = RealId,
SwapTradeId = 1993,
PositionId = positionId, // 指向对应 orig 的 id
PosiDirection = 0,
InterestMode = mode,
InterestPrincipalFix = fix,
IsInitial = false,
Invalid = false
};
[TestMethod]
public void _预付金腿本金应取实时腿剩余本金_而非原始腿初始值()
{
var origs = new List<swap_position> { OrigPrepay() };
var reals = new List<swap_position> { RealPrepay() };
var result = SwapDealService.ResolveInterestLegPositions(origs, reals);
Assert.AreEqual(1, result.Count, "应保留 1 条利息腿");
Assert.AreEqual(RemainingFix, result[0].InterestPrincipalFix,
"多次部分平仓后:预付金腿本金应=实时腿剩余本金 73,260,而非原始腿初始值 99,000bug 症状)");
// 必须是 Clone,不能污染原始腿(原始腿要保留 99,000 供其他路径/审计)
Assert.AreEqual(InitialFix, origs[0].InterestPrincipalFix,
"修复必须走 Clone,绝不能就地改写 origPositions 的初始本金");
}
[TestMethod]
public void _实时腿等于原始腿_返回原始腿本身_零改动()
{
var origs = new List<swap_position> { OrigPrepay(InitialFix) };
var reals = new List<swap_position> { RealPrepay(fix: InitialFix) }; // 尚未平仓,real==orig
var result = SwapDealService.ResolveInterestLegPositions(origs, reals);
Assert.AreEqual(InitialFix, result[0].InterestPrincipalFix, "首次平仓 orig==real,本金保持初始值");
Assert.AreSame(origs[0], result[0], "orig==real 时不应克隆,直接返回原始腿本身(行为与修复前一致)");
}
[TestMethod]
public void _同样取实时腿剩余本金()
{
var origs = new List<swap_position> { OrigPrepay(InitialFix, (int)InterestModeEnum.) };
var reals = new List<swap_position> { RealPrepay(fix: RemainingFix, mode: (int)InterestModeEnum.) };
var result = SwapDealService.ResolveInterestLegPositions(origs, reals);
Assert.AreEqual(RemainingFix, result[0].InterestPrincipalFix,
"追加预付金(mode=6)与初始预付金(mode=5)同源修复,同样取实时腿剩余本金");
}
[TestMethod]
public void _不受影响_始终保持原始腿本金()
{
// 标的期初全价(=9)等非预付金腿:即便 real 腿本金不同也不应被覆盖(其本金语义不同,不走此纠正)
var orig = OrigPrepay(InitialFix, (int)InterestModeEnum.);
var real = RealPrepay(fix: RemainingFix, mode: (int)InterestModeEnum.);
var result = SwapDealService.ResolveInterestLegPositions(
new List<swap_position> { orig }, new List<swap_position> { real });
Assert.AreEqual(InitialFix, result[0].InterestPrincipalFix, "非预付金腿本金不被实时腿覆盖");
Assert.AreSame(orig, result[0], "非预付金腿应原样返回,不克隆");
}
[TestMethod]
public void _返回原始腿()
{
// real 腿 PositionId 指向别的 orig(或根本没有实时腿)→ 找不到匹配,保持原始腿
var origs = new List<swap_position> { OrigPrepay() };
var mismatched = new List<swap_position> { RealPrepay(positionId: 99999) };
var r1 = SwapDealService.ResolveInterestLegPositions(origs, mismatched);
Assert.AreEqual(InitialFix, r1[0].InterestPrincipalFix, "无匹配实时腿:保持原始腿初始本金");
var r2 = SwapDealService.ResolveInterestLegPositions(origs, new List<swap_position>());
Assert.AreEqual(InitialFix, r2[0].InterestPrincipalFix, "实时腿为空:保持原始腿初始本金");
var r3 = SwapDealService.ResolveInterestLegPositions(origs, null);
Assert.AreEqual(InitialFix, r3[0].InterestPrincipalFix, "实时腿为 null:应容错并保持原始腿初始本金");
}
[TestMethod]
public void _过滤掉标的腿()
{
// PosiDirection>0 的标的腿不属于利息端,应被过滤(与原实现 Where(PosiDirection==0) 一致)
var underlyingLeg = new swap_position
{
id = 40000, SwapTradeId = 1993, PosiDirection = 1,
InterestMode = (int)InterestModeEnum., IsInitial = true, Invalid = false
};
var origs = new List<swap_position> { OrigPrepay(), underlyingLeg };
var reals = new List<swap_position> { RealPrepay() };
var result = SwapDealService.ResolveInterestLegPositions(origs, reals);
Assert.AreEqual(1, result.Count, "只应保留利息腿(PosiDirection==0),标的腿被过滤");
Assert.AreEqual(OrigId, result[0].id, "保留的应是预付金利息腿");
Assert.AreEqual(RemainingFix, result[0].InterestPrincipalFix, "且其本金已对齐实时剩余本金");
}
}
}