Files
zszq-trs/Framework/YLErp.Core/Interest/SwapInterest.cs
T
hjhan d78d5a46c4 feat(interest): 新增 TRS 利息腿纯函数库 SwapInterest
独立于 SwapDealService / SwapEodPositionService 的纯函数计息层:无副作用、
同 input 同 output,正交轴(算头算尾 / 单利复利 / unwind / 待实现收益)各自独立。
术语采用金融标准 Unwind / Realized / Unrealized。纯新增零接入核心计息逻辑,
待 Excel 对账确认"绿而不错"后 pilot 替换。
2026-08-09 16:07:27 +08:00

214 lines
10 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 System;
using System.Collections.Generic;
using System.Linq;
namespace YLErp.Derivatives.Interest;
// ─────────────────────────────────────────────────────────────────────────────
// 词汇表(本文件只允许出现下列用词,同一概念不得出现第二种叫法)
//
// 概念 唯一用词 与既有代码的对应
// ───────────────────────────────────────────────────────────────────
// 区间起点/终点 Start / End startDate / endDate
// 计息 Accrue CalcDailySimpleInterest / CalcDailyCompoundInterest
// 平仓 Unwind unwindPercent(既有字段 closePercent
// 已实现利息 Realized realizedInterestlegacy 字段 consumedInterest
// 待实现收益 Unrealized 预付金模式下的待实现收益余额
// 计息基数 principal principal / dynomicPrincipal
// 年化天数 annualDays tradeExtend.ExtendObj.AnnualDays
//
// 入参一律沿用既有代码的字段名,调用点两边读起来同名,不产生心智翻译成本。
// 出参改用自描述名(Accrued / AccruedToday),因为 "Td" 对新读者是黑话。
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>
/// 计息区间边界(算头 / 算尾)。
/// 用具名值取代两个相邻 bool,物理上杜绝 calcFirst / calcLast 传反这一类历史缺陷。
/// </summary>
public readonly struct AccrualBoundary
{
/// <summary>算头:含 startDate。</summary>
public bool IncludeStart { get; }
/// <summary>算尾:含 endDate。</summary>
public bool IncludeEnd { get; }
private AccrualBoundary(bool includeStart, bool includeEnd)
=> (IncludeStart, IncludeEnd) = (includeStart, includeEnd);
/// <summary>算头算尾 [start, end]。</summary>
public static readonly AccrualBoundary Both = new(true, true);
/// <summary>算头不算尾 [start, end)。</summary>
public static readonly AccrualBoundary StartOnly = new(true, false);
/// <summary>不算头算尾 (start, end]。</summary>
public static readonly AccrualBoundary EndOnly = new(false, true);
/// <summary>不算头不算尾 (start, end)。</summary>
public static readonly AccrualBoundary None = new(false, false);
/// <summary>由既有 calcFirst / calcLast 布尔对构造,供旧调用方渐进迁移。</summary>
public static AccrualBoundary Of(bool includeStart, bool includeEnd) => new(includeStart, includeEnd);
public override string ToString()
=> $"{(IncludeStart ? "算头" : "不算头")}{(IncludeEnd ? "算尾" : "不算尾")}";
}
/// <summary>
/// 计息结果。Accrued → 记账字段 InterestAmount / InterestProfitSumAccruedToday → TdInterestAmount。
/// </summary>
public readonly struct InterestResult
{
/// <summary>区间累计应计利息。</summary>
public decimal Accrued { get; }
/// <summary>末日(当日)应计利息。</summary>
public decimal AccruedToday { get; }
public InterestResult(decimal accrued, decimal accruedToday)
=> (Accrued, AccruedToday) = (accrued, accruedToday);
public static readonly InterestResult Zero = new(0m, 0m);
public override string ToString() => $"Accrued={Accrued}, AccruedToday={AccruedToday}";
}
/// <summary>
/// 收益互换(TRS)利息腿计算——纯函数。
///
/// 设计约束:
/// 1. 无副作用——不读写 flowEvent、不取利率、不连库、不碰任何共享可变状态;
/// 2. 同 input → 同 output,结果仅通过返回值流出;
/// 3. 正交轴(算头算尾 / 单利复利 / 平仓 / 待实现收益)各自独立,互不耦合;
/// 4. 调用方负责「取利率 + 构造日期区间 + 落库」,本类只算账。
/// 由此,corp action 调整价格 / 数量时只需把新的 principal 与 rate 喂入,计息逻辑一行不动。
///
/// 领域口径:本系统利息腿是单边融资腿,任一时点只有一个生效利率(见 SwapDealService 的
/// floateRate 单一入参),<b>不存在</b> IRS 那种 fixedRate floatingRate 轧差;
/// 权益腿盈亏与平仓费用属三腿汇总层,不在本类职责内。
///
/// 为何不复用 Qdp 的 IDayCount
/// a. 语义——Qdp 的 DaysInPeriod = end start 是写死的半开区间,只能表达四种算头算尾中的一种;
/// b. 精度——Qdp 返回 double 年化系数,本系统 decimal 且日息先 Round 再乘天数,
/// Round(P*r/365, 11) * n ≠ P*r*(n/365),与 Excel 对账口径不同;
/// c. 依赖方向——Qdp 用自有 Date 类型,引入会让 YLErp.Core 反向依赖定价库。
/// </summary>
public static class SwapInterest
{
/// <summary>系统统一价格精度位数。</summary>
public const int Precision = 11;
/// <summary>年化天数常量(合约字段存的是 int,故不用 enum)。</summary>
public const int Act365 = 365;
public const int Act360 = 360;
/// <summary>应计天数。边界规则由日期区间表达,计息函数内不再出现 flag 分支。</summary>
public static int AccrualDays(DateTime startDate, DateTime endDate, AccrualBoundary boundary)
{
var s = boundary.IncludeStart ? startDate : startDate.AddDays(1);
var e = boundary.IncludeEnd ? endDate : endDate.AddDays(-1);
var days = (int)(e - s).TotalDays + 1; // 含两端
return days < 0 ? 0 : days;
}
/// <summary>单利:计息基数固定,每日利息相同,无逐日循环。</summary>
public static InterestResult AccrueSimple(
decimal principal,
decimal rate,
DateTime startDate,
DateTime endDate,
AccrualBoundary boundary,
int annualDays,
int precision = Precision)
{
var days = AccrualDays(startDate, endDate, boundary);
var daily = Round(principal * rate / annualDays, precision);
return new InterestResult(Round(daily * days, precision), daily);
}
/// <summary>
/// 复利:按重置日切段,段间把累计利息并入计息基数。
/// 段内复用 AccrueSimple(仍无逐日循环);重置日是唯一并本金的地方。
/// </summary>
public static InterestResult AccrueCompound(
decimal principal,
decimal rate,
DateTime startDate,
DateTime endDate,
AccrualBoundary boundary,
IReadOnlyList<DateTime> resetDates,
int annualDays,
int precision = Precision)
{
var basis = principal;
decimal accrued = 0m, accruedToday = 0m;
var segEnds = (resetDates ?? Array.Empty<DateTime>())
.Where(d => d > startDate && d < endDate)
.OrderBy(d => d)
.Append(endDate)
.ToArray();
var segStart = startDate;
var segIncludeStart = boundary.IncludeStart;
foreach (var segEnd in segEnds)
{
var segBoundary = AccrualBoundary.Of(segIncludeStart, segEnd == endDate && boundary.IncludeEnd);
var seg = AccrueSimple(basis, rate, segStart, segEnd, segBoundary, annualDays, precision);
accrued += seg.Accrued;
accruedToday = seg.AccruedToday;
basis += seg.Accrued; // 仅在重置日并本金
segStart = segEnd;
segIncludeStart = false; // 后续段不算头
}
return new InterestResult(accrued, accruedToday);
}
/// <summary>
/// 平仓(Unwind)缩放——全仓唯一缩放点,物理上杜绝 unwindPercent 被重复相乘。
/// 全平即 unwindPercent = 1,不另设方法。
///
/// 已实现 / 未实现边界:传入的 <paramref name="accrued"/> 是平仓前仍「未实现(unrealized)」的
/// 累计应计利息;本方法按比例缩放后返回「平仓后剩余未实现」部分,并扣除历史累计「已实现(realized)」
/// 的 <paramref name="realizedInterest"/>。被平仓比例 unwindPercent 对应的那一份 accrued
/// 即在此刻「实现(realized)」,由调用方记入 realizedInterest。
/// </summary>
/// <param name="accrued">平仓前累计应计利息(未实现)。</param>
/// <param name="unwindPercent">
/// 平仓比例(0~1,实为 ratio 非百分数)。
/// 对应既有字段 closePercent;分母口径必须与传入 <paramref name="accrued"/> 所依据的持仓数量一致——
/// 是「本次计算依据的持仓」而非「初始建仓」,历史缺陷正来自这个歧义。
/// </param>
/// <param name="realizedInterest">已实现利息累计(legacy 字段 consumedInterest):历史各次 unwind 已确认、应从剩余未实现中扣除的部分。</param>
public static InterestResult ApplyUnwind(
InterestResult accrued,
decimal unwindPercent,
decimal realizedInterest = 0m,
int precision = Precision)
{
var remaining = 1m - unwindPercent;
return new InterestResult(
Round(accrued.Accrued * remaining - realizedInterest, precision),
Round(accrued.AccruedToday * remaining, precision));
}
/// <summary>待实现收益余额滚动(预付金 / 授信模式)。</summary>
/// <param name="openingUnrealized">上期待实现收益余额。</param>
/// <param name="todayIncome">本期新增。</param>
/// <param name="unwindDeduction">本期 unwind 应扣减(即本期实现的份额)。</param>
public static decimal AccrueUnrealized(
decimal openingUnrealized,
decimal todayIncome,
decimal unwindDeduction,
int precision = Precision)
=> Round(openingUnrealized + todayIncome - unwindDeduction, precision);
private static decimal Round(decimal value, int precision)
=> Math.Round(value, precision, MidpointRounding.AwayFromZero);
}