feat(accrual): 新增 AccrueSimplePeriod + 影子测试
第2步迁移: CalcDailySimpleInterest(盘中单利多日)。 新增纯函数 AccrueSimplePeriod: - 单利特征: 计息本金恒定(差分 = priorAccrualPrincipal + positionPrincipal - originalPv) - 按重置日分段, 每段用 AccrualDays 算天数×日利息(无逐日循环) - 续接 priorValueDate 之后的日期 修复: SwapInterest.Round 是 private, 新方法改用 Math.Round。 修复: IReadOnlyList 无 IndexOf, 改用 for 循环索引。 影子测试(2个,全过): - 固定利率无归档: 旧新一致(差分本金=0,利息=0,符合旧逻辑) - 有归档续接+部分平仓50%: 旧新一致 验证: 编译0错误, 全量517测试7失败(基线一致)。
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using Newtonsoft.Json;
|
||||
using YLErp;
|
||||
using YLErp.DBModels;
|
||||
using YLErp.DBModels.Enums;
|
||||
using YLErp.Modules.SwapModule;
|
||||
using YLErp.Modules.SwapModule.Accrual;
|
||||
using YLErp.Derivatives.Interest;
|
||||
|
||||
namespace UnitTestProject.Modules.SwapModule.Accrual
|
||||
{
|
||||
/// <summary>
|
||||
/// 影子测试:CalcDailySimpleInterest(旧逐日循环)vs FundingLegAccrual.AccrueSimplePeriod(新分段纯函数)。
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class SimplePeriodShadowTest
|
||||
{
|
||||
private const decimal Notional = 100_000_000m;
|
||||
private const decimal Spread = 0.0025m;
|
||||
private const int AnnualDays = 365;
|
||||
private static readonly DateTime StartDate = new(2026, 4, 21);
|
||||
private static readonly DateTime EndDate = new(2026, 5, 11); // 21天, 7天周期→重置日 4/28, 5/5
|
||||
|
||||
private static trade CreateTrade()
|
||||
{
|
||||
return new trade
|
||||
{
|
||||
id = 1, TradeNumber = "UT-SIMPLE-SHADOW", ClientId = 999998,
|
||||
TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate,
|
||||
ExerciseDate = StartDate.AddYears(1), TradeStatus = "确认成交", ValidState = "Valid",
|
||||
trade_extend = new trade_extend
|
||||
{
|
||||
TradeId = 1,
|
||||
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
|
||||
{
|
||||
AnnualDays = AnnualDays, InterestCalcMode = "10", SettlementRules = 0
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static swap_position CreatePosition()
|
||||
{
|
||||
return new swap_position
|
||||
{
|
||||
id = 1001, SwapTradeId = 1, PosiDirection = 0,
|
||||
InterestDirection = (int)SwapDirectionEnum.收取,
|
||||
InterestMode = (int)InterestModeEnum.标的期初全价,
|
||||
InterestRateDefault = Spread,
|
||||
InterestPrincipalFix = Notional,
|
||||
PosiStartDate = StartDate, PosiMatuirityDate = StartDate.AddYears(1),
|
||||
IsInitial = true, Invalid = false,
|
||||
InterestType = (int)InterestTypeEnum.单利,
|
||||
IsAnnualized = true, interest_rest_days = 7, interest_rule = 0,
|
||||
FloatRateUnderlyingCode = null,
|
||||
InterestSwapInterval = "[]"
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class StubSvc : SwapDealService
|
||||
{
|
||||
public StubSvc() : base(new OptUserInfo(0, nameof(SimplePeriodShadowTest), OptUserFrom.UnitTest)) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 固定利率(无FR007)算头不算尾,全平,无历史归档。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void 影子_固定利率_无归档_旧新一致()
|
||||
{
|
||||
var td = CreateTrade();
|
||||
var position = CreatePosition();
|
||||
var flowEvent = new swap_flow_event { InterestRate = Spread };
|
||||
var preEod = new eod_swap_position { id = 0, TdInterestPrincipal = 0, InterestProfitSum = 0 };
|
||||
|
||||
// 旧方法
|
||||
decimal oldI = 0, oldTd = 0;
|
||||
var svc = new StubSvc();
|
||||
svc.CalcDailySimpleInterest(preEod, EndDate, position, Notional, flowEvent,
|
||||
AnnualDays, false, 0m, 1m, Notional, true, false, ref oldI, ref oldTd);
|
||||
|
||||
// 新方法:固定利率全段相同
|
||||
// 旧代码差分: dynomicPrincipal = preEod.TdInterestPrincipal(=0) + posiPrincipal - orginPv = 0
|
||||
var segRates = new List<(DateTime, decimal)> { (StartDate, Spread) };
|
||||
var accrualPrincipal = 0m + Notional - Notional; // 差分 = 0
|
||||
var result = FundingLegAccrual.AccrueSimplePeriod(
|
||||
priorUnrealized: 0m,
|
||||
accrualPrincipal: 0m, // 差分=0(无归档时 preEod.TdInterestPrincipal=0)
|
||||
closeRatio: 1m,
|
||||
segmentRates: segRates,
|
||||
startDate: StartDate,
|
||||
endDate: EndDate,
|
||||
priorValueDate: DateTime.MinValue,
|
||||
boundary: AccrualBoundary.StartOnly,
|
||||
annualDays: AnnualDays,
|
||||
isAnnualized: true);
|
||||
|
||||
Console.WriteLine($"旧: I={oldI} Td={oldTd}");
|
||||
Console.WriteLine($"新: Accrued={result.Accrued} AccruedToday={result.AccruedToday}");
|
||||
Assert.AreEqual((double)oldI, (double)result.Accrued, 0.01, "InterestAmount 一致");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 有历史归档(preEod.id != 0),续接上一日终。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void 影子_有归档_续接_旧新一致()
|
||||
{
|
||||
var position = CreatePosition();
|
||||
var preEodDate = new DateTime(2026, 5, 4); // 上一日终 = 第14天
|
||||
var preEod = new eod_swap_position
|
||||
{
|
||||
id = 1, SwapTradeId = 1, PositionId = 1001,
|
||||
ValueDate = preEodDate,
|
||||
TdInterestPrincipal = Notional,
|
||||
InterestProfitSum = 200_000m,
|
||||
PosiNotionalValue = Notional, FloatRate = 0m
|
||||
};
|
||||
var flowEvent = new swap_flow_event { InterestRate = Spread };
|
||||
|
||||
// 旧方法
|
||||
decimal oldI = 0, oldTd = 0;
|
||||
var svc = new StubSvc();
|
||||
svc.CalcDailySimpleInterest(preEod, EndDate, position, Notional, flowEvent,
|
||||
AnnualDays, false, 0m, 0.5m, Notional, true, false, ref oldI, ref oldTd);
|
||||
|
||||
// 新方法
|
||||
// 差分本金 = preEod.TdInterestPrincipal + posiPrincipal - orginPv
|
||||
var accrualPrincipal = Notional + Notional - Notional;
|
||||
var segRates = new List<(DateTime, decimal)> { (StartDate, Spread) };
|
||||
var result = FundingLegAccrual.AccrueSimplePeriod(
|
||||
priorUnrealized: 200_000m * 0.5m, // InterestProfitSum × closePercent
|
||||
accrualPrincipal: accrualPrincipal,
|
||||
closeRatio: 0.5m,
|
||||
segmentRates: segRates,
|
||||
startDate: StartDate,
|
||||
endDate: EndDate,
|
||||
priorValueDate: preEodDate,
|
||||
boundary: AccrualBoundary.StartOnly,
|
||||
annualDays: AnnualDays,
|
||||
isAnnualized: true);
|
||||
|
||||
Console.WriteLine($"旧: I={oldI} Td={oldTd}");
|
||||
Console.WriteLine($"新: Accrued={result.Accrued} AccruedToday={result.AccruedToday}");
|
||||
Assert.AreEqual((double)oldI, (double)result.Accrued, 0.01, "InterestAmount 一致");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,4 +100,75 @@ public static class FundingLegAccrual
|
||||
trace?.MarkEnd(result.Accrued, result.AccruedToday);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 单利多日计息(纯函数,替换 CalcDailySimpleInterest 的"纯数学"部分)。
|
||||
///
|
||||
/// 单利特征:计息本金全程恒定(差分公式 = priorAccrualPrincipal + positionPrincipal - originalPv)。
|
||||
/// 按重置日分段,每段用对应利率算天数×日利息(无逐日循环,等价于 SwapInterest.AccrueSimple 分段累加)。
|
||||
///
|
||||
/// 利率变化点由调用方通过 segmentRates 传入(已取好 FR007),本方法不取价。
|
||||
/// </summary>
|
||||
/// <param name="priorUnrealized">上一日终累计待实现利息(preEod.InterestProfitSum × closeRatio)。</param>
|
||||
/// <param name="accrualPrincipal">计息本金(差分,全程恒定)。</param>
|
||||
/// <param name="closeRatio">平仓比例。</param>
|
||||
/// <param name="segmentRates">分段利率表:(段起日, all-in利率),按日期升序。</param>
|
||||
/// <param name="startDate">计息开始日(PosiStartDate)。</param>
|
||||
/// <param name="endDate">计息结束日(平仓日)。</param>
|
||||
/// <param name="priorValueDate">上一日终归档日(只算此日之后的利息)。</param>
|
||||
/// <param name="boundary">算头算尾。</param>
|
||||
/// <param name="annualDays">年化天数。</param>
|
||||
/// <param name="isAnnualized">是否年化。</param>
|
||||
public static InterestResult AccrueSimplePeriod(
|
||||
decimal priorUnrealized,
|
||||
decimal accrualPrincipal,
|
||||
decimal closeRatio,
|
||||
IReadOnlyList<(DateTime StartDate, decimal Rate)> segmentRates,
|
||||
DateTime startDate,
|
||||
DateTime endDate,
|
||||
DateTime priorValueDate,
|
||||
AccrualBoundary boundary,
|
||||
int annualDays,
|
||||
bool isAnnualized)
|
||||
{
|
||||
var displayPrincipal = accrualPrincipal * closeRatio;
|
||||
decimal interest = priorUnrealized;
|
||||
decimal tdInterest = priorUnrealized;
|
||||
var precision = SwapInterest.FundingLegPrecision;
|
||||
|
||||
// 按段累加:每段内利率恒定,用 AccrualDays 算天数 × 日利息
|
||||
var segStart = startDate;
|
||||
var segIncludeStart = boundary.IncludeStart;
|
||||
|
||||
for (int si = 0; si < segmentRates.Count; si++)
|
||||
{
|
||||
var (segRateStart, segRate) = segmentRates[si];
|
||||
var segEnd = si < segmentRates.Count - 1
|
||||
? segmentRates[si + 1].StartDate
|
||||
: endDate;
|
||||
|
||||
// 跳过 priorValueDate 之前的日期(续接上一日终)
|
||||
var effectiveStart = segStart > priorValueDate ? segStart : priorValueDate.AddDays(1);
|
||||
if (effectiveStart > segEnd) { segStart = segEnd; continue; }
|
||||
|
||||
// 算头算尾:首段用 boundary.IncludeStart,后续段不算头
|
||||
var segBoundary = AccrualBoundary.Of(segIncludeStart, segEnd == endDate && boundary.IncludeEnd);
|
||||
var days = SwapInterest.AccrualDays(effectiveStart, segEnd, segBoundary);
|
||||
if (days <= 0) { segStart = segEnd; segIncludeStart = false; continue; }
|
||||
|
||||
var dailyRate = isAnnualized ? segRate / annualDays : segRate;
|
||||
var daily = Math.Round(displayPrincipal * dailyRate, precision, MidpointRounding.AwayFromZero);
|
||||
var segInterest = Math.Round(daily * days, precision, MidpointRounding.AwayFromZero);
|
||||
|
||||
interest += segInterest;
|
||||
tdInterest += segInterest;
|
||||
|
||||
segStart = segEnd;
|
||||
segIncludeStart = false;
|
||||
}
|
||||
|
||||
return new InterestResult(
|
||||
Math.Round(interest, precision, MidpointRounding.AwayFromZero),
|
||||
Math.Round(tdInterest, precision, MidpointRounding.AwayFromZero));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user