Files
zszq-trs/Framework/YLErp.Core/Interest/SwapInterest.cs
T
hjhan dea9c4cba5 docs(margin): 修复保证金计息重构后的过时文档与注释
按最新代码(CalcMarginInterest 已接生产、orginPv hack 已删、MarginAccount 未接线、保证金精度=12)修正:
- 删除 orginPv 维度重映射的孤儿 summary(方法已删)
- SwapDealService 注释:去掉 orginPv(InitSwapDealInterest)/保证金腿 引用
- SwapInterest/AccrualContext/InterestRate:去掉 Precision=11 是"保证金腿"、"保证金场景"等错误归因(保证金实际跑精度12)
- MarginAccount/MarginBalance:标注"尚未接线",生产入口指向 CalcMarginInterest,去掉"余额×利率×天数"过度简化
- 测试注释:去掉"无 orginPv/差分"(盘中保留差分)、"提交2 待切换"(已完成)

仅文档/注释,零代码行为变化。
2026-08-13 10:58:47 +08:00

292 lines
15 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;
using YLErp.Core.Interest;
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)利息腿计算——纯函数。
///
/// <para><b>层级关系</b>:计息数学(单利/复利/连续复利)是通用金融原语,已抽到
/// <see cref="InterestRate"/><c>YLErp.Core.Interest</c>,与互换无关,谁都能用)。
/// 本类只负责 TRS 特有的<b>会计态</b>:每日先舍入再乘天数的对账口径、平仓缩放、
/// 跨日滚动本金、预付金/授信模式——这些不是"利率数学",不应塞进通用原语。</para>
///
/// <para>设计约束:
/// 1. 无副作用——不读写 flowEvent、不取利率、不连库、不碰任何共享可变状态;
/// 2. 同 input → 同 output,结果仅通过返回值流出;
/// 3. 正交轴(算头算尾 / 单利复利 / 平仓 / 待实现收益)各自独立,互不耦合;
/// 4. 调用方负责「取利率 + 构造日期区间 + 落库」,本类只算账。
/// 由此,corp action 调整价格 / 数量时只需把新的 principal 与 rate 喂入,计息逻辑一行不动。</para>
///
/// <para>领域口径:本系统利息腿是单边融资腿,任一时点只有一个生效利率(见 SwapDealService 的
/// floateRate 单一入参),<b>不存在</b> IRS 那种 fixedRate floatingRate 轧差;
/// 权益腿盈亏与平仓费用属三腿汇总层,不在本类职责内。</para>
///
/// <para>TRS 的"复利"是<b>离散重置日复利</b>:按重置日切段,每段用 <see cref="InterestRate.Simple"/>
/// 计息、段末把利息滚入本金——本质就是单利按段叠加,decimal 精度无损,无需 Pow/Exp
/// (见 <see cref="AccrueCompoundInArrears"/>)。所以本类不另立复利方法,计息只有一种,区别在于"是否滚动本金"。</para>
///
/// 为何不复用 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>默认舍入精度位数(历史值;生产融资腿与保证金腿均用 FundingLegPrecision=12)。</summary>
public const int Precision = 11;
/// <summary>资金腿与保证金腿的生产计息精度(落库/对账均以 12 位为准)。
/// 提升至公共常量,消除 SwapDealService 与 SimpleInterestAccrual 的重复定义。</summary>
public const int FundingLegPrecision = 12;
/// <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>把 TRS 年化利率收敛为通用利率原语。
/// TRS 计息按段均为单利——离散重置日复利靠"段末把利息滚入本金"实现,不引入 Compounded 闭式。</summary>
public static InterestRate ToInterestRate(decimal annualRate)
=> new(annualRate, Compounding.Simple);
/// <summary>单利:计息基数固定,每日利息相同,无逐日循环。</summary>
public static InterestResult AccrueSimple(
AccrualContext ctx,
decimal principal,
decimal rate,
DateTime startDate,
DateTime endDate,
AccrualBoundary boundary)
{
var days = AccrualDays(startDate, endDate, boundary);
var daily = Round(principal * rate / ctx.AnnualDays, ctx.Precision);
return new InterestResult(Round(daily * days, ctx.Precision), daily);
}
/// <summary>
/// 离散重置日<b>复利(compounded-in-arrears</b>:按重置日切段,段间把累计利息并入计息基数(滚动本金)。
/// 每段计息即 <see cref="ToInterestRate"/> 得到的 <see cref="InterestRate.Simple"/>(无逐日循环);
/// 重置日是唯一并本金的地方。复利与单利只有"是否滚动本金"这一个区别。
///
/// <para>此模型即 OIS / SOFR / FR007 的 <b>compounded-in-arrears</b>:每个子区间取一次定盘 rᵢ、增长因子
/// 1 + rᵢ·yfᵢ,段末把 accrued 折进下一期本金——比闭式 <see cref="InterestRate.Compounding.Compounded"/>
/// 更贴合 FR007 约定且 decimal 无损。<b>注意:它<b>不是</b> InterestRate 的 Compounded 闭式分支(TRS 下该分支为死路径)。</para>
///
/// <para>每段可有<b>独立利率</b>FR007 浮动逐段不同),由适配器按段取定盘后封装为
/// <paramref name="resetSchedule"/> 传入——取价永远在编排层,原语只吃一个数(与 QuantLib/Strata 同范)。
/// <paramref name="resetSchedule"/> 必须含一条 <c>ResetDate ≤ startDate</c> 的起始利率。</para>
///
/// <para>trace:经 <see cref="AccrualContext.Trace"/> 发射 Start / ResetBefore·ResetAfter(利率切换时) /
/// Rollover(段末并本金) / End,完整记录"重置日前后、利率切换、本金增加前后"。纯函数保持无日志依赖。</para>
/// </summary>
/// <param name="resetSchedule">重置日 → 该段生效利率(段起点 = 重置日)。</param>
public static InterestResult AccrueCompoundInArrears(
AccrualContext ctx,
decimal principal,
IReadOnlyList<(DateTime ResetDate, decimal Rate)> resetSchedule,
DateTime startDate,
DateTime endDate,
AccrualBoundary boundary)
{
var trace = ctx.Trace;
trace?.MarkStart(startDate, endDate, boundary, ctx.AnnualDays, annualized: false);
var basis = principal;
decimal accrued = 0m, accruedToday = 0m;
var segEnds = (resetSchedule ?? Array.Empty<(DateTime, decimal)>())
.Select(s => s.ResetDate)
.Where(d => d > startDate && d < endDate)
.OrderBy(d => d)
.Append(endDate)
.ToArray();
// 段起点生效利率:取"不晚于该段起点"的最近一次重置利率。
decimal RateAt(DateTime segStart)
=> (resetSchedule ?? Array.Empty<(DateTime, decimal)>())
.Where(s => s.ResetDate <= segStart)
.OrderByDescending(s => s.ResetDate)
.Select(s => s.Rate)
.FirstOrDefault();
var segStart = startDate;
var segIncludeStart = boundary.IncludeStart;
var prevRate = RateAt(startDate);
foreach (var segEnd in segEnds)
{
var segRate = RateAt(segStart);
var rateSwitched = segStart != startDate && segRate != prevRate;
if (rateSwitched) trace?.ResetBefore(segStart, prevRate, basis);
var segBoundary = AccrualBoundary.Of(segIncludeStart, segEnd == endDate && boundary.IncludeEnd);
var seg = AccrueSimple(ctx, basis, segRate, segStart, segEnd, segBoundary);
accrued += seg.Accrued;
accruedToday = seg.AccruedToday;
var newBasis = basis + seg.Accrued; // 仅在重置日并本金
// 重置日本身不动本金:RESET↑ 的本金应是"重置边界基数"(basis),与 RESET↓ 一致;
// 段末并本金后的 newBasis 由下方的 ROLLOVER 单独表达,避免重复/误导。
if (rateSwitched) trace?.ResetAfter(segStart, segRate, basis);
trace?.Rollover(segEnd, seg.Accrued, newBasis);
basis = newBasis;
prevRate = segRate;
segStart = segEnd;
segIncludeStart = false; // 后续段不算头
}
var result = new InterestResult(accrued, accruedToday);
trace?.MarkEnd(result.Accrued, result.AccruedToday);
return result;
}
/// <summary>
/// 固定利率复利便捷重载(每段同一 rate),向后兼容旧调用方。
/// 内部把 resetDates 展平为"每段同率"的 schedule 后委托主方法。
/// </summary>
public static InterestResult AccrueCompoundInArrears(
AccrualContext ctx,
decimal principal,
decimal rate,
DateTime startDate,
DateTime endDate,
AccrualBoundary boundary,
IReadOnlyList<DateTime>? resetDates = null)
{
var schedule = new List<(DateTime, decimal)> { (startDate, rate) };
if (resetDates != null)
foreach (var d in resetDates)
if (d > startDate && d < endDate)
schedule.Add((d, rate));
return AccrueCompoundInArrears(ctx, principal, schedule, startDate, endDate, boundary);
}
/// <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>
/// <param name="precision">舍入精度。⚠️ 默认 11(Precision),资金腿务必显式传 <see cref="FundingLegPrecision"/>=12。</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);
/// <summary>统一舍入:MidpointRounding.AwayFromZero。所有计息路径收口到此处,避免散落的 Math.Round 不一致。</summary>
public static decimal Round(decimal value, int precision)
=> Math.Round(value, precision, MidpointRounding.AwayFromZero);
}