Files
zszq-trs/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs
T
hjhan 4a3fee9292 refactor(swap)+test: 删 GetInterests/CalcSwapInterests 死参数 needPrice/grossPrice;补工厂→接缝映射钉子
死参数收口(另一半):
- SwapDealService.GetInterests 删 needPrice/grossPrice(体内零消费,2026-08 验证);
  InitSwapDealInterest.needPrice 同为死参数一并删
- SwapEodPositionService.CalcSwapInterests 签名+转发同步;两个 EOD 生产调用点
  (SaveAutoEodInterestPosition/SaveEodInterestPositionCopy) 重排实参;
  CalcEodPostCloseSettleInterests/GetIntradayUnwindInterests 委托同步
- 14 个测试文件 ~44 处直调点机械更新(8 处 override 签名 + 36 处调用实参)
- 注意:EOD 编排链(DealInterests→Save*家族)的 grossPrice(期初不含费价)有真实用途,保留未动

新增钉子:CalcEodPostCloseSettleInterests 工厂→接缝参数映射测试——
CalcSwapInterestsCapture 捕获 stub 断言 EodPostCloseSettle 的完整转发契约
(posi=平仓后剩余/closePosi=平掉额/恒1/settment:false/orginPv 等 11 项)。
该段位置转发含三个相邻同型 decimal,编译器不查错位,此测试兜底。

验证:定向 241 测试通过(含 T0/T1 Excel 验证期望值、EntrySemantics 精确值钉子——
任何 decimal 错位即红);全量 903=145失败/746通过/12跳过,与基线逐位一致。
2026-08-14 16:57:56 +08:00

549 lines
33 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, 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, 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, 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, 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, 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");
}
// ---- 合约名义本金规模(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 正确,不受影响)");
}
}
}