Files
zszq-trs/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs
T
hjhan 250c41b925 refactor(interest): 抽取 IIndexFixer 取价接缝 + 清理固定值腿错误测试
为后续拆三类腿做准备,新增 FR007 取价的独立接口层(参考 QuantLib IborIndex
分层),本次零生产代码改动,全部为新增小文件。

新增文件(取价接缝,供后续 SwapDealService 6 处 inline 取价迁移用):
- Framework/YLErp.Core/Interest/IIndexFixer.cs
  接口定义:给定取价日和标的代码返回当日利率,取价与计息解耦
- Framework/YLErp.Core/Interest/IndexFixerBase.cs
  取价日计算工具:收敛原散落 6 处的 GetNonHolidayDefore(date.AddDays(rule))
- YLErpDAL/Modules/SwapModule/Fr007IndexFixer.cs
  生产实现 + GetFixingOrThrow 辅助方法,独立文件便于单测

测试清理(删除基于错误假设的测试):
- 删除 SwapFixedInterestLegClosePercentBugTest.cs 整个文件
  该文件假设'固定值腿利息应随平仓比例线性缩放',但固定值是合同写死的、
  永远不随平仓比例变化。d1badfe4 的'修复'本身才是错的,当前分支已正确回退
- 修正 SwapUnwindPrepayPrincipalBugTdd.cs 固定值测试方法
  断言从'应=Fix×0.5'改为'恒=Fix不随比例变化',对齐正确的业务定义

已知失败(非本次引入,留待后续与拆腿一起修):
- BondTrsAutoSwapScenarioTest 的 AS_001/004/008/009/010/011 共 6 个
  原因:SaveAutoSwapDeal 直接调 AddClientCashInCashOut(非virtual)绕过
  AddClientCash(virtual)封装,无库环境抛'客户信息未找到'。
  从 096d2609 引入时即失败,需 AddClientCashInCashOut 加 virtual + 客户数据
  mock,与拆腿那次可测性改造一起做
2026-08-10 18:01:37 +08:00

570 lines
34 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万");
}
// ---- 盘中路径 CalcDailySimpleInterest 的 closePercent^N 指数级缩小 bug ----
// 生产铁证 GLMS-20260701-0006:预付金腿 Fix=9,180,000、interest_rest_days=7、单利、不计息。
// 平仓弹窗(swaptrade2/GetUnwindInterestList → 盘中路径 CalcDailySimpleInterest)返回:
// 100% → 9,180,000 (对) 50% → 71,718.75 (错) 10% → 0.918 (错)
// 数学关系精确成立:9,180,000×0.5^7 = 71,718.75、9,180,000×0.1^7 = 0.918。
// 根因:CalcDailySimpleInterest 非重置日 else 分支
// flowEvent.InterestPrincipal = tdDynomicPrincipal * closePercent;
// tdDynomicPrincipal = flowEvent.InterestPrincipal; // ★把"已×closePercent"的值回填
// 使下一个非重置日再乘一次 closePercent → InterestPrincipal = Fix × closePercent^NN=计息天数),
// 而正确应为 Fix × closePercent(线性,与日终 CalcDailySimpleInterestByEod:1164-1165 只乘一次一致)。
// 现有 6 个用例 interest_rest_days=1 且 UnwindDate=StartDate+1(calcDays=1),循环首尾都被 continue 跳过、
// 从不进 else,故漏掉此 bug;本组用例用 restDays=7、跨多日、带 eod 归档触发 else 累积复现之。
private const decimal ProdPrepayFix = 9_180_000m;
private static readonly DateTime ProdPosiStart = new(2026, 7, 2);
private static readonly DateTime ProdEodValueDate = new(2026, 7, 4);
private static readonly DateTime ProdUnwindDate = new(2026, 7, 13);
/// <summary>
/// 盘中路径复现:restDays=7、PosiStart→Unwind 跨 11 天、eod 归档到 07-04。
/// 与生产 GLMS-20260701-0006 完全对齐,buggy 代码产出 Fix × closePercent^7。
/// </summary>
private swap_flow_event CalcUnwindMultiDay(decimal closePercent, decimal fix = ProdPrepayFix, int restDays = 7,
decimal rate = 0m)
{
var extend = new trade_extend
{
TradeId = 1,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "10", // 算头不算尾(与生产一致)
SettlementRules = 0
})
};
var td = new trade
{
id = 1, TradeNumber = "UT-PREPAY-EXP", ClientId = 999998,
TradeType = "收益互换", TradeDate = ProdPosiStart, StartDate = ProdPosiStart,
ExerciseDate = ProdUnwindDate.AddYears(1), TradeStatus = "确认成交", ValidState = "Valid",
StockEqvNotional = (double)fix, Notional = (double)fix,
trade_extend = extend
};
var position = new swap_position
{
id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum.收取,
InterestMode = (int)InterestModeEnum.初始预付金,
InterestRateDefault = rate, InterestPrincipalFix = fix,
PosiStartDate = ProdPosiStart, PosiMatuirityDate = ProdUnwindDate.AddYears(1),
IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.单利,
IsAnnualized = true, interest_rest_days = restDays,
interest_rule = 0, FloatRateUnderlyingCode = null,
InterestSwapInterval = "[]"
};
var eod = new List<eod_swap_position>
{
new eod_swap_position
{
id = 7, SwapTradeId = 1, PositionId = 1001,
ValueDate = ProdEodValueDate,
TdInterestPrincipal = fix, // 生产 eod_swap_position(35774) TdInterestPrincipal=9,180,000
PosiNotionalValue = fix,
InterestProfitSum = 0m, FloatRate = 0m
}
};
var interests = _svc.GetInterests(td, td.trade_extend, ProdUnwindDate, ProdUnwindDate,
eod, new List<swap_position> { position },
fix, fix, fix, fix, closePercent,
(int)SwapEventTypeEnum.平仓,
false, false, 0, fix, false, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, interests.Count, "预付金腿应生成 1 条 flow_event");
return interests[0];
}
[TestMethod]
public void 部分平仓50_盘中重置周期7_应返还本金应线性缩放而非指数级()
{
var fe = CalcUnwindMultiDay(0.5m);
Console.WriteLine($"[TDD][盘中50%] 实测 InterestPrincipal={fe.InterestPrincipal} (buggy=71,718.75, 期望=4,590,000)");
// 正确:Fix × closePercent = 9,180,000 × 0.5 = 4,590,000100%返 9,180,000 的一半)。
// buggyFix × 0.5^7 = 71,718.75(生产实测),被指数级缩小 ~64 倍。
Assert.AreEqual(4_590_000m, fe.InterestPrincipal,
"50% 平仓: 应返还本金应=Fix×0.5=4,590,000,不应被 closePercent^7 缩成 71,718.75");
}
[TestMethod]
public void 部分平仓10_盘中重置周期7_应返还本金应线性缩放而非指数级()
{
var fe = CalcUnwindMultiDay(0.1m);
Console.WriteLine($"[TDD][盘中10%] 实测 InterestPrincipal={fe.InterestPrincipal} (buggy=0.918, 期望=918,000)");
// 正确:Fix × 0.1 = 918,000。buggyFix × 0.1^7 = 0.918(生产实测),缩小 100 万倍。
Assert.AreEqual(918_000m, fe.InterestPrincipal,
"10% 平仓: 应返还本金应=Fix×0.1=918,000,不应被 closePercent^7 缩成 0.918");
}
[TestMethod]
public void 全平_盘中重置周期7天_应返还本金应等于保证金本金()
{
// closePercent=1 → 1^N=1,指数 bug 对 100% 无影响(故用户看 100% 正常),此用例锚定不回归。
var fe = CalcUnwindMultiDay(1m);
Console.WriteLine($"[TDD][盘中100%] 实测 InterestPrincipal={fe.InterestPrincipal} (期望=9,180,000)");
Assert.AreEqual(ProdPrepayFix, fe.InterestPrincipal,
"100% 平仓: 应返还本金应=Fix=9,180,000closePercent=1 时指数 bug 不显现,须保持正确)");
}
// ---- 非预付金腿(标的期初全价=9)同样验证:证明修复对所有"单利盘中"腿通用且正确 ----
// CalcDailySimpleInterest 是所有单利腿(InterestType=0)的盘中计息通用函数,非预付金专用。
// 用户关切:修复会否波及非预付金腿?结论——
// · closePercent=1(日常计息/全平)时 1^N=1=1^1,修复前后逐位恒等,零影响;
// · closePercent<1(部分平仓)时,所有单利腿此前都被同一 bug 指数级缩小,修复后统一为
// 正确的线性缩放(平仓 X% => 本金×X),这是修正而非破坏。
// 本组用非预付金腿(标的期初全价=9,orginPv 不被对齐为 Fix、走交易名义本金)独立复现并锁定。
private const decimal NonPrepayNotional = 1_000_000m;
private swap_flow_event CalcUnwindMultiDayNonPrepay(decimal closePercent, decimal notional = NonPrepayNotional,
int restDays = 7, decimal rate = 0m)
{
var extend = new trade_extend
{
TradeId = 1,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "10", // 算头不算尾(与生产一致)
SettlementRules = 0
})
};
var td = new trade
{
id = 1, TradeNumber = "UT-NONPREPAY-EXP", ClientId = 999998,
TradeType = "收益互换", TradeDate = ProdPosiStart, StartDate = ProdPosiStart,
ExerciseDate = ProdUnwindDate.AddYears(1), TradeStatus = "确认成交", ValidState = "Valid",
StockEqvNotional = (double)notional, Notional = (double)notional,
trade_extend = extend
};
var position = new swap_position
{
id = 2002, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum.收取,
InterestMode = (int)InterestModeEnum.标的期初全价, // 非预付金腿(=9)orginPv 不会被对齐为 Fix
InterestRateDefault = rate, InterestPrincipalFix = 0m,
PosiStartDate = ProdPosiStart, PosiMatuirityDate = ProdUnwindDate.AddYears(1),
IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.单利,
IsAnnualized = true, interest_rest_days = restDays,
interest_rule = 0, FloatRateUnderlyingCode = null,
InterestSwapInterval = "[]"
};
var eod = new List<eod_swap_position>
{
new eod_swap_position
{
id = 8, SwapTradeId = 1, PositionId = 2002,
ValueDate = ProdEodValueDate,
TdInterestPrincipal = notional, // 计息基数=名义本金 → dynomicPrincipal = eodTd + notional - orginPv = notional
PosiNotionalValue = notional,
InterestProfitSum = 0m, FloatRate = 0m
}
};
// orginPv 传 notional:非预付金腿不走 877-881 的 Fix 对齐,dynomicPrincipal = notional + notional - notional = notional
var interests = _svc.GetInterests(td, td.trade_extend, ProdUnwindDate, ProdUnwindDate,
eod, new List<swap_position> { position },
notional, notional, notional, notional * closePercent, 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 非预付金腿_部分平仓50_盘中重置周期7天_应线性缩放不受指数bug影响()
{
var fe = CalcUnwindMultiDayNonPrepay(0.5m);
Console.WriteLine($"[TDD][非预付金50%] 实测 InterestPrincipal={fe.InterestPrincipal} (buggy=7,812.5, 期望=500,000)");
// 正确:N×0.5=500,000。buggyN×0.5^7=7,812.5(同一指数 bug,证明非预付金腿此前也中招)。
Assert.AreEqual(500_000m, fe.InterestPrincipal,
"非预付金腿(标的期初全价) 50% 平仓应=名义本金×0.5=500,000,不应被 closePercent^7 缩小");
}
[TestMethod]
public void 非预付金腿_部分平仓10_盘中重置周期7天_应线性缩放不受指数bug影响()
{
var fe = CalcUnwindMultiDayNonPrepay(0.1m);
Console.WriteLine($"[TDD][非预付金10%] 实测 InterestPrincipal={fe.InterestPrincipal} (buggy=0.1, 期望=100,000)");
Assert.AreEqual(100_000m, fe.InterestPrincipal,
"非预付金腿(标的期初全价) 10% 平仓应=名义本金×0.1=100,000,不应被 closePercent^7 缩小");
}
[TestMethod]
public void 非预付金腿_全平_修复前后恒等_零影响()
{
// closePercent=1 时 1^N=1=1^1:这是"修复不波及非平仓/全平计息"的数学不变量证明。
var fe = CalcUnwindMultiDayNonPrepay(1m);
Console.WriteLine($"[TDD][非预付金100%] 实测 InterestPrincipal={fe.InterestPrincipal} (期望=1,000,000)");
Assert.AreEqual(NonPrepayNotional, fe.InterestPrincipal,
"非预付金腿 全平应=名义本金(closePercent=1 时修复前后恒等,日常计息/全平零影响)");
}
[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");
}
// ===== 覆盖完整性补强:所有单利腿模式 + 日终路径 =====
// 调用链事实(已用代码确认):
// CalcDailySimpleInterest 的唯一真实调用链 = GetInterests(settment=false) → CalcUnwindInterest → 本函数。
// 日终(settment=true)走 CalcEodInterest → CalcDailySimpleInterestByEod(closePercent 硬编码 1m、
// 且该函数从不改写 InterestPrincipal),根本不调用本函数。故"含日终"的正确命题是:
// 日终不受本 bug 影响,且应有用例锁定这一不变量。
// 本组用同一入口驱动各 InterestMode 在 closePercent<1 + rest_days=7 多天场景,断言
// InterestPrincipal = closePrincipal(线性),捕捉任何指数级回归;并显式加日终(settment=true)用例,
// 断言日终结果恒为线性 closePrincipal(证明日终不受盘中 bug 影响,与正确的 ByEod 变体对齐)。
private swap_flow_event CalcByMode(int mode, decimal baseP, decimal closePercent, int restDays = 7, bool eodPath = false)
{
var extend = new trade_extend
{
TradeId = 1,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "10", // 算头不算尾(与生产一致)
SettlementRules = 0
})
};
var td = new trade
{
id = 1, TradeNumber = "UT-MODE-COV", ClientId = 999998,
TradeType = "收益互换", TradeDate = ProdPosiStart, StartDate = ProdPosiStart,
ExerciseDate = ProdUnwindDate.AddYears(1), TradeStatus = "确认成交", ValidState = "Valid",
StockEqvNotional = (double)baseP, Notional = (double)baseP,
trade_extend = extend
};
bool isPrepayOrFixed = mode == (int)InterestModeEnum.初始预付金
|| mode == (int)InterestModeEnum.追加预付金
|| mode == (int)InterestModeEnum.固定值;
var position = new swap_position
{
id = 3003, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum.收取,
InterestMode = mode,
InterestRateDefault = 0m,
InterestPrincipalFix = isPrepayOrFixed ? baseP : 0m,
PosiStartDate = ProdPosiStart, PosiMatuirityDate = ProdUnwindDate.AddYears(1),
IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.单利,
IsAnnualized = true, interest_rest_days = restDays,
interest_rule = 0, FloatRateUnderlyingCode = null,
InterestSwapInterval = "[]"
};
// 使 dynomicPrincipal = posiPrincipaleod.TdInterestPrincipal = orginPv(=baseP)
// 非预付金腿 orginPv 传 baseP;预付金/固定值腿 orginPv 被内部对齐为 Fix=baseP(同样成立)。
var eodPos = new List<eod_swap_position>
{
new eod_swap_position
{
id = 30, SwapTradeId = 1, PositionId = 3003,
ValueDate = ProdEodValueDate,
TdInterestPrincipal = baseP,
PosiNotionalValue = baseP,
InterestProfitSum = 0m, FloatRate = 0m
}
};
var interests = _svc.GetInterests(td, td.trade_extend, ProdUnwindDate, ProdUnwindDate,
eodPos, new List<swap_position> { position },
baseP, baseP, baseP, baseP * closePercent, closePercent,
(int)SwapEventTypeEnum.平仓,
false, false, 0, baseP, false, settment: eodPath, newCalcLast: false, closeList: null);
Assert.AreEqual(1, interests.Count, $"mode={mode} 应生成 1 条 flow_event");
return interests[0];
}
// ---- 追加预付金(6):与初始预付金(5)同源修复,显式覆盖避免遗漏 ----
[TestMethod]
public void 追加预付金腿_盘中_部分平仓重置周期7天_应线性缩放()
{
var fe = CalcByMode((int)InterestModeEnum.追加预付金, ProdPrepayFix, 0.5m);
Assert.AreEqual(4_590_000m, fe.InterestPrincipal, "追加预付金 50% 应=Fix×0.5(与初始预付金同源修复)");
var fe1 = CalcByMode((int)InterestModeEnum.追加预付金, ProdPrepayFix, 0.1m);
Assert.AreEqual(918_000m, fe1.InterestPrincipal, "追加预付金 10% 应=Fix×0.1");
}
// ---- 多头/空头存续名义本金(7/8):经同一 CalcDailySimpleInterest,需证明修复通用 ----
[TestMethod]
public void 多头存续名义本金腿_盘中_部分平仓重置周期7天_应线性缩放()
{
const decimal baseP = 2_000_000m;
var fe = CalcByMode((int)InterestModeEnum.多头存续名义本金, baseP, 0.5m);
Assert.AreEqual(1_000_000m, fe.InterestPrincipal, "多头存续 50% 应=posiLong×0.5");
var fe1 = CalcByMode((int)InterestModeEnum.多头存续名义本金, baseP, 0.1m);
Assert.AreEqual(200_000m, fe1.InterestPrincipal, "多头存续 10% 应=posiLong×0.1");
}
[TestMethod]
public void 空头存续名义本金腿_盘中_部分平仓重置周期7天_应线性缩放()
{
const decimal baseP = 2_000_000m;
var fe = CalcByMode((int)InterestModeEnum.空头存续名义本金, baseP, 0.5m);
Assert.AreEqual(1_000_000m, fe.InterestPrincipal, "空头存续 50% 应=posiShort×0.5");
var fe1 = CalcByMode((int)InterestModeEnum.空头存续名义本金, baseP, 0.1m);
Assert.AreEqual(200_000m, fe1.InterestPrincipal, "空头存续 10% 应=posiShort×0.1");
}
// ---- 合约名义本金规模(2)CalcNotionalByMode 默认分支(posiNotional×cp ----
[TestMethod]
public void 合约名义本金规模腿_盘中_部分平仓重置周期7天_应线性缩放()
{
const decimal baseP = 2_000_000m;
var fe = CalcByMode((int)InterestModeEnum.合约名义本金规模, baseP, 0.5m);
Assert.AreEqual(1_000_000m, fe.InterestPrincipal, "合约名义本金规模 50% 应=posiNotional×0.5");
}
// ---- 固定值(1):合同写死的固定值,不随平仓比例变化 ----
// 固定值腿的计息基数由合约约定(InterestPrincipalFix),无论平仓比例多少都恒等于该值。
// closePrincipal = posiPrincipal = Fix; newClosePercent = 1m 是正确的业务行为,不是缺陷。
[TestMethod]
public void 固定值腿_盘中_部分平仓_计息基数恒等于Fix不随比例变化()
{
const decimal baseP = 2_000_000m;
var fe = CalcByMode((int)InterestModeEnum.固定值, baseP, 0.5m);
Assert.AreEqual(baseP, fe.InterestPrincipal, "固定值腿计息基数恒=Fix,不随平仓比例变化(合同约定)");
}
// ---- 日终路径(settment=true):证明走 CalcDailySimpleInterestByEod,结果恒为线性 closePrincipal,不受盘中 bug 影响 ----
[TestMethod]
public void 日终_预付金腿_部分平仓_结果应线性且不受盘中bug影响()
{
var fe = CalcByMode((int)InterestModeEnum.初始预付金, ProdPrepayFix, 0.5m, eodPath: true);
Console.WriteLine($"[TDD][EOD 预付金50%] InterestPrincipal={fe.InterestPrincipal} (期望={4_590_000m})");
Assert.AreEqual(4_590_000m, fe.InterestPrincipal, "日终预付金 50% 应=Fix×0.5ByEod 正确变体,closePercent 走 closePrincipal 线性)");
var fe1 = CalcByMode((int)InterestModeEnum.初始预付金, ProdPrepayFix, 0.1m, eodPath: true);
Assert.AreEqual(918_000m, fe1.InterestPrincipal, "日终预付金 10% 应=Fix×0.1");
}
[TestMethod]
public void 日终_非预付金腿_部分平仓_结果应线性且不受盘中bug影响()
{
const decimal baseP = 2_000_000m;
var fe = CalcByMode((int)InterestModeEnum.标的期初全价, baseP, 0.5m, eodPath: true);
Console.WriteLine($"[TDD][EOD 标的期初全价50%] InterestPrincipal={fe.InterestPrincipal} (期望={1_000_000m})");
Assert.AreEqual(1_000_000m, fe.InterestPrincipal, "日终非预付金腿 50% 应=名义本金×0.5(ByEod 正确,不受影响)");
}
}
}