Files
zszq-trs/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs
T

186 lines
12 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.Modules.SwapModule.Accrual;
using YLErp.Modules.SwapModule.FundingLegs;
using YLErp.Modules.SwapModule.ReturnLegs;
namespace YLErp.Modules.SwapModule.Penalty;
/// <summary>
/// EQD-6977 罚息接缝(纯函数,无 DB 依赖):把罚息金额**并入既有平仓利息流的 InterestFee(其他费用含罚息)**。
/// 不产生独立罚息事件——前端其他费用列/盈亏公式(含 InterestFee)与日终 TdCloseInterestFee 链路天然承接。
///
/// 设计:上帝类(SwapDealService.GetIntradayUnwindInterests)仅注入三个外部依赖委托——
/// getSpread(加点利差)/ getPreEod(上一日终快照行)/ tryGetFixing(定盘取价),本类零 DB 耦合、可 headless 单测。
///
/// 复利承接量(精确续接口径的关键)**必须取实际计息状态**,严禁冻结利率重放推导:
/// 已并复利本金 capitalized = max(0, preEod.TdInterestPrincipal×份额 closePrincipal) —— 实际滚动复利基数中已并入部分;
/// 段内已计利息 carryIn = 正常平仓流实结 InterestAmount 已并复利本金 —— 最近重置日后实际已计利息;
/// 无 preEod(首日平仓):已并复利本金=0、段内已计利息=实结金额。
/// 逐腿全程 trace 落盘(SwapCalcTrace),供计算过程分析与错误定位。
/// </summary>
public static class PenaltyInterestFeeMerger
{
/// <summary>
/// 对每条融资腿:解析冻结利率 → 以实际计息状态推导承接量 → 计算罚息 → 并入该腿正常平仓利息事件的 InterestFee。
/// 取不到冻结利率(浮动腿缺价且无 preEod)时跳过该腿(不阻断正常平仓),留 trace。
/// </summary>
public static void Merge(
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, eod_swap_position?> getPreEod,
Func<DateTime, string, decimal?> tryGetFixing,
AccrualTrace? trace = null)
{
if (td.ExerciseDate == null)
{
trace?.Note("PENALTY|跳过 交易无到期日(ExerciseDate=null)");
return;
}
var maturityDate = td.ExerciseDate.Value;
foreach (var position in fundingPositions)
{
// 正常平仓利息流(GetInterests 刚产出)——段内已计利息的事实源与罚息并入目标
var normalEvent = interests.FirstOrDefault(x => x.PositionId == position.id);
if (normalEvent == null)
{
trace?.Note($"PENALTY|融资腿{position.id} 跳过 无正常平仓利息流(意外:融资腿应有对应事件)");
continue;
}
// 复用 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;
}
var share = r.PosiPrincipal > 0m ? Math.Min(1m, closePrincipal / r.PosiPrincipal) : 1m;
var isCompound = position.InterestType == (int)InterestTypeEnum.复利;
// 冻结利率:前一晚收盘在役利率优先(preEod.FloatRate),无快照再按取价日=unwindDate-1 所在区间取定盘
FundingLegRate frozenRate;
string rateSource;
var preEod = getPreEod(position);
try
{
frozenRate = PenaltyLegRateResolver.ResolveFrozenRate(
position, getSpread(position), preEod?.FloatRate, unwindDate,
d => tryGetFixing(d, position.FloatRateUnderlyingCode));
// 来源标签须反映实际路径:固定腿不取价(利差即冻结利率);浮动腿才有快照/取价之分
if (string.IsNullOrEmpty(position.FloatRateUnderlyingCode))
rateSource = "固定腿利差(不取价)";
else if (preEod != null)
rateSource = $"preEod.FloatRate@{preEod.ValueDate:yyyy-MM-dd}";
else
rateSource = "定盘取价(unwindDate-1区间)";
}
catch (Exception ex)
{
trace?.Note($"PENALTY|融资腿{position.id} 跳过 冻结利率解析失败:{ex.Message}");
continue;
}
// 复利承接:实际滚动基数中已并入部分(已并复利本金)+ 段内实际已计利息(段内已计利息)。单利无并本金语义恒 0。
var (capitalized, carryIn) = isCompound
? ResolveCompoundCarry(position, normalEvent, preEod, closePrincipal, share, unwindDate, trace)
: (0m, 0m);
var policy = AccrualPolicy.BuildEod(position, annualDays, isCompound);
// 锚点 = PosiStartDate:与正常计息重放(CalcDailyCompoundInterest 的分段网格)一致,延期腿勿用 td.StartDate
var penalty = SwapPenaltyInterestCalculator.CalcPenaltyAmount(
position, closePrincipal, unwindDate, maturityDate,
unwindDaySettled, maturityCalcLast,
capitalized, carryIn,
frozenRate, policy, position.PosiStartDate, trace);
penalty = InterestMath.Round(penalty, InterestMath.FundingLegPrecision);
var feeBefore = normalEvent.InterestFee;
normalEvent.InterestFee += penalty;
normalEvent.InterestClosePnL += penalty * DirectionRatio.ReceivePay(position.InterestDirection);
trace?.Note(
$"PENALTY|融资腿{position.id} 完成 mode={mode} {(isCompound ? "复利" : "单利")} " +
$"窗口=[{unwindDate:yyyy-MM-dd}→{maturityDate:yyyy-MM-dd}] 平仓日已结={unwindDaySettled} 到期算尾={maturityCalcLast} | " +
$"本金 close={closePrincipal:F2} posi={r.PosiPrincipal:F2} share={share:P4} | " +
$"冻结利率={frozenRate.AllInRate:P6} 来源={rateSource} | " +
$"承接[已并复利本金]={capitalized:F4} [段内已计利息]={carryIn:F4} 实结={normalEvent.InterestAmount:F4} | " +
$"罚息={penalty:F2} → InterestFee {feeBefore:F2}→{normalEvent.InterestFee:F2} PnL含罚息={normalEvent.InterestClosePnL:F2}");
}
}
/// <summary>
/// 复利承接量:已并复利本金(实际滚动基数中已并入部分)+ 段内已计利息(最近重置日后实际已计,= 实结 − 已并复利本金)。
///
/// 已并复利本金的取值依赖平仓日是否为重置日、有无日终快照(数据契约):
/// 段中平仓 + 有快照:TdInterestPrincipal 即当前段滚动基数(=本金+已并复利本金),直接作差;
/// 段中平仓 + 无快照:兜底取 normalEvent.InterestPrincipal——复利重放(CalcDailyCompoundInterest)
/// 会把它写为末次并本金后的基数(=被平份额本金+已并复利本金),同样是实际值而非推导值;
/// 重置日当天平仓:快照基数仍是【上一段】的(今日并入尚未发生),须改取
/// preEod.InterestIncomeSum(昨日全部待实现利息 = 今日并入新段基数的那部分)。
/// </summary>
private static (decimal Capitalized, decimal CarryIn) ResolveCompoundCarry(
swap_position position, swap_flow_event normalEvent, eod_swap_position? preEod,
decimal closePrincipal, decimal share, DateTime unwindDate, AccrualTrace? trace)
{
var periodDays = position.interest_rest_days ?? 1;
var unwindOnResetDay = SwapDealService.IsResetDay(unwindDate, position.PosiStartDate, periodDays);
var capitalized = 0m;
if (unwindOnResetDay)
{
capitalized = (preEod?.InterestIncomeSum ?? 0m) * share;
if (preEod != null)
trace?.Note(
$"PENALTY|融资腿{position.id} 承接量推导(已并复利本金) 重置日平仓+有快照:快照{preEod.ValueDate:yyyy-MM-dd} " +
$"昨日待实现利息InterestIncomeSum={preEod.InterestIncomeSum:F4} ×share={share:P4} → 已并复利本金={capitalized:F4}");
if (preEod == null && (unwindDate - position.PosiStartDate).Days >= periodDays)
trace?.Note($"PENALTY|融资腿{position.id} 注意 无preEod且平仓日=重置日:已并复利本金退化0(此前重置并入额缺失,请核对日终归档完整性)");
}
else if (preEod != null)
{
// 段中平仓+有快照(复利承接主路径):已并复利本金 = 快照滚动基数×份额 − 平仓本金。全程留推导——
// 结果异常时凭此行即可区分"快照基数错 / share错 / 平仓本金错"三因,不必反推。
var rawCarry = preEod.TdInterestPrincipal * share - closePrincipal;
capitalized = Math.Max(0m, rawCarry);
trace?.Note(
$"PENALTY|融资腿{position.id} 承接量推导(已并复利本金) 段中平仓+有快照:快照{preEod.ValueDate:yyyy-MM-dd} " +
$"滚动基数TdInterestPrincipal={preEod.TdInterestPrincipal:F4} ×share={share:P4} −平仓本金{closePrincipal:F4} = {rawCarry:F4} → 已并复利本金={capitalized:F4}" +
(rawCarry < 0m ? "(原始差为负已钳0:快照滚动基数×份额小于平仓本金,疑部分平仓比例与快照归档口径不一致,请核对eod_swap_position.TdInterestPrincipal" : ""));
}
else
{
capitalized = Math.Max(0m, normalEvent.InterestPrincipal - closePrincipal);
trace?.Note(
$"PENALTY|融资腿{position.id} 承接量推导(已并复利本金) 段中平仓+无快照兜底:事件基数InterestPrincipal={normalEvent.InterestPrincipal:F4} −平仓本金{closePrincipal:F4} → 已并复利本金={capitalized:F4}");
// 兜底已并复利本金=0 但账龄已过重置周期:复利每周期并本,理应 >0——多为 interestWindowEmpty
// (当日已结息)早退未重放覆盖种子值、或日终归档缺失。留痕含两侧基数与账龄,供直接定位根因。
var ageDays = (unwindDate - position.PosiStartDate).Days;
if (capitalized == 0m && ageDays >= periodDays)
trace?.Note(
$"PENALTY|融资腿{position.id} 注意 无preEod兜底已并复利本金=0但账龄{ageDays}天≥重置周期{periodDays}天:" +
$"事件基数{normalEvent.InterestPrincipal:F2}=平仓本金{closePrincipal:F2}(疑似interestWindowEmpty种子未重放/日终归档缺失," +
$"请核对swap_flow_event.InterestPrincipal重放回写与eod_swap_position归档)");
}
// 已并复利本金不得超过实结金额(数据异常时钳制并留痕,避免负的段内已计利息进入计息)
if (capitalized > Math.Max(0m, normalEvent.InterestAmount))
{
trace?.Note($"PENALTY|融资腿{position.id} 注意 承接已并复利本金钳制:推导 {capitalized:F4} > 实结 {normalEvent.InterestAmount:F4}(快照/事件数据异常,请核对 preEod.TdInterestPrincipal/InterestIncomeSum");
capitalized = Math.Max(0m, normalEvent.InterestAmount);
}
return (capitalized, normalEvent.InterestAmount - capitalized);
}
}