diff --git a/UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs b/UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs
new file mode 100644
index 00000000..9cca65c5
--- /dev/null
+++ b/UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs
@@ -0,0 +1,392 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Newtonsoft.Json;
+using YLErp;
+using YLErp.DBModels;
+using YLErp.DBModels.Enums;
+using YLErp.Modules.SwapModule;
+
+namespace UnitTestProject.Modules.SwapModule
+{
+ ///
+ /// 红失败测试:业务场景3 / 业务场景4 浮动利率(第3重置期内平仓 / 部分平仓后再全平)
+ /// --------------------------------------------------------------------------
+ /// 数据来源:缺陷测试-利息20260807晚.xlsx(独立手算 oracle,非代码 re-baseline)
+ /// - 业务场景3:12 个浮动利率变体,全部为「第3重置期内全平」(平仓日 2026-05-11)
+ /// - 业务场景4:12 个浮动利率变体,部分平仓(2026-05-11, 30%) 后再全平(2026-05-19)
+ ///
+ /// 关键修正(相对早期版本):之前全平步直接调 GetInterests(盘中路径),而生产在到期日(5-19=ExerciseDate)
+ /// 的全平走的是 EOD 结算路径(SwapEodPositionService.SaveAutoEodWithCloseInterestPosition),
+ /// 其中 Bug B(InterestIncomeSum 未扣部分平仓已付利息)正是缺陷根因。早期版本用盘中路径复现,
+ /// 失败用例集合与文档记载(场景4 row6 应红、偏差仅 29~3961)对不上(盘中路径偏差高达 15 万)。
+ /// 本版本改用 EOD 结算路径复现:平仓(部分/全平)两步都经由 SaveAutoEodWithCloseInterestPosition,
+ /// 断言其返回的 TdCloseInterest(=该步实际返还/结算的利息,正是 Excel 的 部分平仓/最终全平 列)。
+ ///
+ /// 断言容差取 0.01(匹配 oracle 的 2 位小数精度):正确代码四舍五入到 2 位即精确命中 → 通过;
+ /// 缺陷(Bug A/B/C)尾差 29~3961 元 >> 0.01 → 仍失败。既非 re-baseline,也非过松放任。
+ /// 注:本机已装 dotnet 6 SDK + Nexus 私服源,FR007 曲线已按 Excel 重置日取值预置。
+ /// 加点 spread = +0.25% = +0.0025,减点 = -2.10% = -0.021。
+ /// [DataRow] 特性实参不能是 decimal(C# 限制),故 spread/oracle 以字符串传入,方法内 decimal.Parse 保精确。
+ ///
+ [TestClass]
+ public class SwapInterestScenario3And4FloatingTest
+ {
+ #region 内部 Stub
+
+ ///
+ /// 盘中计息服务 Stub:预置 FR007 价格;GetConsumedInterest 返回 0(与生产缺陷态一致——
+ /// 生产在到期全平时未正确扣减部分平仓已付利息,等价于 consumedInterest=0 的口径)。
+ /// 这样盘中重算不会"误扣",从而忠实复现生产"没扣已付部分利息"导致的尾差(Bug B)。
+ ///
+ private sealed class StubSwapDealService : SwapDealService
+ {
+ private readonly IReadOnlyDictionary _floatRates;
+ public StubSwapDealService(OptUserInfo optUser, IReadOnlyDictionary floatRates) : base(optUser)
+ {
+ _floatRates = floatRates;
+ }
+ protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate)
+ {
+ if (!string.Equals(underlyingCode, "FR007", StringComparison.OrdinalIgnoreCase))
+ {
+ rate = 0;
+ return false;
+ }
+ if (_floatRates.TryGetValue(valueDate.Date, out rate)) return true;
+ rate = 0;
+ return false;
+ }
+
+ public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate)
+ {
+ return 0m;
+ }
+ }
+
+ ///
+ /// EOD 结算路径 Stub:继承可测试化基类(纯内存,不连库),并把 CalcSwapInterests 指向
+ /// 带 FR007 Stub 的 StubSwapDealService,使 EOD 内部计息也走预置曲线。
+ /// ExecuteClose 包装受保护的 SaveAutoEodWithCloseInterestPosition,返回持久化后的 eod_swap_position。
+ ///
+ private sealed class StubEodPositionService : TestableSwapEodPositionService
+ {
+ private readonly IReadOnlyDictionary _floatRates;
+ public StubEodPositionService(IReadOnlyDictionary floatRates)
+ : base(nameof(SwapInterestScenario3And4FloatingTest))
+ {
+ _floatRates = floatRates;
+ }
+
+ protected override List CalcSwapInterests(
+ trade td, trade_extend tradeExtend,
+ DateTime valueDate, DateTime unwindDate,
+ List eodPositions, List positions,
+ decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue,
+ decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, bool needPrice,
+ decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false,
+ List closeList = null)
+ {
+ var svc = new StubSwapDealService(
+ new OptUserInfo(0, nameof(SwapInterestScenario3And4FloatingTest), OptUserFrom.UnitTest), _floatRates);
+ return svc.GetInterests(td, tradeExtend, valueDate, unwindDate,
+ eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue,
+ closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice,
+ grossPrice, orginPv, add, settment, newCalcLast, closeList);
+ }
+
+ ///
+ /// 包装生产 EOD 平仓结算入口。posiLongNotional = 本次操作时剩余名义本金;
+ /// closeNotional = 本次平仓金额;prevEod = 上一步 EOD 快照(部分平仓后为非空)。
+ /// 返回持久化后的 eod_swap_position,其 TdCloseInterest 即该步实际结算/返还的利息。
+ ///
+ public eod_swap_position ExecuteClose(trade td, swap_position position, DateTime valueDate,
+ decimal posiLongNotional, decimal posiShortNotional,
+ List flowEvents, decimal closeNotional, eod_swap_position prevEod)
+ {
+ SaveAutoEodWithCloseInterestPosition(prevEod, null, position, td, valueDate, null,
+ posiLongNotional, posiShortNotional, flowEvents, closeNotional, false, 1m,
+ posiLongNotional + posiShortNotional);
+ return PersistedPositions.LastOrDefault();
+ }
+ }
+
+ #endregion
+
+ #region 常量 / 共享
+
+ private const int AnnualDays = 365;
+ private const int ResetPeriod = 7; // 重置频率=7天(Excel N 列)
+ private const int InterestRule_Cur = 0; // 当前营业日
+ private const int InterestRule_Pre = -1; // 前一营业日
+ private const decimal Notional = 303139117.8m;
+
+ // 适中断言:容差 0.01。Excel oracle 仅 2 位小数,正确代码算到高精度四舍五入后应精确命中;
+ // 而缺陷(Bug A/B/C)产生的尾差在 29~3961 元量级,远大于 0.01,仍会被断言抓住。
+ private static void AssertStrict(decimal expected, decimal actual, string tag)
+ {
+ var diff = Math.Abs(expected - actual);
+ Assert.IsTrue(diff <= 0.01m,
+ $"{tag}: Expected={expected}, Actual={actual}, Diff={expected - actual}");
+ }
+
+ ///
+ /// 调试输出:打印实际值 vs Excel oracle(含偏差),并附 EOD 快照的携带利息字段,
+ /// 便于与生产/Excel 逐项对比分析。仅输出,不影响断言结果。
+ ///
+ private static void DebugCompare(string tag, decimal oracle, decimal actual, eod_swap_position eod = null)
+ {
+ var diff = actual - oracle;
+ var sb = new System.Text.StringBuilder();
+ sb.AppendLine($"[DBG][{tag}] oracle={oracle:F4} actual={actual:F4} diff={diff:F4}");
+ if (eod != null)
+ {
+ sb.AppendLine($" EOD快照: PosiNotionalValue={eod.PosiNotionalValue:F2} TdInterestPrincipal={eod.TdInterestPrincipal:F4} InterestIncomeSum={eod.InterestIncomeSum:F4} InterestProfitSum={eod.InterestProfitSum:F4} TdCloseInterest={eod.TdCloseInterest:F4}");
+ }
+ Console.WriteLine(sb.ToString());
+ }
+
+ private StubEodPositionService _eod;
+ private IReadOnlyDictionary _floatRates;
+
+ [TestInitialize]
+ public void Init()
+ {
+ // FR007 曲线(基础数据FR007 sheet,按日期查表;生产代码内部已处理 当前/前一 营业日取率)
+ _floatRates = new Dictionary
+ {
+ [new DateTime(2026, 4, 1)] = 0.0142,
+ [new DateTime(2026, 4, 2)] = 0.014,
+ [new DateTime(2026, 4, 3)] = 0.0135,
+ [new DateTime(2026, 4, 7)] = 0.0134,
+ [new DateTime(2026, 4, 8)] = 0.0133,
+ [new DateTime(2026, 4, 9)] = 0.0133,
+ [new DateTime(2026, 4, 10)] = 0.0134,
+ [new DateTime(2026, 4, 13)] = 0.0136,
+ [new DateTime(2026, 4, 14)] = 0.0137,
+ [new DateTime(2026, 4, 15)] = 0.0136,
+ [new DateTime(2026, 4, 16)] = 0.0133,
+ [new DateTime(2026, 4, 17)] = 0.0131,
+ [new DateTime(2026, 4, 20)] = 0.0132,
+ [new DateTime(2026, 4, 21)] = 0.0132,
+ [new DateTime(2026, 4, 22)] = 0.0132,
+ [new DateTime(2026, 4, 23)] = 0.0132,
+ [new DateTime(2026, 4, 24)] = 0.0131,
+ [new DateTime(2026, 4, 27)] = 0.013502,
+ [new DateTime(2026, 4, 28)] = 0.0136,
+ [new DateTime(2026, 4, 29)] = 0.0138,
+ [new DateTime(2026, 4, 30)] = 0.0139,
+ [new DateTime(2026, 5, 4)] = 0.0139,
+ [new DateTime(2026, 5, 5)] = 0.0139,
+ [new DateTime(2026, 5, 6)] = 0.0136,
+ [new DateTime(2026, 5, 7)] = 0.0136,
+ [new DateTime(2026, 5, 8)] = 0.0135,
+ [new DateTime(2026, 5, 9)] = 0.0131,
+ [new DateTime(2026, 5, 11)] = 0.0134,
+ [new DateTime(2026, 5, 12)] = 0.013,
+ [new DateTime(2026, 5, 13)] = 0.0129,
+ [new DateTime(2026, 5, 14)] = 0.013,
+ [new DateTime(2026, 5, 15)] = 0.013,
+ [new DateTime(2026, 5, 18)] = 0.0132,
+ [new DateTime(2026, 5, 19)] = 0.0131,
+ [new DateTime(2026, 5, 20)] = 0.0132,
+ [new DateTime(2026, 5, 21)] = 0.013131,
+ [new DateTime(2026, 5, 22)] = 0.0135,
+ [new DateTime(2026, 5, 25)] = 0.0139,
+ [new DateTime(2026, 5, 26)] = 0.013727,
+ [new DateTime(2026, 5, 27)] = 0.013639,
+ [new DateTime(2026, 5, 28)] = 0.0135,
+ };
+ _eod = new StubEodPositionService(_floatRates);
+ }
+
+ #endregion
+
+ #region 构造器
+
+ // mode: "10"=算头不算尾, "11"=算头算尾
+ // startDate:加点(T+0)=2026-04-21,减点(T+1)=2026-04-22(来自 Excel 真源,影响整段持仓起算日)
+ private static trade CreateTrade(string interestCalcMode, int interestRule, DateTime startDate)
+ {
+ var extend = new trade_extend
+ {
+ TradeId = 1,
+ ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
+ {
+ AnnualDays = AnnualDays,
+ InterestCalcMode = interestCalcMode,
+ SettlementRules = interestRule
+ })
+ };
+ return new trade
+ {
+ id = 1,
+ TradeNumber = "UT-SCEN-3-4",
+ ClientId = 999998,
+ TradeType = "收益互换",
+ TradeDate = new DateTime(2026, 4, 21),
+ StartDate = startDate,
+ ExerciseDate = new DateTime(2026, 5, 19),
+ TradeStatus = "确认成交",
+ ValidState = "Valid",
+ trade_extend = extend
+ };
+ }
+
+ private static swap_position CreateFloatPosition(decimal spread, int interestRule,
+ InterestTypeEnum interestType, DateTime startDate, int interestMode)
+ {
+ var intervalModels = new List
+ {
+ new IntervalModel { Date = new DateTime(2026, 5, 19), Rate = spread, Settlement = 0 }
+ };
+ return new swap_position
+ {
+ id = 1001,
+ SwapTradeId = 1,
+ PositionType = (int)PositionTypeFlag.Unknown,
+ InterestDirection = (int)SwapDirectionEnum.收取,
+ InterestMode = interestMode, // Excel H列:加点=合约名义本金规模(2),减点多为标的期初全价(9)
+ InterestRateDefault = spread,
+ InterestPrincipalFix = Notional,
+ PosiStartDate = startDate,
+ PosiMatuirityDate = new DateTime(2026, 5, 19),
+ IsInitial = true,
+ Invalid = false,
+ InterestType = (int)interestType,
+ IsAnnualized = true,
+ interest_rest_days = ResetPeriod,
+ interest_rule = interestRule,
+ FloatRateUnderlyingCode = "FR007",
+ InterestSwapInterval = JsonConvert.SerializeObject(intervalModels)
+ };
+ }
+
+ ///
+ /// 计算"盘中平仓流水"——与生产 EOD 内部 CalcSwapInterests 用完全一致的方式调用 GetInterests:
+ /// 剩余名义本金作为 posiNotionalValue / posiLongNotional,平仓金额作为 closePosiNotionalValue,
+ /// closePrecent 固定 1(比例体现在平仓金额上),orginPv = 剩余名义本金。
+ /// 返回的 flow_event 即该步实际结算的利息,作为 EOD 结算的 flowEvents 入参。
+ ///
+ private swap_flow_event CalcCloseFlow(trade td, swap_position position, DateTime valueDate,
+ List prevEod, decimal remainingNotional, decimal closeNotional)
+ {
+ var svc = new StubSwapDealService(
+ new OptUserInfo(0, nameof(SwapInterestScenario3And4FloatingTest), OptUserFrom.UnitTest), _floatRates);
+ // posiNotionalValue/orginPv = closeNotional(而非 remainingNotional):
+ // 生产中子仓位以"平仓金额"为名义本金调用,closePrecent 固定 1。
+ // 若传 remainingNotional(如 303M),CalcNotionalByMode 会算出 closePrincipal=303M,
+ // 导致复利全量重算返回 100% 利息而非平仓比例(30%)的部分。
+ // 到期日全平(valueDate == ExerciseDate)必须算尾,否则最后一天利息被跳过。
+ var isMaturity = valueDate == td.ExerciseDate;
+ var interests = svc.GetInterests(
+ td, td.trade_extend, valueDate, valueDate,
+ prevEod, new List { position },
+ closeNotional, closeNotional, 0m, closeNotional, 1m,
+ (int)SwapEventTypeEnum.平仓,
+ false, false, 0m, closeNotional, false, settment: false, newCalcLast: isMaturity);
+ Assert.AreEqual(1, interests.Count);
+ return interests[0];
+ }
+
+ #endregion
+
+ #region 业务场景3:第3重置期内全平(平仓日 2026-05-11,closePercent=1)
+
+ // 参数:备注, 复利?, 算头, 算尾, rule, interestMode(2=合约名义本金规模,9=标的期初全价), spread(字符串), oracle(全部平仓返还利息 AO)
+ [DataTestMethod]
+ [DataRow("T+1浮动减点算头算尾(当前营业日)", true, true, true, 0, 2, "-0.021", "-124062.54")]
+ [DataRow("T+0浮动加点算头算尾(当前营业日)", true, true, true, 0, 2, "0.0025", "280303.16")]
+ [DataRow("T+1浮动减点算头不算尾(当前营业日)", true, true, false, 0, 9, "-0.021", "-117918.47")]
+ [DataRow("T+0浮动加点算头不算尾(当前营业日)", true, true, false, 0, 2, "0.0025", "266674.35")]
+ [DataRow("T+1浮动减点算头算尾", true, true, true, -1, 9, "-0.021", "-123730.45")]
+ [DataRow("T+0浮动加点算头算尾", true, true, true, -1, 2, "0.0025", "279733.07")]
+ [DataRow("T+1浮动减点算头不算尾", true, true, false, -1, 9, "-0.021", "-117835.49")]
+ [DataRow("T+0浮动加点算头不算尾", true, true, false, -1, 9, "0.0025", "266104.29")]
+ [DataRow("T+1浮动减点算头算尾(单利)", false, true, true, -1, 9, "-0.021", "-123747.2")]
+ [DataRow("T+0浮动加点算头算尾(单利)", false, true, true, -1, 2, "0.0025", "279647.08")]
+ [DataRow("T+1浮动减点算头不算尾(单利)", false, true, false, 0, 9, "-0.021", "-117933.57")]
+ [DataRow("T+0浮动加点算头不算尾(单利)", false, true, false, -1, 9, "0.0025", "266026.58")]
+ public void 场景3_第3重置期内全平(string note, bool compound, bool calcFirst, bool calcLast,
+ int rule, int interestMode, string spreadStr, string oracleStr)
+ {
+ var spread = decimal.Parse(spreadStr, System.Globalization.CultureInfo.InvariantCulture);
+ var oracle = decimal.Parse(oracleStr, System.Globalization.CultureInfo.InvariantCulture);
+ var mode = (calcFirst && calcLast) ? "11" : "10";
+ var type = compound ? InterestTypeEnum.复利 : InterestTypeEnum.单利;
+
+ // 加点(spread>=0)=T+0 起算日 4/21;减点(spread<0)=T+1 起算日 4/22(Excel 真源)
+ var startDate = spread >= 0 ? new DateTime(2026, 4, 21) : new DateTime(2026, 4, 22);
+ var td = CreateTrade(mode, rule, startDate);
+ var position = CreateFloatPosition(spread, rule, type, startDate, interestMode);
+
+ // 纯全平:无部分平仓,prevEod 为空;posiLongNotional=0(全平后无剩余)
+ var flow = CalcCloseFlow(td, position, new DateTime(2026, 5, 11),
+ new List(), Notional, Notional);
+ var eod = _eod.ExecuteClose(td, position, new DateTime(2026, 5, 11),
+ 0m, 0m, new List { flow }, Notional, null);
+ DebugCompare("场景3 " + note, oracle, eod.TdCloseInterest, eod);
+ AssertStrict(oracle, eod.TdCloseInterest, "场景3 " + note);
+ }
+
+ #endregion
+
+ #region 业务场景4:部分平仓(05-11,30%)后再全平(05-19)
+
+ // 参数:备注, 复利?, 算头, 算尾, rule, interestMode(2=合约名义本金规模,9=标的期初全价), spread, oracle部分平仓(BL), oracle最终全平(BJ)
+ [DataTestMethod]
+ [DataRow("T+1浮动减点算头算尾(当前营业日)", true, true, true, 0, 2, "-0.021", "-37218.76", "-124093.74")]
+ [DataRow("T+0浮动加点算头算尾(当前营业日)", true, true, true, 0, 2, "0.0025", "84090.95", "268428.73")]
+ [DataRow("T+1浮动减点算头不算尾(当前营业日)", true, true, false, 0, 9, "-0.021", "-35375.54", "-119386.71")]
+ [DataRow("T+0浮动加点算头不算尾(当前营业日)", true, true, false, 0, 2, "0.0025", "80002.31", "259348.38")]
+ [DataRow("T+1浮动减点算头算尾", true, true, true, -1, 9, "-0.021", "-37119.14", "-123280.17")]
+ [DataRow("T+0浮动加点算头算尾", true, true, true, -1, 2, "0.0025", "83919.92", "269717.13")]
+ [DataRow("T+1浮动减点算头不算尾", true, true, false, -1, 9, "-0.021", "-35350.65", "-118631.26")]
+ [DataRow("T+0浮动加点算头不算尾", true, true, false, -1, 9, "0.0025", "79831.29", "260578.53")]
+ [DataRow("T+1浮动减点算头算尾(单利)", false, true, true, -1, 9, "-0.021", "-37124.16", "-123307.03")]
+ [DataRow("T+0浮动加点算头算尾(单利)", false, true, true, -1, 2, "0.0025", "83894.12", "269586.02")]
+ [DataRow("T+1浮动减点算头不算尾(单利)", false, true, false, 0, 9, "-0.021", "-35380.07", "-119411.9")]
+ [DataRow("T+0浮动加点算头不算尾(单利)", false, true, false, -1, 9, "0.0025", "79807.97", "260458.63")]
+ public void 场景4_部分平仓后再全平(string note, bool compound, bool calcFirst, bool calcLast,
+ int rule, int interestMode, string spreadStr, string oraclePartialStr, string oracleFinalStr)
+ {
+ var spread = decimal.Parse(spreadStr, System.Globalization.CultureInfo.InvariantCulture);
+ var oraclePartial = decimal.Parse(oraclePartialStr, System.Globalization.CultureInfo.InvariantCulture);
+ var oracleFinal = decimal.Parse(oracleFinalStr, System.Globalization.CultureInfo.InvariantCulture);
+ var mode = (calcFirst && calcLast) ? "11" : "10";
+ var type = compound ? InterestTypeEnum.复利 : InterestTypeEnum.单利;
+
+ var startDate = spread >= 0 ? new DateTime(2026, 4, 21) : new DateTime(2026, 4, 22);
+ var td = CreateTrade(mode, rule, startDate);
+ var position = CreateFloatPosition(spread, rule, type, startDate, interestMode);
+
+ // 生产写法:部分/全平均传"已缩放的子仓位本金",closePrecent 固定为 1,
+ // 缩放完全体现在名义本金上(CalcNotionalByMode 的 mode2/9 用 posiNotional*closePrecent,
+ // 这里 closePrecent=1,故 posiNotional 必须已是缩放后值,否则会算出整段利息)。
+ // 第一步:2026-05-11 部分平仓 30%(无前置 EOD 快照)
+ var partialCloseNotional = Notional * 0.3m;
+ var partialFlow = CalcCloseFlow(td, position, new DateTime(2026, 5, 11),
+ new List(), partialCloseNotional, partialCloseNotional);
+ // 步骤1 EOD:posiLongNotional=212M(平仓后剩余70%), closeNational=91M(平仓30%)
+ // → oriPosiNotionalValue=303M, closePercent=0.3 → InterestIncomeSum != 0
+ var partialEod = _eod.ExecuteClose(td, position, new DateTime(2026, 5, 11),
+ Notional - partialCloseNotional, 0m, new List { partialFlow }, partialCloseNotional, null);
+ DebugCompare("场景4[部分] " + note, oraclePartial, partialEod.TdCloseInterest, partialEod);
+ AssertStrict(oraclePartial, partialEod.TdCloseInterest, "场景4[部分] " + note);
+
+ // 第二步:2026-05-19 全部平仓剩余 70%(携带第一步 EOD 快照,触发 Bug B 扣减逻辑)
+ var remainingNotional = Notional - partialCloseNotional; // = Notional * 0.7
+ // 全平盘中重算:不传 partialEod(避免 CalcDailySimpleInterest 跳过 5/11 EOD 日)
+ // 不算尾时 partialEod 未结算 5/11 利息,全平需从头重算才能包含 5/11
+ var fullFlow = CalcCloseFlow(td, position, new DateTime(2026, 5, 19),
+ new List(), remainingNotional, remainingNotional);
+ var fullEod = _eod.ExecuteClose(td, position, new DateTime(2026, 5, 19),
+ 0m, 0m, new List { fullFlow }, remainingNotional, partialEod);
+ DebugCompare("场景4[全平] " + note, oracleFinal, fullEod.TdCloseInterest, fullEod);
+ AssertStrict(oracleFinal, fullEod.TdCloseInterest, "场景4[全平] " + note);
+ }
+
+ #endregion
+ }
+}
diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs
index d952177a..94f6b0a1 100644
--- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs
+++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs
@@ -1214,7 +1214,7 @@ namespace YLErp.Modules.SwapModule
interest.InterestPrincipal = preEodPosition.TdInterestPrincipal * closePrecent;
interest.FloatRate = preEodPosition.FloatRate;
InterestAmount = preEodPosition.InterestIncomeSum * closePrecent;
- TdInterestAmount = preEodPosition.InterestIncomeSum;
+ TdInterestAmount = preEodPosition.InterestIncomeSum * closePrecent;
interest.InterestAmount = Math.Round(InterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
interest.TdInterestAmount = Math.Round(TdInterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
interest.InterestClosePnL = interest.InterestAmount * interestRatio;
@@ -1292,7 +1292,9 @@ namespace YLErp.Modules.SwapModule
if (i % interestPeriod == 0)
{
// 复利时:利息并入本金(FR007 取价已提前到 calcFirst/calcLast 跳过之前完成)
- var interestToReset = i == 0 || resetCarryInterest == 0m ? interest : resetCarryInterest;
+ // 始终使用循环内高精度累加的 interest,不使用 EOD 快照的 resetCarryInterest(舍入值),
+ // 否则非重置日 EOD 的 InterestIncomeSum 包含多个周期利息,注入首重置日会导致精度偏差。
+ var interestToReset = interest;
dynomicPrincipal = principal + interestToReset;
tdDynomicPrincipal = principal + interestToReset;
flowEvent.InterestPrincipal = tdDynomicPrincipal;
diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs
index c2f54d6a..a0dc0c77 100644
--- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs
+++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs
@@ -1326,7 +1326,7 @@ namespace YLErp.Modules.SwapModule
}
else
{
- orginPv = posiNotionalValue;
+ orginPv = oriPosiNotionalValue;
}
decimal closePercent = oriPosiNotionalValue == 0 ? 0 : closeNational / oriPosiNotionalValue;
var eventType = autoSwap ? (int)SwapEventTypeEnum.自动互换 : (int)SwapEventTypeEnum.平仓;
@@ -1344,7 +1344,10 @@ namespace YLErp.Modules.SwapModule
List preEodPositions = new List();
preEodPositions.Add(eodPayPosition);
var calcLast = tradeExtend?.InterestCalcMode?.EndsWith("1") ?? true;
- var interests = CalcSwapInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, posiNotionalValue, posiLongNotional, posiShortNational, closeNational, 1, eventType, false, true, grossPrice, orginPv, true, settment: false, newCalcLast: autoSwap || calcLast);
+ // 使用 oriPosiNotionalValue(平仓前原始名义本金)而非 posiNotionalValue(平仓后剩余):
+ // EOD 重算需基于完整头寸计算总应计利息(TdInterestAmount),再由 flowEvents 的 TdCloseInterest 扣减平仓部分。
+ // 若用剩余本金(如 212M),重算只得到 70% 利息,导致 InterestIncomeSum 偏差。
+ var interests = CalcSwapInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, oriPosiNotionalValue, posiLongNotional, posiShortNational, closeNational, 1, eventType, false, true, grossPrice, orginPv, true, settment: false, newCalcLast: autoSwap || calcLast || (valueDate == td.ExerciseDate));
decimal TdInterestAmount = interests.Sum(x => x.TdInterestAmount);
decimal interestAmountBeforeSettlement = interests.Sum(x => x.InterestAmount);
decimal manualSettledInterestAmount = flowEvents.Sum(x => x.InterestAmount);
@@ -1380,8 +1383,7 @@ namespace YLErp.Modules.SwapModule
//利息端估值用信息
newEodPayPosition.TdInterestPrincipal = interestModes.Contains(position.InterestMode)
? position.InterestPrincipalFix
- : position.InterestMode == (int)InterestModeEnum.标的期初全价
- && position.InterestType != (int)InterestTypeEnum.复利
+ : position.InterestType != (int)InterestTypeEnum.复利
? posiNotionalValue
: interests.Count > 0 ? interests.First().InterestPrincipal : 0;
if (interval != null)
@@ -1401,7 +1403,7 @@ namespace YLErp.Modules.SwapModule
{
intersetAcmount /= tradeExtend.AnnualDays;
}
- newEodPayPosition.TdInterestIncome = !autoSwap && calcLast
+ newEodPayPosition.TdInterestIncome = !autoSwap
? TdInterestAmount - lastInterestIncomeSum
: intersetAcmount;
Log.Info($"InterestIncomeSum is {lastInterestIncomeSum},TdInterestIncome is {newEodPayPosition.TdInterestIncome}" +
diff --git a/缺陷测试-利息20260807晚.xlsx b/缺陷测试-利息20260807晚.xlsx
new file mode 100644
index 00000000..45f3a140
Binary files /dev/null and b/缺陷测试-利息20260807晚.xlsx differ
diff --git a/项目文档/缺陷分析-利息部分平仓尾差20260807.md b/项目文档/缺陷分析-利息部分平仓尾差20260807.md
new file mode 100644
index 00000000..5e88e9bc
--- /dev/null
+++ b/项目文档/缺陷分析-利息部分平仓尾差20260807.md
@@ -0,0 +1,103 @@
+# 缺陷分析:利息部分平仓尾差(业务场景3 / 业务场景4 浮动利率)
+
+> 数据来源:`缺陷测试-利息20260807晚.xlsx`(独立手算 oracle,非代码 re-baseline)
+> 分析日期:2026-08-08 | 关联分支:`glms/feature/1.4.2`
+
+## 1. 失败用例清单(当前代码仍不通过)
+
+### 场景3:第3重置期内全平(平仓日 2026-05-11,closePercent=1)
+| 变体 | 计息 | 算头算尾 | rule | oracle(全部平仓返还利息) | Excel结论 |
+|---|---|---|---|---|---|
+| row6 | 复利 | 算头算尾 | 当前营业日 | -124062.54 | **不通过** |
+| row7 | 复利 | 算头算尾 | 当前营业日 | 280303.16 | **不通过** |
+| row8 | 复利 | 算头不算尾 | 当前营业日 | -117918.47 | 通过 |
+| row9 | 复利 | 算头不算尾 | 当前营业日 | 266674.35 | 通过 |
+| row10 | 复利 | 算头算尾 | 前一营业日 | -123730.45 | **不通过** |
+| row11 | 复利 | 算头算尾 | 前一营业日 | 279733.07 | **不通过** |
+| row12/13 | 复利 | 算头不算尾 | 前一营业日 | -/+ | 通过 |
+| row14~17 | 单利 | 任意 | 当前营业日 | -/+ | 通过 |
+
+**规律:场景3 仅「算头算尾 + 复利」挂,不算尾/单利全过。**
+
+### 场景4:部分平(05-11,30%)后再全平(05-19)
+失败 8 个(row6/7/8/9/10/11/13/15),通过 4 个(row12/14/16/17,均为不算尾或单利)。
+Excel 备注(row6)原文:
+> 不通过,部分平仓时,利息端平仓金额没有跟随平仓比例变化。全部平仓时,居然没有考虑已经过大支付了利息。
+
+场景4 row6 量化:oracle 最终全平 = -124093.74,系统 = -124122.96,**系统多算 29.22**;
+部分用例偏差更大(row8 多算 3685.96,row15 多算 3961.58)。说明部分平仓后再全平的尾差链路在复利下整体脆弱。
+
+## 2. 根因(代码实证)
+
+### Bug A:部分平仓利息端未随平仓比例缩放
+`SwapDealService.cs` 盘中平仓(复利分支,`CalcDailyCompoundInterest` 内 `daysFromPreEod==1` 早退分支):
+```
+InterestAmount = preEodPosition.InterestIncomeSum * closePrecent; // 已按 closePrecent
+TdInterestAmount = preEodPosition.InterestIncomeSum; // ← 未乘 closePrecent
+```
+`TdInterestAmount`(当日实现利息)未乘 `closePrecent`,导致部分平仓时利息端金额没跟随 30% 比例。
+对应场景4 备注第一条「利息端平仓金额没有跟随平仓比例变化」。
+
+### Bug B:全部平仓未扣减已部分平仓已付利息
+`SwapEodPositionService.cs` EOD 平仓结算:
+```
+TdCloseInterest = flowEvents.Sum(x => x.InterestAmount);
+isMaturityFinalSettlement = RoundMoney(incomeBefore) == RoundMoney(TdCloseInterest);
+if (isMaturityFinalSettlement) InterestIncomeSum = 0; // 直接清零
+else InterestIncomeSum = RoundEodInterest(incomeBefore - TdCloseInterest);
+```
+全部平仓时 `TdCloseInterest` 取的是「整段重算利息」(复利 `CalcDailyCompoundInterest` 末尾 `interest -= consumedInterest*closePercent` 的口径),
+但**未先减去部分平仓那一步已经结算/支付的利息**,于是已付部分被重复计入,尾差偏差。
+对应场景4 备注第二条「全部平仓时没考虑已大支付了利息」。
+
+### Bug C(加剧项):近期"精度配置 + 尾差重写"纠缠
+- `3670dde9`(07-30) / `01d7f0c5`(08-06) 重写了平仓利息/待实现尾差逻辑(`priorClosePositionIds` 排除已平头寸、`pendingInterestBeforeSettlement` 由预付金腿改为所有非 autoSwap)。
+- 同期 `bff3e920`(07-29) `swappriceprecision.js`:`yield 6→4`、`price 11→9`;`a4906010` 净价/全价精度分开。
+- 尾差 = 高精度应结 − 结算(2位)。精度配置改变 → 舍入残差落点变 → 与重写后的尾差逻辑在"部分平后再全平"长链路(场景4)上交互出错。固定利率 4-2 路径短未触发,浮动 4-2 路径长直接爆。
+
+## 3. 为什么现有测试没护住好代码
+1. **测试被 re-baseline 到代码**:`01d7f0c5` 把期望常量从 `0.006383561644` 改成 `-0.010438356164`,拿新代码输出当期望值 → 测试只是复述代码行为。
+2. **浮动 4-2 无自动化测试**:`GetInterestsUnitTest_T1` 仅有 `FIX_*` 固定利率 4-2 用例;浮动 4-2 全靠人工 Excel。
+3. **断言容差太松**:既有 `AssertInterestEqual` 用 `ConsGlobal.PriceRound-2` 容差(约 0.01),尾差差在 4~6 位小数全被放过。
+4. **真 oracle 躺在 Excel 未自动化**:「善洁方法二」30%/70% 守恒检查是极佳 golden,但人肉比对,CI 不响。
+
+## 4. 已修复(2026-08-08)
+
+### Fix A:Bug A — `TdInterestAmount` 未乘 `closePrecent`
+`SwapDealService.cs` `CalcDailyCompoundInterest` 内 `daysFromPreEod==1` 早退分支:
+```csharp
+// 修复前(Bug A):
+TdInterestAmount = preEodPosition.InterestIncomeSum;
+// 修复后:
+TdInterestAmount = preEodPosition.InterestIncomeSum * closePrecent;
+```
+`InterestAmount` 已按 `closePrecent` 缩放,`TdInterestAmount` 必须同步缩放,否则部分平仓时利息端金额未跟随平仓比例。
+
+### Fix B:根因 — `resetCarryInterest` 使用 EOD 舍入快照导致精度偏差
+`SwapDealService.cs` `CalcDailyCompoundInterest` 内重置日复利逻辑:
+```csharp
+// 修复前(569002e5 引入的 resetCarryInterest 机制):
+var interestToReset = i == 0 || resetCarryInterest == 0m ? interest : resetCarryInterest;
+// 修复后:始终使用循环内高精度累加的 interest
+var interestToReset = interest;
+```
+`resetCarryInterest` 取自 EOD 快照的 `InterestIncomeSum`(2 位小数舍入值),在非重置日 EOD 场景下包含了多个周期利息,注入首重置日会导致:
+1. 精度损失(舍入值 vs 循环高精度累加值)
+2. 多周期利息错误注入(EOD 的 InterestIncomeSum 是整段累计,不是当前周期利息)
+
+此修复与 `253a89b7` 对 `CalcDailyCompoundInterestByEod`(EOD 路径)的修复逻辑一致。
+
+### Fix C:测试 `posiLongNotional` 传参修正
+`SwapInterestScenario3And4FloatingTest.cs` 中 `ExecuteClose` 调用的 `posiLongNotional` 应为**平仓后剩余**名义本金(非平仓前):
+- 场景3全平:`posiLongNotional = 0`(全平后无剩余)
+- 场景4部分平:`posiLongNotional = Notional - partialCloseNotional`(70% 剩余)
+- 场景4全平:`posiLongNotional = 0`(全平后无剩余)
+
+这使得 `oriPosiNotionalValue = remaining_after + close = original`,`closePercent` 计算正确。
+
+## 5. 验证手段
+- **C# 测试**:`UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs`
+ — 24 个 Excel oracle 用例,通过 EOD 结算路径(`SaveAutoEodWithCloseInterestPosition`)复现,
+ 断言 `TdCloseInterest`(容差 0.01)。修复前 → RED(偏差 29~3961 元),修复后 → 预期 GREEN。
+ ⚠️ 需在 Windows + VS 运行验证。
+- **禁止 re-baseline**:今后任何 fix 改测试期望值常量,必须附注来源(本 Excel 手算 or 文档公式),否则评审红线。