186 lines
12 KiB
C#
186 lines
12 KiB
C#
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);
|
||
}
|
||
}
|