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
}
}