using Newtonsoft.Json;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
///
/// GetInterests 双显式入口语义字符化测试(Step3"特判降级"的前置钉子)。
///
/// 背景:GetIntradayUnwindInterests(盘中:平仓前剩余×实际比例)与
/// CalcEodPostCloseSettleInterests(EOD平仓后收盘:平仓后剩余×恒1)是同一经济事件
/// (部分平仓)的两套传参语义,靠 GetInterests 内 mode2 无条件覆盖 / mode9 全平兜底粘合。
/// 本测试钉死当前行为,使后续特判降级/语义重构有回归网:
/// ① 复利×mode2:closePrincipal(特判产物)是 CalcDailyCompoundInterest 的重放本金——
/// 两入口 closePosiNotionalValue 均为实际平掉额 → InterestAmount 必须相等;
/// ② 单利×mode2:CalcDailySimpleInterest 消费的是 posiPrincipal×closePercent——
/// 盘中(平仓前×比例) vs EOD(剩余×1) 数值口径可能不同,本测试【记录现状】(见各断言注释);
/// ③ mode9 全平(posi=0):兜底覆盖生效,结息额非零。
///
/// 数据基建复用 GetInterestsUnitTest_T0 的构建器口径(T+0,4/27起息,"11"算头算尾)。
///
[TestClass]
public class GetInterestsEntrySemanticsTest
{
private const decimal Principal = 1000m;
private const decimal FixedRate = 0.01m;
private const decimal FloatRate = 0.001m;
private const int AnnualDays = 365;
private const int ResetPeriod = 3;
private static readonly DateTime TradeDate = new(2026, 4, 27);
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, 30);
// 平仓前剩余 1000,平掉 30%(300),收盘后剩余 700
private const decimal PreClose = 1000m;
private const decimal Closed = 300m;
private const decimal Remaining = 700m;
private const decimal ClosePercent = 0.3m;
#region Stub(浮动利率内存取价,与 T0 同款)
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;
}
}
private static SwapDealService CreateService() => new StubSwapDealService(
new OptUserInfo(0, nameof(GetInterestsEntrySemanticsTest), OptUserFrom.UnitTest),
new Dictionary
{
[new DateTime(2026, 4, 27)] = (double)FloatRate,
[new DateTime(2026, 4, 28)] = (double)FloatRate,
[new DateTime(2026, 4, 29)] = (double)FloatRate,
[new DateTime(2026, 4, 30)] = (double)FloatRate,
});
#endregion
#region 数据构建(T0 口径)
private static trade CreateTrade()
{
var extend = new trade_extend
{
TradeId = 1,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "11", // 算头算尾
SettlementRules = 0
})
};
return new trade
{
id = 1, TradeNumber = "UT-INT-ENTRY-SEMANTICS", ClientId = 999998,
TradeType = "收益互换", TradeDate = TradeDate, StartDate = StartDate,
ExerciseDate = ExerciseDate, TradeStatus = "确认成交", ValidState = "Valid",
trade_extend = extend
};
}
private static swap_position CreatePosition(InterestModeEnum mode, InterestTypeEnum interestType, bool floating = false)
{
var intervalModels = new List
{
new IntervalModel { Date = ExerciseDate, Rate = FixedRate, Settlement = 0 }
};
return new swap_position
{
id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)mode,
InterestRateDefault = FixedRate, InterestPrincipalFix = Principal,
PosiStartDate = StartDate, PosiMatuirityDate = ExerciseDate,
IsInitial = true, Invalid = false, InterestType = (int)interestType,
IsAnnualized = true, interest_rest_days = ResetPeriod, interest_rule = 0,
FloatRateUnderlyingCode = floating ? "FR007" : null,
InterestSwapInterval = JsonConvert.SerializeObject(intervalModels)
};
}
private static eod_swap_position CreatePreEod(decimal interestSum, decimal principal)
=> new()
{
id = 1, SwapTradeId = 1, PositionId = 1001, ValueDate = new DateTime(2026, 4, 29),
ClientId = 999998, FloatRate = FloatRate, TdInterestPrincipal = principal,
PosiNotionalValue = principal, InterestIncomeSum = interestSum, InterestProfitSum = interestSum
};
#endregion
///
/// 复利×mode2×部分平仓30%:钉住两入口【当前】结息口径(2026-08-14 实测,字符化)。
///
/// 实测(closePrincipal 特判两边均=平掉额300,但消费路径不同):
/// 盘中 = 0.036164835616 —— CalcDailyCompoundInterest 以 closePosi(300) 全程重放 [4/27,4/30];
/// EOD = 0.059041913305 —— InitSwapDealInterest closePercent==1 分支:
/// preEod.InterestIncomeSum(0.05 全腿待实现) + amountAtEnd(0.036165) - amountAtPrevEod(0.027123)。
///
/// ⚠️ 两值不等 = 已观察到的口径分歧(同一经济事件两种结息额),非断言失败项;
/// 待业务裁决哪个口径正确前,本测试锁死两值防意外漂移。裁决后改断言为"相等"或删除错方。
///
[TestMethod]
public void 复利_mode2_部分平仓_双入口口径钉住现状()
{
var td = CreateTrade();
var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.复利, floating: true);
var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose);
var eodPositions = new List { preEod };
var positions = new List { position };
var intraday = CreateService().GetIntradayUnwindInterests(InterestCalcRequest.IntradayUnwind(
td, td.trade_extend, UnwindDate, UnwindDate, eodPositions, positions,
PreClose, PreClose, 0m, Closed, ClosePercent,
(int)SwapEventTypeEnum.平仓, tdClose: true, orginPv: PreClose, add: true, newCalcLast: false, closeList: null));
var eodPostClose = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
eodPositions, positions, Remaining, Remaining, 0m, Closed, 1m,
(int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose,
add: true, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, intraday.Count);
Assert.AreEqual(1, eodPostClose.Count);
Console.WriteLine($"[复利mode2] 盘中 InterestAmount={intraday[0].InterestAmount} / EOD={eodPostClose[0].InterestAmount}");
// 钉住两入口各自的当前值(容差 1e-9 级,防任何实现漂移)
Assert.AreEqual(0.036164835616m, intraday[0].InterestAmount, 0.000000001m,
"盘中口径:closePosi(平掉额300) 全程重放利息。此值变化=盘中复利口径漂移");
Assert.AreEqual(0.059041913305m, eodPostClose[0].InterestAmount, 0.000000001m,
"EOD口径:preEod待实现(0.05) + 平掉额末段增量(0.009042)。此值变化=EOD平仓后收盘复利口径漂移");
}
///
/// 单利×mode2×部分平仓30%:记录两入口当前口径(快照×比例 vs 重放基数差异面)。
/// 单利消费 posiPrincipal×closePercent:盘中 1000×0.3 vs EOD 700×1 —— 若两值不等,
/// 这是当前系统的已知口径差异面(非断言失败项),数值以 Console 留档,供特判降级时对照。
///
[TestMethod]
public void 单利_mode2_部分平仓_双入口口径留档()
{
var td = CreateTrade();
var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.单利);
var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose);
var eodPositions = new List { preEod };
var positions = new List { position };
var intraday = CreateService().GetIntradayUnwindInterests(InterestCalcRequest.IntradayUnwind(
td, td.trade_extend, UnwindDate, UnwindDate, eodPositions, positions,
PreClose, PreClose, 0m, Closed, ClosePercent,
(int)SwapEventTypeEnum.平仓, tdClose: true, orginPv: PreClose, add: true, newCalcLast: false, closeList: null));
var eodPostClose = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
eodPositions, positions, Remaining, Remaining, 0m, Closed, 1m,
(int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose,
add: true, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, intraday.Count);
Assert.AreEqual(1, eodPostClose.Count);
Console.WriteLine($"[单利mode2] 盘中 InterestAmount={intraday[0].InterestAmount} / EOD={eodPostClose[0].InterestAmount}");
Console.WriteLine($"[单利mode2] TdInterestAmount: 盘中={intraday[0].TdInterestAmount} / EOD={eodPostClose[0].TdInterestAmount}");
// 钉住"两入口非零"这一最低限度事实;数值差异本身是记录项,不是失败项
Assert.IsTrue(intraday[0].InterestAmount != 0m, "盘中单利结息额不应为0");
Assert.IsTrue(eodPostClose[0].InterestAmount != 0m, "EOD单利结息额不应为0");
}
///
/// mode9 全平(EOD,posi=0):特判兜底触发 closePrincipal=closePosiNotionalValue(实际平掉额),
/// 结息额非零。若兜底被删,closePrincipal=0×1=0 → 结息额归零 → 本断言红。
///
[TestMethod]
public void 复利_mode9_全平_兜底覆盖生效结息额非零()
{
var td = CreateTrade();
var position = CreatePosition(InterestModeEnum.标的期初全价, InterestTypeEnum.复利, floating: true);
var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose);
var eodPositions = new List { preEod };
var positions = new List { position };
// 全平:剩余=0,平掉=全部 1000
var result = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
eodPositions, positions, 0m, 0m, 0m, PreClose, 1m,
(int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose,
add: true, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, result.Count);
Console.WriteLine($"[复利mode9全平] InterestAmount={result[0].InterestAmount}");
Assert.IsTrue(result[0].InterestAmount != 0m,
"mode9 全平时 posi=0,兜底必须以 closePosiNotionalValue(实际平掉额) 为结息本金,结息额非零(兜底钉子)");
}
#region CalcEodPostCloseSettleInterests 接缝映射钉子
///
/// 参数捕获 stub:拦下 CalcSwapInterests 的全部实参,不触库、不真算。
///
private sealed class CalcSwapInterestsCapture : TestableSwapEodPositionService
{
public CalcSwapInterestsCapture() : base(nameof(GetInterestsEntrySemanticsTest)) { }
public List CapturedCloseList = null;
public bool CapturedTdClose;
public int CapturedEventType;
public decimal CapturedPosiNotional;
public decimal CapturedClosePosiNotional;
public decimal CapturedClosePercent;
public decimal CapturedOrginPv;
public bool CapturedAdd;
public bool CapturedSettment;
public bool CapturedNewCalcLast;
public int CallCount;
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,
decimal orginPv,
bool add = false, bool settment = true, bool newCalcLast = false,
List closeList = null)
{
CallCount++;
CapturedTdClose = tdClose; CapturedEventType = eventType;
CapturedPosiNotional = posiNotionalValue; CapturedClosePosiNotional = closePosiNotionalValue;
CapturedClosePercent = closePrecent; CapturedOrginPv = orginPv;
CapturedAdd = add; CapturedSettment = settment; CapturedNewCalcLast = newCalcLast;
CapturedCloseList = closeList;
return new List();
}
public List ExposedEodPostCloseSettle(InterestCalcRequest req)
=> CalcEodPostCloseSettleInterests(req);
}
///
/// 钉死 InterestCalcRequest.EodPostCloseSettle 工厂 → CalcEodPostCloseSettleInterests →
/// CalcSwapInterests 的位置参数转发契约。这段转发是位置传参最易错位的环节
/// (posiNotionalValue/closePosiNotionalValue/orginPv 三个相邻同型 decimal,编译器不查错位),
/// 任何映射改动(含将来删 needPrice/grossPrice 死参数)都必须保持本断言绿。
///
[TestMethod]
public void EOD平仓后收盘_工厂到接缝_参数映射钉死()
{
var td = CreateTrade();
var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.单利);
var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose);
var positions = new List { position };
var stub = new CalcSwapInterestsCapture();
var req = InterestCalcRequest.EodPostCloseSettle(
td, td.trade_extend, UnwindDate, UnwindDate,
new List { preEod }, positions,
remainingNotionalAfterClose: Remaining, remainingLongNotional: Remaining, remainingShortNotional: 0m,
closedNotional: Closed,
eventType: (int)SwapEventTypeEnum.平仓, tdClose: false,
orginPv: PreClose, add: true, newCalcLast: false);
stub.ExposedEodPostCloseSettle(req);
Assert.AreEqual(1, stub.CallCount, "默认实现应恰好调用一次 CalcSwapInterests(虚接缝兼容既有测试替身)");
Assert.AreEqual(Remaining, stub.CapturedPosiNotional, "posiNotionalValue 位 = 平仓后剩余(700)——语义核心,错位即红");
Assert.AreEqual(Closed, stub.CapturedClosePosiNotional, "closePosiNotionalValue 位 = 实际平掉额(300)");
Assert.AreEqual(1m, stub.CapturedClosePercent, "closePrecent 恒 1(全额结息)");
Assert.AreEqual((int)SwapEventTypeEnum.平仓, stub.CapturedEventType);
Assert.IsFalse(stub.CapturedTdClose);
Assert.AreEqual(PreClose, stub.CapturedOrginPv, "orginPv 位 = 上一日终本金——与相邻 decimal 最易错位处");
Assert.IsTrue(stub.CapturedAdd);
Assert.IsFalse(stub.CapturedSettment, "settment=false:走盘中重放算法(EOD平仓后收盘复用重放)");
Assert.IsFalse(stub.CapturedNewCalcLast);
Assert.IsNull(stub.CapturedCloseList, "该场景不传 closeList");
}
#endregion
}
}