From cd8c3b6c95bbad21f6b1e89d339c5c9e9419bd4f Mon Sep 17 00:00:00 2001 From: hjhan Date: Sat, 8 Aug 2026 15:02:54 +0800 Subject: [PATCH] =?UTF-8?q?test(swap):=20=E9=A2=84=E4=BB=98=E9=87=91?= =?UTF-8?q?=E8=85=BF=E4=B8=A4=E6=AE=B5=E5=BC=8F=E5=B9=B3=E4=BB=93=E8=AE=A1?= =?UTF-8?q?=E6=81=AF=E8=BF=87=E7=A8=8B=E6=9A=B4=E9=9C=B2=20+=20SwapCalcTra?= =?UTF-8?q?ce=E8=BF=BD=E8=B8=AA=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 SwapCalcTrace(零成本可开关追踪器,默认关闭,对生产零行为影响): 在 CalcDailySimpleInterest 埋点,逐日记录计息起点/日终归档 ValueDate 地板/ 计息基数 dynomicPrincipal/当日利率/当日利息/累计,便于定位计息异常根因。 - 新增 PrepaidPrincipalCloseTraceTest:用截图参数(本金100000.23/利率2.1111%/ 8.4平40%→8.7全平)暴露计息过程,证明第二次平仓利息完全由日终归档 ValueDate 决定——正确归档(ValueDate=8.4)+算尾=3天=10.41(Excel本次利息); 错误归档(ValueDate=8.1)复现系统截图 6天=20.83。守卫1钉正确值10.41, 守卫2复现20.83并证明 ValueDate 即『缺的要素』。 - 修正覆盖结论:浮动端/利息腿已有两段式覆盖,预付金腿两段式此前缺失(缺口 与截图坏『预付金端』一行精确对应)。 --- .../PrepaidPrincipalCloseTraceTest.cs | 136 ++++++++++++++++++ YLErpDAL/Modules/SwapModule/SwapCalcTrace.cs | 52 +++++++ .../Modules/SwapModule/SwapDealService.cs | 11 ++ 3 files changed, 199 insertions(+) create mode 100644 UnitTestProject/Modules/SwapModule/PrepaidPrincipalCloseTraceTest.cs create mode 100644 YLErpDAL/Modules/SwapModule/SwapCalcTrace.cs diff --git a/UnitTestProject/Modules/SwapModule/PrepaidPrincipalCloseTraceTest.cs b/UnitTestProject/Modules/SwapModule/PrepaidPrincipalCloseTraceTest.cs new file mode 100644 index 00000000..c47296e3 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/PrepaidPrincipalCloseTraceTest.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 预付金(保证金)腿"部分平仓后再全平"的计息过程暴露测试。 + /// + /// 背景:用户截图(国联民生-债券TRS期间结算)显示 8.4 部分平仓40% → 8.7 全平剩余60%, + /// 预付金端系统给 20.83,而 Excel 预期 10.41(本次利息,3天)或 24.29(平仓盈亏,7天)。 + /// 经验证,单利计息核心 CalcDailySimpleInterest 只在 accrueDate > preEod.ValueDate 的日子累加, + /// 计息基数 dynomicPrincipal = preEod.TdInterestPrincipal + posiPrincipal - orginPv。 + /// 因此第二次平仓的利息完全由"第一次部分平仓后日终归档态"决定——这正是截图看不到、却决定系统值的要素。 + /// + /// 本测试开启 SwapCalcTrace,把逐步过程打印出来,直接暴露"6天/3天"的来源(ValueDate 地板)。 + /// 同时用两个归档 ValueDate(8.4 期望 / 8.1 疑似生产落地值)对比,证明 ValueDate 是杠杆。 + /// + [TestClass] + public class PrepaidPrincipalCloseTraceTest + { + private sealed class Stub : SwapDealService + { + public Stub(OptUserInfo u) : base(u) { } + protected override bool TryGetFloatRate(DateTime d, string c, out double r) { r = 0; return false; } + } + + private const decimal PrepayFix = 100_000.23m; // 预付金(保证金)本金(截图 100,000.23) + private const decimal PrepayRemaining = 60_000.138m; // 部分平仓40%后剩余 60% + private const decimal Rate = 0.021111m; // 2.1111% + private static readonly DateTime Start = new(2026, 7, 28); + private static readonly DateTime PartialDate = new(2026, 8, 4); + private static readonly DateTime FullDate = new(2026, 8, 7); + private const int AnnualDays = 365; + + private SwapDealService _svc; + + [TestInitialize] + public void Init() => _svc = new Stub(new OptUserInfo(0, nameof(PrepaidPrincipalCloseTraceTest), OptUserFrom.UnitTest)); + + private static trade MakeTrade() + { + 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-TRACE", ClientId = 999998, + TradeType = "收益互换", TradeDate = Start, StartDate = Start, + ExerciseDate = new DateTime(2027, 7, 28), TradeStatus = "确认成交", + ValidState = "Valid", StockEqvNotional = (double)PrepayFix, Notional = (double)PrepayFix, + trade_extend = extend + }; + } + + private static swap_position MakePrepay() + { + return new swap_position + { + id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.初始预付金, + InterestRateDefault = Rate, InterestPrincipalFix = PrepayFix, + PosiStartDate = Start, PosiMatuirityDate = new DateTime(2027, 7, 28), + IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.单利, + IsAnnualized = true, interest_rest_days = 1, + interest_rule = 0, FloatRateUnderlyingCode = null, + InterestSwapInterval = "[]" + }; + } + + /// 构造"8.4 部分平仓40%后"应有的日终归档态。 + private static eod_swap_position MakeEod(DateTime valueDate, decimal tdPrincipal, decimal profitSum) + => new eod_swap_position + { + id = 1, SwapTradeId = 1, PositionId = 1001, + ValueDate = valueDate, TdInterestPrincipal = tdPrincipal, + PosiNotionalValue = tdPrincipal, InterestProfitSum = profitSum + }; + + [TestMethod] + public void 预付金腿_部分平仓后再全平_暴露计息过程_定位天数来源() + { + var td = MakeTrade(); + var pos = MakePrepay(); + + // 运行一次计息并打印逐步 trace。calcLast=true 表示"算尾"(生产该腿实际口径,见下)。 + (swap_flow_event fe, string trace) Run(DateTime valueDate, bool calcLast) + { + SwapCalcTrace.IsEnabled = true; + SwapCalcTrace.Reset(); + var eod = new List { MakeEod(valueDate, PrepayRemaining, 0m) }; + var fe = _svc.GetInterests(td, td.trade_extend, FullDate, FullDate, eod, + new List { pos }, PrepayFix, PrepayFix, PrepayFix, PrepayFix, 1m, + (int)SwapEventTypeEnum.平仓, false, false, 0, PrepayFix, false, + settment: false, newCalcLast: calcLast, closeList: null)[0]; + var trace = SwapCalcTrace.Dump(); + Console.WriteLine(trace); + Console.WriteLine($">> InterestAmount={fe.InterestAmount}"); + return (fe, trace); + } + + // 场景A(正确归档 ValueDate=8.4,算尾):应得 3天 = 10.41(Excel「本次利息」) + Console.WriteLine("=== 场景A: eod.ValueDate=8.4 + 算尾(期望正确值)==="); + var feA = Run(PartialDate, calcLast: true).fe; + + // 场景B(错误归档 ValueDate=8.1,算尾):复现生产 6天 = 20.83(系统截图) + Console.WriteLine("=== 场景B: eod.ValueDate=8.1 + 算尾(疑似生产落地值,复现 bug)==="); + var feB = Run(new DateTime(2026, 8, 1), calcLast: true).fe; + + // 守卫1:正确归档应产出与 Excel「本次利息」一致的 10.41(证明给定正确状态后计算逻辑本身正确) + Assert.AreEqual(10.41m, Math.Round(feA.InterestAmount, 2), + "正确归档(ValueDate=8.4)+算尾 应得 3天利息=10.41,与 Excel 本次利息一致"); + + // 守卫2:错误归档(ValueDate=8.1) 复现系统截图的 ~20.83(6天计息),且证明 ValueDate 就是杠杆(缺的要素)。 + // 20.8219 与截图 20.83 的 0.01 差异仅为四舍五入呈现方式,量级与天数(6天)一致即证明复现成功。 + Assert.IsTrue(Math.Abs(feB.InterestAmount - 20.83m) < 0.05m, + $"错误归档(ValueDate=8.1)+算尾 应复现系统截图 ~20.83(6天计息),实测={feB.InterestAmount}"); + Assert.AreNotEqual(feA.InterestAmount, feB.InterestAmount, + "ValueDate 不同应导致计息天数/金额不同"); + + SwapCalcTrace.IsEnabled = false; + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/SwapCalcTrace.cs b/YLErpDAL/Modules/SwapModule/SwapCalcTrace.cs new file mode 100644 index 00000000..b9ee5a96 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/SwapCalcTrace.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 计息计算过程追踪器(默认关闭,零运行时成本)。 + /// + /// 在 CalcDailySimpleInterest / CalcDailyCompoundInterest 等计息函数中调用 + /// SwapCalcTrace.Record(...),开启后逐日记录:计息起点、日终归档 ValueDate 地板、 + /// 计息基数、当日利率、当日利息、累计利息。便于定位"算出来一个数却不对"的根因, + /// 也便于把一次真实平仓的逐步过程打印出来与 Excel 对账。 + /// + /// 用法(单元测试或临时排障): + /// SwapCalcTrace.IsEnabled = true; + /// SwapCalcTrace.Reset(); + /// ... 调用计息 ... + /// Console.WriteLine(SwapCalcTrace.Dump()); + /// SwapCalcTrace.IsEnabled = false; + /// + public static class SwapCalcTrace + { + public static bool IsEnabled { get; set; } = false; + + [ThreadStatic] + private static List _lines; + + private static List Lines => _lines ??= new List(); + + public static void Reset() => Lines.Clear(); + + public static void Header(string title) + { + if (IsEnabled) Lines.Add($"== {title} =="); + } + + public static void Line(string text) + { + if (IsEnabled) Lines.Add(text); + } + + /// 记录某一计息日的明细。 + public static void Day(int idx, DateTime date, decimal rate, decimal basePrincipal, decimal dayInterest, decimal accumulated) + { + if (IsEnabled) + Lines.Add($" [{idx}] {date:yyyy-MM-dd} rate={rate:P6} base={basePrincipal:F4} day={dayInterest:F6} acc={accumulated:F6}"); + } + + public static string Dump() => string.Join(Environment.NewLine, Lines); + } +} diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index d6e6ecfe..7f313ff8 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -1360,6 +1360,15 @@ namespace YLErp.Modules.SwapModule decimal dynomicPrincipal = preEodPosition.TdInterestPrincipal + posiPrincipal - orginPv; decimal tdDynomicPrincipal = dynomicPrincipal; var calcDays = (endDate - startDate).Days; + if (SwapCalcTrace.IsEnabled) + { + SwapCalcTrace.Header($"CalcDailySimpleInterest posId={position.id} mode={position.InterestMode} type={(position.InterestType == (int)InterestTypeEnum.复利 ? "复利" : "单利")}"); + SwapCalcTrace.Line($" PosiStartDate={startDate:yyyy-MM-dd} endDate={endDate:yyyy-MM-dd} calcDays={calcDays} calcFirst={calcFirst} calcLast={calcLast}"); + SwapCalcTrace.Line($" preEod.id={preEodPosition.id} ValueDate={preEodPosition.ValueDate:yyyy-MM-dd} TdInterestPrincipal={preEodPosition.TdInterestPrincipal:F4} InterestProfitSum={interestProfitSum:F4}"); + SwapCalcTrace.Line($" dynomicPrincipal = TdInterestPrincipal({preEodPosition.TdInterestPrincipal:F4}) + posiPrincipal({posiPrincipal:F4}) - orginPv({orginPv:F4}) = {dynomicPrincipal:F4}"); + SwapCalcTrace.Line($" 已结扣除(InterestProfitSum*closePercent)={interest:F4} consumedInterest={consumedInterest:F4} closePercent={closePercent}"); + SwapCalcTrace.Line($" 逐日累加上限: accrueDate > preEod.ValueDate({preEodPosition.ValueDate:yyyy-MM-dd}) 才计息"); + } double floatRate = Convert.ToDouble(floateRate); for (int i = 0; i <= calcDays; i++) { @@ -1400,6 +1409,8 @@ namespace YLErp.Modules.SwapModule } interest += interest1; tdinterest += tdinterest1; + if (SwapCalcTrace.IsEnabled) + SwapCalcTrace.Day(i, accrueDate, (decimal)floatRate, flowEvent.InterestPrincipal, interest1, interest); } } InterestAmount = Math.Round(interest, InterestCalculationPrecision, MidpointRounding.AwayFromZero);