feat(swap): EQD-6977 罚息计息接缝——GetIntradayUnwindInterests 按 IsPenaltyInterest 在 GetInterests 返回后追加同构罚息流

This commit is contained in:
hjhan
2026-08-20 13:23:10 +08:00
parent 10a12b7eef
commit 35a84cd60d
4 changed files with 155 additions and 2 deletions
@@ -67,6 +67,10 @@ public sealed class AccrualTrace
=> Add(AccrualTraceEvent.End, default,
$"END accrued={totalAccrued:F6} today={totalToday:F6}");
/// <summary>自由文本注解(如罚息接缝的诊断行),不绑定特定计息语义。</summary>
public void Note(string message)
=> Add(AccrualTraceEvent.Note, default, message);
private void Add(AccrualTraceEvent step, DateTime date, string line)
=> _entries.Add(new AccrualTraceEntry(step, date, line));
@@ -78,7 +82,7 @@ public sealed class AccrualTrace
/// <summary>追踪条目的语义类别(对应 QuantLib/Strata 的"事件"概念),便于程序化筛选(如"只看重置日")。</summary>
public enum AccrualTraceEvent
{
Start, DayAccrual, ResetBefore, ResetAfter, Rollover, Unwind, End
Start, DayAccrual, ResetBefore, ResetAfter, Rollover, Unwind, End, Note
}
/// <summary>单条追踪记录:类别 + 日期 + 已渲染文本。</summary>
@@ -0,0 +1,83 @@
using YLErp.Modules.SwapModule.Accrual;
using YLErp.Modules.SwapModule.FundingLegs;
namespace YLErp.Modules.SwapModule.Penalty;
/// <summary>
/// EQD-6977 罚息接缝(纯函数,不含 DB 依赖):把罚息利息流追加进盘中平仓利息列表。
///
/// 设计:上帝类(SwapDealService.GetIntradayUnwindInterests)仅需把三个外部依赖以委托注入——
/// getSpread(加点利差)/ getPreEodFloatRate(上一日终快照利率)/ tryGetFixing(定盘取价),
/// 本类自身零 DB 耦合、可 headless 单测。罚息事件经 CalcPenalty 产出,置 IsPenaltyInterest=1。
///
/// 仅作用融资腿(调用方已预过滤保证金腿 MarginModes);浮动端 P&amp;L 不进入。
/// 取不到冻结利率(浮动腿缺价且无 preEod)时跳过该腿罚息(不阻断正常平仓),并留 trace。
/// </summary>
public static class PenaltyInterestAppender
{
public static void Append(
trade td,
List<swap_position> fundingPositions,
List<swap_flow_event> interests,
DateTime unwindDate,
int annualDays,
bool unwindDaySettled,
bool maturityCalcLast,
decimal posiNotionalValue,
decimal closePosiNotionalValue,
decimal closePercent,
Func<swap_position, decimal> getSpread,
Func<swap_position, decimal?> getPreEodFloatRate,
Func<DateTime, string, decimal?> tryGetFixing,
AccrualTrace? trace = null)
{
if (td.ExerciseDate == null)
{
trace?.Note("PENALTY|跳过 交易无到期日(ExerciseDate=null)");
return;
}
var maturityDate = td.ExerciseDate.Value;
var resetAnchor = td.StartDate ?? td.TradeDate ?? unwindDate;
var eventType = (int)SwapEventTypeEnum.;
foreach (var position in fundingPositions)
{
// 复用 GetInterests 的本金口径(mode2 无条件覆盖 / mode9 全平兜底,见其根因位置注释)
var mode = (InterestModeEnum)position.InterestMode;
var r = FundingLegStrategyFactory.Get(mode)
.CalcNotional(position.InterestPrincipalFix, posiNotionalValue, closePercent);
decimal closePrincipal = r.ClosePrincipal;
if (mode == InterestModeEnum.
|| (mode == InterestModeEnum. && posiNotionalValue == 0m))
{
closePrincipal = closePosiNotionalValue;
}
FundingLegRate frozenRate;
try
{
Func<DateTime, decimal?> posTryGetFixing = d => tryGetFixing(d, position.FloatRateUnderlyingCode);
frozenRate = PenaltyLegRateResolver.ResolveFrozenRate(
position, getSpread(position), getPreEodFloatRate(position), unwindDate, posTryGetFixing);
}
catch (Exception ex)
{
trace?.Note($"PENALTY|跳过 p{position.id} 冻结利率解析失败:{ex.Message}");
continue;
}
var policy = AccrualPolicy.BuildEod(position, annualDays, position.InterestType == (int)InterestTypeEnum.);
var (capitalized, carryIn) = SwapPenaltyInterestCalculator.ComputeCarryBreakdown(
position, unwindDate, resetAnchor, frozenRate, policy, closePrincipal);
var penalty = SwapPenaltyInterestCalculator.CalcPenalty(
td, position, closePrincipal, unwindDate, maturityDate,
unwindDaySettled, maturityCalcLast, capitalized, carryIn,
frozenRate, policy, resetAnchor, eventType, unwindDate);
penalty.IsPenaltyInterest = 1;
interests.Add(penalty);
trace?.Note($"PENALTY|p{position.id} 完成 本金={closePrincipal:F2} 承接 已资本化={capitalized:F4} 段内={carryIn:F4} 罚息={penalty.InterestAmount:F2}");
}
}
}
@@ -171,4 +171,48 @@ public static class SwapPenaltyInterestCalculator
}
return segments;
}
/// <summary>
/// 复利承接量推导(冻结率重放,自洽的“冻结率持有至到期”语义,与金标准测试模型一致):
/// capitalizedInterest = 已并入最近重置日的累计利息 → 窗口首日起即入基数;
/// carryInInterest = 最近重置日之后已计至平仓日的利息 → 首个窗口重置日并入。
/// 二者之和 = 被平部分平仓日已结利息(正常平仓流 InterestAmount 在冻结率下的重放值)。
/// 重放采用与 AccrueCompound 相同的 BuildFrozenSegments + AccruePeriod,保证轨迹严格一致。
/// 单利(无重置)时窗口无 carrycapitalized=全段利息、carryIn=0。
/// </summary>
public static (decimal Capitalized, decimal CarryIn) ComputeCarryBreakdown(
swap_position position, DateTime unwindDate, DateTime resetAnchor,
FundingLegRate frozenRate, AccrualPolicy policy, decimal closePrincipal)
{
if (policy.ResetPeriodDays <= 1)
{
var full = AccrueFrom(resetAnchor, unwindDate, frozenRate, policy, closePrincipal, AccrualBoundary.Both);
return (full, 0m);
}
var lastReset = LastResetBefore(unwindDate, resetAnchor, policy.ResetPeriodDays);
var elapsed = AccrueFrom(resetAnchor, unwindDate, frozenRate, policy, closePrincipal, AccrualBoundary.Both);
var capitalized = AccrueFrom(resetAnchor, lastReset.AddDays(-1), frozenRate, policy, closePrincipal, AccrualBoundary.Both);
return (capitalized, elapsed - capitalized);
}
/// <summary>冻结率下 [start, end] 复利重放利息(notional 线性,故以 closePrincipal 直接重放即可)。</summary>
private static decimal AccrueFrom(DateTime start, DateTime end, FundingLegRate frozenRate, AccrualPolicy policy, decimal notional, AccrualBoundary boundary)
{
if (end <= start) return 0m;
var segs = BuildFrozenSegments(start, end, policy.ResetPeriodDays, start, frozenRate.AllInRate);
return CompoundInterestAccrual.AccruePeriod(
notional: notional, segmentRates: segs, startDate: start, endDate: end,
boundary: boundary, annualDays: policy.AnnualDays, isAnnualized: policy.IsAnnualized,
resetCarryInterest: 0m, realizedInterest: 0m, unwindFraction: 1m,
finalBasis: out _, carryInInterest: 0m).Accrued;
}
/// <summary>最近重置日(≤ date)。重置日判定与 SwapDealService.IsResetDay 同公式,避免跨类耦合。</summary>
private static DateTime LastResetBefore(DateTime date, DateTime anchor, int period)
{
var days = (date - anchor).Days;
if (days <= 0) return anchor;
var offset = days % period;
return anchor.AddDays(days - offset);
}
}
+23 -1
View File
@@ -10,6 +10,7 @@ using YLErp.Modules.EodModule;
using YLErp.Modules.SwapModule.Accrual;
using YLErp.Modules.SwapModule.FundingLegs;
using YLErp.Modules.SwapModule.Margin;
using YLErp.Modules.SwapModule.Penalty;
using YLErp.Modules.SwapModule.ReturnLegs;
using YLErp.Modules.TradeModule;
using YLErp.Modules.TradeModule.DealModule;
@@ -619,10 +620,31 @@ namespace YLErp.Modules.SwapModule
/// 语义契约见 InterestCalcRequest.IntradayUnwind 工厂注释;计息走 CalcUnwindInterest 全区间重放。
/// </summary>
public List<swap_flow_event> GetIntradayUnwindInterests(InterestCalcRequest req)
=> GetInterests(req.Td, req.TradeExtend, req.ValueDate, req.UnwindDate, req.EodPositions, req.Positions,
{
var interests = GetInterests(req.Td, req.TradeExtend, req.ValueDate, req.UnwindDate, req.EodPositions, req.Positions,
req.PosiNotionalValue, req.ClosePosiNotionalValue,
req.ClosePercent, req.EventType, req.TdClose,
req.OrginPv, req.Add, settment: false, req.NewCalcLast, req.CloseList);
// EQD-6977 罚息:在 GetInterests 返回后追加(避开其 closeList 去重块),仅手动平仓路径(isPenaltyInterest)触发。
if (req.IsPenaltyInterest)
{
var fundingPositions = req.Positions.Where(p => !MarginModes.Contains(p.InterestMode)).ToList();
var annualDays = req.TradeExtend == null ? 365 : req.TradeExtend.ExtendObj.AnnualDays;
var calcLast = req.TradeExtend?.ExtendObj.CalcLast ?? true;
var trace = new AccrualTrace();
PenaltyInterestAppender.Append(
req.Td, fundingPositions, interests, req.UnwindDate, annualDays,
unwindDaySettled: calcLast || req.NewCalcLast,
maturityCalcLast: calcLast,
req.PosiNotionalValue, req.ClosePosiNotionalValue, req.ClosePercent,
getSpread: p => GetFixedRate(p, req.UnwindDate),
getPreEodFloatRate: p => req.EodPositions.FirstOrDefault(x => x.PositionId == p.id)?.FloatRate,
tryGetFixing: (d, code) => IndexFixer.TryGetFixing(d, code, out decimal r) ? (decimal?)r : null,
trace: trace);
SwapCalcTrace.Write(trace); // 与既有 4 处 SwapCalcTrace.Write 同款常驻落盘
}
return interests;
}
public List<swap_flow_event> GetInterests(
trade td,