Files
zszq-trs/YLErpDAL/Modules/SwapModule/Penalty/SwapPenaltyInterestCalculator.cs
T
hjhan f712dfc98f refactor(swap): EQD-6977 拆分 CalcPenalty 计息分支提升可读性
将 CalcPenalty 内复利/单利两个 if/else 大块抽为 AccrueCompound/AccrueSimple 私有方法(返回 (accrued, finalBasis)),主体只表达分派+组装事件。签名与行为零变化,Penalty 套件 11/11 通过。
2026-08-20 12:31:41 +08:00

175 lines
9.6 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 YLErp.DBModels.Enums;
using YLErp.Modules.SwapModule.Accrual;
using YLErp.Modules.SwapModule.ReturnLegs;
namespace YLErp.Modules.SwapModule.Penalty;
/// <summary>
/// EQD-6977 平仓罚息计算器(纯函数)。
///
/// 语义:提前终止时把利息端计息窗口从「平仓日」延长到「原始到期日」,剩余期限按冻结利率计息,
/// 使利息端盈亏等同于持有至到期。金标准恒等式:
///
/// 全期利息 = 平仓日已结利息(正常平仓流) + 罚息窗口利息(本方法)
///
/// 边界规格(需求 2.2 / 评审 8.2,经金标准恒等式测试钉死):
/// - IncludeStart = !unwindDaySettled:正常结算已计平仓日(算尾)→ 罚息自次日起;不算尾 → 含平仓日;
/// - IncludeEnd = maturityCalcLast:到期日沿用交易自身算尾约定(非本次平仓的 newCalcLast);
/// - 复利承接(平仓日落在重置段中间时与全期轨迹逐日对齐的两个量):
/// capitalizedInterest = 已并入最近重置日的累计利息 → 窗口首日起即加入计息基数
/// (全期轨迹中当前重置段的滚动基数 = 本金 + 该量,段内每一天都在其上计息);
/// carryInInterest = 最近重置日之后已计至平仓日的利息 → 首个窗口重置日并入并持续留在基数。
/// 二者之和 = 被平部分的平仓日已结利息(正常平仓流 InterestAmount)。
///
/// 产物:与正常利息流同构的 swap_flow_eventEventReason="罚息"),下游结算/报表无差别消费。
/// 仅融资腿;保证金腿(MarginModes)与浮动端 P&amp;L 不进入本模块。
/// IsPenaltyInterest 列标记待 DB 迁移(阶段1)落地后由接缝层写入。
/// </summary>
public static class SwapPenaltyInterestCalculator
{
/// <summary>罚息利息流的事件原因(兼作判别字段,存量事件恒为"交易")。</summary>
public const string PenaltyEventReason = "罚息";
/// <summary>
/// 计算罚息窗口 [unwindDate, maturityDate] 的利息流。
/// </summary>
/// <param name="td">交易(SwapTradeId/SwapTradeNo/ClientId 载体)</param>
/// <param name="position">被平的融资腿</param>
/// <param name="closePrincipal">被平部分计息本金(部分平仓仅算被平份额)</param>
/// <param name="capitalizedInterest">复利承接①:已并入最近重置日的累计利息(被平份额),窗口首日起即入基数;单利传 0</param>
/// <param name="carryInInterest">复利承接②:最近重置日后已计至平仓日的利息(被平份额),首个窗口重置日并入;单利传 0</param>
/// <param name="unwindDate">提前终止日(窗口起点)</param>
/// <param name="maturityDate">合约原始到期日(窗口终点,= td.ExerciseDate</param>
/// <param name="unwindDaySettled">正常平仓利息是否已计平仓日(effectiveCalcLast = calcLast || newCalcLast</param>
/// <param name="maturityCalcLast">交易到期日算尾约定(tradeExtend.CalcLast</param>
/// <param name="carryInInterest">复利承接:被平部分已计至平仓日利息(正常平仓流 InterestAmount);单利传 0</param>
/// <param name="frozenRate">冻结利率(PenaltyLegRateResolver.ResolveFrozenRate 产物)</param>
/// <param name="policy">计息政策(单复利/重置周期/年化天数;Convention 由本方法覆盖)</param>
/// <param name="resetAnchor">重置日锚点(对齐 GetFloatRate 的 td.StartDate 口径;锚点二义性注记见其声明)</param>
/// <param name="eventType">事件类型(平仓)</param>
/// <param name="valueDate">操作日期</param>
/// <param name="trace">计息轨迹(可选,SwapCalcTrace 落盘)</param>
public static swap_flow_event CalcPenalty(
trade td,
swap_position position,
decimal closePrincipal,
DateTime unwindDate,
DateTime maturityDate,
bool unwindDaySettled,
bool maturityCalcLast,
decimal capitalizedInterest,
decimal carryInInterest,
FundingLegRate frozenRate,
AccrualPolicy policy,
DateTime resetAnchor,
int eventType,
DateTime valueDate,
AccrualTrace? trace = null)
{
var boundary = AccrualBoundary.Of(includeStart: !unwindDaySettled, includeEnd: maturityCalcLast);
var allInRate = frozenRate.AllInRate;
// 按计息方式分派到对应的纯计息路径;二者均产出 (累计利息, 末段计息基数)。
// 计息数学细节下沉到具名方法,使本方法只表达“分派 + 组装事件”的编排意图,便于阅读与单测。
var (accrued, finalBasis) = policy.IsCompound
? AccrueCompound(closePrincipal, capitalizedInterest, carryInInterest, unwindDate, maturityDate, boundary, policy, resetAnchor, allInRate, trace)
: AccrueSimple(closePrincipal, unwindDate, maturityDate, boundary, policy, allInRate, trace);
var rounded = InterestMath.Round(accrued, InterestMath.FundingLegPrecision);
var interest = new swap_flow_event
{
SwapTradeId = td.id,
SwapTradeNo = td.TradeNumber,
EventType = eventType,
EventReason = PenaltyEventReason,
EventDate = valueDate,
UnwindDate = unwindDate,
PositionId = position.id,
InterestDirection = position.InterestDirection,
InterestRate = allInRate,
InterestPrincipal = finalBasis,
InterestSwapInterval = position.InterestSwapInterval,
InterestMode = position.InterestMode,
FloatRate = string.IsNullOrEmpty(position.FloatRateUnderlyingCode) ? position.FloatRate : allInRate,
DataState = (int)SwapFlowDateStateEnum.完成,
ClientId = td.ClientId,
InterestAmount = rounded,
TdInterestAmount = rounded
};
interest.InterestClosePnL = interest.InterestAmount * DirectionRatio.ReceivePay(position.InterestDirection);
return interest;
}
/// <summary>
/// 复利罚息计息:即使冻结利率为单值,也必须按重置日分段(并本金发生在分段边界),每段同一冻结利率。
/// notional = 本金 + 已并入最近重置日的利息(capitalizedInterest):全期轨迹中当前重置段的滚动基数,
/// 段内每一天都在其上计息——平仓日落在段中间时与全期逐日对齐的关键。carryInInterest 在首个窗口重置日并入。
/// 返回 (累计利息, 末段计息基数)。
/// </summary>
private static (decimal Accrued, decimal FinalBasis) AccrueCompound(
decimal closePrincipal, decimal capitalizedInterest, decimal carryInInterest,
DateTime unwindDate, DateTime maturityDate, AccrualBoundary boundary, AccrualPolicy policy,
DateTime resetAnchor, decimal allInRate, AccrualTrace? trace)
{
var segments = BuildFrozenSegments(unwindDate, maturityDate, policy.ResetPeriodDays, resetAnchor, allInRate);
var r = CompoundInterestAccrual.AccruePeriod(
notional: closePrincipal + capitalizedInterest,
segmentRates: segments,
startDate: unwindDate,
endDate: maturityDate,
boundary: boundary,
annualDays: policy.AnnualDays,
isAnnualized: policy.IsAnnualized,
resetCarryInterest: 0m,
realizedInterest: 0m,
unwindFraction: 1m,
finalBasis: out var finalBasis,
trace: trace,
carryInInterest: carryInInterest);
return (r.Accrued, finalBasis);
}
/// <summary>
/// 单利罚息计息:无并本金语义,冻结利率即单段全程。finalBasis 恒为本金(单利不滚基数)。
/// 返回 (累计利息, 末段计息基数)。
/// </summary>
private static (decimal Accrued, decimal FinalBasis) AccrueSimple(
decimal closePrincipal, DateTime unwindDate, DateTime maturityDate,
AccrualBoundary boundary, AccrualPolicy policy, decimal allInRate, AccrualTrace? trace)
{
var r = SimpleInterestAccrual.AccruePeriod(
priorAccrued: 0m,
notional: closePrincipal,
unwindFraction: 1m,
segmentRates: new List<(DateTime StartDate, decimal Rate)> { (unwindDate, allInRate) },
startDate: unwindDate,
endDate: maturityDate,
priorValueDate: unwindDate.AddDays(-1),
boundary: boundary,
annualDays: policy.AnnualDays,
isAnnualized: policy.IsAnnualized,
trace: trace);
return (r.Accrued, closePrincipal);
}
/// <summary>
/// 复利冻结分段:段边界 = 窗口内重置日((d - anchor) % period == 0,对齐 IsResetDay 公式),
/// 每段填同一冻结利率。首段必为 (unwindDate, rate)[start,end] 含端点的重置日也生成段
/// (末段起点==到期日时由 AccruePeriod 的边界决定是否计息)。
/// </summary>
private static List<(DateTime StartDate, decimal Rate)> BuildFrozenSegments(
DateTime start, DateTime end, int periodDays, DateTime anchor, decimal rate)
{
if (periodDays <= 1)
return new List<(DateTime, decimal)> { (start, rate) };
var segments = new List<(DateTime StartDate, decimal Rate)> { (start, rate) };
for (var d = start.AddDays(1); d <= end; d = d.AddDays(1))
{
if (SwapDealService.IsResetDay(d, anchor, periodDays))
segments.Add((d, rate));
}
return segments;
}
}