feat(swap): EQD-6977 罚息计息接缝——GetIntradayUnwindInterests 按 IsPenaltyInterest 在 GetInterests 返回后追加同构罚息流
This commit is contained in:
@@ -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&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,保证轨迹严格一致。
|
||||
/// 单利(无重置)时窗口无 carry:capitalized=全段利息、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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user