Files
zszq-trs/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayOrigVsRealBugTdd.cs
T
hjhan 24266cfad4 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。
2026-07-16 10:49:12 +08:00

153 lines
8.5 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 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, "且其本金已对齐实时剩余本金");
}
}
}