Merge branch 'glms/feature/1.4.2' into glms/feature/0812_zmr_divPower

# Conflicts:
#	YLErpDAL/Modules/EodModule/BondPaymentService.cs
#	YLErpDAL/Modules/SwapModule/SwapDealService.cs
This commit is contained in:
张名锐
2026-08-20 16:21:13 +08:00
158 changed files with 7931 additions and 3252 deletions
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
MSTest 并行化配置(opt-in:仅在 `dotnet test --settings .runsettings` 时生效)。
Workers=0 自动取 CPU 核数;Scope=TestClass 比 Method 安全(同类内静态单例不互踩)。
注意:本仓库测试共享静态状态(LogFactory / DataCacheManager / YLServiceLocator 单例,见 UnitTestProject/Program.cs M1)。
启用前务必先跑一次串行基线,确认无交叉污染后再作为默认。
-->
<RunSettings>
<MSTest>
<Parallelize>
<Workers>0</Workers>
<Scope>TestClass</Scope>
</Parallelize>
</MSTest>
</RunSettings>
@@ -65,6 +65,13 @@ namespace YLErp.DBModels
[DisplayName("实际付息(兑付)日")]
[Column("pay_date_act")]
public DateTime? payment_date { get; set; }
/// <summary>
/// 债权登记日(除息/归属截止日)——票息归属按此判定,而非支付日
/// </summary>
[DisplayName("债权登记日")]
[Column("reg_date")]
public DateTime? reg_date { get; set; }
/// <summary>
/// 每张兑付利息额
/// </summary>
@@ -0,0 +1,32 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace YLErp.DBModels
{
/// <summary>
/// 客户黑名单审批及操作日志。
/// </summary>
[Table("client_blacklog")]
public class ClientBlackLog
{
public long id { get; set; }
public int ClientBlackId { get; set; }
public string Changes { get; set; }
public string OptType { get; set; }
public string DataType { get; set; }
public int OptId { get; set; }
public string OptName { get; set; }
public DateTime OptDate { get; set; }
}
[NotMapped]
public class ClientBlackLogDto : ClientBlackLog
{
}
}
@@ -10,6 +10,13 @@ namespace YLErp.Model
[Table("client_black")]
public class client_black : DBModelWithOperator, IDataEntity, IDataTraceV2, IClonable<client_black>
{
public const string = "未提交";
public const string = "新增审批中";
public const string = "新增已拒绝";
public const string = "已加入";
public const string = "删除审批中";
public const string = "删除已拒绝";
/// <summary>
/// 客户名称
/// </summary>
@@ -25,6 +32,26 @@ namespace YLErp.Model
[DataChange]
public string Remarks { get; set; }
[DisplayName("提交审批时间")]
public DateTime? ApprovalOptDate { get; set; }
[DisplayName("提交审批人")]
public string ApprovalOptName { get; set; }
public int ApprovalProcess { get; set; }
[DisplayName("状态")]
public string State { get; set; } = "";
[DisplayName("创建人")]
public int? creator_id { get; set; }
[DisplayName("创建人")]
public string creator_name { get; set; }
[DisplayName("创建时间")]
public DateTime? creator_time { get; set; }
public client_black Clone()
{
return (client_black)MemberwiseClone();
@@ -205,7 +205,7 @@ namespace YLErp.DBModels
[DataChange]
public int InterestDirection { get; set; }
/// <summary>
/// 计息基本类型 1:固定值,2:合约名义本金规模,3:持仓名义本金,4:持仓市值,5:初始预付金,6:追加预付金
/// 计息基本类型 1:固定值,2:合约名义本金规模,5:初始预付金,6:追加预付金9:标的期初全价
/// </summary>
[DisplayName("计息基本类型")]
[DataChange]
@@ -149,6 +149,11 @@ namespace YLErp.DBModels
/// </summary>
public decimal ClosePercent { get; set; }
/// <summary>
/// 是否计罚息(EQD-6977):提前终止平仓时利息端按持有至到期计息。默认 false=否。
/// 由平仓页“是否罚息”下拉写入,经 GetUnwindInterests 透传至罚息接缝层。
/// </summary>
public bool IsPenaltyInterest { get; set; }
/// <summary>
/// 平仓名义本金
/// </summary>
public decimal CloseNotionalValue { get; set; }
@@ -280,7 +280,7 @@ namespace YLErp.DBModels
}
}
/// <summary>
/// 计息方式 1:固定值,2:合约名义本金规模,3:持仓名义本金,4:持仓市值
/// 计息方式 1:固定值,2:合约名义本金规模,5:初始预付金,6:追加预付金,9:标的期初全价
/// </summary>
[DisplayName("计息方式")]
[DataChange]
@@ -163,7 +163,7 @@ namespace YLErp.DBModels
/// </summary>
public string FloatRateUnderlyingCode { get; set; }
/// <summary>
/// 计息基本类型 1:固定值,2:合约名义本金规模,3:持仓名义本金,4:持仓市值,5:初始预付金,6:追加预付金
/// 计息基本类型 1:固定值,2:合约名义本金规模,5:初始预付金,6:追加预付金9:标的期初全价
/// </summary>
[DisplayName("计息基本类型")]
[DataChange]
@@ -89,6 +89,21 @@ namespace YLErp.DBModels
/// </summary>
public string InterestCalcMode { get; set; } = "11";
/// <summary>算头:InterestCalcMode 首位为 1null/缺省视为算头。收口原 GetInterests/InitInterestDate/EOD三处 StartsWith 解析。</summary>
[JsonIgnore]
public bool CalcFirst => InterestCalcMode?.StartsWith("1") ?? true;
/// <summary>算尾:InterestCalcMode 末位为 1null/缺省视为算尾(EQD-6968 取价依赖判定的核心开关之一)。</summary>
[JsonIgnore]
public bool CalcLast => InterestCalcMode?.EndsWith("1") ?? true;
/// <summary>
/// 是否罚息(EQD-6977):提前终止平仓时利息端按持有至到期计息的簿记默认值。
/// 平仓页默认带出此值、可修改,以平仓时选择为准(当次选择经 UnwindData.IsPenaltyInterest 走请求,不回写)。
/// 存量 JSON 无此键 → 反序列化默认 false("否"),零回填。
/// </summary>
public bool IsPenaltyInterest { get; set; }
/// <summary>
///多空组合浮动端 收取方向 1:收取,2:支付
/// </summary>
@@ -1,29 +0,0 @@
namespace YLErp.Core.Interest;
/// <summary>
/// 计息执行上下文:把"与具体金额/利率无关"的横向参数(年化天数、精度、trace 收集器)
/// 打包成一个<b>只读值对象</b>,避免每个计息方法都重复携带这些参数。
///
/// <para><b>为何 trace 是"成员"而非散落参数</b>:利息纯函数(AccrueSimple / AccrueCompoundInArrears
/// 的核心职责是算账,trace 只是可观测性的旁路。把 trace 作为上下文的成员传入,
/// 调用点只需传一个 ctx,签名更干净;同时 ctx 是只读值对象,不破坏纯函数
/// (无共享可变状态 → 线程安全、可重入、可测)。<b>切勿</b>把 trace 设成类的实例/静态字段,
/// 那会让并发的两笔交易共用同一 trace、并使函数带隐藏状态。</para>
///
/// <para>与 AccrualState(跨日滚动本金状态)/ AccrualPolicyEOD 会计政策)正交:
/// 本上下文只描述"如何算 + 往哪记",不持有任何交易进度。</para>
/// </summary>
public readonly struct AccrualContext
{
/// <summary>年化天数(365 / 360)。</summary>
public int AnnualDays { get; }
/// <summary>舍入精度位数。默认 11(保证金腿);资金腿调用方应显式传 FundingLegPrecision=12。</summary>
public int Precision { get; }
/// <summary>可选 trace 收集器;为 null 时不记录(纯计算场景直接传 null,与开关无关)。</summary>
public AccrualTrace? Trace { get; }
public AccrualContext(int annualDays, int precision = 11, AccrualTrace? trace = null)
=> (AnnualDays, Precision, Trace) = (annualDays, precision, trace);
}
@@ -1,71 +0,0 @@
using System;
namespace YLErp.Core.Interest;
/// <summary>
/// 利率 + 计息方式(单利 / 复利 / 连续复利)。
///
/// <para><b>通用金融原语,与互换、衍生品、任何具体业务均无耦合</b>——谁需要算利息都能用。
/// 利息计算不是互换特有的,所以它不住在 SwapModule,也不带任何 swap 词汇。</para>
///
/// <para>用法(年化时间 t,如 30天/365):</para>
/// <list type="bullet">
/// <item><description>计息因子 = <see cref="CompoundFactor(decimal)"/>;含息额 = 本金 × 因子;</description></item>
/// <item><description>利息 = 本金 × (因子 1) = <see cref="Interest(decimal, decimal)"/>。</description></item>
/// </list>
///
/// <para>与 QuantLib 模型一致:单利 / 复利 / 连续复利只是 <see cref="Compounding"/> 的一个分支,
/// 不是三套独立方法。TRS 的"重置日并本金"属于离散复利,用 <see cref="Compounding.Simple"/>
/// 按段计息、段末把利息滚入本金即可(见 SwapInterest.AccrueCompoundInArrears),无需 Pow/Expdecimal 精度无损。</para>
///
/// <para>互换特有的会计态(每日先舍入再乘天数、平仓缩放、跨日滚动本金)不属于本原语,
/// 请在各自的 accrual 层处理。</para>
/// </summary>
public enum Compounding
{
/// <summary>单利:因子 = 1 + r·t。</summary>
Simple,
/// <summary>复利(理想化闭式):因子 = (1 + r/f)^(f·t)f 为年复利频次。</summary>
Compounded,
/// <summary>连续复利:因子 = e^(r·t)。</summary>
Continuous
}
/// <summary>
/// 不可变利率值对象。构造即完整,无副作用。
/// </summary>
public readonly struct InterestRate
{
/// <summary>年化利率 r。</summary>
public decimal Rate { get; }
/// <summary>计息方式。</summary>
public Compounding Compounding { get; }
/// <summary>年复利频次(仅 <see cref="Compounding.Compounded"/> 使用,其余忽略,默认 1)。</summary>
public int Frequency { get; }
public InterestRate(decimal rate, Compounding compounding, int frequency = 1)
=> (Rate, Compounding, Frequency) = (rate, compounding, frequency);
/// <summary>
/// 计息因子(输入年化时间 t)。
/// <list type="bullet">
/// <item><description><see cref="Compounding.Simple"/>decimal 精确运算。</description></item>
/// <item><description><see cref="Compounding.Compounded"/> / <see cref="Compounding.Continuous"/>:闭式(double 计算后回 decimal),
/// 满足通用定价 / 保证金场景;若要 decimal 精度的离散重置日复利,请用 Simple 按段计息并滚动本金。</description></item>
/// </list>
/// </summary>
public decimal CompoundFactor(decimal t)
=> Compounding switch
{
Compounding.Simple => 1m + Rate * t,
Compounding.Compounded => (decimal)Math.Pow((double)(1m + Rate / Frequency), (double)(Frequency * t)),
Compounding.Continuous => (decimal)Math.Exp((double)(Rate * t)),
_ => throw new ArgumentOutOfRangeException(nameof(Compounding))
};
/// <summary>利息 = 本金 × (因子 1)。</summary>
public decimal Interest(decimal principal, decimal t)
=> principal * (CompoundFactor(t) - 1m);
}
@@ -1,291 +0,0 @@
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>系统统一价格精度位数(保证金腿)。</summary>
public const int Precision = 11;
/// <summary>资金腿计息精度(生产口径)。资金腿所有落库/对账均以 12 位为准,
/// 与保证金腿的 Precision=11 不同。提升至公共常量,消除 SwapDealService 与 FundingLegAccrual 的重复定义。</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);
}
@@ -0,0 +1,133 @@
using YLErp.Model;
namespace YLErp.Modules.ClientModule.Tests
{
[TestClass]
public class ClientBlackApprovalPolicyTests
{
[DataTestMethod]
[DataRow(client_black.未提交, false)]
[DataRow(client_black.新增审批中, false)]
[DataRow(client_black.新增已拒绝, false)]
[DataRow(client_black.已加入, true)]
[DataRow(client_black.删除审批中, true)]
[DataRow(client_black.删除已拒绝, true)]
public void IsEffective_OnlyAppliedOrPendingRemovalStatesAreEffective(string state, bool expected)
{
Assert.AreEqual(expected, ClientBlackApprovalPolicy.IsEffective(state));
}
[DataTestMethod]
[DataRow(client_black.未提交, true)]
[DataRow(client_black.新增已拒绝, true)]
[DataRow(client_black.新增审批中, false)]
[DataRow(client_black.已加入, false)]
[DataRow(client_black.删除审批中, false)]
[DataRow(client_black.删除已拒绝, false)]
public void CanSubmitAddition_OnlyDraftOrRejectedAdditionCanSubmit(string state, bool expected)
{
Assert.AreEqual(expected, ClientBlackApprovalPolicy.CanSubmitAddition(state));
}
[DataTestMethod]
[DataRow(client_black.已加入, true)]
[DataRow(client_black.删除已拒绝, true)]
[DataRow(client_black.未提交, false)]
[DataRow(client_black.新增审批中, false)]
[DataRow(client_black.新增已拒绝, false)]
[DataRow(client_black.删除审批中, false)]
public void CanRequestRemoval_OnlyEffectiveNonPendingRemovalStatesCanRequest(string state, bool expected)
{
Assert.AreEqual(expected, ClientBlackApprovalPolicy.CanRequestRemoval(state));
}
[DataTestMethod]
[DataRow(client_black.未提交, true)]
[DataRow(client_black.新增已拒绝, true)]
[DataRow(client_black.新增审批中, false)]
[DataRow(client_black.已加入, false)]
[DataRow(client_black.删除审批中, false)]
[DataRow(client_black.删除已拒绝, false)]
public void CanDeleteDraft_OnlyNeverEffectiveStatesCanDeleteDirectly(string state, bool expected)
{
Assert.AreEqual(expected, ClientBlackApprovalPolicy.CanDeleteDraft(state));
}
[DataTestMethod]
[DataRow(client_black.新增审批中, 1, true)]
[DataRow(client_black.删除审批中, 1, true)]
[DataRow(client_black.新增审批中, 2, false)]
[DataRow(client_black.删除审批中, 2, false)]
[DataRow(client_black.未提交, 0, false)]
public void CanWithdraw_OnlyFirstApprovalNodeCanWithdraw(string state, int approvalProcess, bool expected)
{
Assert.AreEqual(expected, ClientBlackApprovalPolicy.CanWithdraw(state, approvalProcess));
}
[DataTestMethod]
[DataRow(client_black.新增审批中, client_black.新增已拒绝)]
[DataRow(client_black.删除审批中, client_black.删除已拒绝)]
public void RejectedState_DistinguishesAdditionAndRemoval(string state, string expected)
{
Assert.AreEqual(expected, ClientBlackApprovalPolicy.GetRejectedState(state));
}
[DataTestMethod]
[DataRow(client_black.新增审批中, client_black.未提交, 0)]
[DataRow(client_black.删除审批中, client_black.已加入, -2)]
public void WithdrawState_RestoresStateBeforeSubmission(string state, string expectedState, int expectedProcess)
{
var result = ClientBlackApprovalPolicy.GetWithdrawResult(state);
Assert.AreEqual(expectedState, result.State);
Assert.AreEqual(expectedProcess, result.ApprovalProcess);
}
[DataTestMethod]
[DataRow(client_black.新增审批中, client_black.已加入, false)]
[DataRow(client_black.删除审批中, null, true)]
public void GetFinalResult_AdditionAppliesAndRemovalDeletes(string state, string expectedState, bool expectedDelete)
{
var result = ClientBlackApprovalPolicy.GetFinalResult(state);
Assert.AreEqual(expectedState, result.State);
Assert.AreEqual(expectedDelete, result.ShouldDelete);
}
[DataTestMethod]
[DataRow(false, client_black.已加入, -2, true)]
[DataRow(true, client_black.未提交, 0, false)]
public void GetAdditionResult_OnlyEffectiveWithoutApprovalProcess(bool hasApprovalProcess, string expectedState, int expectedProcess, bool expectedEffective)
{
var result = ClientBlackApprovalPolicy.GetAdditionResult(hasApprovalProcess);
Assert.AreEqual(expectedState, result.State);
Assert.AreEqual(expectedProcess, result.ApprovalProcess);
Assert.AreEqual(expectedEffective, result.IsEffective);
}
[DataTestMethod]
[DataRow(false, client_black.已加入, -2, true)]
[DataRow(true, client_black.删除审批中, 1, false)]
public void GetRemovalResult_OnlyDeletesImmediatelyWithoutApprovalProcess(bool hasApprovalProcess, string expectedState, int expectedProcess, bool expectedDelete)
{
var result = ClientBlackApprovalPolicy.GetRemovalResult(hasApprovalProcess);
Assert.AreEqual(expectedState, result.State);
Assert.AreEqual(expectedProcess, result.ApprovalProcess);
Assert.AreEqual(expectedDelete, result.ShouldDelete);
}
[DataTestMethod]
[DataRow(client_black.未提交, true)]
[DataRow(client_black.新增已拒绝, true)]
[DataRow(client_black.新增审批中, false)]
[DataRow(client_black.删除审批中, false)]
[DataRow(client_black.已加入, true)]
[DataRow(client_black.删除已拒绝, true)]
public void CanReplaceRemarks_ApprovalPendingRowsCannotBeOverwritten(string state, bool expected)
{
Assert.AreEqual(expected, ClientBlackApprovalPolicy.CanReplaceRemarks(state));
}
}
}
@@ -0,0 +1,137 @@
using YLErp.Modules.DataProviderModule;
namespace YLErp.Modules.DataProviderModule
{
/// <summary>
/// Fr007FixingCache 快照缓存行为契约(纯内存,不连库;Loader/时钟均为注入接缝):
/// ① 首次访问批量预载、命中 O(1);② miss 不进快照(负缓存防线——当日发布前 miss、发布后须能查到);
/// ③ 写侧版本失效:Invalidate 后立即重载取到新值(不等 TTL);④ TTL 过期自动重载(直改库兜底);
/// ⑤ TTL 窗口内无写入不重载(零查询稳态)。
/// </summary>
[TestClass]
public class Fr007FixingCacheTest
{
private static readonly DateTime D1 = new(2026, 7, 6);
private static readonly DateTime D2 = new(2026, 7, 13);
private int _loadCount;
private Dictionary<DateTime, double> _market;
[TestInitialize]
public void Init()
{
_loadCount = 0;
_market = new Dictionary<DateTime, double> { [D1] = 0.0142, [D2] = 0.01425 };
Fr007FixingCache.ResetForTest();
Fr007FixingCache.LoadSnapshot = () => { _loadCount++; return new Dictionary<DateTime, double>(_market); };
}
[TestCleanup]
public void Cleanup() => Fr007FixingCache.ResetForTest();
private static void AdvanceClock(long ticks) => Fr007FixingCache.NowTicks = () => ticks;
[TestMethod]
public void 访()
{
Fr007FixingCache.NowTicks = () => 1_000_000L;
Assert.IsTrue(Fr007FixingCache.TryGet(D1, out var p1), "预载后历史定盘应命中");
Assert.AreEqual(0.0142, p1, 1e-12);
Assert.AreEqual(1, _loadCount, "首次访问恰好装载一次");
Assert.IsTrue(Fr007FixingCache.TryGet(D2, out var p2), "同快照内多次命中");
Assert.AreEqual(0.01425, p2, 1e-12);
Assert.AreEqual(1, _loadCount, "命中不应重复装载");
}
[TestMethod]
public void miss不进快照_当日新发布经直查兜底后TTL内可见()
{
Fr007FixingCache.NowTicks = () => 1_000_000L;
var today = new DateTime(2026, 8, 18);
Assert.IsFalse(Fr007FixingCache.TryGet(today, out _), "快照无该行应 miss(调用方直查库兜底)");
Assert.AreEqual(1, _loadCount);
// 直查库发现了新发布的当日行 → 写侧失效(SwapFlowService 场景)→ 重载后可见
_market[today] = 0.0143;
Fr007FixingCache.Invalidate();
Assert.IsTrue(Fr007FixingCache.TryGet(today, out var p), "写侧失效重载后当日行应可见");
Assert.AreEqual(0.0143, p, 1e-12);
Assert.AreEqual(2, _loadCount);
}
[TestMethod]
public void _不等TTL()
{
Fr007FixingCache.NowTicks = () => 1_000_000L;
Fr007FixingCache.TryGet(D1, out _);
Assert.AreEqual(1, _loadCount);
_market[D1] = 0.0150; // 界面修正错价
Fr007FixingCache.Invalidate();
// 时钟只走了 1 tick(远小于 TTL),仍必须重载
Fr007FixingCache.NowTicks = () => 1_000_001L;
Assert.IsTrue(Fr007FixingCache.TryGet(D1, out var p), "修正后仍应命中");
Assert.AreEqual(0.0150, p, 1e-12, "TTL 未到也必须看到写侧修正值");
Assert.AreEqual(2, _loadCount, "版本失效应立即触发重载");
}
[TestMethod]
public void TTL过期自动重载_直改库兜底()
{
Fr007FixingCache.NowTicks = () => 1_000_000L;
Fr007FixingCache.TryGet(D1, out _);
Assert.AreEqual(1, _loadCount);
_market[D1] = 0.0160; // 直改库(无 Invalidate
Fr007FixingCache.NowTicks = () => 1_000_000L + TimeSpan.FromMinutes(1).Ticks + 1; // TTL+1 tick
Assert.IsTrue(Fr007FixingCache.TryGet(D1, out var p));
Assert.AreEqual(0.0160, p, 1e-12, "TTL 过期后应重载并看到直改库的新值");
Assert.AreEqual(2, _loadCount);
}
[TestMethod]
public void TTL窗口内无写入零重载()
{
Fr007FixingCache.NowTicks = () => 1_000_000L;
Fr007FixingCache.TryGet(D1, out _);
Fr007FixingCache.NowTicks = () => 1_000_000L + TimeSpan.FromMinutes(1).Ticks - 1; // TTL-1 tick:稳态窗口
for (int i = 0; i < 10; i++) Fr007FixingCache.TryGet(D1, out _);
Assert.AreEqual(1, _loadCount, "稳态窗口内多次读取零重载(零查询)");
}
[TestMethod]
public void TTL过期重载失败_保留旧快照不抛_且窗口内不重试()
{
Fr007FixingCache.NowTicks = () => 1_000_000L;
Assert.IsTrue(Fr007FixingCache.TryGet(D1, out _), "先成功装载一次");
Assert.AreEqual(1, _loadCount);
Fr007FixingCache.LoadSnapshot = () => { _loadCount++; throw new InvalidOperationException("db down"); };
Fr007FixingCache.NowTicks = () => 1_000_000L + TimeSpan.FromMinutes(1).Ticks + 1; // TTL 过期 → 触发重载 → 失败
// 不抛:历史定盘不可变,命中继续走旧快照(异常会直接 fail 本用例)
Assert.IsTrue(Fr007FixingCache.TryGet(D1, out var p), "重载失败应保留旧快照继续命中");
Assert.AreEqual(0.0142, p, 1e-12, "旧快照值不变");
Assert.AreEqual(2, _loadCount, "失败的重载尝试恰好一次");
Fr007FixingCache.TryGet(D2, out _); // TTL 时钟已被重置:窗口内不再重试(防重试风暴)
Assert.AreEqual(2, _loadCount, "失败后TTL窗口内不得反复重试全表SELECT");
}
[TestMethod]
public void _miss不抛_窗口内不重试()
{
Fr007FixingCache.LoadSnapshot = () => { _loadCount++; throw new InvalidOperationException("db down"); };
Fr007FixingCache.NowTicks = () => 1_000_000L;
Assert.IsFalse(Fr007FixingCache.TryGet(D1, out _), "装载失败=空快照miss(调用方直查库兜底,新数据该响仍响)");
Assert.AreEqual(1, _loadCount);
Fr007FixingCache.TryGet(D1, out _);
Assert.AreEqual(1, _loadCount, "失败后TTL窗口内不重试");
}
}
}
@@ -0,0 +1,33 @@
namespace YLErp.Modules
{
/// <summary>
/// DbDiagnose 用例自守卫:真实探测一次测试库可达性并缓存结论(整个测试进程共享)。
/// 不可达 → Assert.Inconclusive:裸跑全量不再出假红,且多个用例只付一次连接超时。
/// 原先各用例把 try 挂在 GetYLDbContext() 上是无效守卫——EF context 构造不连库,
/// Connect Timeout 发生在首个查询执行时,守卫永远打不中。
/// </summary>
public static class DbDiagnoseGuard
{
private static int _state; // 0=未探测 1=可达 2=不可达
public static void RequireTestDb()
{
var state = Volatile.Read(ref _state);
if (state == 1) return;
if (state == 2) Assert.Inconclusive("测试库不可达(结论已缓存),跳过 DbDiagnose 用例");
try
{
using var db = DbContextFactory.GetYLDbContext();
if (!db.Database.CanConnect())
throw new InvalidOperationException("CanConnect=false");
Volatile.Write(ref _state, 1);
}
catch (Exception ex)
{
Volatile.Write(ref _state, 2);
Assert.Inconclusive($"无法连接测试库,跳过 DbDiagnose 用例:{ex.Message}");
}
}
}
}
@@ -31,6 +31,7 @@ namespace YLErp.Modules.EodModule
[TestCategory("DbDiagnose")]
public void Diagnose_FR007_UnderlyingIdMismatch()
{
DbDiagnoseGuard.RequireTestDb();
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
@@ -164,6 +165,7 @@ namespace YLErp.Modules.EodModule
public void Diagnose_Trade_FR007_ResetDays()
{
const string TradeNumber = "GLMS-JIATT-20260805-FICC-01-2180120IB";
DbDiagnoseGuard.RequireTestDb();
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
@@ -256,6 +258,7 @@ namespace YLErp.Modules.EodModule
public void Diagnose_EOD_vs_Unwind_Compound_DailyCompare()
{
const string TradeNumber = "GLMS-JIATT-20260805-FICC-01-2180120IB";
DbDiagnoseGuard.RequireTestDb();
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
@@ -367,6 +370,7 @@ namespace YLErp.Modules.EodModule
{
const string TradeNumber = "GLMS-JIATT-20260805-FICC-01-2180120IB";
const long CompoundPositionId = 38122;
DbDiagnoseGuard.RequireTestDb();
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
@@ -431,6 +435,7 @@ namespace YLErp.Modules.EodModule
public void Diagnose_Trade_804_ResetDay_FloatRate_Trace()
{
const string TradeNumber = "GLMS-JIATT-20260805-FICC-01-2180120IB";
DbDiagnoseGuard.RequireTestDb();
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
@@ -528,6 +533,7 @@ namespace YLErp.Modules.EodModule
public void Diagnose_Trade_AllLegs_And_EodMapping()
{
const string TradeNumber = "GLMS-JIATT-20260805-FICC-01-2180120IB";
DbDiagnoseGuard.RequireTestDb();
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
@@ -0,0 +1,107 @@
using Newtonsoft.Json;
using YLErp;
using YLErp.Modules.SwapModule;
using YLErp.Modules.SwapModule.Accrual;
namespace UnitTestProject.Modules.SwapModule.Accrual
{
/// <summary>
/// EQD-6977 carryInInterest 契约测试:
/// 1) 默认 0 与旧逐日循环逐位一致(加参零行为变化的安全证明);
/// 2) carry-in 仅在【首个重置日】并入计息基数(非窗口首日起息)——
/// 与"持有至到期"全期轨迹对齐的数学不变量:增量 = carryIn × 后续段日利率 × 后续段天数。
/// </summary>
[TestClass]
public class CompoundCarryInTest
{
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天 = 3×7,末日是重置日
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 static List<(DateTime, decimal)> Segments()
=> new()
{
(StartDate, Spread),
(StartDate.AddDays(7), Spread),
(StartDate.AddDays(14), Spread),
};
private sealed class StubSvc : SwapDealService
{
public StubSvc() : base(new OptUserInfo(0, nameof(CompoundCarryInTest), OptUserFrom.UnitTest)) { }
}
[TestMethod]
public void carryIn_默认省略_与旧逐日循环一致()
{
var position = CreatePosition();
var flowEvent = new swap_flow_event { InterestRate = Spread };
decimal oldI = 0, oldTd = 0;
new StubSvc().CalcDailyCompoundInterest(EndDate, position, Notional, flowEvent,
AnnualDays, 0m, 1m, true, false, ref oldI, ref oldTd);
// 省略 carryInInterest(默认 0
var r1 = CompoundInterestAccrual.AccruePeriod(
notional: Notional, segmentRates: Segments(),
startDate: StartDate, endDate: EndDate,
boundary: AccrualBoundary.StartOnly, annualDays: AnnualDays, isAnnualized: true,
resetCarryInterest: 0m, realizedInterest: 0m, unwindFraction: 1m,
finalBasis: out _);
// 显式传 0 与省略等价
var r2 = CompoundInterestAccrual.AccruePeriod(
notional: Notional, segmentRates: Segments(),
startDate: StartDate, endDate: EndDate,
boundary: AccrualBoundary.StartOnly, annualDays: AnnualDays, isAnnualized: true,
resetCarryInterest: 0m, realizedInterest: 0m, unwindFraction: 1m,
finalBasis: out _, trace: null, carryInInterest: 0m);
Assert.AreEqual((double)oldI, (double)r1.Accrued, 0.0000001, "省略 carryIn 与旧实现一致");
Assert.AreEqual((double)r1.Accrued, (double)r2.Accrued, 0.0000001, "省略与显式0一致");
}
[TestMethod]
public void carryIn_仅在首重置日起息_增量等于后续两段复利()
{
const decimal carryIn = 1_000_000m;
decimal Accrued(decimal c)
=> CompoundInterestAccrual.AccruePeriod(
notional: Notional, segmentRates: Segments(),
startDate: StartDate, endDate: EndDate,
boundary: AccrualBoundary.Both, annualDays: AnnualDays, isAnnualized: true,
resetCarryInterest: 0m, realizedInterest: 0m, unwindFraction: 1m,
finalBasis: out _, trace: null, carryInInterest: c).Accrued;
var delta = Accrued(carryIn) - Accrued(0m);
// Both 边界下三段各 7 天。carryIn 于 4/28(首个重置日)并入基数:
// 首段 [4/21,4/28] 不含 carryIn;其后两段 carryIn 自身起息且其首段利息再复利。
// 精确增量 = c×d + (c + c×d)×d = c×(2d + d²) = c×((1+d)² 1)d = 7天利率因子。
var d = Spread * 7m / AnnualDays;
var expected = carryIn * (2m * d + d * d);
Assert.AreEqual((double)expected, (double)delta, 0.001,
"carryIn 增量 = 首个重置日起息的两段复利,首段不含 carryIn");
}
}
}
@@ -7,8 +7,6 @@ using YLErp.DBModels;
using YLErp.DBModels.Enums;
using YLErp.Modules.SwapModule;
using YLErp.Modules.SwapModule.Accrual;
using YLErp.Derivatives.Interest;
using YLErp.Core.Interest;
namespace UnitTestProject.Modules.SwapModule.Accrual
{
@@ -92,7 +90,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual
decimal oldInterest = 0, oldTd = 0;
var svc = new StubSvc();
svc.CalcDailyCompoundInterestByEod(preEod, EodDate, TradeDate, position,
Notional, Notional, flowEvent, AnnualDays, false, 0m, 1m,
Notional, Notional, flowEvent, AnnualDays, 0m, 1m,
ref oldInterest, ref oldTd);
// 新方法
@@ -125,7 +123,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual
decimal oldInterest = 0, oldTd = 0;
var svc = new StubSvc();
svc.CalcDailyCompoundInterestByEod(preEod, nonResetDate, TradeDate, position,
Notional, Notional, flowEvent, AnnualDays, false, 0m, 1m,
Notional, Notional, flowEvent, AnnualDays, 0m, 1m,
ref oldInterest, ref oldTd);
// 新方法
@@ -1,6 +1,5 @@
using Newtonsoft.Json;
using YLErp;
using YLErp.Derivatives.Interest;
using YLErp.Modules.SwapModule;
using YLErp.Modules.SwapModule.Accrual;
@@ -74,7 +73,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual
decimal oldI = 0, oldTd = 0;
var svc = new StubSvc();
svc.CalcDailyCompoundInterest(EndDate, position, Notional, flowEvent,
AnnualDays, false, 0m, 1m, true, false,
AnnualDays, 0m, 1m, true, false,
ref oldI, ref oldTd);
// 新方法:固定利率全段相同,分段点 = PosiStartDate + k×7
@@ -122,7 +121,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual
decimal oldI = 0, oldTd = 0;
var svc = new StubSvc();
svc.CalcDailyCompoundInterest(EndDate, position, Notional * closePct, flowEvent,
AnnualDays, false, 0m, closePct, true, false,
AnnualDays, 0m, closePct, true, false,
ref oldI, ref oldTd, consumedInterest: consumed, resetCarryInterest: carry);
// 新方法
@@ -166,7 +165,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual
decimal oldI = 0, oldTd = 0;
var svc = new StubSvc();
svc.CalcDailyCompoundInterest(EndDate, position, Notional, flowEvent,
AnnualDays, false, 0m, 1m, true, true,
AnnualDays, 0m, 1m, true, true,
ref oldI, ref oldTd);
// 新方法
@@ -0,0 +1,72 @@
namespace UnitTestProject.Modules.SwapModule.Accrual
{
/// <summary>
/// 契约参考实现(确认书公式,TEST-MATRIX §8a)——全矩阵统一 oracle 供给。
///
/// 【独立性约束·勿破坏】本类只实现确认书公式原文,禁止引用任何生产计息引擎类
/// YLErp.Modules.SwapModule.Accrual.* / SwapDealService),否则 oracle 与被测对象同源,
/// 失去"独立参考"资格(oracle 分级第一级,见 TEST-MATRIX §7.4)。
///
/// 确认书公式(国联民生收益互换确认书-现券/ETF 四份一致):
/// 参考利率(绝对) = ∏[i=1..k] ( 1 + (FR007i + 利差) × di / 365 ) 1
/// 结息额(平仓部分) = 实际平掉额 × 参考利率(绝对)
/// - k = 计息期包含的重置期个数;完整重置期 di = 重置频率(生产 7 天),末段不足按实际日历日
/// - 重置期自计息期首日按重置频率依次推算;首个重置期始于计息期首日;末段收口到计息期最后一日
/// - 利率确定日 = 每个重置期首日(重置日)的上一个营业日,取该日 FR007
/// - 计息期 = 自起始日(含)至到期日(不含)——即算头不算尾 "10"(生产主力条款)
/// - 计息基准 A/365
///
/// 营业日准则:本参考实现按周末近似(周六/周日非营业日);法定节假日历由调用方通过
/// 取价委托自行吸收(如按确定日提供同一利率)。测试与生产参数对齐(§8):重置 7 天 / 365。
/// </summary>
public static class ContractReferenceCalc
{
/// <summary>
/// 参考利率(绝对) = ∏(1 + (FR007i+利差)×di/annualDays) 1。
/// </summary>
/// <param name="startDate">计息期首日(含)</param>
/// <param name="endDate">计息期末日("10"不含/"11"含,由 calcLast 决定)</param>
/// <param name="resetDays">重置频率天数(生产 7</param>
/// <param name="spread">利差(InterestRateDefault,如 +0.25% = 0.0025</param>
/// <param name="fixing">取价委托:入参=利率确定日(重置日上一营业日),返回该日 FR007</param>
/// <param name="calcFirst">算头(生产 "10"/"11" 为 true</param>
/// <param name="calcLast">算尾(生产 "10" 为 false</param>
/// <param name="annualDays">计息基准(生产 365</param>
public static decimal ReferenceRateAbsolute(
DateTime startDate, DateTime endDate,
int resetDays, decimal spread,
Func<DateTime, decimal> fixing,
bool calcFirst = true, bool calcLast = false,
int annualDays = 365)
{
var totalDays = (endDate - startDate).Days + (calcFirst ? 0 : -1) + (calcLast ? 1 : 0);
if (totalDays <= 0) return 0m;
decimal factor = 1m;
var resetDate = startDate; // 首个重置期始于计息期首日
var remaining = totalDays;
while (remaining > 0)
{
var di = Math.Min(resetDays, remaining); // 完整期 di=resetDays,末段按实际日历日
var fixingDate = PreviousBusinessDay(resetDate);
var allIn = fixing(fixingDate) + spread;
factor *= 1m + allIn * di / annualDays;
remaining -= di;
resetDate = resetDate.AddDays(di);
}
return factor - 1m;
}
/// <summary>结息额(平仓部分)= 实际平掉额 × 参考利率(绝对)。</summary>
public static decimal ClosedInterest(decimal closedNotional, decimal referenceRate)
=> closedNotional * referenceRate;
/// <summary>利率确定日 = 重置日的上一营业日(周末近似)。</summary>
public static DateTime PreviousBusinessDay(DateTime date)
{
do { date = date.AddDays(-1); }
while (date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday);
return date;
}
}
}
@@ -0,0 +1,196 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Newtonsoft.Json;
using YLErp;
using YLErp.DBModels.Enums;
using YLErp.Modules.SwapModule;
namespace UnitTestProject.Modules.SwapModule.Accrual
{
/// <summary>
/// 契约参考实现 oracle 落地(TEST-MATRIX §7 第 5 步)——两段式:
///
/// ① oracle 自验证:手算锚点直接钉 ContractReferenceCalc(独立于生产引擎,公式正确性
/// 由手算锚点保证——真实规模 5000 万/2.05%/90 天 与玩具 4 天。Excel 金标准期望值亦符合本公式(2026-08-18 复核)。
/// ② 引擎对照:主力族(mode9 标的期初全价 / mode2 合约名义本金规模 × FR007 × 复利 × "10"
/// 盘中 T+0 部分平仓 30%GetInterests 重放结果 必须 == 契约 oracle(容差 0.01 元,§7.4)。
/// 这是本矩阵第一个"契约公式独立参考实现"级 oracle 的引擎对照用例(此前仅有 Excel 手算/工单值)。
///
/// 引擎对照用恒定 FR007 利率表——刻意免疫"利率确定日=重置日上一营业日 vs 当日"的取价日
/// 约定差异(任何确定日取到的都是同一利率),单独验证 ∏ 公式/重置期切分/算头不算尾/末段收口;
/// 取价日维度(E 维,66a97e03)由变利率用例在 oracle 侧钉住(§①第 4 例),引擎侧后续补。
///
/// 坐标登记:mode9/mode2 × 复利 × "10" × T+0 × 部分平仓30% × B=跨12个完整重置期+末段 × E=恒定利率。
/// </summary>
[TestClass]
public class ContractReferenceOracleTest
{
// ── 生产参数(TEST-MATRIX §87 天重置 / A365 / 真实点差 +0.25% / 千万级名义)──
private const decimal Spread = 0.0025m; // 点差 +0.25%(确认书真实点差)
private const decimal Fr007 = 0.018m; // FR007 示意水平 1.8% → all-in 2.05%
private const int ResetDays = 7;
private const int AnnualDaysConst = 365;
private const decimal Notional = 50_000_000m; // 名义 5000 万
private const decimal ClosedNotional = 15_000_000m; // 平掉 30% = 1500 万
private const decimal ClosePercent = 0.3m;
private static readonly DateTime StartDate = new(2026, 4, 27); // 周一,起息日
private static readonly DateTime Unwind90 = new(2026, 7, 26); // 90 天 = 12×7 + 6 末段
private static readonly DateTime Unwind89 = new(2026, 7, 25); // 89 天 = 12×7 + 5 末段
private static readonly DateTime ExerciseDate = new(2027, 4, 27);
#region oracle
[TestMethod]
public void _恒定利率_90天12整期加6天末段_等于手算()
{
var rate = ContractReferenceCalc.ReferenceRateAbsolute(
StartDate, Unwind90, ResetDays, Spread, _ => Fr007,
calcFirst: true, calcLast: false, annualDays: AnnualDaysConst);
// 手算:(1+0.0205×7/365)^12 × (1+0.0205×6/365) 1python 高精度复核)
Assert.AreEqual(0.0050666026m, rate, 0.0000000009m, "90 天参考利率(绝对)必须等于 ∏ 公式手算值");
var interest = ContractReferenceCalc.ClosedInterest(ClosedNotional, rate);
Assert.AreEqual(75999.04m, interest, 0.01m, "平掉 1500 万 × 参考利率 = 确认书公式应结值");
}
[TestMethod]
public void _恒定利率_89天末段5天_等于手算()
{
var rate = ContractReferenceCalc.ReferenceRateAbsolute(
StartDate, Unwind89, ResetDays, Spread, _ => Fr007,
calcFirst: true, calcLast: false, annualDays: AnnualDaysConst);
Assert.AreEqual(0.0050101727m, rate, 0.0000000009m, "89 天参考利率(绝对)手算值");
var interest = ContractReferenceCalc.ClosedInterest(ClosedNotional, rate);
Assert.AreEqual(75152.59m, interest, 0.01m);
}
[TestMethod]
public void _玩具参数_算头算尾4天_等于手算锚点()
{
// 手算:300×[(1+0.011×3/365)×(1+0.011×1/365)1] = 0.0361652(重置 3 天,利差 1%,FR 0.1%
var rate = ContractReferenceCalc.ReferenceRateAbsolute(
new DateTime(2026, 4, 27), new DateTime(2026, 4, 30), resetDays: 3,
spread: 0.01m, fixing: _ => 0.001m,
calcFirst: true, calcLast: true, annualDays: 365);
var interest = ContractReferenceCalc.ClosedInterest(300m, rate);
Assert.AreEqual(0.0361652m, interest, 0.000001m);
}
[TestMethod]
public void _分段变利率_利率确定日为重置日上一营业日()
{
// 计息期 [5/4(一), 5/15(五)) "10" → 11 天 = 7 + 4 末段;重置日 5/4、5/11(均为周一)
// 契约:利率确定日 = 重置日上一营业日 → 5/1(五)、5/8(五)
Assert.AreEqual(new DateTime(2026, 5, 1), ContractReferenceCalc.PreviousBusinessDay(new DateTime(2026, 5, 4)), "5/4(一)的上一营业日是 5/1(五)");
Assert.AreEqual(new DateTime(2026, 5, 8), ContractReferenceCalc.PreviousBusinessDay(new DateTime(2026, 5, 11)), "5/11(一)的上一营业日是 5/8(五)");
var fixings = new Dictionary<DateTime, decimal>
{
[new DateTime(2026, 5, 1)] = 0.02m, // 第一段 FR007 2.0% → all-in 2.25%
[new DateTime(2026, 5, 8)] = 0.03m, // 第二段 FR007 3.0% → all-in 3.25%
};
var rate = ContractReferenceCalc.ReferenceRateAbsolute(
new DateTime(2026, 5, 4), new DateTime(2026, 5, 15), ResetDays, Spread,
d => fixings[d], calcFirst: true, calcLast: false, annualDays: AnnualDaysConst);
// 手算:(1+0.0225×7/365)×(1+0.0325×4/365)1 = 0.0007878249
Assert.AreEqual(0.0007878249m, rate, 0.0000000009m,
"分段变利率下每段必须用各自确定日的 FR007(E 维:取价日=重置日上一营业日)");
}
#endregion
#region FR007
private sealed class StubSwapDealService : SwapDealService
{
public StubSwapDealService() : base(
new OptUserInfo(0, nameof(ContractReferenceOracleTest), OptUserFrom.UnitTest)) { }
protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate)
{
if (!string.Equals(underlyingCode, "FR007", StringComparison.OrdinalIgnoreCase)) { rate = 0; return false; }
rate = (double)Fr007;
return true;
}
/// <summary>fresh 重放无历史已结利息,覆写掉 DB 查询(本场景语义即 0)。</summary>
public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate) => 0m;
}
private static trade CreateTrade()
{
var extend = new trade_extend
{
TradeId = 1,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDaysConst,
InterestCalcMode = "10", // 算头不算尾(生产主力条款)
SettlementRules = 0
})
};
return new trade
{
id = 1, TradeNumber = "UT-CONTRACT-REF-ORACLE", ClientId = 999998,
TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate,
ExerciseDate = ExerciseDate, TradeStatus = "确认成交", ValidState = "Valid",
trade_extend = extend
};
}
private static swap_position CreatePosition(InterestModeEnum mode) =>
new()
{
id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum., InterestMode = (int)mode,
InterestRateDefault = Spread, InterestPrincipalFix = Notional,
PosiStartDate = StartDate, PosiMatuirityDate = ExerciseDate,
IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.,
IsAnnualized = true, interest_rest_days = ResetDays, interest_rule = 0,
FloatRateUnderlyingCode = "FR007",
InterestSwapInterval = JsonConvert.SerializeObject(
new List<IntervalModel> { new() { Date = ExerciseDate, Rate = Spread, Settlement = 0 } })
};
/// <summary>引擎盘中重放(T+0 fresh 持仓,T0 形状)vs 契约 oracle,容差 0.01 元。</summary>
private static void AssertEngineMatchesOracle(
InterestModeEnum mode, DateTime unwindDate, decimal posi, decimal closePosi,
decimal expectedOracleInterest)
{
var td = CreateTrade();
var position = CreatePosition(mode);
var interests = new StubSwapDealService().GetInterests(
td, td.trade_extend, unwindDate, unwindDate,
new List<eod_swap_position>(), new List<swap_position> { position },
posi, closePosi, ClosePercent,
(int)SwapEventTypeEnum.,
tdClose: false, orginPv: posi, add: false, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, interests.Count);
Assert.IsTrue(Math.Abs(interests[0].InterestAmount - expectedOracleInterest) <= 0.01m,
$"mode={mode} 引擎重放 {interests[0].InterestAmount} vs 契约 oracle {expectedOracleInterest}" +
$"diff={interests[0].InterestAmount - expectedOracleInterest}——引擎偏离确认书公式(TEST-MATRIX §8a");
}
private static decimal OracleInterest(DateTime unwindDate) =>
ContractReferenceCalc.ClosedInterest(ClosedNotional,
ContractReferenceCalc.ReferenceRateAbsolute(
StartDate, unwindDate, ResetDays, Spread, _ => Fr007,
calcFirst: true, calcLast: false, annualDays: AnnualDaysConst));
[TestMethod]
public void _mode9_复利FR007_10_部分平仓30_90天_等于契约oracle()
=> AssertEngineMatchesOracle(InterestModeEnum., Unwind90, Notional, Notional, OracleInterest(Unwind90));
[TestMethod]
public void _mode9_复利FR007_10_部分平仓30_89天_等于契约oracle()
=> AssertEngineMatchesOracle(InterestModeEnum., Unwind89, Notional, Notional, OracleInterest(Unwind89));
[TestMethod]
public void _mode2_复利FR007_10_部分平仓30_显式平掉额_等于契约oracle()
=> AssertEngineMatchesOracle(InterestModeEnum., Unwind90, Notional, ClosedNotional, OracleInterest(Unwind90));
#endregion
}
}
@@ -100,7 +100,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual
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);
AnnualDays, 0m, 1m, Notional, true, false, ref oldI, ref oldTd);
// 新方法:固定利率全段相同
// 旧代码差分: dynomicPrincipal = preEod.TdInterestPrincipal(=0) + posiPrincipal - orginPv = 0
@@ -146,7 +146,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual
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);
AnnualDays, 0m, 0.5m, Notional, true, false, ref oldI, ref oldTd);
// 新方法
// 差分本金 = preEod.TdInterestPrincipal + posiPrincipal - orginPv
@@ -200,7 +200,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual
decimal oldI = 0, oldTd = 0;
var svc = new FloatStubSvc(new StubIndexFixer(fixingAtReset));
svc.CalcDailySimpleInterest(preEod, EndDate, position, Notional, flowEvent,
AnnualDays, false, floatRateIn, closePct, Notional, true, false, ref oldI, ref oldTd);
AnnualDays, floatRateIn, closePct, Notional, true, false, ref oldI, ref oldTd);
// 新方法:手算 segmentRates(对齐旧代码取价循环的逻辑)
// 4/21 <= preEodDate(4/30) → 跳过取价,currentFloat 保持入参 floatRateIn
@@ -1,144 +0,0 @@
using System.Text.RegularExpressions;
using YLErp.Core.Interest;
using YLErp.Derivatives.Interest;
namespace UnitTestProject.Modules.SwapModule.Accrual
{
/// <summary>
/// 聚焦测试:AccrueCompoundInArrears 的「本金滚存时机」必须符合确认书规定。
/// 核心不变量:本金只允许在重置日/段末滚入利息,非重置日不得资本化。
///
/// 与原草稿的关键区别:本版<b>直接通过 AccrualTrace 断言不变量</b>。
/// 真实实现在每次段末会发出 ROLLOVER 事件并记录 newBasis(见 SwapInterest.cs:215 /
/// AccrualTrace.Rollover),因此「非重置日是否发生资本化」是可程序化验证的,
/// 无需仅靠总利息回归来保护(原草稿的自我怀疑"无法断言计息基数"已不成立)。
/// </summary>
[TestClass]
public class SwapInterest_CompoundInArrears_RolloverTimingTests
{
private const int FundingLegPrecision = 12;
private const int AnnualDays = 365;
/// <summary>
/// 场景:14天窗口,第8天(01-08)重置一次,利率恒定 3.65%(日利率 0.01%)。
/// 验证:
/// (1) 总利息 = 1400.49(第1期700 + 第2期700.49);
/// (2) ROLLOVER 仅发生在重置日(01-08)与窗口终点(01-15),非重置日(如01-03)绝不滚存;
/// (3) 重置日 ROLLOVER 的 newBasis = 原始本金 + 前7天利息 = 1,000,700
/// 证明第1段计息基数恒为原始本金、段内未提前资本化。
/// </summary>
[TestMethod]
public void InterestPrincipal_ShouldRollOnlyOnResetDays_NotOnNonResetDays()
{
var startDate = new DateTime(2026, 1, 1);
var endDate = new DateTime(2026, 1, 15);
var principal = 1_000_000m;
var rate = 0.0365m;
var resetDates = new List<DateTime> { new DateTime(2026, 1, 8) };
var trace = new AccrualTrace();
var ctx = new AccrualContext(AnnualDays, FundingLegPrecision, trace);
var result = SwapInterest.AccrueCompoundInArrears(
ctx,
principal,
rate,
startDate,
endDate,
AccrualBoundary.Both,
resetDates);
Assert.AreEqual(1400.49m, Math.Round(result.Accrued, 2));
var rolloverDates = trace.Entries
.Where(e => e.Step == AccrualTraceEvent.Rollover)
.Select(e => e.Date)
.ToList();
var allowed = resetDates.Concat(new[] { endDate }).OrderBy(d => d).ToList();
CollectionAssert.AreEqual(allowed, rolloverDates.OrderBy(d => d).ToList());
Assert.IsFalse(rolloverDates.Contains(new DateTime(2026, 1, 3)),
"非重置日发生了本金滚存,违反确认书规定");
var resetRollover = trace.Entries
.First(e => e.Step == AccrualTraceEvent.Rollover && e.Date == new DateTime(2026, 1, 8));
var newBasis = ParseNewBasis(resetRollover.Line);
Assert.AreEqual(principal + 700m, newBasis,
"重置日滚入的本金应为原始本金 + 前段利息,证明段内未提前资本化");
}
/// <summary>
/// 极端场景:startDate = endDate1天),无重置日。
/// 期望利息 = 本金 × 日利率 = 1,000,000 × 0.0365/365 = 100。
/// 且唯一 ROLLOVER 必须落在窗口终点(=startDate),无任何内部重置滚存。
/// </summary>
[TestMethod]
public void SingleDay_ShouldNotRollInterest_NoResetDay()
{
var date = new DateTime(2026, 1, 1);
var principal = 1_000_000m;
var rate = 0.0365m;
var trace = new AccrualTrace();
var ctx = new AccrualContext(AnnualDays, FundingLegPrecision, trace);
var result = SwapInterest.AccrueCompoundInArrears(
ctx,
principal,
rate,
date,
date,
AccrualBoundary.Both);
Assert.AreEqual(100m, Math.Round(result.Accrued, 2));
var rolloverDates = trace.Entries
.Where(e => e.Step == AccrualTraceEvent.Rollover)
.Select(e => e.Date)
.ToList();
CollectionAssert.AreEqual(new[] { date }, rolloverDates.ToArray());
}
/// <summary>
/// 段内无重置日:验证整段等同于单利,且不发生任何内部滚存。
/// 6天窗口(01-01..01-06)在7天重置周期内,Both 边界含两端 = 6 个计息日,
/// 期望利息 = 本金 × 日利率 × 6 = 600。
/// </summary>
[TestMethod]
public void WithinPeriod_NoRollover_ShouldMatchSimpleInterest()
{
var startDate = new DateTime(2026, 1, 1);
var endDate = new DateTime(2026, 1, 6);
var principal = 1_000_000m;
var rate = 0.0365m;
var trace = new AccrualTrace();
var ctx = new AccrualContext(AnnualDays, FundingLegPrecision, trace);
var result = SwapInterest.AccrueCompoundInArrears(
ctx,
principal,
rate,
startDate,
endDate,
AccrualBoundary.Both);
// 计息天数必须用边界感知的 AccrualDays,不能拿 (end-start).Days(会少算1天)
var days = SwapInterest.AccrualDays(startDate, endDate, AccrualBoundary.Both); // = 6
var expected = Math.Round(principal * rate * days / AnnualDays, FundingLegPrecision, MidpointRounding.AwayFromZero);
Assert.AreEqual(expected, Math.Round(result.Accrued, 10));
var rolloverDates = trace.Entries
.Where(e => e.Step == AccrualTraceEvent.Rollover)
.Select(e => e.Date)
.ToList();
CollectionAssert.AreEqual(new[] { endDate }, rolloverDates.ToArray());
}
private static decimal ParseNewBasis(string line)
{
var m = Regex.Match(line, @"newBasis=([0-9.]+)");
Assert.IsTrue(m.Success, $"ROLLOVER 行缺少 newBasis{line}");
return decimal.Parse(m.Groups[1].Value);
}
}
}
@@ -0,0 +1,69 @@
using YLErp.Modules.EodModule;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 自动平仓路径(AuotoSwapUnwind → EnrichDividendIn, SwapDealService.cs:1668-1687)多次部分平仓是否多算的实证。
/// EnrichDividendIn 核心:GetBondPayments(td.StartDate, closeDate) × unwindQty(当次平仓量,非剩余持仓)。
/// 本测试直接驱动真实 BondPaymentService.CalcPayment(与 EnrichDividendIn 等价:GetBondPayments 按 reg_date 过滤 + CalcPayment × unwindQty),
/// 内存注入 reg_date 数据,不连库。完整 AuotoSwapUnwind 链路因 EnrichDividendIn 直接 new BondPaymentService 查库、无内存 seam 注入点,故用计算核心等价验证。
///
/// 结论验证:多次跨越登记日的部分平仓,每次 × 当次平仓量 → 总额 = 各批按登记日持有 × 平仓量分摊,
/// 不自洽多算、不重复计入重叠窗口。
/// (纠正此前"从建仓日重算导致重复计入"的推断:该推断误以为 CalcPayment 乘剩余持仓,实际乘当次 unwindQty。)
/// </summary>
[TestClass]
public class AutoUnwindMultiPartialDividendTest
{
private const string BondCode = "230004.IB";
private static readonly DateTime StartDate = new(2026, 1, 5);
private static readonly DateTime Reg1 = new(2026, 5, 15); // 每百元付息 10
private static readonly DateTime Reg2 = new(2026, 6, 15); // 每百元付息 12
private sealed class BridgeBps : BondPaymentService
{
public BridgeBps(OptUserInfo u) : base(u) { }
protected override IQueryable<BondPayment> QueryBondPayments(string underlyingCode)
=> new List<BondPayment>
{
new BondPayment { underlyingCode = BondCode, reg_date = Reg1, payment_date_pl = Reg1, payment_date = Reg1, payment_interest = 10m },
new BondPayment { underlyingCode = BondCode, reg_date = Reg2, payment_date_pl = Reg2, payment_date = Reg2, payment_interest = 12m },
}.Where(x => x.underlyingCode == underlyingCode).AsQueryable();
}
// 等价于 EnrichDividendIn 的数值核心:GetBondPayments(StartDate, closeDate) × unwindQty
private static decimal EnrichOnce(DateTime closeDate, decimal unwindQty)
{
var svc = new BridgeBps(OptUserInfo.UnitTestUser);
return svc.CalcPayment(BondCode, StartDate, closeDate, unwindQty, 1, 1);
}
[TestMethod]
public void _自动路径总额按登记日持仓分摊_不自洽多算()
{
decimal totalFace = 10_000m; // 总面额 1 万元
decimal halfFace = totalFace / 2m; // 每次平一半
// 第一次 5/20 平一半:窗口(Start,5/20] 仅含 reg1 → 10 × 5000/100 = 500
var d1 = EnrichOnce(new DateTime(2026, 5, 20), halfFace);
// 第二次 6/20 平一半:窗口(Start,6/20] 含 reg1+reg2 → (10+12) × 5000/100 = 1100
var d2 = EnrichOnce(new DateTime(2026, 6, 20), halfFace);
var total = d1 + d2;
// 经济应得(登记日持有规则):
// 第一批5000元:5/15持有✓(10)、6/15未持有✗ → 10×5000/100 = 500
// 第二批5000元:5/15持有✓(10)、6/15持有✓(12) → 22×5000/100 = 1100
decimal expected = 10m * halfFace / 100m + (10m + 12m) * halfFace / 100m;
Assert.AreEqual(500m, d1, 0.001m, "第一次(5/20)只含 reg1 = 500");
Assert.AreEqual(1100m, d2, 0.001m, "第二次(6/20)含 reg1+reg2 = 1100");
Assert.AreEqual(expected, total, 0.001m,
"两次部分平仓总额 = 按登记日持有×平仓量分摊的应得值,重叠窗口不重复计同量(纠正:乘当次 unwindQty 而非剩余持仓)");
// 反证:若手动路径口径(第一次平仓即给全量待实现 = 两次分红×总面额)会多算
decimal manualFullIfFirst = (10m + 12m) * totalFace / 100m; // 2200
Assert.IsTrue(manualFullIfFirst > total,
"反证:手动全量落袋口径(2200) > 自动分摊口径(1600),多算方是手动路径而非自动路径");
}
}
}
@@ -134,6 +134,17 @@ namespace YLErp.Modules.SwapModule
return new swap_event { id = SwapEvents.Count };
}
/// <summary>
/// 捕获 SaveAutoSwapDeal 落库的 flow_event(生产写 DbContext.swap_flow_event)。
/// 同步到 PersistedFlowEvents 供 AS_009/010/011 断言;基类 FlowEvents 仍由它填充,
/// 供 GetConsumedInterest 真实计算已结利息。
/// </summary>
protected override void PersistFlowEvent(swap_flow_event flowEvent)
{
base.PersistFlowEvent(flowEvent);
PersistedFlowEvents.Add(flowEvent);
}
/// <summary>
/// 利息腿金额直接给定(付息金额),避免把 GetInterests 的计息细节混入本用例——
/// 本文件关注的是「自动互换是否触发 / 几条 / 资金发生日 / 金额量级」,
@@ -144,9 +155,9 @@ namespace YLErp.Modules.SwapModule
protected override List<swap_flow_event> CalcSwapInterests(
trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate,
List<eod_swap_position> eodPositions, List<swap_position> positions,
decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, bool needPrice,
decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false,
decimal posiNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose,
decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false,
List<swap_flow_event> closeList = null)
{
return positions.Select(p => new swap_flow_event
@@ -125,8 +125,8 @@ namespace YLErp.Modules.SwapModule
var position = CreateCompoundPosition();
var interests = service.GetInterests(td, td.trade_extend, unwindDate, unwindDate,
new List<eod_swap_position>(), new List<swap_position> { position },
Principal, Principal, Principal, Principal, closePercent,
(int)SwapEventTypeEnum., false, false, Principal, Principal,
Principal, Principal, closePercent,
(int)SwapEventTypeEnum., false, Principal,
add: false, settment: false, newCalcLast: false);
Assert.AreEqual(1, interests.Count);
return interests[0];
@@ -273,45 +273,28 @@ namespace YLErp.Modules.SwapModule
}
// ================================================================
// 场景7复现"平仓日=重置日 + calcLast=false → 重置日跳过 FR007 取价"
// 场景7:平仓日=重置日 + calcLast=false 的事件利率口径(EQD-6968 自洽化后)
// ================================================================
/// <summary>
/// [CI_007] 平仓日恰好是重置日时,calcLast=false 不应导致该重置日的 FR007 取价被跳过
/// [CI_007] 平仓日=重置日 + calcLast=false:排除日不取价,事件利率=末段已消费利率(确定性)
/// ----------------------------------------------------------------
/// 背景(GLMS-JIATT-20260805 根因)InterestCalcMode='10'(算头不算尾,calcLast=false)
/// CalcDailyCompoundInterest 循环里 `if(!calcLast && accrueDate==endDate) continue` 会跳过平仓日当天。
/// 若平仓日恰好是重置日(i%period==0),这个跳过会让"重置日取新FR007"的代码块永远不执行,
/// 沿用上一个重置周期的旧利率。
/// 历史(GLMS-JIATT-20260805):原缺陷是重置日取价被 calcLast 跳过 → flowEvent.FloatRate 停留旧值
/// → 落库后传染 EOD。当时的修复=排除日"有价则取新定盘",事件利率因而取决于平仓时刻
/// (上午=旧/下午=新),与金额实际使用的利率脱钩。
///
/// 构造:PosiStartDate=4/27, ResetPeriod=3, InterestCalcMode='10'(calcLast=false)
/// - FR007 按日期分段:5/3之前返回 rateOld=0.0015/3及之后返回 rateNew=0.002
/// - 对照A:平仓日=5/5(非重置日,9? 不: (5/5-4/27)=8, 8%3=2 非重置) → 不该取新值
/// - 对照B:平仓日=5/6(重置日,(5/6-4/27)=9, 9%3=0) → 应取新值 rateNew
/// EQD-6968 自洽化后的新契约:
/// ① 已平部分:排除日一概不取价(有价也不取),事件 FloatRate=末段已消费利率(rateOld),
/// 与金额同源、与平仓时刻无关;
/// ② 剩余持仓的新周期利率:由 EOD 快照"重置日再定盘"显式获取
/// InterestEodTailSnapshotTest.CloseOnly_平仓日为重置日_剩余持仓快照再定盘)。
///
/// 修复前:5/6 重置日被 calcLast 跳过 → 取到旧 rateOld → 与 5/5 相同
/// 修复后:5/6 重置日正常取价 → 取到 rateNew → 与 5/5 不同
/// ----------------------------------------------------------------
/// </summary>
/// <summary>
/// [CI_007] 平仓日恰好是重置日时,calcLast=false 不应导致该重置日的 FR007 取价被跳过
/// ----------------------------------------------------------------
/// 根因(GLMS-JIATT-20260805)InterestCalcMode='10'(calcLast=false)
/// CalcDailyCompoundInterest 循环 `if(!calcLast && accrueDate==endDate) continue` 跳过平仓日。
/// 若平仓日=重置日,取价代码块被跳过 → flowEvent.FloatRate 停留旧值 → 落库后传染 EOD。
///
/// 构造(避开周末,period=7)
/// PosiStartDate=4/27(周一), period=7, interest_rule=0, InterestCalcMode='10'
/// 重置日:i=0→4/27(周一), i=7→5/4(周一,工作日)
/// 平仓日=5/4(=重置日=endDate)
/// FR007 分界:rateDate>=5/4 返回 rateNew,否则 rateOld
///
/// 修复前:i=7(5/4)被 calcLast 跳过 → FloatRate=rateOld(旧值)
/// 修复后:i=7(5/4)正常取价 → FloatRate=rateNew(新值)
/// 构造(避开周末,period=7)PosiStartDate=6/1(周一), 平仓日=6/8(周一,重置日,7%7=0)
/// FR007 分界:取价日>=6/8 返回 rateNew,否则 rateOld。
/// ----------------------------------------------------------------
/// </summary>
[TestMethod]
public void CI_007_平仓日等于重置日_calcLast_false_仍应取新FR007()
public void CI_007_平仓日等于重置日_calcLast_false_事件利率为末段已消费利率()
{
const double rateOld = 0.001;
const double rateNew = 0.002;
@@ -356,19 +339,21 @@ namespace YLErp.Modules.SwapModule
var interests = ServiceByDate().GetInterests(td, td.trade_extend, unwindDate, unwindDate,
new List<eod_swap_position>(), new List<swap_position> { position },
Principal, Principal, Principal, Principal, 1m,
(int)SwapEventTypeEnum., false, false, Principal, Principal,
Principal, Principal, 1m,
(int)SwapEventTypeEnum., false, Principal,
add: false, settment: false, newCalcLast: false);
Assert.AreEqual(1, interests.Count);
var result = interests[0];
Console.WriteLine($"6/8(重置日,周一)平仓:FloatRate={result.FloatRate} Amount={result.InterestAmount:F6}");
Console.WriteLine($" 期望 FloatRate={rateNew}(6/8 重置日查询日=6/8工作日,应取新利率)");
Console.WriteLine($" 新口径期望 FloatRate={rateOld}(排除日不取价,事件利率=末段已消费利率)");
// 核心断言6/8 是重置日,flowEvent.FloatRate 应反映新利率 rateNew
Assert.IsTrue(Math.Abs((result.FloatRate ?? 0) - (decimal)rateNew) < 0.0001m,
$"平仓日=重置日时 FloatRate 应={rateNew}(取到新利率)。" +
$"实际={result.FloatRate},若={rateOld} 说明 calcLast=false 跳过了重置日取价(GLMS-JIATT-20260805 根因)");
// 核心断言(EQD-6968 自洽化契约):排除日(不计息)一概不取价——即使 6/8 新定盘已发布,
// 事件 FloatRate 也必须是末段已消费利率 rateOld,与金额同源、与平仓时刻无关。
// 剩余持仓的新周期利率由 EOD 快照"重置日再定盘"显式获取(见 InterestEodTailSnapshotTest)。
Assert.IsTrue(Math.Abs((result.FloatRate ?? 0) - (decimal)rateOld) < 0.0001m,
$"排除日不取价:FloatRate 应=末段已消费利率 {rateOld}。实际={result.FloatRate}" +
$"若={rateNew} 说明排除日仍在取价(旧口径:记录利率取决于平仓时刻)");
var interestBeforeResetDate = Principal * (FixedRate + (decimal)rateOld) * 7m / AnnualDays;
AssertDecimal(Principal + interestBeforeResetDate, result.InterestPrincipal,
@@ -420,16 +405,18 @@ namespace YLErp.Modules.SwapModule
var result = service.GetInterests(td, td.trade_extend, resetDate, resetDate,
new List<eod_swap_position> { preEod }, new List<swap_position> { position },
remainingPrincipal, remainingPrincipal, 0m, remainingPrincipal, 1m,
(int)SwapEventTypeEnum., true, false, 0m, remainingPrincipal,
remainingPrincipal, remainingPrincipal, 1m,
(int)SwapEventTypeEnum., true, remainingPrincipal,
add: false, settment: false, newCalcLast: false).Single();
var remainingInterest = previousInterest * remainingPrincipal / previousPrincipal;
var expectedPrincipal = remainingPrincipal + remainingInterest;
var expectedDailyInterest = expectedPrincipal * (fixedRate + (decimal)newFloatRate) / AnnualDays;
AssertDecimal(expectedPrincipal, result.InterestPrincipal);
AssertDecimal(expectedDailyInterest,
result.InterestPrincipal * (result.InterestRate + result.FloatRate.Value) / AnnualDays);
// EQD-6968 自洽化:排除日(平仓日=重置日)不取价,事件利率=末段已消费利率(旧)——与金额同源、
// 与平仓时刻无关。剩余持仓的新周期利率由 EOD 快照"重置日再定盘"显式获取
// InterestEodTailSnapshotTest.CloseOnly_平仓日为重置日_剩余持仓快照再定盘)。
AssertDecimal((decimal)oldFloatRate, result.FloatRate.Value,
"排除日不取价:事件 FloatRate 应=末段已消费旧利率");
}
[TestMethod]
@@ -466,8 +453,8 @@ namespace YLErp.Modules.SwapModule
var result = service.GetInterests(td, td.trade_extend, unwindDate, unwindDate,
new List<eod_swap_position> { preEod }, new List<swap_position> { position },
remainingPrincipal, remainingPrincipal, 0m, remainingPrincipal, 1m,
(int)SwapEventTypeEnum., false, false, 0m, remainingPrincipal,
remainingPrincipal, remainingPrincipal, 1m,
(int)SwapEventTypeEnum., false, remainingPrincipal,
add: false, settment: false, newCalcLast: false).Single();
AssertDecimal(pendingInterest, result.InterestAmount,
@@ -509,7 +496,7 @@ namespace YLErp.Modules.SwapModule
decimal tdInterestAmount = 0m;
service.CalcDailyCompoundInterestByEod(preEod, resetDate, startDate, position,
principal, principal, flowEvent, AnnualDays, false, 0.013502m, 1m,
principal, principal, flowEvent, AnnualDays, 0.013502m, 1m,
ref interestAmount, ref tdInterestAmount);
AssertDecimal(principal + pendingInterest, flowEvent.InterestPrincipal,
@@ -47,7 +47,7 @@ namespace YLErp.Modules.SwapModule
{
DealInterests(interestList, eodPositions, new List<eod_swap_position>(),
settleDate, td, new List<swap_flow_event>(), new List<swap_flow_event>(), null,
posiLongNational, 0m, 0m, grossPrice, orginPv);
posiLongNational + 0m, 0m, grossPrice, orginPv);
}
}
@@ -51,6 +51,8 @@ namespace YLErp.Modules.SwapModule
public SwapDealService DealService { get; set; }
public eod_swap_position LastInterestCalculationEodPosition { get; private set; }
public int LastEventType { get; set; }
public StubEodPositionService() : base(nameof(DealInterestsScenarioTest))
{
@@ -63,23 +65,25 @@ namespace YLErp.Modules.SwapModule
trade td, trade_extend tradeExtend,
DateTime valueDate, DateTime unwindDate,
List<eod_swap_position> eodPositions, List<swap_position> positions,
decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue,
decimal posiNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent,
int eventType, bool tdClose, bool needPrice,
decimal grossPrice, decimal orginPv,
int eventType, bool tdClose,
decimal orginPv,
bool add = false, bool settment = true, bool newCalcLast = false,
List<swap_flow_event> closeList = null)
{
LastInterestCalculationEodPosition = eodPositions.SingleOrDefault();
LastEventType = eventType;
if (AutoInterests != null)
{
return AutoInterests;
}
return (DealService ?? new SwapDealService(this)).GetInterests(td, tradeExtend, valueDate, unwindDate,
eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue,
closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice,
grossPrice, orginPv, add, settment, newCalcLast, closeList);
eodPositions, positions, posiNotionalValue,
closePosiNotionalValue, closePrecent, eventType, tdClose,
orginPv, add, settment, newCalcLast, closeList);
}
// public 包装:让测试能调用 protected 方法
@@ -99,7 +103,7 @@ namespace YLErp.Modules.SwapModule
decimal orginPv = DealInterestsScenarioTest.Principal)
{
SaveAutoEodInterestPosition(eodPayPosition, null, position, td, valueDate, interval,
lastEodSwap, posiLongNotional, 0m, 1m, orginPv);
lastEodSwap, posiLongNotional + 0m, 1m, orginPv);
return PersistedPositions.LastOrDefault();
}
@@ -110,7 +114,7 @@ namespace YLErp.Modules.SwapModule
decimal closeNotional, bool autoSwap)
{
SaveAutoEodWithCloseInterestPosition(eodPayPosition, null, position, td, valueDate, interval,
posiLongNotional, posiShortNotional, flowEvents, closeNotional, autoSwap, 1m,
posiLongNotional + posiShortNotional, flowEvents, closeNotional, autoSwap, 1m,
DealInterestsScenarioTest.Principal);
return PersistedPositions.LastOrDefault();
}
@@ -121,7 +125,7 @@ namespace YLErp.Modules.SwapModule
decimal grossPrice, decimal orginPv)
{
SaveEodInterestPositionCopy(eodPayPosition, null, valueDate, td, position, null,
false, posiLongNotional, posiShortNotional, grossPrice, orginPv);
false, posiLongNotional + posiShortNotional, grossPrice, orginPv);
return PersistedPositions.LastOrDefault();
}
@@ -134,7 +138,7 @@ namespace YLErp.Modules.SwapModule
{
DealInterests(interestList, eodPositions, new List<eod_swap_position>(),
settleDate, td, flowEvents, new List<swap_flow_event>(), null,
posiLongNational, posiShortNational, closeNational, grossPrice, orginPv);
posiLongNational + posiShortNational, closeNational, grossPrice, orginPv);
}
}
@@ -447,7 +451,7 @@ namespace YLErp.Modules.SwapModule
/// <summary>
/// [DI_BRANCH_001] 普通日(无互换无平仓无观察日)→ 走 copy 分支
/// ---------------------------------------------------------------
/// flowEvents 为空,insterval=nullhasSwap=falsehasClose=false
/// flowEvents 为空,observationInterval=nullhasSwap=falsehasClose=false
/// → 应走 SaveEodInterestPositionCopycs:338
/// ---------------------------------------------------------------
/// <summary>
@@ -522,6 +526,109 @@ namespace YLErp.Modules.SwapModule
#endregion
// ================================================================
#region 23 DealInterests
/// <summary>
/// [DI_BRANCH_003] 观察日无平仓(observationDay!=null, hasSwap=false, hasClose=false
/// -> 走 SaveAutoEodInterestPositionautoSwap 路径)。
/// 守卫:CalcSwapInterests 收到 eventType=自动互换、tdClose=false。
/// 补盖 TEST-MATRIX §6 的 AutoSettle 分支。
/// </summary>
[TestMethod]
public void DI_BRANCH_003_观察日自动结息走SaveAutoEodInterestPosition()
{
var service = new StubEodPositionService();
var td = CreateTrade();
var position = CreateInterestPosition();
var settleDate = new DateTime(2026, 5, 10);
var preEod = CreatePreEod(settleDate.AddDays(-1), DailyInterest);
position.InterestSwapInterval = JsonConvert.SerializeObject(new List<IntervalModel>
{
new IntervalModel { Date = settleDate, Rate = FixedRate, Settlement = 1 }
});
service.ExecuteDealInterests(
new List<swap_position> { position },
new List<eod_swap_position> { preEod },
settleDate, td, new List<swap_flow_event>(),
Principal, 0m, 0m, 1m, Principal);
Assert.IsTrue(service.PersistedPositions.Count > 0, "观察日应生成eod");
Assert.AreEqual((int)SwapEventTypeEnum., service.LastEventType, "观察日自动结息 eventType 应为自动互换");
// 观察日自动结息走 SaveAutoEodInterestPosition(无平仓):TdCloseInterest 仅为当日利息,不被平仓放大
Assert.IsTrue(service.PersistedPositions[0].TdCloseInterest < 0.1m, "观察日自动结息无平仓,TdCloseInterest 应仅为当日利息(<0.1),不应含平仓利息");
Console.WriteLine("观察日自动结息分支 ✅ eventType=自动互换, tdClose=false");
}
/// <summary>
/// [DI_BRANCH_004] 观察日+平仓(observationDay!=null, hasClose=true
/// -> 走 SaveAutoEodWithCloseInterestPosition(autoSwap:true)。
/// 守卫:CalcSwapInterests 收到 eventType=自动互换、tdClose=true。
/// 补盖 TEST-MATRIX §6 最弱格子(autoSwap=true 部分平仓)。
/// </summary>
[TestMethod]
public void DI_BRANCH_004_观察日平仓走WithClose_autoSwapTrue()
{
var service = new StubEodPositionService();
var td = CreateTrade();
var position = CreateInterestPosition();
var settleDate = new DateTime(2026, 5, 10);
var preEod = CreatePreEod(settleDate.AddDays(-1), DailyInterest * 13);
position.InterestSwapInterval = JsonConvert.SerializeObject(new List<IntervalModel>
{
new IntervalModel { Date = settleDate, Rate = FixedRate, Settlement = 1 }
});
var closeEvent = CreateSwapFlowEvent(settleDate, DailyInterest * 13);
closeEvent.EventType = (int)SwapFlowEventTypeEnum.;
service.ExecuteDealInterests(
new List<swap_position> { position },
new List<eod_swap_position> { preEod },
settleDate, td, new List<swap_flow_event> { closeEvent },
Principal, 0m, 300m, 1m, Principal);
Assert.IsTrue(service.PersistedPositions.Count > 0, "观察日+平仓应生成eod");
Assert.AreEqual((int)SwapEventTypeEnum., service.LastEventType, "autoSwap:true -> eventType 应为自动互换");
// 含平仓:TdCloseInterest 应明显大于纯当日利息(实测约 0.38)
Assert.IsTrue(service.PersistedPositions[0].TdCloseInterest > 0.1m, "观察日+平仓 TdCloseInterest 应含平仓利息(>0.1)");
Console.WriteLine("观察日+平仓分支 ✅ eventType=自动互换, TdCloseInterest>0.1 (autoSwap:true)");
}
/// <summary>
/// [DI_BRANCH_005] 非观察日+平仓(observationDay==null, hasClose=true, hasSwap=false
/// -> 走 SaveAutoEodWithCloseInterestPosition(autoSwap:false)。
/// 守卫:CalcSwapInterests 收到 eventType=平仓、tdClose=true。
/// 补盖 TEST-MATRIX §6 的 CloseOnly 分支。
/// </summary>
[TestMethod]
public void DI_BRANCH_005_纯平仓走WithClose_autoSwapFalse()
{
var service = new StubEodPositionService();
var td = CreateTrade();
var position = CreateInterestPosition();
var settleDate = new DateTime(2026, 5, 10);
var preEod = CreatePreEod(settleDate.AddDays(-1), DailyInterest * 13);
position.InterestSwapInterval = null;
var closeEvent = CreateSwapFlowEvent(settleDate, DailyInterest * 13);
closeEvent.EventType = (int)SwapFlowEventTypeEnum.;
service.ExecuteDealInterests(
new List<swap_position> { position },
new List<eod_swap_position> { preEod },
settleDate, td, new List<swap_flow_event> { closeEvent },
Principal, 0m, 300m, 1m, Principal);
Assert.IsTrue(service.PersistedPositions.Count > 0, "纯平仓应生成eod");
Assert.AreEqual((int)SwapEventTypeEnum., service.LastEventType, "autoSwap:false -> eventType 应为平仓");
Assert.IsTrue(service.PersistedPositions[0].TdCloseInterest > 0.1m, "纯平仓 TdCloseInterest 应含平仓利息(>0.1)");
Console.WriteLine("纯平仓分支 ✅ eventType=平仓, TdCloseInterest>0.1 (autoSwap:false)");
}
#endregion
// 场景3:多日守恒——连续收盘归档,InterestIncomeSum 应线性递增
// ================================================================
@@ -1229,8 +1336,8 @@ namespace YLErp.Modules.SwapModule
var result = new SwapDealService(service).GetInterests(
td, td.trade_extend, closeDate, closeDate,
new List<eod_swap_position> { previousEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 1m, orginPv,
remainingNotional, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, orginPv,
false, settment: false, newCalcLast: false, closeList: null).Single();
AssertDecimal(remainingNotional, result.InterestPrincipal,
@@ -1268,8 +1375,8 @@ namespace YLErp.Modules.SwapModule
var firstCloseInterest = dealService.GetInterests(
td, td.trade_extend, firstCloseDate, firstCloseDate,
new List<eod_swap_position>(), new List<swap_position> { position },
originalNotional, originalNotional, 0m, remainingNotional, 0.5m,
(int)SwapEventTypeEnum., false, false, 1m, originalNotional,
originalNotional, remainingNotional, 0.5m,
(int)SwapEventTypeEnum., false, originalNotional,
settment: false).Single();
var firstCloseCash = Math.Round(firstCloseInterest.InterestAmount, ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero);
@@ -1286,14 +1393,14 @@ namespace YLErp.Modules.SwapModule
var replayAtPreviousEod = dealService.GetInterests(
td, td.trade_extend, firstCloseDate, firstCloseDate,
new List<eod_swap_position>(), new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 1m, originalNotional,
remainingNotional, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, originalNotional,
settment: false).Single();
var replayAtFinalClose = dealService.GetInterests(
td, td.trade_extend, finalCloseDate, finalCloseDate,
new List<eod_swap_position>(), new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 1m, originalNotional,
remainingNotional, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, originalNotional,
settment: false).Single();
var expectedFinalInterest = firstCloseEod.InterestIncomeSum
+ replayAtFinalClose.InterestAmount - replayAtPreviousEod.InterestAmount;
@@ -1306,8 +1413,8 @@ namespace YLErp.Modules.SwapModule
var finalCloseInterest = dealService.GetInterests(
td, td.trade_extend, finalCloseDate, finalCloseDate,
new List<eod_swap_position> { firstCloseEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 1m, originalNotional,
remainingNotional, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, originalNotional,
settment: false).Single();
var finalCloseCash = Math.Round(finalCloseInterest.InterestAmount, ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero);
@@ -1434,8 +1541,8 @@ namespace YLErp.Modules.SwapModule
var partial = service.GetInterests(
td, td.trade_extend, partialCloseDate, partialCloseDate,
new List<eod_swap_position> { previousEod }, new List<swap_position> { position },
notional, notional, 0m, partialNotional, partialPercent,
(int)SwapEventTypeEnum., false, false, 0m, notional,
notional, partialNotional, partialPercent,
(int)SwapEventTypeEnum., false, notional,
settment: false).Single();
AssertDecimal(84090.95m, Math.Round(partial.InterestAmount, ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero),
@@ -1444,8 +1551,8 @@ namespace YLErp.Modules.SwapModule
var final = service.GetInterests(
td, td.trade_extend, maturityDate, maturityDate,
new List<eod_swap_position>(), new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 0m, remainingNotional,
remainingNotional, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, remainingNotional,
settment: false, newCalcLast: true).Single();
AssertDecimal(268428.73m, Math.Round(final.InterestAmount, ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero),
@@ -1575,8 +1682,8 @@ namespace YLErp.Modules.SwapModule
var intermediateInterest = dealService.GetInterests(
td, td.trade_extend, intermediateDate, intermediateDate,
new List<eod_swap_position> { partialEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 0m, originalNotional,
remainingNotional, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, originalNotional,
settment: false, newCalcLast: true).Single();
Assert.IsTrue(Math.Abs(259348.386714765m - intermediateInterest.InterestAmount) <= 0.01m,
$"5/18 复利平仓应承接 5/11 日终剩余本金的累计利息 Expected approximately 259348.386714765, Actual: {intermediateInterest.InterestAmount}");
@@ -1706,8 +1813,8 @@ namespace YLErp.Modules.SwapModule
var intermediateInterest = dealService.GetInterests(
td, td.trade_extend, intermediateDate, intermediateDate,
new List<eod_swap_position> { partialEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 0m, originalNotional,
remainingNotional, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, originalNotional,
settment: false, newCalcLast: true).Single();
Assert.IsTrue(Math.Abs(259348.386714765m - intermediateInterest.InterestAmount) <= 0.01m,
$"0005 5/18 复利应承接部分平仓后的累计利息 Expected approximately 259348.386714765, Actual: {intermediateInterest.InterestAmount}");
@@ -1724,14 +1831,14 @@ namespace YLErp.Modules.SwapModule
decimal expectedAmountAtEnd = 0m;
decimal expectedTdAmountAtEnd = 0m;
dealService.CalcDailyCompoundInterest(
finalCloseDate, position, remainingNotional, expectedEndFlow, AnnualDays, false,
finalCloseDate, position, remainingNotional, expectedEndFlow, AnnualDays,
intermediateEod.FloatRate, 1m, true, false,
ref expectedAmountAtEnd, ref expectedTdAmountAtEnd);
var expectedPreviousFlow = new swap_flow_event { InterestRate = spread };
decimal expectedAmountAtPreviousEod = 0m;
decimal expectedTdAmountAtPreviousEod = 0m;
dealService.CalcDailyCompoundInterest(
intermediateDate, position, remainingNotional, expectedPreviousFlow, AnnualDays, false,
intermediateDate, position, remainingNotional, expectedPreviousFlow, AnnualDays,
intermediateEod.FloatRate, 1m, true, true,
ref expectedAmountAtPreviousEod, ref expectedTdAmountAtPreviousEod);
var expectedFinalInterest = intermediateEod.InterestIncomeSum
@@ -1739,8 +1846,8 @@ namespace YLErp.Modules.SwapModule
var finalInterest = dealService.GetInterests(
td, td.trade_extend, finalCloseDate, finalCloseDate,
new List<eod_swap_position> { intermediateEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 0m, originalNotional,
remainingNotional, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, originalNotional,
settment: false, newCalcLast: false).Single();
AssertDecimal(expectedFinalInterest, finalInterest.InterestAmount,
"0005 最终全平重放时,历史5/18终点必须包含当日利息后再做差额");
@@ -1829,8 +1936,8 @@ namespace YLErp.Modules.SwapModule
var result = dealService.GetInterests(
td, td.trade_extend, finalCloseDate, finalCloseDate,
new List<eod_swap_position> { previousEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 0m, remainingNotional,
remainingNotional, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, remainingNotional,
settment: false).Single();
AssertDecimal(expectedInterest, result.InterestAmount,
@@ -1927,8 +2034,8 @@ namespace YLErp.Modules.SwapModule
var partialInterest = dealService.GetInterests(
td, td.trade_extend, partialCloseDate, partialCloseDate,
new List<eod_swap_position> { preCloseEod }, new List<swap_position> { position },
originalNotional, originalNotional, 0m, partialNotional, partialClosePercent,
(int)SwapEventTypeEnum., false, false, 1m, originalNotional,
originalNotional, partialNotional, partialClosePercent,
(int)SwapEventTypeEnum., false, originalNotional,
settment: false).Single();
AssertExcelMoney(scenario.ExpectedPartialInterest, partialInterest.InterestAmount,
$"{scenario.TradeNumber} 5/11 部分平仓利息应匹配 Excel BL 列");
@@ -1976,8 +2083,8 @@ namespace YLErp.Modules.SwapModule
var finalInterest = dealService.GetInterests(
td, td.trade_extend, finalCloseDate, finalCloseDate,
new List<eod_swap_position> { finalPreEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 1m, remainingNotional,
remainingNotional, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, remainingNotional,
settment: false).Single();
AssertExcelMoney(scenario.ExpectedFinalInterest, finalInterest.InterestAmount,
$"{scenario.TradeNumber} 5/19 全部平仓利息应匹配 Excel BN 列");
@@ -0,0 +1,291 @@
using YLErp;
using YLErp.DBModels;
using YLErp.DBModels.Enums;
using YLErp.Modules.EodModule;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 端到端:盘中收益互换(DividendIn 由生产方法 GetPreEodDividendSum 真实算出)→ 保存 → EOD,
/// 验证分红【不重复累计】(EOD TdCloseDividend 扣减 DividendIn)且【不丢失】(当日新计进 PosiDividendSum)。
///
/// 与 MultiUnwindDividendConservationTest.MU_001 的区别:MU_001 的互换 DividendIn 是测试喂的常量;
/// 本测试的 DividendIn 由生产方法 GetPreEodDividendSum 真实算出(读 EOD 快照),再喂给 EOD——
/// 覆盖"预览算 DividendIn + EOD 扣减"的完整链路(MU_001 的缺口)。
/// </summary>
[TestClass]
public class DividendEodNoDoubleCountTest
{
private const int SwapTradeId = 9200;
private const long PositionId = 9201;
private const decimal InitialQty = 1000m;
private const decimal RegPer100 = 1.0m; // 每 100 元面值票息 1.0 → qty(1000) 时单期分红 = 1.0×1000/100 = 10
private static readonly DateTime StartDate = new(2026, 1, 5);
#region reg_date GetBondPayments
private const string BondUnderlying = "210210.IB";
private static List<BondPayment> BondPayments() => new List<BondPayment>
{
// 登记日 1/6、1/7 各一期;支付日滞后若干日(刻意与登记日不同,验证按 reg_date 而非 pay_date 计提)
new BondPayment { underlyingCode = BondUnderlying, reg_date = new DateTime(2026, 1, 6), payment_date_pl = new DateTime(2026, 1, 9), payment_date = new DateTime(2026, 1, 9), payment_interest = RegPer100 },
new BondPayment { underlyingCode = BondUnderlying, reg_date = new DateTime(2026, 1, 7), payment_date_pl = new DateTime(2026, 1, 10), payment_date = new DateTime(2026, 1, 10), payment_interest = RegPer100 },
};
#endregion
#region Stubs
/// <summary>SwapDealService stub:暴露 GetPreEodDividendSum,注入 EOD 数据(不连库)。</summary>
private sealed class DealSvcStub : SwapDealService
{
private readonly List<eod_swap> _eodSwaps;
private readonly List<eod_swap_position> _eodPositions;
public DealSvcStub(List<eod_swap> eodSwaps, List<eod_swap_position> eodPositions)
: base(OptUserInfo.UnitTestUser) { _eodSwaps = eodSwaps; _eodPositions = eodPositions; }
public decimal ExposeGetPreEodDividendSum(int tradeId, long positionId, DateTime dealDate)
=> GetPreEodDividendSum(tradeId, positionId, dealDate);
protected override IQueryable<eod_swap> QueryPreEodSwaps(int tradeId)
=> _eodSwaps.Where(x => x.SwapTradeId == tradeId).AsQueryable();
protected override eod_swap_position QueryPreEodPosition(int tradeId, long positionId, DateTime valueDate)
=> _eodPositions.FirstOrDefault(x => x.SwapTradeId == tradeId && x.PositionId == positionId && x.ValueDate == valueDate);
}
/// <summary>真实 BondPaymentServicereg_date 口径)seam:仅注入内存 BondPayment 数据,票息计算走生产 GetBondPayments+CalcPayment。</summary>
private sealed class RealBondPaymentService : BondPaymentService
{
private readonly List<BondPayment> _data;
public RealBondPaymentService(List<BondPayment> data, OptUserInfo userInfo) : base(userInfo) { _data = data; }
protected override IQueryable<BondPayment> QueryBondPayments(string underlyingCode)
=> _data.Where(x => x.underlyingCode == underlyingCode).AsQueryable();
}
/// <summary>SwapEodPositionService stub:暴露 UpdateEodPosition/CopyEodPositionCalcBondPayment 桥接真实 BondPaymentServicereg_date 口径,不再用线性假公式)。</summary>
private sealed class EodSvcStub : TestableSwapEodPositionService
{
private readonly List<BondPayment> _bondPayments;
public EodSvcStub(List<BondPayment> bondPayments) : base(nameof(DividendEodNoDoubleCountTest)) { _bondPayments = bondPayments; }
protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio)
{
// 桥接真实生产口径:GetBondPayments 按 reg_date 过滤 + CalcPayment 累加(替换原线性假公式 DailyRatePerUnit*days*qty
var svc = new RealBondPaymentService(_bondPayments, OptUserInfo.UnitTestUser);
return svc.CalcPayment(underlyingCode, fromDate, toDate, qty, shortRatio, directionRatio);
}
protected override underlying_manager GetUnderlyingData(string underlyingCode)
=> new underlying_manager { ValueAddedTax = 0m };
protected override decimal GetUnderlyingPrice(string code, DateTime settleDate, out decimal vobp)
{ vobp = 0m; return 1.00m; }
public eod_swap_position ExecuteUpdateEodPosition(swap_position swapPosition, eod_swap_position eod, trade td, DateTime valueDate, DateTime preSettleDate, List<swap_flow_event> unwindEvents)
=> UpdateEodPosition(swapPosition, eod, null, td, valueDate, preSettleDate, unwindEvents);
public eod_swap_position ExecuteCopyEodPosition(eod_swap_position eod, trade td, DateTime valueDate, DateTime preSettleDate)
=> CopyEodPosition(eod, null, td, valueDate, preSettleDate);
}
#endregion
#region
private static trade CreateTrade() => new trade
{
id = SwapTradeId, TradeNumber = "UT-DIV-EOD-001", ClientId = 999999,
TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate,
ExerciseDate = new DateTime(2027, 1, 5), TradeStatus = "确认成交", ValidState = "Valid",
StructureType = "单标的", QuoteCurrency = "CNY", SettlementCurrency = "CNY",
OriginalStockEqvNotional = (double)(InitialQty * 1.00m)
};
private static swap_position CreatePosition() => new swap_position
{
id = PositionId, SwapTradeId = SwapTradeId,
PosiDirection = (int)SwapDirectionEnum., PositionType = (int)PositionTypeFlag.Long,
UnderlyingCode = "210210.IB", ContractSize = 1m,
PosiQuantity = InitialQty, PosiNotionalValue = InitialQty,
PosiNetPrice = 1.000m, PosiGrossPrice = 1.000m,
PosiNetFeePrice = 1.000m, PosiNetNoFeePrice = 1.000m,
IsInitial = true, Invalid = false,
PosiTradingFee = 0, PosiTradingFeePending = 0
};
private static eod_swap_position CreateInitialEod() => new eod_swap_position
{
id = 1, SwapTradeId = SwapTradeId, PositionId = PositionId,
ValueDate = StartDate, PosiQuantity = InitialQty,
PosiDirection = (int)SwapDirectionEnum., PositionType = (int)PositionTypeFlag.Long,
UnderlyingCode = "210210.IB", ContractSize = 1m,
PosiNetPrice = 1.000m, PosiGrossPrice = 1.000m,
PosiNetFeePrice = 1.000m, PosiNetNoFeePrice = 1.000m,
PosiDividendSum = 0m, TdPosiDividend = 0m, TdCloseDividend = 0m,
RealizedDividend = 0m, PosiFeePending = 0m,
InterestProfitSum = 0m, Invalid = false
};
private static swap_flow_event SwapEvent(decimal dividendIn, DateTime eventDate) => new swap_flow_event
{
SwapTradeId = SwapTradeId, EventType = (int)SwapFlowEventTypeEnum.,
PositionId = PositionId, Quantity = 0m, DividendIn = dividendIn,
MarkClosePnl = 0m, CloseFee = 0m, TradingFeePending = 0m,
EventDate = eventDate, PayDate = eventDate,
DataState = (int)SwapFlowDateStateEnum.
};
private static swap_flow_event CloseEvent(decimal qty, decimal dividendIn, DateTime eventDate) => new swap_flow_event
{
SwapTradeId = SwapTradeId, EventType = (int)SwapFlowEventTypeEnum.,
PositionId = PositionId, Quantity = qty, DividendIn = dividendIn,
MarkClosePnl = 0m, CloseFee = 0m, TradingFeePending = 0m,
TradingAmount = qty * 1.000m,
UnwindDate = eventDate, EventDate = eventDate, PayDate = eventDate,
DataState = (int)SwapFlowDateStateEnum.
};
private static void AssertDecimalEqual(decimal expected, decimal actual, decimal tol, string msg)
=> Assert.IsTrue(Math.Abs(expected - actual) <= tol, $"{msg}: expected={expected} actual={actual}");
#endregion
/// <summary>
/// 盘中收益互换:DividendIn 由 GetPreEodDividendSum 真实算(读 T-1 EOD)→ 保存 → EOD。
/// 验证:不重复(EOD TdCloseDividend 扣 DividendIn+ 不丢失(当日新计进 PosiDividendSum+ 守恒。
///
/// 序列(StartDate=1/5reg_date 1/6、1/7 各一期,每期 = qty×per100/100 = 10):
/// D1=1/6 无事件 Copy:窗口(1/5,1/6] 命中 reg_date 1/6 → TdPosiDividend=10PosiDividendSum=10
/// D2=1/7 盘中互换:GetPreEodDividendSum(读 D1) → DividendIn=10;保存 swap_eventEOD 窗口(1/6,1/7] 命中 reg_date 1/7 → 新计 10 - 实现 10 → PosiDividendSum=10
/// 守恒:全程新计(10+10) - 全程实现(10) = 末尾 PosiDividendSum(10)
/// </summary>
[TestMethod]
public void _DividendIn真实算_保存后EOD_不重复不丢失()
{
var eodSvc = new EodSvcStub(BondPayments());
var td = CreateTrade();
var position = CreatePosition();
var initialEod = CreateInitialEod();
// D1=1/6 无事件 EOD
var d1 = new DateTime(2026, 1, 6);
var r1 = eodSvc.ExecuteCopyEodPosition(initialEod, td, d1, StartDate);
AssertDecimalEqual(10m, r1.PosiDividendSum, 0.01m, "D1 PosiDividendSum(0+1天×10)");
// D2=1/7 盘中:DividendIn 由生产方法 GetPreEodDividendSum 真实算(读 D1 EOD,当日 EOD 未生成)
var d2 = new DateTime(2026, 1, 7);
var dealSvc = new DealSvcStub(
new List<eod_swap> { new eod_swap { SwapTradeId = SwapTradeId, ValueDate = d1 } },
new List<eod_swap_position> { r1 });
decimal dividendIn = dealSvc.ExposeGetPreEodDividendSum(SwapTradeId, PositionId, d2);
AssertDecimalEqual(10m, dividendIn, 0.01m, "盘中 DividendIn=GetPreEodDividendSum 读 T-1(D1)=10");
Console.WriteLine($"[盘中预览] DividendIn={dividendIn}(读 T-1 EOD PosiDividendSum={r1.PosiDividendSum}");
// 保存互换事件(DividendIn=真实算出的值,模拟界面点收益互换后保存)
var swapEvent = SwapEvent(dividendIn, d2);
// D2=1/7 EODUpdateEodPosition,真实生产递推)
var r2 = eodSvc.ExecuteUpdateEodPosition(position, r1, td, d2, d1, new List<swap_flow_event> { swapEvent });
// 断言:不重复 + 不丢失
AssertDecimalEqual(10m, r2.TdPosiDividend, 0.01m, "D2 当日新计(1天×10)");
AssertDecimalEqual(dividendIn, r2.TdCloseDividend, 0.01m, "D2 TdCloseDividend=互换DividendIn(扣减→不重复累计)");
AssertDecimalEqual(10m, r2.PosiDividendSum, 0.01m, "D2 PosiDividendSum=前日10+新计10-实现10=10(当日新计挂着→不丢失)");
// 守恒:全程新计 - 全程实现 = 末尾 PosiDividendSum
decimal totalNew = r1.TdPosiDividend + r2.TdPosiDividend;
decimal totalRealized = r2.TdCloseDividend;
AssertDecimalEqual(r2.PosiDividendSum, totalNew - totalRealized, 0.01m,
$"守恒:末尾 PosiDividendSum({r2.PosiDividendSum}) = 全程新计({totalNew}) - 全程实现({totalRealized})");
Console.WriteLine($"[EOD 后] TdPosiDividend={r2.TdPosiDividend} TdCloseDividend={r2.TdCloseDividend} PosiDividendSum={r2.PosiDividendSum}");
Console.WriteLine($"结论:互换实现 {dividendIn} 被扣减(不重复);当日新计 {r2.TdPosiDividend} 挂 PosiDividendSum(不丢失)");
}
/// <summary>
/// 登记日当日全平(盘中平仓→收盘持仓 0):按各交易场所规定,不享有登记日当日的分红
/// (股权登记日以收盘在册为准;盘中全平→收盘不在册)。验证系统行为符合该规定。
///
/// 系统行为:①盘中 DividendIn=GetPreEodDividendSum 读 T-1=T日前待实现,正确不含登记日当日 reg_date 1/7 的分红);
/// ②EOD 全平 PosiQuantity=0 → TdPosiDividend=0(不计提登记日当日 reg_date 1/7+ PosiDividendSum=0。
/// 即登记日当日分红(reg_date 1/7 的 10)既不进 DividendIn、也不进 PosiDividendSum = 正确不享有。
/// 应得 = T日前待实现累计(r1.PosiDividendSum,仅含 1/6 那期 10);实拿 = DividendIn → 相等,无丢失(不享有当日是正确的)。
/// </summary>
[TestMethod]
public void _按交易场所规定不享有当日分红()
{
var eodSvc = new EodSvcStub(BondPayments());
var td = CreateTrade();
var position = CreatePosition();
var initialEod = CreateInitialEod();
// D1=1/6 无事件 EOD
var d1 = new DateTime(2026, 1, 6);
var r1 = eodSvc.ExecuteCopyEodPosition(initialEod, td, d1, StartDate);
AssertDecimalEqual(10m, r1.PosiDividendSum, 0.01m, "D1 PosiDividendSum");
// D2=1/7 盘中全平:DividendIn 由生产方法真实算(读 D1 EOD,当日 EOD 未生成)
var d2 = new DateTime(2026, 1, 7);
var dealSvc = new DealSvcStub(
new List<eod_swap> { new eod_swap { SwapTradeId = SwapTradeId, ValueDate = d1 } },
new List<eod_swap_position> { r1 });
decimal dividendIn = dealSvc.ExposeGetPreEodDividendSum(SwapTradeId, PositionId, d2);
AssertDecimalEqual(10m, dividendIn, 0.01m, "全平 DividendIn=读T-1(D1)=10(漏 D2 当日新计)");
// 全平事件(扣全部持仓)
var closeEvent = CloseEvent(InitialQty, dividendIn, d2);
// D2=1/7 EODUpdateEodPosition,全平→PosiQuantity=0
var r2 = eodSvc.ExecuteUpdateEodPosition(position, r1, td, d2, d1, new List<swap_flow_event> { closeEvent });
// 业务规定:登记日当日全平(盘中平仓→收盘持仓为 0),按各交易场所规定不享有登记日当日的分红
// (股权登记日以收盘在册为准)。故应得 = T日(登记日)之前的待实现累计 = r1.PosiDividendSum(不含登记日当日)。
// 系统行为正确:①DividendIn 读 T-1(=T日前待实现,正确不含当日);②EOD 全平 PosiQuantity=0 不计提当日。
// 即登记日当日分红既不进 DividendIn 也不进 PosiDividendSum = 正确不享有。
decimal expectedTotal = r1.PosiDividendSum; // 应得 = T日前待实现(不含登记日当日,因全平不享有)
decimal actualGot = dividendIn + r2.PosiDividendSum;
Console.WriteLine($"[登记日全平] 应得(T日前待实现)={expectedTotal}, 实拿(DividendIn+PosiDividendSum)={actualGot}");
Console.WriteLine($"[登记日全平] DividendIn={dividendIn}, EOD:TdPosiDividend={r2.TdPosiDividend} PosiDividendSum={r2.PosiDividendSum} PosiQuantity={r2.PosiQuantity}");
// 断言:实拿 = 应得(登记日全平不享有当日,符合交易场所规定)
AssertDecimalEqual(expectedTotal, actualGot, 0.01m,
$"实拿应=应得(T日前待实现{expectedTotal}),登记日全平不享有当日分红(符合交易场所规定)");
AssertDecimalEqual(0m, r2.TdPosiDividend, 0.01m, "登记日全平 EOD 不计提当日(PosiQuantity=0,正确)");
AssertDecimalEqual(0m, r2.PosiDividendSum, 0.01m, "全平后 PosiDividendSum=0");
}
/// <summary>
/// 【死代码删除的边界规格】脏数据(OriginalStockEqvNotional=null / PosiNetPrice=0)不得让
/// UpdateEodPosition 崩溃,且分红产出与正常数据完全一致。
/// 背景:这两个字段在 UpdateEodPosition 内的唯一消费点是历史遗留死代码
/// originNotional→totalPayment 全历史重算,结果从未被使用,2026-08 论证后删除)——
/// 删除前该脏数据会在 EOD 抛 InvalidOperationException/除零;删除后是设计内行为。
/// 本测试同时钉住:删除后输出等价(与同输入正常数据路径一致)。
/// </summary>
[TestMethod]
public void _死代码涉及字段_不影响EOD分红产出()
{
// 正常数据基准
var eodSvcClean = new EodSvcStub(BondPayments());
var tdClean = CreateTrade();
var positionClean = CreatePosition();
var initialEod = CreateInitialEod();
var d1 = new DateTime(2026, 1, 6);
var d2 = new DateTime(2026, 1, 7);
var r1Clean = eodSvcClean.ExecuteCopyEodPosition(initialEod, tdClean, d1, StartDate);
var r2Clean = eodSvcClean.ExecuteUpdateEodPosition(positionClean, r1Clean, tdClean, d2, d1,
new List<swap_flow_event> { CloseEvent(InitialQty, r1Clean.PosiDividendSum, d2) });
// 脏数据:死代码涉及的两字段置脏(活路径零消费,见方法内 grep 论证)
var eodSvcDirty = new EodSvcStub(BondPayments());
var tdDirty = CreateTrade();
tdDirty.OriginalStockEqvNotional = null; // 死代码 (decimal) 强转崩溃点
var positionDirty = CreatePosition();
positionDirty.PosiNetPrice = 0m; // 死代码除零崩溃点
var r1Dirty = eodSvcDirty.ExecuteCopyEodPosition(initialEod, tdDirty, d1, StartDate);
var r2Dirty = eodSvcDirty.ExecuteUpdateEodPosition(positionDirty, r1Dirty, tdDirty, d2, d1,
new List<swap_flow_event> { CloseEvent(InitialQty, r1Dirty.PosiDividendSum, d2) });
// 脏数据不崩 + 输出与正常数据逐字段一致
AssertDecimalEqual(r2Clean.TdPosiDividend, r2Dirty.TdPosiDividend, 0.0001m, "TdPosiDividend 不受脏字段影响");
AssertDecimalEqual(r2Clean.TdCloseDividend, r2Dirty.TdCloseDividend, 0.0001m, "TdCloseDividend 不受脏字段影响");
AssertDecimalEqual(r2Clean.PosiDividendSum, r2Dirty.PosiDividendSum, 0.0001m, "PosiDividendSum 不受脏字段影响");
AssertDecimalEqual(r2Clean.RealizedDividend, r2Dirty.RealizedDividend, 0.0001m, "RealizedDividend 不受脏字段影响");
Console.WriteLine($"[脏数据边界] 正常={r2Clean.PosiDividendSum} 脏数据={r2Dirty.PosiDividendSum}(应相等且不抛异常)");
}
}
}
@@ -6,16 +6,14 @@ using YLErp.Modules.SwapModule.FundingLegs;
namespace UnitTestProject.Modules.SwapModule.FundingLegs
{
/// <summary>
/// 融资腿策略单测。验证每个策略的 CalcNotional 与现有 CalcNotionalByMode switch 完全一致
/// 这组测试是后续"迁移调用点"的安全网——迁移前后行为必须不变
/// 融资腿策略单测。验证每个 IFundingLegStrategy 实现的 CalcNotional 计息基数公式正确
/// 原 CalcNotionalByMode switch 已重构为策略类(见 FundingLegStrategyFactory
/// </summary>
[TestClass]
public class FundingLegStrategyTest
{
private const decimal Fix = 2_000_000m;
private const decimal Notional = 100_000_000m;
private const decimal LongNotional = 60_000_000m;
private const decimal ShortNotional = 40_000_000m;
#region (mode 1)
@@ -23,7 +21,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs
public void _部分平仓_计息基数恒等于Fix()
{
var leg = new FixedAmountLeg();
var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0.5m);
var r = leg.CalcNotional(Fix, Notional, 0.5m);
Assert.AreEqual(Fix, r.ClosePrincipal, "平仓本金恒=Fix");
Assert.AreEqual(Fix, r.PosiPrincipal, "持仓本金恒=Fix");
@@ -34,7 +32,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs
public void _全平_计息基数仍等于Fix()
{
var leg = new FixedAmountLeg();
var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 1m);
var r = leg.CalcNotional(Fix, Notional, 1m);
Assert.AreEqual(Fix, r.ClosePrincipal);
}
@@ -46,7 +44,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs
public void _部分平仓_本金按比例缩放()
{
var leg = new ContractNotionalLeg();
var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0.5m);
var r = leg.CalcNotional(Fix, Notional, 0.5m);
Assert.AreEqual(50_000_000m, r.ClosePrincipal);
Assert.AreEqual(Notional, r.PosiPrincipal);
@@ -57,7 +55,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs
public void _全平_本金等于全额()
{
var leg = new ContractNotionalLeg();
var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 1m);
var r = leg.CalcNotional(Fix, Notional, 1m);
Assert.AreEqual(Notional, r.ClosePrincipal);
}
@@ -65,7 +63,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs
public void _零平仓_本金为零()
{
var leg = new ContractNotionalLeg();
var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0m);
var r = leg.CalcNotional(Fix, Notional, 0m);
Assert.AreEqual(0m, r.ClosePrincipal);
Assert.AreEqual(Notional, r.PosiPrincipal);
@@ -79,7 +77,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs
public void _部分平仓_主路径公式同mode2()
{
var leg = new UnderlyingEntryFullPriceLeg();
var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0.5m);
var r = leg.CalcNotional(Fix, Notional, 0.5m);
Assert.AreEqual(50_000_000m, r.ClosePrincipal);
Assert.AreEqual(Notional, r.PosiPrincipal);
@@ -90,7 +88,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs
public void _全平_本金等于全额()
{
var leg = new UnderlyingEntryFullPriceLeg();
var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 1m);
var r = leg.CalcNotional(Fix, Notional, 1m);
Assert.AreEqual(Notional, r.ClosePrincipal);
}
@@ -0,0 +1,235 @@
using YLErp.Modules.EodModule;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// GLMS-20260105-0006 回归:债券 TRS 登记日当天手动平仓/互换,分红收益应为 36160 而非 0。
/// 根因双成因:
/// A. BondPaymentService.GetBondPayments 用支付日(pay_date_PL/pay_date_act)而非债权登记日(reg_date)判定谁享有票息
/// -> 登记日(4/3)当日 EOD 不计提,跨过支付日(4/6)才计提(巧合:4/4-4/5周末,下一交易日恰=支付日,掩盖缺陷)
/// B. SwapDealService.GetPreEodDividendSum 用 ValueDate 严格小于 dealDate 读 T-1 EOD 快照
/// -> 登记日当天手动平仓读不到当日 EOD,拿到 0
/// 本文件用手工合成内存数据(不连 96 库),通过 virtual seam 注入,真实跑生产日期逻辑。
/// </summary>
[TestClass]
public class GLMS20260105_0006_RegisterDateDividendTest
{
private const string BondCode = "230004.IB";
private const int TradeId = 6006;
private const long PositionId = 60061;
private const decimal Qty = 20_000_000m;
private const decimal PaymentPer100 = 0.1808m;
private const decimal ExpectedDividend = 36_160m; // 20,000,000 × 0.1808 / 100
// 付息日历(截图):登记日 4/3,支付日 4/6
private static readonly DateTime RegDate = new(2026, 4, 3);
private static readonly DateTime PayDate = new(2026, 4, 6);
private static readonly DateTime PreRegDate = new(2026, 4, 2);
// 多次付息日历(截图:债券 230004.IB,每期票息 0.1808,共 5 次登记日)
private static readonly DateTime[] RegDates = {
new(2026, 2, 28), new(2026, 4, 3), new(2026, 4, 29),
new(2026, 5, 29), new(2026, 6, 29)
};
private static readonly DateTime[] PayDates = {
new(2026, 3, 2), new(2026, 4, 6), new(2026, 4, 30),
new(2026, 6, 1), new(2026, 6, 30)
};
#region A seam
private sealed class TestableBondPaymentService : BondPaymentService
{
private readonly List<BondPayment> _data;
public TestableBondPaymentService(List<BondPayment> data) : base(OptUserInfo.UnitTestUser) { _data = data; }
protected override IQueryable<BondPayment> QueryBondPayments(string underlyingCode)
=> _data.Where(x => x.underlyingCode == underlyingCode).AsQueryable();
}
[TestMethod]
public void CauseA_登记日当日EOD_应按登记日口径选中付息记录()
{
var record = new BondPayment
{
underlyingCode = BondCode,
reg_date = RegDate, // 债权登记日 4/3(关键:分红归属按此判定)
payment_date_pl = PayDate, // 理论付息日 4/6
payment_date = PayDate, // 实际付息日 4/6
payment_interest = PaymentPer100
};
var svc = new TestableBondPaymentService(new List<BondPayment> { record });
// 登记日当日的 EOD 计提区间 (4/2, 4/3]
var payments = svc.GetBondPayments(BondCode, PreRegDate, RegDate);
// 修复前:用支付日(pay_date_PL=4/6)过滤 -> 4/6 不在 (4/2,4/3] -> 0 条(漏计分红)
// 修复后:用债权登记日(reg_date=4/3)过滤 -> 4/3 落在区间 -> 1 条(GLMS-20260105-0006 已修复)
Assert.AreEqual(1, payments.Count,
"登记日(4/3)当日 EOD 应按债权登记日(reg_date)选中该笔付息;" +
"当前按支付日(pay_date_PL=4/6)过滤会漏选->0条,导致分红不计提。");
}
[TestMethod]
public void CauseA_MultiRegDate_跨登记日区间命中正确子集()
{
var records = Enumerable.Range(0, 5).Select(i => new BondPayment
{
underlyingCode = BondCode,
reg_date = RegDates[i],
payment_date_pl = PayDates[i],
payment_date = PayDates[i],
payment_interest = PaymentPer100
}).ToList();
var svc = new TestableBondPaymentService(records);
// 单次窗口:每个登记日各自命中 1 条(验证按 reg_date 过滤,非支付日)
for (int i = 0; i < 5; i++)
{
var prev = i == 0 ? RegDates[i].AddDays(-1) : RegDates[i - 1];
var hit = svc.GetBondPayments(BondCode, prev, RegDates[i]);
Assert.AreEqual(1, hit.Count, $"窗口({prev:yyyy-MM-dd},{RegDates[i]:yyyy-MM-dd}] 应仅命中登记日 {RegDates[i]:yyyy-MM-dd} 那条");
Assert.AreEqual(RegDates[i], hit[0].reg_date, "命中的应是该登记日记录");
}
// 长区间应命中全部 5 条,不漏不混
var all = svc.GetBondPayments(BondCode, RegDates[0].AddDays(-1), RegDates[4]);
Assert.AreEqual(5, all.Count, "长区间(登记日1前,登记日5] 应命中全部 5 次付息");
// 跨登记日中间区间:(4/2, 4/29] 应命中 4/3 与 4/29 两条(不含 2/28、5/29、6/29
var mid = svc.GetBondPayments(BondCode, new DateTime(2026, 4, 2), new DateTime(2026, 4, 29));
Assert.AreEqual(2, mid.Count, "(4/2,4/29] 应命中 4/3+4/29 两条");
CollectionAssert.AreEquivalent(
new[] { new DateTime(2026, 4, 3), new DateTime(2026, 4, 29) },
mid.Select(x => x.reg_date!.Value).ToArray());
}
[TestMethod]
public void CauseA_MultiRegDate_CalcPayment累加五期票息()
{
var records = Enumerable.Range(0, 5).Select(i => new BondPayment
{
underlyingCode = BondCode,
reg_date = RegDates[i],
payment_date_pl = PayDates[i],
payment_date = PayDates[i],
payment_interest = PaymentPer100
}).ToList();
var svc = new TestableBondPaymentService(records);
// 长区间取全部 5 期,CalcPayment 应累加 = 5 × 36160 = 180,800(原测试仅覆盖单期)
var payments = svc.GetBondPayments(BondCode, RegDates[0].AddDays(-1), RegDates[4]);
var total = svc.CalcPayment(payments, Qty, 1, 1);
Assert.AreEqual(5 * ExpectedDividend, total, 0.01m,
"5 期票息累加应为 5 × 36,160 = 180,800;单期口径会漏计其余 4 期");
}
#endregion
#region BT-1 seam
private sealed class TestableSwapDealService : SwapDealService
{
private readonly List<eod_swap> _eodSwaps;
private readonly List<eod_swap_position> _eodPositions;
public TestableSwapDealService(List<eod_swap> eodSwaps, List<eod_swap_position> eodPositions)
: base(OptUserInfo.UnitTestUser) { _eodSwaps = eodSwaps; _eodPositions = eodPositions; }
public decimal ExposeGetPreEodDividendSum(int tradeId, long positionId, DateTime dealDate)
=> GetPreEodDividendSum(tradeId, positionId, dealDate);
protected override IQueryable<eod_swap> QueryPreEodSwaps(int tradeId)
=> _eodSwaps.Where(x => x.SwapTradeId == tradeId).AsQueryable();
protected override eod_swap_position QueryPreEodPosition(int tradeId, long positionId, DateTime valueDate)
=> _eodPositions.FirstOrDefault(x => x.SwapTradeId == tradeId && x.PositionId == positionId && x.ValueDate == valueDate);
}
[TestMethod]
public void CauseB_登记日当天手动平仓_应读到当日EOD分红36160()
{
// 4/2 EOD:累计分红 0;4/3 EOD(登记日):累计分红 36160(即登记日应有的状态)
var eodSwaps = new List<eod_swap>
{
new eod_swap { SwapTradeId = TradeId, ValueDate = PreRegDate },
new eod_swap { SwapTradeId = TradeId, ValueDate = RegDate }
};
var eodPositions = new List<eod_swap_position>
{
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = PreRegDate, PosiDividendSum = 0m, PosiQuantity = Qty },
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = RegDate, PosiDividendSum = ExpectedDividend, PosiQuantity = Qty }
};
var svc = new TestableSwapDealService(eodSwaps, eodPositions);
// 登记日(4/3)当天手动平仓
var dividend = svc.ExposeGetPreEodDividendSum(TradeId, PositionId, RegDate);
// 修复前:ValueDate 严格小于 dealDate 读 T-1(4/2) -> 0(漏读当日分红)
// 修复后:ValueDate 小于等于 dealDate 读当日(4/3) -> 36160GLMS-20260105-0006 已修复)
Assert.AreEqual(ExpectedDividend, dividend, 0.01m,
"登记日(4/3)当天手动平仓应读到当日 EOD 累计分红 36,160" +
"当前 GetPreEodDividendSum 用 ValueDate < dealDate 读 T-1 快照->0。");
}
[TestMethod]
public void CauseB_MultiRegDate_Auto实现归0后下次登记日重新累加()
{
// 模拟:登记日1(2/28)计提 36160 → auto互换实现归0(3/1) → 登记日2(4/3)再计提 36160
var eodSwaps = new List<eod_swap>
{
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,2,27) },
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,2,28) },
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,3,1) },
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,4,2) },
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,4,3) },
};
var eodPositions = new List<eod_swap_position>
{
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,2,27), PosiDividendSum = 0m, PosiQuantity = Qty },
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,2,28), PosiDividendSum = ExpectedDividend, PosiQuantity = Qty },
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,3,1), PosiDividendSum = 0m, PosiQuantity = Qty },
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,4,2), PosiDividendSum = 0m, PosiQuantity = Qty },
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,4,3), PosiDividendSum = ExpectedDividend, PosiQuantity = Qty },
};
var svc = new TestableSwapDealService(eodSwaps, eodPositions);
// 登记日2(4/3)当天手动互换:应读 4/3 EOD = 36160(第二次,非第一次已实现的、非 0)
var dividend = svc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 3));
Assert.AreEqual(ExpectedDividend, dividend, 0.01m,
"登记日2(4/3)手动互换应读当日EOD=第二次分红36160" +
"若读T-1(4/2=0)则漏当日,若读2/28则错取第一次已实现的。");
}
[TestMethod]
public void CauseB_MultiRegDate_手动互换期间分红挂账累计四期()
{
// 模拟:多次登记日之间未 auto 实现,分红挂账累加
// 4/3=36160, 4/29=72320, 5/29=108480, 6/29=1446404期累计)
var eodSwaps = new List<eod_swap>
{
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,4,3) },
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,4,29) },
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,5,29) },
new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,6,29) },
};
var eodPositions = new List<eod_swap_position>
{
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,4,3), PosiDividendSum = 1 * ExpectedDividend, PosiQuantity = Qty },
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,4,29), PosiDividendSum = 2 * ExpectedDividend, PosiQuantity = Qty },
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,5,29), PosiDividendSum = 3 * ExpectedDividend, PosiQuantity = Qty },
new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,6,29), PosiDividendSum = 4 * ExpectedDividend, PosiQuantity = Qty },
};
var svc = new TestableSwapDealService(eodSwaps, eodPositions);
// 每次登记日当天手动互换应读到该日累计值(验证多次付息累计被正确读取)
Assert.AreEqual(1 * ExpectedDividend, svc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 3)), 0.01m, "4/3 应读 36160");
Assert.AreEqual(2 * ExpectedDividend, svc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 29)), 0.01m, "4/29 应读 723202期累计)");
Assert.AreEqual(3 * ExpectedDividend, svc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 5, 29)), 0.01m, "5/29 应读 1084803期累计)");
// 关键:第 4 期登记日累计 = 4 × 36160 = 144640(原 9df39491 仅覆盖单期 36160,未验证多次付息累计)
Assert.AreEqual(4 * ExpectedDividend, svc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 6, 29)), 0.01m,
"6/29 应读 1446404期累计);原 9df39491 仅覆盖单期 36160,未验证多次付息累计。");
}
#endregion
}
}
@@ -28,6 +28,7 @@ namespace YLErp.Modules.SwapModule
[TestCategory("DbDiagnose")]
public void Record_RealSnapshot()
{
DbDiagnoseGuard.RequireTestDb();
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
@@ -113,6 +114,7 @@ namespace YLErp.Modules.SwapModule
private void DiagnoseTrade(string tradeNumber)
{
DbDiagnoseGuard.RequireTestDb();
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
@@ -266,6 +268,7 @@ namespace YLErp.Modules.SwapModule
public void Diagnose_0006_UnwindPercentRate_Display()
{
const string tradeNumber = "GLMS-20260701-0006";
DbDiagnoseGuard.RequireTestDb();
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
@@ -209,10 +209,10 @@ namespace YLErp.Modules.SwapModule
CloseDate, CloseDate, // valueDate / unwindDate
new List<eod_swap_position>(), // eodPositions(空)
new List<swap_position> { position },
Notional, Notional, Notional, Notional, // posiNotional / long / short / closePosiNotional
Notional, Notional, // posiNotional / closePosiNotional
1m, // closePercent
(int)SwapEventTypeEnum.,
false, false, 0m, Notional, // tdClose / needPrice / grossPrice / orginPv
false, Notional, // tdClose / orginPv
false, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, interests.Count);
return interests[0];
@@ -32,6 +32,7 @@ namespace YLErp.Modules.SwapModule
[TestCategory("DbDiagnose")]
public void Record_RealSnapshot()
{
DbDiagnoseGuard.RequireTestDb();
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
@@ -193,6 +194,7 @@ namespace YLErp.Modules.SwapModule
[TestCategory("DbDiagnose")]
public void Diagnose_100vs40_InterestDiff()
{
DbDiagnoseGuard.RequireTestDb();
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
@@ -246,8 +248,19 @@ namespace YLErp.Modules.SwapModule
var valueDate = DateTime.Today;
var unwindDate = DateTime.Today;
var interests100 = new SwapDealService(user).GetUnwindInterests(valueDate, unwindDate, td.id, cp100_B, (int)SwapEventTypeEnum.);
var interests40 = new SwapDealService(user).GetUnwindInterests(valueDate, unwindDate, td.id, cp40_B, (int)SwapEventTypeEnum.);
// FR007 fixing 是外部数据依赖:非交易日/数据未发布时取价会抛 Exception。
// 与"连不上库自动 Inconclusive"同语义——外部数据不可用不应判为测试失败。
List<swap_flow_event> interests100, interests40;
try
{
interests100 = new SwapDealService(user).GetUnwindInterests(valueDate, unwindDate, td.id, cp100_B, (int)SwapEventTypeEnum.);
interests40 = new SwapDealService(user).GetUnwindInterests(valueDate, unwindDate, td.id, cp40_B, (int)SwapEventTypeEnum.);
}
catch (Exception ex) when (ex.Message.Contains("获取不到") && ex.Message.Contains("价格"))
{
Assert.Inconclusive($"FR007 fixing 数据不可用({valueDate:yyyy-MM-dd} 非交易日或数据未发布):{ex.Message}");
return;
}
PrintInterestComparison(interests100, interests40, cp100_B, cp40_B);
}
@@ -0,0 +1,603 @@
using Newtonsoft.Json;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// EQD-6968 FR007 不算尾平仓"上午未发布"误拦截 —— 修复后回归套件(内存,不连库)。
/// 任务编号 EQD-6968;现象报告日 2026-08-17。参照 GLMS20260703CloseInterestTest 内存 FR007 写法。
///
/// 设计:所有场景经单一 Run 运行器驱动真实 GetInterests 平仓利息路径;
/// 内存 StubSwapDealService 重写 TryGetFloatRate 按日期返回 FR007(缺失即返回 false → 触发取价失败)。
/// 覆盖两条计息路径(复利 CalcDailyCompoundInterest / 单利 CalcDailySimpleInterest)共用的修复点 BuildSegmentRates
/// 以及全平重放分支、非整倍数边界、数值一致性("跳过取价=沿用上一重置日利率")。
///
/// 核心语义:算头不算尾(calcLast=false)时 endDate 当天不计息,其 FR007 利率不参与计息。
/// 缺价时跳过取价(currentFloat 保持不变),不回退取其他日期利率,不告警。
///
/// 守卫矩阵(防"放宽过头",对应 EQD-6968 方案一四场景):
/// Guard_*:算尾("11"或newCalcLast=true)+当日重置日+当日缺价 → 必须仍拦截(正确依赖);
/// PrevBizDay_*interest_rule=-1(前一营业日基准)→ 取价日回拨,当日未发布也放行(场景2);
/// TailCalced_NonResetDay_*:算尾+当日非重置日 → 当日价未消费,缺价放行(场景3)。
/// </summary>
[TestClass]
public class GLMS20260817Fr007UnwindMorningTest
{
private static readonly Dictionary<DateTime, double> Fr007Market = new()
{
[new DateTime(2026, 7, 6)] = 0.0142,
[new DateTime(2026, 7, 13)] = 0.01425,
[new DateTime(2026, 7, 20)] = 0.0143,
};
/// <summary>interest_rule=-1(前一营业日基准)取价日市场:重置日 7/6、7/13、7/20(周一)
/// 经 GetFixingDate 回拨至前一营业日 7/3、7/10、7/17(周五)。
/// 同时供未回拨的 7/5、7/12、7/19(周日):QDP "chn" 日历在测试进程内可能被其他用例替换为
/// "全营业日"退化态(全量运行实测 GetNonHolidayDefore(7/19)=7/19 不回拨),
/// 两套日期都供价使本套件对进程内日历状态不敏感——被测对象是取价放宽语义,不是日历本身。</summary>
private static readonly Dictionary<DateTime, double> Fr007MarketPrevBizDay = new()
{
[new DateTime(2026, 7, 3)] = 0.0142,
[new DateTime(2026, 7, 5)] = 0.0142,
[new DateTime(2026, 7, 10)] = 0.01425,
[new DateTime(2026, 7, 12)] = 0.01425,
[new DateTime(2026, 7, 17)] = 0.0143,
[new DateTime(2026, 7, 19)] = 0.0143,
};
private const double PreviousResetRate = 0.01425;
private const decimal Notional = 279486108.21m;
private const int AnnualDays = 365;
private const decimal Spread = -0.0155m;
private static readonly DateTime StartDate = new(2026, 7, 6);
private static readonly DateTime TradeDate = new(2026, 7, 3);
private static readonly DateTime CloseDate = new(2026, 7, 20);
private static readonly DateTime NonIntCloseDate = new(2026, 7, 22);
private sealed class StubSwapDealService : SwapDealService
{
private readonly HashSet<DateTime> _omit;
private readonly double _closeRate;
private readonly Dictionary<DateTime, double> _market;
public readonly List<DateTime> PricedDates = new();
public StubSwapDealService(OptUserInfo optUser, IEnumerable<DateTime> omit, double closeRate = 0.0143,
Dictionary<DateTime, double> market = null)
: base(optUser)
{
_omit = new HashSet<DateTime>(omit.Select(d => d.Date));
_closeRate = closeRate;
_market = market;
}
protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate)
{
rate = 0d;
if (underlyingCode != "FR007") return false;
var map = new Dictionary<DateTime, double>(_market ?? Fr007Market) { [CloseDate] = _closeRate };
if (_omit.Contains(valueDate.Date)) return false;
if (map.TryGetValue(valueDate.Date, out rate))
{
PricedDates.Add(valueDate.Date);
return true;
}
return false;
}
// 内存世界无历史结息流水,consumedInterest=0(与本类"内存,不连库"声明一致;否则复利路径偷连 96 库)
public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate) => 0m;
}
private sealed class Outcome
{
public swap_flow_event Fe;
public Exception Ex;
public StubSwapDealService Svc;
public bool Threw => Ex != null;
}
private static OptUserInfo MakeOptUser() =>
new(0, nameof(GLMS20260817Fr007UnwindMorningTest), OptUserFrom.UnitTest);
private static trade BuildTrade(DateTime closeDate, string calcMode = "10", DateTime? exerciseDate = null)
{
var extend = new trade_extend
{
TradeId = 1,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = calcMode,
SettlementRules = 0
})
};
return new trade
{
id = 1,
TradeNumber = "GLMS-20260817-FR007-MORNING",
ClientId = 999998,
TradeType = "债券TRS",
TradeDate = TradeDate,
StartDate = StartDate,
ExerciseDate = exerciseDate ?? closeDate.AddDays(1),
TradeStatus = "已平仓",
ValidState = "Valid",
trade_extend = extend
};
}
private static swap_position BuildPosition(InterestTypeEnum interestType, DateTime closeDate, int restDays = 7,
int interestRule = 0)
{
var intervalModels = new List<IntervalModel>
{
new IntervalModel { Date = closeDate, Rate = Spread, Settlement = 0 }
};
return new swap_position
{
id = 1001,
SwapTradeId = 1,
PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = (int)InterestModeEnum.,
InterestRateDefault = Spread,
InterestPrincipalFix = Notional,
PosiStartDate = StartDate,
PosiMatuirityDate = closeDate,
IsInitial = true,
Invalid = false,
InterestType = (int)interestType,
IsAnnualized = true,
interest_rest_days = restDays,
interest_rule = interestRule,
FloatRateUnderlyingCode = "FR007",
FloatRate = 0m,
PosiNotionalValue = Notional,
UnderlyingCode = "2500002.IB",
InterestSwapInterval = JsonConvert.SerializeObject(intervalModels)
};
}
private static eod_swap_position BuildPreEod(DateTime valueDate, decimal floatRate = 0.01425m)
{
return new eod_swap_position
{
id = 5001,
PositionId = 1001,
ValueDate = valueDate,
FloatRate = floatRate,
InterestProfitSum = -100000m,
TdInterestPrincipal = Notional,
InterestIncomeSum = -150000m
};
}
private static Outcome Run(
InterestTypeEnum interestType,
bool includeCloseDate,
DateTime? closeDate = null,
int restDays = 7,
eod_swap_position preEod = null,
decimal closePrecent = 1m,
DateTime? omitDate = null,
double closeRate = 0.0143,
string calcMode = "10",
int interestRule = 0,
bool newCalcLast = false,
Dictionary<DateTime, double> market = null,
DateTime? omitDate2 = null,
bool settment = false,
DateTime? exerciseDate = null)
{
var cd = closeDate ?? CloseDate;
var omit = new HashSet<DateTime>();
if (omitDate.HasValue || omitDate2.HasValue)
{
if (omitDate.HasValue) omit.Add(omitDate.Value.Date);
if (omitDate2.HasValue) omit.Add(omitDate2.Value.Date);
}
else if (!includeCloseDate) omit.Add(cd.Date);
var svc = new StubSwapDealService(MakeOptUser(), omit, closeRate, market);
var td = BuildTrade(cd, calcMode, exerciseDate);
var position = BuildPosition(interestType, cd, restDays, interestRule);
var eodList = preEod == null
? new List<eod_swap_position>()
: new List<eod_swap_position> { preEod };
try
{
var interests = svc.GetInterests(
td, td.trade_extend, cd, cd, eodList,
new List<swap_position> { position },
Notional, Notional, closePrecent,
(int)SwapEventTypeEnum., false, Notional,
false, settment: settment, newCalcLast: newCalcLast, closeList: null);
Assert.AreEqual(1, interests.Count, "应返回恰好 1 条利息事件");
return new Outcome { Fe = interests[0], Svc = svc };
}
catch (Exception ex)
{
return new Outcome { Ex = ex };
}
}
private static void AssertNoThrow(Outcome o, string scenario)
{
Assert.IsFalse(o.Threw, scenario + " 不应因平仓日 FR007 未发布而抛异常:" + o.Ex?.Message);
Assert.IsNotNull(o.Fe, scenario + " 应返回利息事件");
Assert.IsFalse(o.Fe.InterestAmount == 0 && o.Fe.FloatRate == 0, scenario + " 利息不应全为零");
}
[TestMethod]
public void Red_Compound_WithoutCloseDateFr007_Succeeds()
{
AssertNoThrow(Run(InterestTypeEnum., includeCloseDate: false), "复利-无preEod-缺平仓日");
}
[TestMethod]
public void Baseline_Compound_WithCloseDateFr007_Succeeds()
{
AssertNoThrow(Run(InterestTypeEnum., includeCloseDate: true), "复利-无preEod-有平仓日");
}
[TestMethod]
public void Red_Compound_FullClose_WithPreEod_WithoutCloseDateFr007_Succeeds()
{
AssertNoThrow(Run(InterestTypeEnum., includeCloseDate: false, preEod: BuildPreEod(new DateTime(2026, 7, 13))),
"复利-全平重放-缺平仓日");
}
[TestMethod]
public void Baseline_Compound_FullClose_WithPreEod_WithCloseDateFr007_Succeeds()
{
AssertNoThrow(Run(InterestTypeEnum., includeCloseDate: true, preEod: BuildPreEod(new DateTime(2026, 7, 13))),
"复利-全平重放-有平仓日");
}
[TestMethod]
public void Red_Simple_WithoutPreEod_WithoutCloseDateFr007_Succeeds()
{
AssertNoThrow(Run(InterestTypeEnum., includeCloseDate: false), "单利-无preEod-缺平仓日");
}
[TestMethod]
public void Red_Simple_WithPreEod_WithoutCloseDateFr007_Succeeds()
{
AssertNoThrow(Run(InterestTypeEnum., includeCloseDate: false, preEod: BuildPreEod(StartDate)),
"单利-带preEod-缺平仓日");
}
[TestMethod]
public void Consistency_Compound_SkipEqualsPreviousRate()
{
var worldA = Run(InterestTypeEnum., includeCloseDate: false);
var worldB = Run(InterestTypeEnum., includeCloseDate: true, closeRate: PreviousResetRate);
Assert.IsFalse(worldA.Threw, "世界A 不应抛:" + worldA.Ex?.Message);
Assert.IsFalse(worldB.Threw, "世界B 不应抛:" + worldB.Ex?.Message);
Assert.AreEqual(worldA.Fe.InterestAmount, worldB.Fe.InterestAmount,
"缺价跳过取价世界 应与 显式置上一期利率世界 利息完全一致(该日利率不参与计息,沿用上期)");
}
[TestMethod]
public void Consistency_Simple_SkipEqualsPreviousRate()
{
var worldA = Run(InterestTypeEnum., includeCloseDate: false, preEod: BuildPreEod(StartDate));
var worldB = Run(InterestTypeEnum., includeCloseDate: true, preEod: BuildPreEod(StartDate), closeRate: PreviousResetRate);
Assert.IsFalse(worldA.Threw, "世界A 不应抛:" + worldA.Ex?.Message);
Assert.IsFalse(worldB.Threw, "世界B 不应抛:" + worldB.Ex?.Message);
Assert.AreEqual(worldA.Fe.InterestAmount, worldB.Fe.InterestAmount,
"单利:缺价跳过取价世界 应与 显式置上一期利率世界 利息完全一致(该日利率不参与计息)");
}
[TestMethod]
public void Boundary_Compound_NonIntegerMultiple_LastResetStillPrices()
{
var o = Run(InterestTypeEnum., includeCloseDate: false, closeDate: NonIntCloseDate, omitDate: new DateTime(2026, 7, 20));
Assert.IsTrue(o.Threw, "非整倍数时末段重置日 7/20 缺价应抛异常(该日利率被消费)");
StringAssert.Contains(o.Ex.Message, "FR007");
}
[TestMethod]
public void Boundary_Compound_NonIntegerMultiple_WithCloseDateSucceeds()
{
AssertNoThrow(Run(InterestTypeEnum., includeCloseDate: true, closeDate: NonIntCloseDate),
"非整倍数-有7/20价-应成功");
}
[TestMethod]
public void Boundary_Simple_NonIntegerMultiple_LastResetStillPrices()
{
var o = Run(InterestTypeEnum., includeCloseDate: false, closeDate: NonIntCloseDate,
preEod: BuildPreEod(StartDate), omitDate: new DateTime(2026, 7, 20));
Assert.IsTrue(o.Threw, "单利 非整倍数时末段重置日 7/20 缺价应抛异常");
StringAssert.Contains(o.Ex.Message, "FR007");
}
[TestMethod]
public void Boundary_Simple_NonIntegerMultiple_WithCloseDateSucceeds()
{
AssertNoThrow(Run(InterestTypeEnum., includeCloseDate: true, closeDate: NonIntCloseDate, preEod: BuildPreEod(StartDate)),
"单利-非整倍数-有7/20价-应成功");
}
// ── 算尾守卫(EQD-6968 方案一场景4:算尾+当前营业日+当日重置日+当日缺价 → 必须仍拦截)──
// 放宽只针对"该日利率不参与计息"的场景;算尾时当日利率被消费,缺价拦截是正确依赖,不得误放。
[TestMethod]
public void Guard_TailCalced_ResetDayFr007Missing_StillThrows()
{
var o = Run(InterestTypeEnum., includeCloseDate: false, calcMode: "11",
preEod: BuildPreEod(new DateTime(2026, 7, 13)));
Assert.IsTrue(o.Threw, "算尾(11)+当日重置日+当日缺价 → 应拦截(该日利率被消费)");
StringAssert.Contains(o.Ex.Message, "FR007");
}
[TestMethod]
public void Guard_TailCalced_Simple_ResetDayFr007Missing_StillThrows()
{
var o = Run(InterestTypeEnum., includeCloseDate: false, calcMode: "11",
preEod: BuildPreEod(StartDate));
Assert.IsTrue(o.Threw, "单利 算尾(11)+当日重置日+当日缺价 → 应拦截");
StringAssert.Contains(o.Ex.Message, "FR007");
}
[TestMethod]
public void Baseline_TailCalced_ResetDayFr007Present_Succeeds()
{
AssertNoThrow(Run(InterestTypeEnum., includeCloseDate: true, calcMode: "11",
preEod: BuildPreEod(new DateTime(2026, 7, 13))),
"算尾(11)+当日重置日+当日有价 → 应成功");
}
// ── newCalcLast 守卫:交易本身"10"不算尾,但本次平仓显式指定算尾 → effectiveCalcLast=true → 缺价仍拦截 ──
[TestMethod]
public void Guard_NewCalcLast_OverridesToTail_MissingPrice_StillThrows()
{
var o = Run(InterestTypeEnum., includeCloseDate: false, newCalcLast: true,
preEod: BuildPreEod(new DateTime(2026, 7, 13)));
Assert.IsTrue(o.Threw, "不算尾(10)+本次平仓指定算尾+当日缺价 → 应按算尾拦截");
StringAssert.Contains(o.Ex.Message, "FR007");
}
[TestMethod]
public void Baseline_NewCalcLast_OverridesToTail_WithPrice_Succeeds()
{
AssertNoThrow(Run(InterestTypeEnum., includeCloseDate: true, newCalcLast: true,
preEod: BuildPreEod(new DateTime(2026, 7, 13))),
"不算尾(10)+本次平仓指定算尾+当日有价 → 应成功");
}
// ── 前一营业日基准(interest_rule=-1EQD-6968 方案一场景2)──
// 取价日=重置日前一营业日,当日(7/20)定盘未发布也用不到 → 放行;
// 但取价日(前一营业日)本身缺价 → 仍是真实依赖,必须拦截。
[TestMethod]
public void PrevBizDay_TailCalced_ResetDayTodayMissing_Succeeds()
{
AssertNoThrow(Run(InterestTypeEnum., includeCloseDate: true, calcMode: "11", interestRule: -1,
omitDate: CloseDate, market: Fr007MarketPrevBizDay, preEod: BuildPreEod(new DateTime(2026, 7, 13))),
"算尾(11)+前一营业日基准+当日(7/20)未发布 → 应放行(取价日7/17已发布)");
}
[TestMethod]
public void PrevBizDay_TailCalced_Simple_ResetDayTodayMissing_Succeeds()
{
AssertNoThrow(Run(InterestTypeEnum., includeCloseDate: true, calcMode: "11", interestRule: -1,
omitDate: CloseDate, market: Fr007MarketPrevBizDay, preEod: BuildPreEod(new DateTime(2026, 7, 13))),
"单利 算尾(11)+前一营业日基准+当日未发布 → 应放行");
}
[TestMethod]
public void PrevBizDay_NoTail_ResetDayTodayMissing_Succeeds()
{
AssertNoThrow(Run(InterestTypeEnum., includeCloseDate: true, interestRule: -1,
omitDate: CloseDate, market: Fr007MarketPrevBizDay, preEod: BuildPreEod(new DateTime(2026, 7, 13))),
"不算尾(10)+前一营业日基准+当日未发布 → 应放行");
}
[TestMethod]
public void PrevBizDay_TailCalced_FixingDayMissing_StillThrows()
{
// 取价日候选 7/17(周五,正常日历回拨) 与 7/19(周日,退化日历不回拨) 都扣掉 → 两种日历态下都缺价
var o = Run(InterestTypeEnum., includeCloseDate: true, calcMode: "11", interestRule: -1,
omitDate: new DateTime(2026, 7, 17), omitDate2: new DateTime(2026, 7, 19),
market: Fr007MarketPrevBizDay, preEod: BuildPreEod(new DateTime(2026, 7, 13)));
Assert.IsTrue(o.Threw, "算尾(11)+前一营业日基准+取价日本身缺价 → 仍应拦截(真实依赖)");
StringAssert.Contains(o.Ex.Message, "FR007");
}
// ── 算尾+当前营业日+当日非重置日(EQD-6968 方案一场景3)──
// 当日价未被任何计息段消费(末段重置日7/20是历史日),当日(7/22)缺价 → 放行。
[TestMethod]
public void TailCalced_NonResetDay_TodayMissing_Succeeds()
{
AssertNoThrow(Run(InterestTypeEnum., includeCloseDate: true, calcMode: "11", closeDate: NonIntCloseDate,
preEod: BuildPreEod(new DateTime(2026, 7, 13))),
"算尾(11)+当日非重置日+当日(7/22)缺价 → 应放行(非重置日不取当日价)");
}
[TestMethod]
public void TailCalced_NonResetDay_Simple_TodayMissing_Succeeds()
{
AssertNoThrow(Run(InterestTypeEnum., includeCloseDate: true, calcMode: "11", closeDate: NonIntCloseDate,
preEod: BuildPreEod(new DateTime(2026, 7, 13))),
"单利 算尾(11)+当日非重置日+当日缺价 → 应放行");
}
// ── 不算头不算尾(calcMode="00")CalcFirst=false 组合 ──
// 对尾日 FR007 行为与"10"一致(calcLast 同 false);另以单利精确断言钉 CalcFirst 语义——
// "00" 比"10"恰好少计开始日一天的利息(单利无基数效应,差值可精确到分毫)。
[TestMethod]
public void Red_Compound_NoHeadNoTail_WithoutCloseDateFr007_Succeeds()
{
AssertNoThrow(Run(InterestTypeEnum., includeCloseDate: false, calcMode: "00",
preEod: BuildPreEod(new DateTime(2026, 7, 13))),
"不算头不算尾(00)+缺平仓日价 → 应放行(与10同口径,尾日不参与计息)");
}
[TestMethod]
public void CalcFirst_Simple_NoHeadDropsExactlyStartDayInterest()
{
// 无日终快照时 priorValueDate=开始日-1(首重置日 7/6 恒在取价窗内),两世界首段利率同为 7/6 定盘;
// "00" 比"10"恰好少计开始日一天——精确断言钉 CalcFirst 边界与首重置日取价窗。
var w10 = Run(InterestTypeEnum., includeCloseDate: true, calcMode: "10");
var w00 = Run(InterestTypeEnum., includeCloseDate: true, calcMode: "00");
Assert.IsFalse(w10.Threw, "10 不应抛:" + w10.Ex?.Message);
Assert.IsFalse(w00.Threw, "00 不应抛:" + w00.Ex?.Message);
// 开始日 7/6 属首段:all-in = spread(-0.0155) + 定盘(0.0142) = -0.0013
// 单利下 00 与 10 的利息差 = 恰好首日一天利息(年化 A365)。
var expectedStartDayInterest = Notional * (Spread + 0.0142m) / AnnualDays;
Assert.AreEqual(expectedStartDayInterest, w10.Fe.InterestAmount - w00.Fe.InterestAmount, 0.0000001m,
"不算头(00)应恰好少计开始日一天利息(CalcFirst 回归锚)");
}
// ── 事件利率确定性(EQD-6968 自洽化):排除日不取价,事件利率=末段已消费利率 ──
// 同一交易同一天,尾日价缺(上午平仓) vs 有(下午平仓):金额与落库 FloatRate 必须完全一致,
// 杜绝"记录利率取决于点击时刻"。
[TestMethod]
public void Determinism_NoTail_EventFloatRateIndependentOfPublishTime()
{
var wMorning = Run(InterestTypeEnum., includeCloseDate: false, calcMode: "10",
preEod: BuildPreEod(new DateTime(2026, 7, 13)));
var wAfternoon = Run(InterestTypeEnum., includeCloseDate: true, calcMode: "10", closeRate: 0.0199,
preEod: BuildPreEod(new DateTime(2026, 7, 13)));
Assert.IsFalse(wMorning.Threw, "上午世界不应抛:" + wMorning.Ex?.Message);
Assert.IsFalse(wAfternoon.Threw, "下午世界不应抛:" + wAfternoon.Ex?.Message);
Assert.AreEqual(wMorning.Fe.InterestAmount, wAfternoon.Fe.InterestAmount,
"金额不应因尾日价发布与否而变化(尾日利率零消费)");
Assert.AreEqual(0.01425m, wMorning.Fe.FloatRate,
"事件利率=末段已消费利率(7/13定盘 0.01425),非尾日价");
Assert.AreEqual(wMorning.Fe.FloatRate, wAfternoon.Fe.FloatRate,
"事件利率必须与平仓时刻(尾日价发布前后)无关");
}
// ── 快照利率携带契约(carry-forward):带 preEod 时不重复取 ≤ValueDate 的重置日 ──
// fetchAfterDate=preEod.ValueDate + seed=preEod.FloatRate 是设计分工:≤上一日终的重置日
// 沿用快照携带的"截至 ValueDate 生效利率"(真实 EOD 快照由当日重置日再定盘写入),
// >上一日终的重新取价。与无日终场景的根本区别:种子有正确来源,不需要强制重取首重置日。
[TestMethod]
public void CarryForward_Simple_NoHead_PreEodAtStartCarriesFirstPeriodRate()
{
// preEod.ValueDate=开始日(7/6)FloatRate=7/6定盘0.0142(模拟开始日EOD快照的真实语义)
var o = Run(InterestTypeEnum., includeCloseDate: true, calcMode: "00",
closeDate: new DateTime(2026, 7, 10), preEod: BuildPreEod(StartDate, 0.0142m));
Assert.IsFalse(o.Threw, "不应抛:" + o.Ex?.Message);
Assert.AreEqual(0, o.Svc.PricedDates.Count,
"≤上一日终(7/6)的重置日不重复取价——首段利率由快照携带(carry-forward 契约)");
Assert.AreEqual(0.0142m, o.Fe.FloatRate,
"首段(也是末段)利率=快照携带的 7/6 定盘");
// 不算头不算尾:计息日 [7/7,7/10) 共 3 天 @ (spread+0.0142);单利重放含上日待实现(-100000)
Assert.AreEqual(-100000m + Notional * (Spread + 0.0142m) * 3m / AnnualDays, o.Fe.InterestAmount, 0.0000001m,
"金额=上日待实现+3天×(spread+快照利率),首段未误用种子外的任何值");
}
[TestMethod]
public void Eod_NoHead_StartDay_SnapshotRateCarriesFirstFixing()
{
// 链条起点钉死:开始日当天 EOD("00",窗口为空 interestStart=7/7>interestEnd=7/6)——
// 窗口为空时"不算尾不取价"分支不命中,走正常取价,快照 FloatRate=开始日定盘。
// 次日重放才能以 fetchAfter=开始日 + 携带利率=开始日定盘 正确续算(见上一用例)。
var o = Run(InterestTypeEnum., includeCloseDate: true, calcMode: "00",
closeDate: StartDate, settment: true);
AssertNoThrow(o, "开始日EOD(00) 不应抛");
Assert.AreEqual(0.0142m, o.Fe.FloatRate,
"开始日EOD快照利率=当日(首重置日)定盘——窗口为空不触发不取价分支");
}
// ── 窗口判定语义(InitInterestDate 直测;死子句 td.StartDate>interestStart 删除后的边界钉死)──
[TestMethod]
public void WindowSemantics_NoHeadStartDay_EmptyViaFirstClause()
{
var svc = new StubSwapDealService(MakeOptUser(), new HashSet<DateTime>());
var td = BuildTrade(StartDate, "00"); // ExerciseDate=7/7
bool empty = svc.InitInterestDate(StartDate, null, td, tdClose: false,
out var start, out var end);
Assert.IsTrue(empty, "不算头首日:interestStart=7/7 > interestEnd=7/6 → 窗口为空(第一子句兜住)");
Assert.AreEqual(StartDate, end);
}
[TestMethod]
public void WindowSemantics_SameDaySettle_EqualDates_NotEmpty()
{
var svc = new StubSwapDealService(MakeOptUser(), new HashSet<DateTime>());
var td = BuildTrade(new DateTime(2026, 7, 10), "10"); // ExerciseDate=7/11
bool empty = svc.InitInterestDate(new DateTime(2026, 7, 10), new DateTime(2026, 7, 10), td, tdClose: false,
out var start, out var end);
Assert.IsFalse(empty, "当日已结息(日期相等)窗口非空——利息归零由 GetInterests closeList 净额层处理,不在此判定");
Assert.AreEqual(new DateTime(2026, 7, 10), start);
Assert.AreEqual(new DateTime(2026, 7, 10), end);
}
[TestMethod]
public void WindowSemantics_SameDaySettle_OnMaturityRollback_Empty()
{
var svc = new StubSwapDealService(MakeOptUser(), new HashSet<DateTime>());
var td = BuildTrade(CloseDate, "00", exerciseDate: CloseDate); // 到期日=7/20 且不算尾
bool empty = svc.InitInterestDate(CloseDate, CloseDate, td, tdClose: false,
out var start, out var end);
Assert.IsTrue(empty, "当日已结息+到期日不算尾回拨:interestStart=7/20 > interestEnd=7/19 → 窗口为空");
}
// ── EOD 收盘归档路径(settment=true,此前全套件仅覆盖盘中 settment:false)──
// EOD 不取尾日价的依赖链:InitInterestDate 到期日回拨(endDate=D-1) + CalcEodInterest 的
// calcToday=false(valueDate==到期日且不算尾) 整体跳过 ByEod 重算——ByEod 的取价
// (CalcDailyCompoundInterestByEod/CalcDailySimpleInterestByEod 的 isResetDay→ResolveFloatRate)
// 不看 calcLast,任何一环回归都会让到期日收盘重新索要尾日 FR007。以下三例钉死该链。
[TestMethod]
public void Eod_NoTail_MaturityDayFr007Missing_Succeeds()
{
// 到期日=7/20(重置日)当天收盘,尾日价未发布 → 不算尾应放行(回拨+跳过重算两道闸)
AssertNoThrow(Run(InterestTypeEnum., includeCloseDate: false, calcMode: "10",
exerciseDate: CloseDate, preEod: BuildPreEod(new DateTime(2026, 7, 13)), settment: true),
"EOD 不算尾(10)+到期日缺价 → 应放行(尾日不参与计息,不得取价)");
}
[TestMethod]
public void Eod_Tail_MaturityDayFr007Missing_StillThrows()
{
var o = Run(InterestTypeEnum., includeCloseDate: false, calcMode: "11",
exerciseDate: CloseDate, preEod: BuildPreEod(new DateTime(2026, 7, 13)), settment: true);
Assert.IsTrue(o.Threw, "EOD 算尾(11)+到期日(重置日)缺价 → 应拦截(该日利率被消费)");
StringAssert.Contains(o.Ex.Message, "FR007");
}
[TestMethod]
public void Eod_MidTradeResetDayFr007Missing_StillThrows()
{
// 非到期日的盘中重置日:不算尾也不豁免——新利率自当日起被持续持仓消费,ByEod 必须取到
var o = Run(InterestTypeEnum., includeCloseDate: false, calcMode: "10",
preEod: BuildPreEod(new DateTime(2026, 7, 13)), settment: true);
Assert.IsTrue(o.Threw, "EOD 不算尾(10)+非到期重置日(7/20)缺价 → 仍应拦截(ByEod 取价链,真实依赖)");
StringAssert.Contains(o.Ex.Message, "FR007");
}
// ── 到期日当天全平:replayEndDate=endDate+1 补计分支(exclusionStart 的存在理由)──
// 不算尾时 InitInterestDate 把 endDate 回拨一天;最终全平的历史差分重放需把窗口补回真实
// 平仓/到期日(replayEndDate=endDate+1),但该边界日的定盘经 exclusionStart 标记为
// "有价则取/缺价跳过"——否则到期日上午全平会被尾日价误拦(EQD-6968 在到期日的镜像场景)。
[TestMethod]
public void FinalClose_OnMaturityDay_NoTail_CloseDayFr007Missing_Succeeds()
{
AssertNoThrow(Run(InterestTypeEnum., includeCloseDate: false, calcMode: "10",
exerciseDate: CloseDate, preEod: BuildPreEod(new DateTime(2026, 7, 13))),
"到期日当天全平+不算尾+到期日(重置日)缺价 → 应放行(补计重放的边界日不索取定盘)");
}
[TestMethod]
public void Guard_FinalClose_OnMaturityDay_Tail_CloseDayFr007Missing_StillThrows()
{
var o = Run(InterestTypeEnum., includeCloseDate: false, calcMode: "11",
exerciseDate: CloseDate, preEod: BuildPreEod(new DateTime(2026, 7, 13)));
Assert.IsTrue(o.Threw, "到期日当天全平+算尾+到期日缺价 → 应拦截(该日利率被消费)");
StringAssert.Contains(o.Ex.Message, "FR007");
}
}
}
@@ -0,0 +1,120 @@
using System;
using System.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using YLErp.DBModels;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// EQD-6968 UAT 辅助:从 96 真实库抽取"在途 FR007 互换"具体历史交易,
/// 打印完整交易要素,供 UAT 直接选用(替代手动猜要素)。
/// 标 [Ignore],手动跑一次即可;依赖 app.config 中 xray 连接(你的环境已指向 96)。
/// 复用 GLMS20260105GoldenTest 的连库写法:DbContextFactory.GetYLDbContext()。
/// 用原生 ADO.NET 读结果,规避 EF 实体映射类型踩坑。
/// </summary>
[TestClass]
public class GLMS20260819Fr007TradeDiscoveryTest
{
private const string TradeSql = @"
SELECT
t.id AS TradeId,
p.id AS PositionId,
t.TradeNumber AS TradeNumber,
t.StartDate AS StartDate,
t.ExerciseDate AS ExerciseDate,
t.ValidState AS ValidState,
p.interest_rest_days AS interest_rest_days,
p.interest_rule AS interest_rule,
p.FloatRateUnderlyingCode AS FloatRateUnderlyingCode,
p.IsInitial AS IsInitial,
p.InterestType AS InterestType,
p.InterestMode AS InterestMode,
CASE WHEN te.ExtendJson LIKE '%""InterestCalcMode""%'
THEN SUBSTRING_INDEX(SUBSTRING_INDEX(te.ExtendJson, '""InterestCalcMode"":""', -1), '""', 1)
ELSE '11' END AS InterestCalcMode
FROM trade t
JOIN swap_position p ON p.SwapTradeId = t.id
LEFT JOIN trade_extend te ON te.TradeId = t.id
WHERE t.ValidState = 'Valid'
AND p.Invalid = 0
AND p.IsInitial = 1
AND p.FloatRateUnderlyingCode = 'FR007'
AND t.ExerciseDate >= CURDATE()
ORDER BY t.StartDate;";
private const string FixingSql = @"
SELECT ValueDate, ReferencePrice
FROM eod_commodity_future_price
WHERE FutureContractId = 'FR007'
AND ValueDate >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
ORDER BY ValueDate DESC;";
private TestContext _testContext;
public TestContext TestContext
{
get => _testContext;
set => _testContext = value;
}
private static string Fmt(object v) =>
v == null || v == DBNull.Value ? "NULL"
: (v is DateTime dt ? dt.ToString("yyyy-MM-dd") : v.ToString());
[TestMethod]
[Ignore]
[TestCategory("Discovery")]
public void Discover_InTransitFr007Trades()
{
using (var db = DbContextFactory.GetYLDbContext())
{
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open) conn.Open();
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = TradeSql;
using (var reader = cmd.ExecuteReader())
{
int n = 0;
while (reader.Read())
{
n++;
TestContext.WriteLine(
$"TradeId={reader["TradeId"]} PosId={reader["PositionId"]} No={reader["TradeNumber"]} " +
$"Start={Fmt(reader["StartDate"])} Expr={Fmt(reader["ExerciseDate"])} " +
$"CalcMode={Fmt(reader["InterestCalcMode"])} rule={Fmt(reader["interest_rule"])} rest={Fmt(reader["interest_rest_days"])} " +
$"IntType={Fmt(reader["InterestType"])} Mode={Fmt(reader["InterestMode"])}");
}
TestContext.WriteLine($"=== 在途 FR007 互换共 {n} 笔 ===");
}
}
}
}
[TestMethod]
[Ignore]
[TestCategory("Discovery")]
public void Discover_Fr007FixingStatus()
{
using (var db = DbContextFactory.GetYLDbContext())
{
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open) conn.Open();
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = FixingSql;
using (var reader = cmd.ExecuteReader())
{
int n = 0;
while (reader.Read())
{
n++;
TestContext.WriteLine($"FR007 ValueDate={Fmt(reader["ValueDate"])} ReferencePrice={Fmt(reader["ReferencePrice"])}");
}
TestContext.WriteLine($"=== FR007 定盘近 30 天共 {n} 条 ===");
}
}
}
}
}
}
@@ -0,0 +1,470 @@
using Newtonsoft.Json;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// GetInterests 双显式入口语义字符化测试(Step3"特判降级"的前置钉子)。
///
/// 背景:GetIntradayUnwindInterests(盘中:平仓前剩余×实际比例)与
/// CalcEodPostCloseSettleInterestsEOD平仓后收盘:平仓后剩余×恒1)是同一经济事件
/// (部分平仓)的两套传参语义,靠 GetInterests 内 mode2 无条件覆盖 / mode9 全平兜底粘合。
/// 本测试钉死当前行为,使后续特判降级/语义重构有回归网:
/// ① 复利×mode2closePrincipal(特判产物)是 CalcDailyCompoundInterest 的重放本金——
/// 两入口 closePosiNotionalValue 均为实际平掉额 → InterestAmount 必须相等;
/// ② 单利×mode2CalcDailySimpleInterest 消费的是 posiPrincipal×closePercent——
/// 盘中(平仓前×比例) vs EOD(剩余×1) 数值口径可能不同,本测试【记录现状】(见各断言注释);
/// ③ mode9 全平(posi=0):兜底覆盖生效,结息额非零。
///
/// 数据基建复用 GetInterestsUnitTest_T0 的构建器口径(T+04/27起息,"11"算头算尾)。
/// </summary>
[TestClass]
public class GetInterestsEntrySemanticsTest
{
private const decimal Principal = 1000m;
private const decimal FixedRate = 0.01m;
private const decimal FloatRate = 0.001m;
private const int AnnualDays = 365;
private const int ResetPeriod = 3;
private static readonly DateTime TradeDate = new(2026, 4, 27);
private static readonly DateTime StartDate = new(2026, 4, 27);
private static readonly DateTime ExerciseDate = new(2027, 4, 27);
private static readonly DateTime UnwindDate = new(2026, 4, 30);
// 平仓前剩余 1000,平掉 30%(300),收盘后剩余 700
private const decimal PreClose = 1000m;
private const decimal Closed = 300m;
private const decimal Remaining = 700m;
private const decimal ClosePercent = 0.3m;
#region Stub T0
private sealed class StubSwapDealService : SwapDealService
{
private readonly IReadOnlyDictionary<DateTime, double> _floatRates;
public StubSwapDealService(OptUserInfo optUser, IReadOnlyDictionary<DateTime, double> floatRates) : base(optUser)
{
_floatRates = floatRates;
}
protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate)
{
if (!string.Equals(underlyingCode, "FR007", StringComparison.OrdinalIgnoreCase)) { rate = 0; return false; }
if (_floatRates.TryGetValue(valueDate.Date, out rate)) return true;
rate = 0;
return false;
}
// 离线自洽:本测试场景无历史已结利息,等价于此前"空库查询返回 0"的行为,
// 使复利路径(GetConsumedInterest)不再依赖数据库连通(YLErp_UNIT_TEST_SKIP_INITIALIZATION=1 可跑)。
public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate)
=> 0m;
}
private static SwapDealService CreateService() => new StubSwapDealService(
new OptUserInfo(0, nameof(GetInterestsEntrySemanticsTest), OptUserFrom.UnitTest),
new Dictionary<DateTime, double>
{
[new DateTime(2026, 4, 27)] = (double)FloatRate,
[new DateTime(2026, 4, 28)] = (double)FloatRate,
[new DateTime(2026, 4, 29)] = (double)FloatRate,
[new DateTime(2026, 4, 30)] = (double)FloatRate,
});
#endregion
#region T0
private static trade CreateTrade()
{
var extend = new trade_extend
{
TradeId = 1,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "11", // 算头算尾
SettlementRules = 0
})
};
return new trade
{
id = 1, TradeNumber = "UT-INT-ENTRY-SEMANTICS", ClientId = 999998,
TradeType = "收益互换", TradeDate = TradeDate, StartDate = StartDate,
ExerciseDate = ExerciseDate, TradeStatus = "确认成交", ValidState = "Valid",
trade_extend = extend
};
}
private static swap_position CreatePosition(InterestModeEnum mode, InterestTypeEnum interestType, bool floating = false)
{
var intervalModels = new List<IntervalModel>
{
new IntervalModel { Date = ExerciseDate, Rate = FixedRate, Settlement = 0 }
};
return new swap_position
{
id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum., InterestMode = (int)mode,
InterestRateDefault = FixedRate, InterestPrincipalFix = Principal,
PosiStartDate = StartDate, PosiMatuirityDate = ExerciseDate,
IsInitial = true, Invalid = false, InterestType = (int)interestType,
IsAnnualized = true, interest_rest_days = ResetPeriod, interest_rule = 0,
FloatRateUnderlyingCode = floating ? "FR007" : null,
InterestSwapInterval = JsonConvert.SerializeObject(intervalModels)
};
}
private static eod_swap_position CreatePreEod(decimal interestSum, decimal principal)
=> new()
{
id = 1, SwapTradeId = 1, PositionId = 1001, ValueDate = new DateTime(2026, 4, 29),
ClientId = 999998, FloatRate = FloatRate, TdInterestPrincipal = principal,
PosiNotionalValue = principal, InterestIncomeSum = interestSum, InterestProfitSum = interestSum
};
#endregion
/// <summary>
/// 复利×mode2×部分平仓30%:【同请求形状⇒同额】oracle(契约目标语义,修复落地时的现成回归网)。
///
/// 修复前(b01b485e 钉住的分歧):盘中 0.036165(平掉额全程重放=确认书公式)vs
/// EOD 0.059042(恒1 掉进全平专属分支,全腿待实现+末段增量,无契约依据,重算结果被丢弃)。
/// 说明:EOD 平仓后收盘走恒1惯例形状;端到端结算结果由 DI_EXCEL_SCENARIO4 家族对账确认书公式保障
/// (平仓前剩余+实际平掉额+真实比例),部分平仓不再进 closePrecent==1 分支。
/// (最终全平=剩余额×∏利率,2026-08-18 手算复核 Excel BL/BN 均符合)。
/// 观察日(autoSwap=true)路径仍走 EodPostCloseSettle(剩余+恒1),:1220 为其设计语义,不在本断言范围。
/// </summary>
[TestMethod]
public void _mode2_部分平仓_双入口契约口径一致()
{
var td = CreateTrade();
var position = CreatePosition(InterestModeEnum., InterestTypeEnum., floating: true);
var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose);
var eodPositions = new List<eod_swap_position> { preEod };
var positions = new List<swap_position> { position };
var intraday = CreateService().GetIntradayUnwindInterests(InterestCalcRequest.IntradayUnwind(
td, td.trade_extend, UnwindDate, UnwindDate, eodPositions, positions,
PreClose, Closed, ClosePercent,
(int)SwapEventTypeEnum., tdClose: true, orginPv: PreClose, add: true, newCalcLast: false, closeList: null));
// 契约口径形状(与盘中同形状):平仓前剩余 1000 + 平掉额 300 + 真实比例 0.3
var eodPostClose = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
eodPositions, positions, PreClose, Closed, ClosePercent,
(int)SwapEventTypeEnum., tdClose: false, orginPv: PreClose,
add: true, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, intraday.Count);
Assert.AreEqual(1, eodPostClose.Count);
Console.WriteLine($"[复利mode2] 盘中 InterestAmount={intraday[0].InterestAmount} / EOD={eodPostClose[0].InterestAmount}");
// 契约 oracle:两入口同请求形状必须同额(=确认书公式"平掉额×全程参考利率")
Assert.AreEqual(intraday[0].InterestAmount, eodPostClose[0].InterestAmount, 0.000000001m,
"GetInterests 层契约目标:同请求形状必须同额(生产入口修复暂缓中,本断言为落地时的现成回归网)");
// 手算锚点:300×[(1+0.011×3/365)×(1+0.011×1/365)1]=0.036165(确认书公式)
Assert.AreEqual(0.036165m, Math.Round(intraday[0].InterestAmount, 6, MidpointRounding.AwayFromZero),
"盘中重放=契约公式手算锚点 0.036165");
}
/// <summary>
/// 【回归钉子】复利×mode2×部分平仓:观察日路径(EodPostCloseSettle 剩余+恒1)保持设计语义不回退。
/// 修复只改 autoSwap=false 分支;观察日恒1 全量结息是 :1220 分支的设计意图(结现),锁死其当前值。
/// </summary>
[TestMethod]
public void _mode2_部分平仓_观察日恒1语义保持()
{
var td = CreateTrade();
var position = CreatePosition(InterestModeEnum., InterestTypeEnum., floating: true);
var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose);
var eodPositions = new List<eod_swap_position> { preEod };
var positions = new List<swap_position> { position };
var observationDay = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
eodPositions, positions, Remaining, Closed, 1m,
(int)SwapEventTypeEnum., tdClose: false, orginPv: PreClose,
add: true, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, observationDay.Count);
Assert.AreEqual(0.059041913305m, observationDay[0].InterestAmount, 0.000000001m,
"观察日(autoSwap=true)路径:剩余+恒1 的全平分支为其设计语义(结现),修复不得改变此值");
}
/// <summary>
/// 单利×mode2×部分平仓30%:记录两入口当前口径(快照×比例 vs 重放基数差异面)。
/// 单利消费 posiPrincipal×closePercent:盘中 1000×0.3 vs EOD 700×1 —— 若两值不等,
/// 这是当前系统的已知口径差异面(非断言失败项),数值以 Console 留档,供特判降级时对照。
/// </summary>
[TestMethod]
public void _mode2_部分平仓_双入口口径留档()
{
var td = CreateTrade();
var position = CreatePosition(InterestModeEnum., InterestTypeEnum.);
var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose);
var eodPositions = new List<eod_swap_position> { preEod };
var positions = new List<swap_position> { position };
var intraday = CreateService().GetIntradayUnwindInterests(InterestCalcRequest.IntradayUnwind(
td, td.trade_extend, UnwindDate, UnwindDate, eodPositions, positions,
PreClose, Closed, ClosePercent,
(int)SwapEventTypeEnum., tdClose: true, orginPv: PreClose, add: true, newCalcLast: false, closeList: null));
// 契约口径形状(与盘中同形状):平仓前剩余 1000 + 平掉额 300 + 真实比例 0.3
var eodPostClose = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
eodPositions, positions, PreClose, Closed, ClosePercent,
(int)SwapEventTypeEnum., tdClose: false, orginPv: PreClose,
add: true, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, intraday.Count);
Assert.AreEqual(1, eodPostClose.Count);
Console.WriteLine($"[单利mode2] 盘中 InterestAmount={intraday[0].InterestAmount} / EOD={eodPostClose[0].InterestAmount}");
Console.WriteLine($"[单利mode2] TdInterestAmount: 盘中={intraday[0].TdInterestAmount} / EOD={eodPostClose[0].TdInterestAmount}");
// 契约目标:两入口同请求形状必须同额(单利:平掉额基数 + 快照×比例链路一致)
Assert.AreEqual(intraday[0].InterestAmount, eodPostClose[0].InterestAmount, 0.000000001m,
"GetInterests 层契约目标:单利×mode2 同请求形状必须同额(生产入口修复暂缓中)");
Assert.IsTrue(intraday[0].InterestAmount != 0m, "盘中单利结息额不应为0");
}
/// <summary>
/// mode9 全平(契约目标形状:平仓前剩余=平掉额=1000、比例恒1):
/// 结息额非零且=全平语义(:1220 全平分支:待实现+末段增量,尾差一次带走——设计意图维持)。
/// </summary>
[TestMethod]
public void _mode9_全平_兜底覆盖生效结息额非零()
{
var td = CreateTrade();
var position = CreatePosition(InterestModeEnum., InterestTypeEnum., floating: true);
var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose);
var eodPositions = new List<eod_swap_position> { preEod };
var positions = new List<swap_position> { position };
// 全平:平仓前剩余=平掉=1000,比例恒1(全平专属分支)
var result = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
eodPositions, positions, PreClose, PreClose, 1m,
(int)SwapEventTypeEnum., tdClose: false, orginPv: PreClose,
add: true, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, result.Count);
Console.WriteLine($"[复利mode9全平] InterestAmount={result[0].InterestAmount}");
Assert.IsTrue(result[0].InterestAmount != 0m,
"mode9 全平:结息本金=平掉额(1000),结息额非零(全平语义钉子)");
}
#region CalcEodPostCloseSettleInterests
/// <summary>
/// 参数捕获 stub:拦下 CalcSwapInterests 的全部实参,不触库、不真算。
/// </summary>
private sealed class CalcSwapInterestsCapture : TestableSwapEodPositionService
{
public CalcSwapInterestsCapture() : base(nameof(GetInterestsEntrySemanticsTest)) { }
public List<swap_flow_event> CapturedCloseList = null;
public bool CapturedTdClose;
public int CapturedEventType;
public decimal CapturedPosiNotional;
public decimal CapturedClosePosiNotional;
public decimal CapturedClosePercent;
public decimal CapturedOrginPv;
public bool CapturedAdd;
public bool CapturedSettment;
public bool CapturedNewCalcLast;
public int CallCount;
protected override List<swap_flow_event> CalcSwapInterests(
trade td, trade_extend tradeExtend,
DateTime valueDate, DateTime unwindDate,
List<eod_swap_position> eodPositions, List<swap_position> positions,
decimal posiNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent,
int eventType, bool tdClose,
decimal orginPv,
bool add = false, bool settment = true, bool newCalcLast = false,
List<swap_flow_event> closeList = null)
{
CallCount++;
CapturedTdClose = tdClose; CapturedEventType = eventType;
CapturedPosiNotional = posiNotionalValue; CapturedClosePosiNotional = closePosiNotionalValue;
CapturedClosePercent = closePrecent; CapturedOrginPv = orginPv;
CapturedAdd = add; CapturedSettment = settment; CapturedNewCalcLast = newCalcLast;
CapturedCloseList = closeList;
return new List<swap_flow_event>();
}
public List<swap_flow_event> ExposedEodPostCloseSettle(InterestCalcRequest req)
=> CalcEodPostCloseSettleInterests(req);
}
/// <summary>
/// 钉死 InterestCalcRequest.EodPostCloseSettle 工厂 → CalcEodPostCloseSettleInterests →
/// CalcSwapInterests 的位置参数转发契约。这段转发是位置传参最易错位的环节
/// posiNotionalValue/closePosiNotionalValue/orginPv 三个相邻同型 decimal,编译器不查错位),
/// 任何映射改动(含将来删 needPrice/grossPrice 死参数)都必须保持本断言绿。
/// </summary>
[TestMethod]
public void EOD平仓后收盘_工厂到接缝_参数映射钉死()
{
var td = CreateTrade();
var position = CreatePosition(InterestModeEnum., InterestTypeEnum.);
var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose);
var positions = new List<swap_position> { position };
var stub = new CalcSwapInterestsCapture();
var req = InterestCalcRequest.EodPostCloseSettle(
td, td.trade_extend, UnwindDate, UnwindDate,
new List<eod_swap_position> { preEod }, positions,
remainingNotionalAfterClose: Remaining,
closedNotional: Closed,
eventType: (int)SwapEventTypeEnum., tdClose: false,
orginPv: PreClose, add: true, newCalcLast: false);
stub.ExposedEodPostCloseSettle(req);
Assert.AreEqual(1, stub.CallCount, "默认实现应恰好调用一次 CalcSwapInterests(虚接缝兼容既有测试替身)");
Assert.AreEqual(Remaining, stub.CapturedPosiNotional, "posiNotionalValue 位 = 平仓后剩余(700)——语义核心,错位即红");
Assert.AreEqual(Closed, stub.CapturedClosePosiNotional, "closePosiNotionalValue 位 = 实际平掉额(300)");
Assert.AreEqual(1m, stub.CapturedClosePercent, "closePrecent 恒 1(全额结息)");
Assert.AreEqual((int)SwapEventTypeEnum., stub.CapturedEventType);
Assert.IsFalse(stub.CapturedTdClose);
Assert.AreEqual(PreClose, stub.CapturedOrginPv, "orginPv 位 = 上一日终本金——与相邻 decimal 最易错位处");
Assert.IsTrue(stub.CapturedAdd);
Assert.IsFalse(stub.CapturedSettment, "settment=false:走盘中重放算法(EOD平仓后收盘复用重放)");
Assert.IsFalse(stub.CapturedNewCalcLast);
Assert.IsNull(stub.CapturedCloseList, "该场景不传 closeList");
}
#endregion
#region (§7-1, oracle/, EOD平仓后收盘× )
// 守恒不变量统一断言在"剩余持仓前递"(preEod.PosiNotionalValue)上:该字段由 CalcUnwindInterest/
// InitSwapDealInterest 在 preEod.id==0 时写入(posiPrincipal),与利息算法(单/复、FR007)无关,
// 是最稳健、码算、免库的守恒观测点。期初(orginPv) = 前递剩余 + 平掉额(closePosiNotionalValue) 必须成立。
// 全部内存构造(StubSwapDealService 避库)funding-leg(mode2)不触发早路由 continue,故亦是早路由改动护栏。
/// <summary>
/// 建一个"无历史 eod"快照(id==0),使引擎把本次剩余持仓写入 preEod.PosiNotionalValue。
/// </summary>
private static eod_swap_position NewPreEod(decimal carryPrincipal)
=> new()
{
id = 0, SwapTradeId = 1, PositionId = 1001,
ValueDate = new DateTime(2026, 4, 29), ClientId = 999998,
FloatRate = 0m, TdInterestPrincipal = carryPrincipal,
PosiNotionalValue = carryPrincipal, InterestIncomeSum = 0.05m, InterestProfitSum = 0.05m
};
/// <summary>
/// §7-1 守恒①:EOD平仓后收盘×部分平仓,引擎把剩余持仓(700)前递进 preEod.PosiNotionalValue
/// 且 期初 = 前递剩余(码算) + 平掉额(输入) = 1000。
/// 守 2035e1df 裸格(§6 空洞1):若 EOD 入口把前递值误写成平掉额/期初,守恒等式即破。
/// </summary>
[TestMethod]
public void EOD平仓后收盘_部分平仓_守恒_剩余前递且期初等于剩余加平掉额()
{
var td = CreateTrade();
var position = CreatePosition(InterestModeEnum., InterestTypeEnum.);
var preEod = NewPreEod(Remaining); // 无历史 eod → 引擎写回剩余
var eodPositions = new List<eod_swap_position> { preEod };
var positions = new List<swap_position> { position };
var result = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
eodPositions, positions, Remaining, Closed, 1m,
(int)SwapEventTypeEnum., tdClose: false, orginPv: PreClose,
add: false, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, result.Count, "EOD平仓后收盘部分平仓应产生 1 条利息事件");
// 码算:引擎把剩余持仓前递(return 700)
Assert.AreEqual(Remaining, preEod.PosiNotionalValue,
"EOD平仓后收盘必须把剩余持仓(700)前递进 preEod.PosiNotionalValue;若误写平掉额/期初则守恒破坏");
// 守恒:期初 = 前递剩余(码算) + 平掉额(输入)
Assert.AreEqual(PreClose, preEod.PosiNotionalValue + Closed,
"期初(orginPv=1000) 必须 = 剩余(700) + 平掉额(300);本金口径不守恒则利息算错");
}
/// <summary>
/// §7-1 守恒②:EOD平仓后收盘×全平,剩余持仓前递=0(清仓)。守全平非零边界的互补面。
/// </summary>
[TestMethod]
public void EOD平仓后收盘_全平_守恒_剩余前递归零()
{
var td = CreateTrade();
var position = CreatePosition(InterestModeEnum., InterestTypeEnum.);
var preEod = NewPreEod(0m);
var eodPositions = new List<eod_swap_position> { preEod };
var positions = new List<swap_position> { position };
var result = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
eodPositions, positions, 0m, PreClose, 1m,
(int)SwapEventTypeEnum., tdClose: false, orginPv: PreClose,
add: false, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, result.Count);
Assert.AreEqual(0m, preEod.PosiNotionalValue,
"全平后剩余持仓前递必须为 0;非 0 表示平仓未清仓,守恒破坏");
Assert.AreEqual(PreClose, preEod.PosiNotionalValue + PreClose,
"全平守恒:期初(1000) = 剩余(0) + 平掉额(1000)");
}
/// <summary>
/// §7-1 守恒③(逐日):两次部分平仓,Day2 剩余前递 = 当日剩余(码算),且 期初 - 前递剩余 = 平掉额,
/// 构成跨日携带链守恒。Day1 期初1000→平300剩700Day2 期初700→平210剩490;累计平掉510+剩余490=1000。
/// </summary>
[TestMethod]
public void EOD平仓后收盘_两次部分平仓_逐日守恒_期初减剩余前递等于平掉额()
{
var td = CreateTrade();
var position = CreatePosition(InterestModeEnum., InterestTypeEnum.);
// Day1:期初1000,平300,剩700
var preEod1 = NewPreEod(PreClose);
var result1 = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
new List<eod_swap_position> { preEod1 }, new List<swap_position> { position },
Remaining, Closed, 1m,
(int)SwapEventTypeEnum., tdClose: false, orginPv: PreClose,
add: false, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, result1.Count);
Assert.AreEqual(Remaining, preEod1.PosiNotionalValue, "Day1 剩余前递应为 700");
// Day2:期初=Day1剩余700,平210,剩490
const decimal day2OrginPv = 700m;
const decimal day2Closed = 210m;
const decimal day2Remaining = 490m;
var preEod2 = NewPreEod(day2OrginPv); // 承载=Day1剩余700
var result2 = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
new List<eod_swap_position> { preEod2 }, new List<swap_position> { position },
day2Remaining, day2Closed, 1m,
(int)SwapEventTypeEnum., tdClose: false, orginPv: day2OrginPv,
add: false, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, result2.Count);
// 码算:Day2 剩余前递=当日剩余(490)
Assert.AreEqual(day2Remaining, preEod2.PosiNotionalValue, "Day2 剩余前递=剩余(490,码算值)");
// 逐日守恒:期初 - 剩余前递 = 平掉额(210)
Assert.AreEqual(day2Closed, day2OrginPv - preEod2.PosiNotionalValue,
"Day2 守恒:期初(700) - 剩余前递(490) 必须 = 平掉额(210);跨日携带链本金不守恒则利息算错");
}
/// <summary>
/// §7-1 守恒④(纯数学,ClosePercentMath):多次平仓累计占期初比例 = 1 - ∏(1 - 各次剩余口径)。
/// 初次占期初30%(平300/名义1000)→剩余口径0.3;二次占期初50%(平350/剩余700)→剩余口径0.5
/// 累计平掉 = 1 - 0.7×0.5 = 0.65。验证 ClosePercentMath 双口径换算在多次平仓下不漂移。
/// </summary>
[TestMethod]
public void _占期初累计比例等于各次剩余口径连乘补数()
{
var b1 = ClosePercentMath.ToRemainingClosePercent(0.3m, 1000m, 1000m);
Assert.AreEqual(0.3m, b1, "初次平仓占期初30% → 剩余口径应为 0.3");
var b2 = ClosePercentMath.ToRemainingClosePercent(0.5m, 700m, 700m);
Assert.AreEqual(0.5m, b2, "二次平仓占期初50%(占剩余700) → 剩余口径应为 0.5");
var cumulativeClosed = 1m - (1m - b1) * (1m - b2);
Assert.AreEqual(0.65m, cumulativeClosed, 0.0000001m,
"多次平仓累计平掉比例必须=各次剩余口径连乘的补数;否则本金口径在多次平仓下分裂");
var back = ClosePercentMath.ToOriginalClosePercent(cumulativeClosed, 1000m, 1000m);
Assert.AreEqual(0.65m, back, 0.0000001m, "累计占期初比例反向还原必须一致");
}
#endregion
}
}
@@ -228,9 +228,9 @@ namespace YLErp.Modules.SwapModule
var position = CreateFloatInterestPosition(interestRule, interestType, fixedRate);
var interests = _service.GetInterests(td, td.trade_extend, valueDate, unwindDate,
eodPositions, new List<swap_position> { position },
posiNotional, posiNotional, posiNotional, posiNotional, closePercent,
posiNotional, posiNotional, closePercent,
(int)SwapEventTypeEnum.,
false, false, 0, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList);
false, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList);
AssertInterestEqual(1, interests.Count);
return interests[0];
}
@@ -244,9 +244,9 @@ namespace YLErp.Modules.SwapModule
var position = CreateFloatInterestPosition(interestRule, interestType, fixedRate);
var interests = _service.GetInterests(td, td.trade_extend, valueDate, valueDate,
eodPositions, new List<swap_position> { position },
Principal, Principal, Principal, Principal, 1m,
Principal, Principal, 1m,
(int)SwapEventTypeEnum.,
false, false, 0, Principal, false, settment: true, newCalcLast: false, closeList: closeList);
false, Principal, false, settment: true, newCalcLast: false, closeList: closeList);
AssertInterestEqual(1, interests.Count);
return interests[0];
}
@@ -263,9 +263,9 @@ namespace YLErp.Modules.SwapModule
var position = CreateFixedInterestPosition(fixedRate, interestRule);
var interests = _service.GetInterests(td, td.trade_extend, valueDate, unwindDate,
eodPositions, new List<swap_position> { position },
posiNotional, posiNotional, posiNotional, posiNotional, closePercent,
posiNotional, posiNotional, closePercent,
(int)SwapEventTypeEnum.,
false, false, 0, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList);
false, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList);
AssertInterestEqual(1, interests.Count);
return interests[0];
}
@@ -279,9 +279,9 @@ namespace YLErp.Modules.SwapModule
var position = CreateFixedInterestPosition(fixedRate, interestRule);
var interests = _service.GetInterests(td, td.trade_extend, valueDate, valueDate,
eodPositions, new List<swap_position> { position },
Principal, Principal, Principal, Principal, 1m,
Principal, Principal, 1m,
(int)SwapEventTypeEnum.,
false, false, 0, Principal, false, settment: true, newCalcLast: false, closeList: closeList);
false, Principal, false, settment: true, newCalcLast: false, closeList: closeList);
AssertInterestEqual(1, interests.Count);
return interests[0];
}
@@ -322,9 +322,9 @@ namespace YLErp.Modules.SwapModule
valueDate, unwindDate,
eodPositions,
new List<swap_position> { position },
posiNotional, posiNotional, posiNotional, posiNotional, closePercent,
posiNotional, posiNotional, closePercent,
(int)SwapEventTypeEnum.,
false, false, 0, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList);
false, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList);
AssertInterestEqual(1, interests.Count);
return interests[0];
@@ -346,9 +346,9 @@ namespace YLErp.Modules.SwapModule
valueDate, valueDate,
eodPositions,
new List<swap_position> { position },
Principal, Principal, Principal, Principal, 1m,
Principal, Principal, 1m,
(int)SwapEventTypeEnum.,
false, false, 0, Principal, false, settment: true, newCalcLast: false, closeList: closeList);
false, Principal, false, settment: true, newCalcLast: false, closeList: closeList);
AssertInterestEqual(1, interests.Count);
return interests[0];
@@ -371,9 +371,9 @@ namespace YLErp.Modules.SwapModule
valueDate, valueDate,
eodPositions,
new List<swap_position> { position },
Principal, Principal, Principal, Principal, closePercent,
Principal, Principal, closePercent,
(int)SwapEventTypeEnum.,
false, false, 0, Principal, false, settment: false, newCalcLast: false, closeList: closeList);
false, Principal, false, settment: false, newCalcLast: false, closeList: closeList);
AssertInterestEqual(1, interests.Count);
return interests[0];
@@ -407,9 +407,9 @@ namespace YLErp.Modules.SwapModule
valueDate, unwindDate,
eodPositions,
new List<swap_position> { position },
posiNotional, posiNotional, posiNotional, posiNotional, closePercent,
posiNotional, posiNotional, closePercent,
(int)SwapEventTypeEnum.,
false, false, 0, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList);
false, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList);
AssertInterestEqual(1, interests.Count);
return interests[0];
@@ -430,9 +430,9 @@ namespace YLErp.Modules.SwapModule
valueDate, valueDate,
eodPositions,
new List<swap_position> { position },
Principal, Principal, Principal, Principal, 1m,
Principal, Principal, 1m,
(int)SwapEventTypeEnum.,
false, false, 0, Principal, false, settment: true, newCalcLast: false, closeList: closeList);
false, Principal, false, settment: true, newCalcLast: false, closeList: closeList);
AssertInterestEqual(1, interests.Count);
return interests[0];
@@ -1716,9 +1716,9 @@ namespace YLErp.Modules.SwapModule
valueDate, unwindDate,
eodPositions,
new List<swap_position> { position },
posiNotional, posiNotional, posiNotional, posiNotional, closePercent,
posiNotional, posiNotional, closePercent,
(int)SwapEventTypeEnum.,
false, false, 0, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList);
false, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList);
AssertInterestEqual(1, interests.Count);
return interests[0];
@@ -1747,9 +1747,9 @@ namespace YLErp.Modules.SwapModule
valueDate, unwindDate,
eodPositions,
new List<swap_position> { position },
posiNotional, posiNotional, posiNotional, posiNotional, closePercent,
posiNotional, posiNotional, closePercent,
(int)SwapEventTypeEnum.,
false, false, 0, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList);
false, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList);
AssertInterestEqual(1, interests.Count);
return interests[0];
@@ -1,4 +1,3 @@
using System.Reflection;
using YLErp.DBModels;
namespace YLErp.Modules.SwapModule
@@ -7,26 +6,10 @@ namespace YLErp.Modules.SwapModule
public class InitUnwindTradingFeeTest
{
private static decimal InvokeCalcInitTradingFee(swap_position position, UnwindData unwindData)
{
var method = typeof(SwapDealService).GetMethod(
"CalcInitTradingFee",
BindingFlags.NonPublic | BindingFlags.Static);
Assert.IsNotNull(method, "未找到 CalcInitTradingFee 私有静态方法");
return (decimal)method.Invoke(null, new object[] { position, unwindData });
}
=> TradingFeeCalc.CalcInitTradingFee(position, unwindData);
private static decimal InvokeCalcInitTradingFeePending(swap_position oriPosition, swap_position position, UnwindData unwindData)
{
var method = typeof(SwapDealService).GetMethod(
"CalcInitTradingFeePending",
BindingFlags.NonPublic | BindingFlags.Static);
Assert.IsNotNull(method, "CalcInitTradingFeePending was not found");
return (decimal)method.Invoke(null, new object[] { oriPosition, position, unwindData });
}
=> TradingFeeCalc.CalcInitTradingFeePending(oriPosition, position, unwindData);
[TestMethod]
public void _按平仓名义本金计算并四舍五入到两位()
@@ -0,0 +1,30 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// DealInterests 分派优先级(TEST-MATRIX §6 空洞补盖)。
/// 纯函数 InterestEodScenarioDispatch.ResolveInterestScenario 的 8 组合表驱动单测,不连库、无 DB。
/// 守卫:手工互换压制观察日自动结息;观察日±平仓区分 autoSwap 真/假;纯平仓与普通日分流。
/// </summary>
[TestClass]
public class InterestEodScenarioDispatchTest
{
[DataTestMethod]
[DataRow(false, false, false, InterestEodScenario.RollForward)] // 普通日
[DataRow(false, false, true, InterestEodScenario.CloseOnly)] // 纯平仓(非观察日)
[DataRow(false, true, false, InterestEodScenario.ManualSwap)] // 手工互换(非观察日)
[DataRow(false, true, true, InterestEodScenario.ManualSwap)] // 手工互换+平仓 → 手工优先
[DataRow(true, false, false, InterestEodScenario.AutoSettle)] // 观察日, 无平仓
[DataRow(true, false, true, InterestEodScenario.AutoSettleWithClose)] // 观察日+平仓 (autoSwap=true)
[DataRow(true, true, false, InterestEodScenario.ManualSwap)] // 观察日+手工互换 → 手工优先
[DataRow(true, true, true, InterestEodScenario.ManualSwap)] // 观察日+手工互换+平仓 → 手工优先
public void ResolveInterestScenario_CoversAllEightCombinations(
bool hasInterval, bool hasSwap, bool hasClose, InterestEodScenario expected)
{
var actual = InterestEodScenarioDispatch.ResolveInterestScenario(hasInterval, hasSwap, hasClose);
Assert.AreEqual(expected, actual,
$"hasInterval={hasInterval}, hasSwap={hasSwap}, hasClose={hasClose}");
}
}
}
@@ -0,0 +1,319 @@
using Newtonsoft.Json;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// L1(类内去重)前置安全网:DealInterests 四分支中无 golden 语料的三格
/// AutoSettle / AutoSettleWithClose / CloseOnly)尾部滚存字段特征化快照。
///
/// - ManualSwap / RollForward 两格已由 DealInterestsGoldenReplayTest 语料钉住
/// (字段集见 GoldenReplayFramework.EodPositionToJson)。
/// - 本测试钉"现状行为":L1 抽共享助手(腿字段拷贝段 + 滚存收尾段)前后,
/// 以下字段必须逐字段不变。变化=去重改了口径。
/// - 同时断言接 seam 指纹(哪个计息接缝 + eventType)与 autoInterests 收集行为,
/// 兼作 L2(按腿拆类)的路由验收。
/// - 计息金额由受控 CalcResult 注入(不连库、不依赖真实计息引擎)。
/// </summary>
[TestClass]
public class InterestEodTailSnapshotTest
{
private const decimal Principal = 10000m;
private const decimal Rate = 0.03m;
private static readonly DateTime StartDate = new(2026, 4, 27);
private static readonly DateTime SettleDate = StartDate.AddDays(10); // 第10天收盘
private const decimal Accrued10d = 8.22m; // 受控:10天理论应结
private const decimal DailyNew = 0.82m; // 受控:当日新增
private const decimal ManualSettled = 3.5m; // 受控:盘中平仓已结
private const decimal Remaining = 7000m; // 平仓后剩余本金
private const decimal ClosedNotional = 3000m; // 本次平掉本金
private sealed class TailStubService : TestableSwapEodPositionService
{
public TailStubService() : base(nameof(InterestEodTailSnapshotTest)) { }
/// <summary>受控计息结果:两个计息 seam 均返回它</summary>
public List<swap_flow_event> CalcResult { get; set; } = new();
public string LastCalcSeam { get; private set; } = "";
public List<int> CalcEventTypes { get; } = new();
protected override List<swap_flow_event> CalcSwapInterests(
trade td, trade_extend tradeExtend,
DateTime valueDate, DateTime unwindDate,
List<eod_swap_position> eodPositions, List<swap_position> positions,
decimal posiNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent,
int eventType, bool tdClose,
decimal orginPv,
bool add = false, bool settment = true, bool newCalcLast = false,
List<swap_flow_event> closeList = null)
{
LastCalcSeam = nameof(CalcSwapInterests);
CalcEventTypes.Add(eventType);
return CalcResult;
}
protected override List<swap_flow_event> CalcEodPostCloseSettleInterests(InterestCalcRequest req)
{
LastCalcSeam = nameof(CalcEodPostCloseSettleInterests);
CalcEventTypes.Add(req.EventType);
return CalcResult;
}
/// <summary>持仓延续腿重置日再定盘接缝:计数并返回受控新定盘(不连库)</summary>
public decimal RefixResult { get; set; }
public int RefixCalls { get; private set; }
protected override decimal ResolveOngoingResetFixing(swap_position position, DateTime valueDate)
{
RefixCalls++;
return RefixResult;
}
public List<swap_flow_event> ExecuteDealInterests(
List<swap_position> interestList, List<eod_swap_position> eodPositions,
DateTime settleDate, trade td, List<swap_flow_event> flowEvents,
decimal posiTotalNotional, decimal closeNational, decimal grossPrice, decimal orginPv)
{
var autoInterests = new List<swap_flow_event>();
DealInterests(interestList, eodPositions, new List<eod_swap_position>(),
settleDate, td, flowEvents, autoInterests, null,
posiTotalNotional, closeNational, grossPrice, orginPv);
return autoInterests;
}
}
private static trade CreateTrade() => new()
{
id = 1, TradeNumber = "TAIL-SNAP-001", ClientId = 999998,
TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate,
ExerciseDate = new DateTime(2027, 4, 27), TradeStatus = "确认成交",
ValidState = "Valid", StructureType = "单标的",
QuoteCurrency = "CNY", SettlementCurrency = "CNY",
trade_extend = new trade_extend
{
TradeId = 1,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson { AnnualDays = 365, InterestCalcMode = "10", SettlementRules = 0 })
}
};
/// <param name="observationDay">true=当日观察日(Settlement=1)false=观察日在别日</param>
private static swap_position CreateInterestPosition(bool observationDay)
{
var interval = observationDay
? new IntervalModel { Date = SettleDate, Rate = Rate, Settlement = 1 }
: new IntervalModel { Date = StartDate, Rate = Rate, Settlement = 1 };
return new swap_position
{
id = 1001, SwapTradeId = 1, InterestDirection = (int)SwapDirectionEnum.,
InterestMode = (int)InterestModeEnum., InterestRateDefault = Rate,
InterestPrincipalFix = Principal, PosiStartDate = StartDate,
PosiMatuirityDate = new DateTime(2027, 4, 27), IsInitial = true,
InterestType = (int)InterestTypeEnum., IsAnnualized = true,
interest_rest_days = 1, interest_rule = 0,
InterestSwapInterval = JsonConvert.SerializeObject(new List<IntervalModel> { interval })
};
}
private static eod_swap_position CreatePreEod(decimal accumulated) => new()
{
id = 100, PositionId = 1001, ValueDate = SettleDate.AddDays(-1),
InterestDirection = (int)SwapDirectionEnum., InterestMode = (int)InterestModeEnum.,
InterestIncomeSum = accumulated, InterestProfitSum = accumulated,
InterestRateDefault = Rate, TdInterestPrincipal = Principal,
InterestType = (int)InterestTypeEnum., IsAnnualized = true, interest_rest_days = 1
};
private static swap_flow_event CreateCalcResult() => new()
{
EventType = (int)SwapEventTypeEnum., PositionId = 1001,
InterestAmount = Accrued10d, TdInterestAmount = DailyNew,
InterestClosePnL = Accrued10d,
InterestPrincipal = Principal, InterestRate = Rate,
InterestDirection = (int)SwapDirectionEnum.
};
private static swap_flow_event CreateCloseEvent() => new()
{
EventType = (int)SwapEventTypeEnum., PositionId = 1001,
InterestAmount = ManualSettled, InterestClosePnL = ManualSettled,
InterestRate = Rate, InterestFee = 0m,
InterestPrincipal = ClosedNotional,
InterestDirection = (int)SwapDirectionEnum.,
DataState = (int)SwapFlowDateStateEnum.
};
/// <summary>AutoSettle 格:观察日无平仓 → SaveAutoEodInterestPosition,返回值收集进 autoInterests</summary>
[TestMethod]
public void AutoSettle_观察日无平仓_尾部快照()
{
var service = new TailStubService { CalcResult = new List<swap_flow_event> { CreateCalcResult() } };
var autoInterests = service.ExecuteDealInterests(
new List<swap_position> { CreateInterestPosition(observationDay: true) },
new List<eod_swap_position> { CreatePreEod(Accrued10d) },
SettleDate, CreateTrade(), new List<swap_flow_event>(),
Principal, 0m, 100m, Principal);
Assert.AreEqual("CalcSwapInterests", service.LastCalcSeam, "观察日无平仓应走 CalcSwapInterests seam");
Assert.AreEqual((int)SwapEventTypeEnum., service.CalcEventTypes.Single(), "eventType 应为自动互换");
Assert.AreEqual(1, autoInterests.Count, "观察日分支应收集返回值进 autoInterests(→资金记录)");
var p = service.PersistedPositions.Single();
// 钉值于 2026-08-17 现状行为(受控输入:应结8.22/新增0.82/本金10000
Assert.AreEqual(8.22m, p.TdCloseInterest);
Assert.AreEqual(0.82m, p.TdInterestIncome);
Assert.AreEqual(10000m, p.TdInterestPrincipal);
Assert.AreEqual(0.03m, p.TdInterestRate);
Assert.AreEqual(0.00m, p.InterestIncomeSum, "应结=结算,待实现清零");
Assert.AreEqual(0m, p.InterestFeeSum);
Assert.AreEqual(0m, p.InterestProfitSum);
Assert.AreEqual(8.22m, p.RealizedInterest);
Assert.AreEqual(0m, p.RealizedInterestFee);
Assert.AreEqual(0m, p.SwapPositionValue);
Assert.AreEqual(1.0m, p.TdCurrency);
}
/// <summary>AutoSettleWithClose 格(TEST-MATRIX §6 最弱格):观察日+平仓 → SaveAutoEodWithCloseInterestPosition(autoSwap:true),补结差额=恒1全额−盘中已结</summary>
[TestMethod]
public void AutoSettleWithClose_观察日加平仓_尾部快照()
{
var service = new TailStubService { CalcResult = new List<swap_flow_event> { CreateCalcResult() } };
var autoInterests = service.ExecuteDealInterests(
new List<swap_position> { CreateInterestPosition(observationDay: true) },
new List<eod_swap_position> { CreatePreEod(Accrued10d) },
SettleDate, CreateTrade(), new List<swap_flow_event> { CreateCloseEvent() },
Remaining, ClosedNotional, 100m, Remaining);
Assert.AreEqual("CalcEodPostCloseSettleInterests", service.LastCalcSeam, "观察日+平仓应走 EodPostCloseSettle seam");
Assert.AreEqual((int)SwapEventTypeEnum., service.CalcEventTypes.Single(), "autoSwap=true → eventType=自动互换");
Assert.AreEqual(1, autoInterests.Count, "观察日分支应收集返回值进 autoInterests");
Assert.AreEqual(Accrued10d - ManualSettled, autoInterests[0].InterestAmount, "补结差额=恒1全额8.22−盘中已结3.50");
var p = service.PersistedPositions.Single();
// 钉值于 2026-08-17 现状行为(受控输入:恒1全额8.22/盘中已结3.5/剩余7000/平掉3000
Assert.AreEqual(8.22m, p.TdCloseInterest, "TdCloseInterest=盘中已结3.50+补结4.72");
Assert.AreEqual(0.5753424657534246575342465753m, p.TdInterestIncome, "autoSwap 重算展示应计=剩余7000×3%/365");
Assert.AreEqual(7000m, p.TdInterestPrincipal, "单利部分平仓:跨日本金=剩余");
Assert.AreEqual(0.03m, p.TdInterestRate);
Assert.AreEqual(0.00m, p.InterestIncomeSum, "恒1口径:理论应结8.22−结算8.22=0");
Assert.AreEqual(0m, p.InterestFeeSum);
Assert.AreEqual(0m, p.InterestProfitSum);
Assert.AreEqual(8.22m, p.RealizedInterest);
Assert.AreEqual(0m, p.RealizedInterestFee);
Assert.AreEqual(0m, p.SwapPositionValue);
Assert.AreEqual(1.0m, p.TdCurrency);
}
/// <summary>CloseOnly 格:非观察日平仓 → SaveAutoEodWithCloseInterestPosition(autoSwap:false),返回值不收集,TdCloseInterest=盘中已结</summary>
[TestMethod]
public void CloseOnly_非观察日平仓_尾部快照()
{
var service = new TailStubService { CalcResult = new List<swap_flow_event> { CreateCalcResult() } };
var autoInterests = service.ExecuteDealInterests(
new List<swap_position> { CreateInterestPosition(observationDay: false) },
new List<eod_swap_position> { CreatePreEod(Accrued10d) },
SettleDate, CreateTrade(), new List<swap_flow_event> { CreateCloseEvent() },
Remaining, ClosedNotional, 100m, Remaining);
Assert.AreEqual("CalcEodPostCloseSettleInterests", service.LastCalcSeam, "纯平仓应走 EodPostCloseSettle seam");
Assert.AreEqual((int)SwapEventTypeEnum., service.CalcEventTypes.Single(), "autoSwap=false → eventType=平仓");
Assert.AreEqual(0, autoInterests.Count, "纯平仓分支不收集返回值(结算已在盘中流水定格)");
var p = service.PersistedPositions.Single();
// 钉值于 2026-08-17 现状行为(受控输入:恒1重算8.22/盘中已结3.5/剩余7000/平掉3000
Assert.AreEqual(ManualSettled, p.TdCloseInterest, "TdCloseInterest 应仅为盘中已结3.50,不叠加恒1重算值");
Assert.AreEqual(0.5753424657534246575342465753m, p.TdInterestIncome, "不算尾路径:剩余7000×3%/365");
Assert.AreEqual(7000m, p.TdInterestPrincipal, "单利部分平仓:跨日本金=剩余");
Assert.AreEqual(0.03m, p.TdInterestRate, "非观察日:利率取平仓流水 InterestRate");
Assert.AreEqual(5.295342465753m, p.InterestIncomeSum, "尾差递推:上日8.22+新增0.575342−已结3.50");
Assert.AreEqual(0m, p.InterestFeeSum);
Assert.AreEqual(5.295342465753m, p.InterestProfitSum);
Assert.AreEqual(3.5m, p.RealizedInterest);
Assert.AreEqual(0m, p.RealizedInterestFee);
Assert.AreEqual(5.295342465753m, p.SwapPositionValue);
Assert.AreEqual(1.0m, p.TdCurrency);
}
#region EQD-6968
private const decimal OldFloat = 0.01425m;
private const decimal NewFloat = 0.0143m;
/// <summary>4/27+147 天周期的重置日平仓</summary>
private static readonly DateTime ResetSettle = StartDate.AddDays(14);
/// <summary>4/27+10:非重置日平仓(10%7≠0</summary>
private static readonly DateTime NonResetSettle = StartDate.AddDays(10);
private static swap_position CreateFloatLegPosition() => new()
{
id = 1001, SwapTradeId = 1, InterestDirection = (int)SwapDirectionEnum.,
InterestMode = (int)InterestModeEnum., InterestRateDefault = Rate,
InterestPrincipalFix = Principal, PosiStartDate = StartDate,
PosiMatuirityDate = new DateTime(2027, 4, 27), IsInitial = true,
InterestType = (int)InterestTypeEnum., IsAnnualized = true,
interest_rest_days = 7, interest_rule = 0,
FloatRateUnderlyingCode = "FR007",
InterestSwapInterval = JsonConvert.SerializeObject(new List<IntervalModel>
{ new IntervalModel { Date = StartDate, Rate = Rate, Settlement = 1 } })
};
private static eod_swap_position CreatePreEodBefore(DateTime settle, decimal accumulated) => new()
{
id = 100, PositionId = 1001, ValueDate = settle.AddDays(-1),
InterestDirection = (int)SwapDirectionEnum., InterestMode = (int)InterestModeEnum.,
InterestIncomeSum = accumulated, InterestProfitSum = accumulated,
InterestRateDefault = Rate, TdInterestPrincipal = Principal,
InterestType = (int)InterestTypeEnum., IsAnnualized = true, interest_rest_days = 7
};
private static swap_flow_event CalcResultWithFloat(decimal floatRate)
{
var e = CreateCalcResult();
e.FloatRate = floatRate;
return e;
}
/// <summary>
/// 平仓日恰为重置日且剩余持仓>0:快照 FloatRate 必须显式再定盘为当日新定盘——
/// 它是后续非重置日(ByEod 沿用 preEod.FloatRate)与当日应计(intersetAcmount)的利率载体。
/// 排除日"纯跳过"后事件利率=末段已消费利率(OldFloat),载体职责与本步骤显式分离。
/// </summary>
[TestMethod]
public void CloseOnly_平仓日为重置日_剩余持仓快照再定盘()
{
var service = new TailStubService
{
CalcResult = new List<swap_flow_event> { CalcResultWithFloat(OldFloat) },
RefixResult = NewFloat,
};
service.ExecuteDealInterests(
new List<swap_position> { CreateFloatLegPosition() },
new List<eod_swap_position> { CreatePreEodBefore(ResetSettle, Accrued10d) },
ResetSettle, CreateTrade(), new List<swap_flow_event> { CreateCloseEvent() },
Remaining, ClosedNotional, 100m, Remaining);
Assert.AreEqual(1, service.RefixCalls, "不算尾+平仓日=重置日+剩余>0:应恰好显式再定盘一次");
Assert.AreEqual(NewFloat, service.PersistedPositions.Single().FloatRate,
"剩余持仓快照利率=当日新定盘(非事件末段旧利率)");
}
[TestMethod]
public void CloseOnly_平仓日非重置日_不再定盘_快照沿用事件利率()
{
var service = new TailStubService
{
CalcResult = new List<swap_flow_event> { CalcResultWithFloat(OldFloat) },
RefixResult = NewFloat,
};
service.ExecuteDealInterests(
new List<swap_position> { CreateFloatLegPosition() },
new List<eod_swap_position> { CreatePreEodBefore(NonResetSettle, Accrued10d) },
NonResetSettle, CreateTrade(), new List<swap_flow_event> { CreateCloseEvent() },
Remaining, ClosedNotional, 100m, Remaining);
Assert.AreEqual(0, service.RefixCalls, "非重置日平仓:无需再定盘");
Assert.AreEqual(OldFloat, service.PersistedPositions.Single().FloatRate,
"快照沿用事件末段已消费利率(周期未切换)");
}
#endregion
}
}
@@ -0,0 +1,165 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using YLErp;
using YLErp.DBModels;
using YLErp.DBModels.Enums;
using YLErp.Modules.SwapModule;
using YLErp.Modules.SwapModule.Margin;
namespace UnitTestProject.Modules.SwapModule.Margin
{
/// <summary>
/// 黄金回放验证(连真实测试库 192.168.2.96):对真实保证金交易逐日 EOD 比对
/// GetInterests(settment=true,保证金分支现走 CalcMarginInterest) vs 直接调 CalcMarginInterest
/// 验证 GetInterests→CalcMarginInterest 接线的参数对齐(rate/posiPrincipal/preEod 等)正确。
///
/// 数据来自 96 库的真实保证金交易,覆盖追加预付金多行、多次部分平仓(InterestPrincipalFix 下台阶)、
/// 跨 EOD 续接等单元测试够不到的边界。作为保证金计息迁移后的真实库回归守护。
/// </summary>
[TestClass]
public class MarginInterestGoldenReplayTest
{
// 96 库里已确认含真实保证金腿的交易
private static readonly string[] TradeNumbers =
{
"GLMS-20260701-0008",
"GLMS-20260701-0013",
"GLMS-20260701-0006",
};
private sealed class StubSvc : SwapDealService
{
public StubSvc() : base(new OptUserInfo(0, nameof(MarginInterestGoldenReplayTest), OptUserFrom.UnitTest)) { }
}
/// <summary>
/// 逐交易、逐 EOD 日,比对保证金腿新旧计息 InterestAmount/TdInterestAmount。
/// 入参对齐口径(与 GetInterests 内部一致):
/// posiPrincipal = InterestPrincipalFixclosePrincipal = Fix×closePercent(EOD=1)
/// rate = oldEvt.InterestRate(严格取旧管线算出的 rate,消除 GetFixedRate 差异);
/// 方向 = FlipDirection(position.InterestDirection)GetInterests:742 对保证金翻转);
/// preEod = 该 PositionId 上一日终 eod_swap_positionannualDays/calcFirst/calcLast 来自 trade_extend。
/// </summary>
[TestMethod]
[TestCategory("DbDiagnose")]
public void _真实库_EOD逐日新旧比对()
{
DbDiagnoseGuard.RequireTestDb();
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库(192.168.2.96){ex.Message}"); return; }
var svc = new StubSvc();
int totalCompared = 0, mismatches = 0, skipped = 0;
var diffLog = new StringBuilder();
foreach (var tradeNumber in TradeNumbers)
{
var td = db.trade.FirstOrDefault(t => t.TradeNumber == tradeNumber);
if (td == null) { Console.WriteLine($"跳过:库无 {tradeNumber}"); skipped++; continue; }
var extend = db.trade_extend.FirstOrDefault(x => x.TradeId == td.id);
int annualDays = extend?.ExtendObj.AnnualDays ?? 365;
bool calcFirst = extend?.ExtendObj.InterestCalcMode?.StartsWith("1") ?? true;
bool calcLast = extend?.ExtendObj.InterestCalcMode?.EndsWith("1") ?? true;
var marginPositions = db.swap_position
.Where(p => p.SwapTradeId == td.id && !p.Invalid
&& (p.InterestMode == (int)InterestModeEnum.
|| p.InterestMode == (int)InterestModeEnum.))
.ToList();
if (marginPositions.Count == 0) { Console.WriteLine($"跳过:{tradeNumber} 无保证金腿"); skipped++; continue; }
// 该交易保证金腿的 EOD 日期序列
var eodDates = db.eod_swap_position
.Where(e => e.SwapTradeId == td.id && (e.InterestMode == 5 || e.InterestMode == 6))
.Select(e => e.ValueDate).Distinct().OrderBy(d => d).ToList();
Console.WriteLine($"===== {tradeNumber} (id={td.id}){marginPositions.Count} 条保证金腿,{eodDates.Count} 个 EOD 日 =====");
foreach (var valueDate in eodDates)
{
// 上一日终 preEod(取 eod_swap 最近 < valueDate 的日期)
var preDate = db.eod_swap
.Where(e => e.SwapTradeId == td.id && e.ValueDate < valueDate)
.OrderByDescending(e => e.ValueDate)
.Select(e => (DateTime?)e.ValueDate).FirstOrDefault();
var preEods = preDate == null
? new List<eod_swap_position>()
: db.eod_swap_position
.Where(e => e.SwapTradeId == td.id && e.ValueDate == preDate.Value
&& (e.InterestMode == 5 || e.InterestMode == 6))
.ToList();
// 旧管线:GetInterests(settment=true)。保证金分支不用 posiNotionalValue/closePosiNotionalValue/grossPrice/orginPv,传 0。
List<swap_flow_event> oldList;
try
{
oldList = svc.GetInterests(td, extend, valueDate, valueDate,
preEods, marginPositions,
0m, 0m, 1.0m,
(int)SwapEventTypeEnum., tdClose: false,
orginPv: 0m,
add: false, settment: true, newCalcLast: false, closeList: null);
}
catch (Exception ex)
{
Console.WriteLine($" {tradeNumber} @ {valueDate:yyyy-MM-dd} 旧管线异常:{ex.GetType().Name} {ex.Message}");
continue;
}
// 新方法:逐保证金腿
foreach (var pos in marginPositions)
{
var oldEvt = oldList.FirstOrDefault(i => i.PositionId == pos.id);
if (oldEvt == null) continue;
var preEod = preEods.FirstOrDefault(e => e.PositionId == pos.id) ?? new eod_swap_position { id = 0 };
var posClone = pos.Clone();
posClone.InterestDirection = MarginCalc.FlipDirection(pos.InterestDirection);
decimal rate = oldEvt.InterestRate; // 严格对齐旧管线 rate(含 GetFixedRate + Round(12)
swap_flow_event newEvt;
try
{
newEvt = svc.CalcMarginInterest(td, valueDate, valueDate, posClone, rate,
pos.InterestPrincipalFix, pos.InterestPrincipalFix, 1.0m,
annualDays, calcFirst, calcLast, preEod,
(int)SwapEventTypeEnum., add: false, settment: true, interestWindowEmpty: false);
}
catch (Exception ex)
{
mismatches++;
diffLog.AppendLine($"✗ {tradeNumber} PosId={pos.id} @ {valueDate:yyyy-MM-dd} 新方法异常:{ex.GetType().Name} {ex.Message}");
continue;
}
totalCompared++;
decimal diffI = Math.Abs(newEvt.InterestAmount - oldEvt.InterestAmount);
decimal diffTd = Math.Abs(newEvt.TdInterestAmount - oldEvt.TdInterestAmount);
const decimal tol = 0.000001m;
if (diffI > tol || diffTd > tol)
{
mismatches++;
diffLog.AppendLine($"✗ {tradeNumber} PosId={pos.id} Mode={pos.InterestMode} @ {valueDate:yyyy-MM-dd}: " +
$"旧 I={oldEvt.InterestAmount} Td={oldEvt.TdInterestAmount} | " +
$"新 I={newEvt.InterestAmount} Td={newEvt.TdInterestAmount} | " +
$"diffI={diffI} diffTd={diffTd} | " +
$"preEod.id={preEod.id} TdIntPrin={preEod.TdInterestPrincipal} ProfitSum={preEod.InterestProfitSum} | " +
$"Fix={pos.InterestPrincipalFix} rate={rate} IntType={pos.InterestType} IsAnnualized={pos.IsAnnualized}");
}
}
}
}
Console.WriteLine($"\n===== 比对汇总:共 {totalCompared} 条,不一致 {mismatches} 条,跳过 {skipped} 个交易 =====");
if (diffLog.Length > 0) Console.WriteLine(diffLog.ToString());
Assert.IsTrue(mismatches == 0,
$"保证金新旧管线 EOD 真实库比对有 {mismatches}/{totalCompared} 条不一致——提交2 前必须解决(详见输出)");
}
}
}
@@ -0,0 +1,201 @@
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.Modules.SwapModule.Margin;
namespace UnitTestProject.Modules.SwapModule.Margin
{
/// <summary>
/// 影子对账:保证金腿方法 CalcMarginInterestEOD 用昨日终本金、盘中用 accrualBasis 差分)vs
/// 旧通用管线 CalcDailySimpleInterestByEod/CalcDailySimpleInterest。
///
/// 保证金是纯固定利率单利(FloatRateUnderlyingCode 恒空、InterestType 恒单利、SwapIntervalList 单段)。
/// 本测试在生产切到 CalcMarginInterest 后作为回归守护,确认其 InterestAmount/TdInterestAmount
/// 与旧纯函数(SimpleInterestAccrual)数值一致。覆盖 EOD 续接/首日、盘中全平/部分平仓/互换。
/// </summary>
[TestClass]
public class MarginInterestShadowTest
{
private const decimal Principal = 2_000_000m; // 保证金本金(InterestPrincipalFix
private const decimal Rate = 0.03m; // 3% 年化固定利率
private const int AnnualDays = 365;
private static readonly DateTime StartDate = new(2026, 7, 1);
private static readonly DateTime ExerciseDate = new(2027, 6, 30);
private sealed class StubSvc : SwapDealService
{
public StubSvc() : base(new OptUserInfo(0, nameof(MarginInterestShadowTest), OptUserFrom.UnitTest)) { }
}
private static trade CreateTrade() => new trade
{
id = 1, TradeNumber = "UT-MARGIN-SHADOW", ClientId = 999998,
TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate,
ExerciseDate = ExerciseDate, TradeStatus = "确认成交", ValidState = "Valid",
trade_extend = new trade_extend
{
TradeId = 1,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{ AnnualDays = AnnualDays, InterestCalcMode = "10", SettlementRules = 0 })
}
};
/// <summary>保证金腿(初始预付金 mode 5):固定利率、单利、年化、无浮动标的。</summary>
private static swap_position CreateMarginPosition() => new swap_position
{
id = 2001, SwapTradeId = 1, PosiDirection = 0,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = (int)InterestModeEnum.,
InterestRateDefault = Rate, InterestPrincipalFix = Principal,
PosiStartDate = StartDate, PosiMatuirityDate = ExerciseDate,
IsInitial = true, Invalid = false,
InterestType = (int)InterestTypeEnum.,
IsAnnualized = true, interest_rest_days = 1, interest_rule = 0,
FloatRateUnderlyingCode = null, InterestSwapInterval = "[]"
};
/// <summary>构造昨日终 eod_swap_position(已含累计利息 InterestProfitSum 与昨日终本金)。</summary>
private static eod_swap_position CreatePreEod(DateTime valueDate, decimal profitSum) => new eod_swap_position
{
id = 1, SwapTradeId = 1, PositionId = 2001,
ValueDate = valueDate,
TdInterestPrincipal = Principal, InterestPrincipalFix = Principal,
InterestProfitSum = profitSum, PosiNotionalValue = Principal, FloatRate = 0m
};
// ──────────────────────────── EOD 路径 ────────────────────────────
/// <summary>EOD 续接单日:有历史归档,notional=昨日终本金。</summary>
[TestMethod]
public void _EOD续接单日_新旧一致()
{
var td = CreateTrade();
var position = CreateMarginPosition();
var valueDate = StartDate.AddDays(5);
const decimal profitSum = 820m;
// 旧方法
decimal oldI = 0, oldTd = 0;
var svc = new StubSvc();
svc.CalcDailySimpleInterestByEod(CreatePreEod(StartDate.AddDays(4), profitSum),
valueDate, td.StartDate.Value, position, Principal, Principal,
new swap_flow_event { InterestRate = Rate }, AnnualDays, 0m, 1m, ref oldI, ref oldTd);
// 新方法(独立 preEod,相同初始值)
var newEvt = svc.CalcMarginInterest(td, valueDate, valueDate, position, Rate, Principal, Principal, 1m,
AnnualDays, calcFirst: true, calcLast: true,
CreatePreEod(StartDate.AddDays(4), profitSum), 0, add: false, settment: true, interestWindowEmpty: false);
Console.WriteLine($"旧: I={oldI} Td={oldTd}");
Console.WriteLine($"新: I={newEvt.InterestAmount} Td={newEvt.TdInterestAmount} ClosePnL={newEvt.InterestClosePnL}");
Assert.AreEqual(oldI, newEvt.InterestAmount, "InterestAmount 一致");
Assert.AreEqual(oldTd, newEvt.TdInterestAmount, "TdInterestAmount 一致");
}
/// <summary>EOD 首日(preEod.id==0):首日初始化 notional=posiPrincipal。</summary>
[TestMethod]
public void _EOD首日_新旧一致()
{
var td = CreateTrade();
var position = CreateMarginPosition();
var valueDate = StartDate;
decimal oldI = 0, oldTd = 0;
var svc = new StubSvc();
svc.CalcDailySimpleInterestByEod(new eod_swap_position { id = 0 },
valueDate, td.StartDate.Value, position, Principal, Principal,
new swap_flow_event { InterestRate = Rate }, AnnualDays, 0m, 1m, ref oldI, ref oldTd);
var newEvt = svc.CalcMarginInterest(td, valueDate, valueDate, position, Rate, Principal, Principal, 1m,
AnnualDays, calcFirst: true, calcLast: true,
new eod_swap_position { id = 0 }, 0, add: false, settment: true, interestWindowEmpty: false);
Assert.AreEqual(oldI, newEvt.InterestAmount, "InterestAmount 一致");
Assert.AreEqual(oldTd, newEvt.TdInterestAmount, "TdInterestAmount 一致");
}
// ──────────────────────────── 盘中路径 ────────────────────────────
/// <summary>盘中全平(closePercent=1):新方法 notional=posiPrincipal,旧方法差分 accrualBasis 恒=posiPrincipal。</summary>
[TestMethod]
public void _盘中全平_新旧一致()
{
var td = CreateTrade();
var position = CreateMarginPosition();
var valueDate = StartDate.AddDays(5);
const decimal profitSum = 820m;
// 旧方法:orginPv 经 PreviousBalance 对齐到昨日终保证金余额 → accrualBasis 恒= Principal
decimal oldI = 0, oldTd = 0;
var svc = new StubSvc();
var preEodOld = CreatePreEod(StartDate.AddDays(4), profitSum);
decimal orginPv = MarginCalc.PreviousBalance(preEodOld, Principal);
svc.CalcDailySimpleInterest(preEodOld, valueDate, position, Principal,
new swap_flow_event { InterestRate = Rate }, AnnualDays, 0m, 1m, orginPv,
calcFirst: true, calcLast: false, ref oldI, ref oldTd);
// 新方法:notional = posiPrincipal(无差分、无 orginPv
var newEvt = svc.CalcMarginInterest(td, valueDate, valueDate, position, Rate, Principal, Principal, 1m,
AnnualDays, calcFirst: true, calcLast: false,
CreatePreEod(StartDate.AddDays(4), profitSum), 0, add: false, settment: false, interestWindowEmpty: false);
Console.WriteLine($"旧: I={oldI} Td={oldTd}");
Console.WriteLine($"新: I={newEvt.InterestAmount} Td={newEvt.TdInterestAmount}");
Assert.AreEqual(oldI, newEvt.InterestAmount, "InterestAmount 一致");
Assert.AreEqual(oldTd, newEvt.TdInterestAmount, "TdInterestAmount 一致");
}
/// <summary>盘中部分平仓(closePercent=0.5):缩放累计,新旧线性等价。</summary>
[TestMethod]
public void _盘中部分平仓_新旧一致()
{
var td = CreateTrade();
var position = CreateMarginPosition();
var valueDate = StartDate.AddDays(5);
const decimal profitSum = 820m;
const decimal closePct = 0.5m;
decimal oldI = 0, oldTd = 0;
var svc = new StubSvc();
var preEodOld = CreatePreEod(StartDate.AddDays(4), profitSum);
decimal orginPv = MarginCalc.PreviousBalance(preEodOld, Principal);
svc.CalcDailySimpleInterest(preEodOld, valueDate, position, Principal,
new swap_flow_event { InterestRate = Rate }, AnnualDays, 0m, closePct, orginPv,
calcFirst: true, calcLast: false, ref oldI, ref oldTd);
var newEvt = svc.CalcMarginInterest(td, valueDate, valueDate, position, Rate,
Principal * closePct, Principal, closePct,
AnnualDays, calcFirst: true, calcLast: false,
CreatePreEod(StartDate.AddDays(4), profitSum), 0, add: false, settment: false, interestWindowEmpty: false);
Console.WriteLine($"旧: I={oldI} Td={oldTd}");
Console.WriteLine($"新: I={newEvt.InterestAmount} Td={newEvt.TdInterestAmount}");
Assert.AreEqual(oldI, newEvt.InterestAmount, "InterestAmount 一致");
Assert.AreEqual(oldTd, newEvt.TdInterestAmount, "TdInterestAmount 一致");
}
/// <summary>互换事件(swap=true,盘中):利息应归零。</summary>
[TestMethod]
public void _盘中互换_利息归零()
{
var td = CreateTrade();
var position = CreateMarginPosition();
var valueDate = StartDate.AddDays(5);
var svc = new StubSvc();
var newEvt = svc.CalcMarginInterest(td, valueDate, valueDate, position, Rate, Principal, Principal, 1m,
AnnualDays, calcFirst: true, calcLast: false,
CreatePreEod(StartDate.AddDays(4), 820m), 0, add: false, settment: false, interestWindowEmpty: true);
Assert.AreEqual(0m, newEvt.InterestAmount, "互换利息归零");
Assert.AreEqual(0m, newEvt.TdInterestAmount, "互换 TdInterestAmount 归零");
Assert.AreEqual(0m, newEvt.InterestClosePnL, "互换 InterestClosePnL 归零");
}
}
}
@@ -1,121 +0,0 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using YLErp.Derivatives.Interest;
using YLErp.Modules.SwapModule.Margin;
namespace UnitTestProject.Modules.SwapModule.Margin
{
/// <summary>
/// 保证金账户(MarginAccount)单测。验证余额变动(追加/释放/返还)。
/// 保证金就是保证金——有余额、有利率、有利息,不存在"计息基数/Notional"概念。
/// </summary>
[TestClass]
public class MarginLegTest
{
private const decimal Opening = 2_000_000m;
#region MarginAccount
[TestMethod]
public void _初始余额等于期初保证金()
{
var account = new MarginAccount(new MarginBalance(Opening));
Assert.AreEqual(Opening, account.Balance.Balance);
}
[TestMethod]
public void _追加保证金_余额增加()
{
var account = new MarginAccount(new MarginBalance(Opening));
account.Deposit(500_000m);
Assert.AreEqual(2_500_000m, account.Balance.Balance);
}
[TestMethod]
public void _释放保证金_余额减少()
{
var account = new MarginAccount(new MarginBalance(Opening));
account.Withdraw(800_000m);
Assert.AreEqual(1_200_000m, account.Balance.Balance);
}
[TestMethod]
public void _释放超过余额_不低于零()
{
var account = new MarginAccount(new MarginBalance(Opening));
account.Withdraw(3_000_000m);
Assert.AreEqual(0m, account.Balance.Balance, "保证金余额不低于零");
}
#endregion
#region
[TestMethod]
public void _各自返回正确Form和余额()
{
IMarginResolver cash = new CashMargin();
IMarginResolver credit = new CreditMargin();
IMarginResolver guarantee = new GuaranteeMargin();
Assert.AreEqual(MarginForm.Cash, cash.Form);
Assert.AreEqual(MarginForm.Credit, credit.Form);
Assert.AreEqual(MarginForm.Guarantee, guarantee.Form);
Assert.AreEqual(Opening, cash.Resolve(Opening).Balance);
Assert.AreEqual(Opening, credit.Resolve(Opening).Balance);
Assert.AreEqual(Opening, guarantee.Resolve(Opening).Balance);
}
#endregion
#region MarginAccount
[TestMethod]
public void _单利7天_余额200万年化3pct()
{
var account = new MarginAccount(new MarginBalance(2_000_000m));
// 200万 × 3% / 365 × 7天 = 1150.68...
var r = account.AccrueInterest(
rate: 0.03m,
startDate: new System.DateTime(2026, 5, 4),
endDate: new System.DateTime(2026, 5, 11),
boundary: AccrualBoundary.StartOnly,
annualDays: 365);
Assert.IsTrue(r.Accrued > 0, "7天利息应大于0");
System.Console.WriteLine($"保证金7天利息={r.Accrued}");
}
[TestMethod]
public void _零余额_利息为零()
{
var account = new MarginAccount(new MarginBalance(0m));
var r = account.AccrueInterest(0.03m,
new System.DateTime(2026, 5, 4), new System.DateTime(2026, 5, 11),
AccrualBoundary.StartOnly, 365);
Assert.AreEqual(0m, r.Accrued);
}
[TestMethod]
public void _释放后余额减少_利息相应减少()
{
var full = new MarginAccount(new MarginBalance(2_000_000m));
var half = new MarginAccount(new MarginBalance(2_000_000m));
half.Withdraw(1_000_000m);
var rFull = full.AccrueInterest(0.03m,
new System.DateTime(2026, 5, 4), new System.DateTime(2026, 5, 11),
AccrualBoundary.StartOnly, 365);
var rHalf = half.AccrueInterest(0.03m,
new System.DateTime(2026, 5, 4), new System.DateTime(2026, 5, 11),
AccrualBoundary.StartOnly, 365);
Assert.IsTrue(rHalf.Accrued < rFull.Accrued, "释放后利息应更少");
Assert.IsTrue(System.Math.Abs(rFull.Accrued - rHalf.Accrued * 2m) < 0.01m,
"余额减半, 利息也应减半");
}
#endregion
}
}
@@ -8,7 +8,7 @@ namespace UnitTestProject.Modules.SwapModule.Margin
{
/// <summary>
/// MarginModes 统一判断口径测试。
/// 验证它和现有散落的 marginTypes/InterestMarginModels/premiumModes 内容一致。
/// 验证 MarginModes 由框架常量 ConsTrade.InterestMarginModels 派生,内容一致。
/// </summary>
[TestClass]
public class MarginModesTest
@@ -37,7 +37,7 @@ namespace UnitTestProject.Modules.SwapModule.Margin
Assert.IsFalse(MarginModes.Contains((int)InterestModeEnum.));
}
/// <summary>守护:和 ConsTrade.InterestMarginModels 内容须一致(迁移期对齐)。</summary>
/// <summary>回归护栏:MarginModes 由 ConsTrade.InterestMarginModels 派生,内容须一致(防止有人又独立重写集合导致口径分裂)。</summary>
[TestMethod]
public void ConsTradeInterestMarginModels内容一致()
{
@@ -119,8 +119,8 @@ namespace YLErp.Modules.SwapModule
var position = CreateInterestPosition();
var interests = service.GetInterests(td, td.trade_extend, unwindDate, unwindDate,
new List<eod_swap_position>(), new List<swap_position> { position },
Principal, Principal, Principal, Principal, 1m,
(int)SwapEventTypeEnum., false, false, Principal, Principal,
Principal, Principal, 1m,
(int)SwapEventTypeEnum., false, Principal,
add: false, settment: false, newCalcLast: false);
return interests.Count > 0 ? interests[0].InterestAmount : 0m;
}
@@ -142,8 +142,8 @@ namespace YLErp.Modules.SwapModule
};
var interests = service.GetInterests(td, td.trade_extend, valueDate, valueDate,
new List<eod_swap_position> { preEod }, new List<swap_position> { position },
Principal, Principal, Principal, Principal, 1m,
(int)SwapEventTypeEnum., false, false, Principal, Principal,
Principal, Principal, 1m,
(int)SwapEventTypeEnum., false, Principal,
add: false, settment: true, newCalcLast: false);
if (interests.Count == 0) return (0m, 0m);
return (interests[0].TdInterestAmount, interests[0].InterestAmount);
@@ -313,8 +313,8 @@ namespace YLErp.Modules.SwapModule
var svc5 = new StubDealService(0m, floatRate: 0.001);
var i5 = svc5.GetInterests(td, td.trade_extend, day5, day5,
new List<eod_swap_position>(), new List<swap_position> { position },
Principal, Principal, Principal, Principal, 1m,
(int)SwapEventTypeEnum., false, false, Principal, Principal,
Principal, Principal, 1m,
(int)SwapEventTypeEnum., false, Principal,
settment: false);
decimal swap1 = i5.Count > 0 ? i5[0].InterestAmount : 0m;
@@ -322,8 +322,8 @@ namespace YLErp.Modules.SwapModule
var svc10 = new StubDealService(swap1, floatRate: 0.001);
var i10 = svc10.GetInterests(td, td.trade_extend, day10, day10,
new List<eod_swap_position>(), new List<swap_position> { position },
Principal, Principal, Principal, Principal, 1m,
(int)SwapEventTypeEnum., false, false, Principal, Principal,
Principal, Principal, 1m,
(int)SwapEventTypeEnum., false, Principal,
settment: false);
decimal swap2 = i10.Count > 0 ? i10[0].InterestAmount : 0m;
@@ -332,8 +332,8 @@ namespace YLErp.Modules.SwapModule
var svc15 = new StubDealService(totalConsumed, floatRate: 0.001);
var i15 = svc15.GetInterests(td, td.trade_extend, day15, day15,
new List<eod_swap_position>(), new List<swap_position> { position },
Principal, Principal, Principal, Principal, 1m,
(int)SwapEventTypeEnum., false, false, Principal, Principal,
Principal, Principal, 1m,
(int)SwapEventTypeEnum., false, Principal,
settment: false);
decimal finalUnwind = i15.Count > 0 ? i15[0].InterestAmount : 0m;
@@ -362,8 +362,8 @@ namespace YLErp.Modules.SwapModule
var svc = new StubDealService(0m, floatRate: 0.001);
var interests = svc.GetInterests(td, td.trade_extend, unwindDate, unwindDate,
new List<eod_swap_position>(), new List<swap_position> { position },
Principal, Principal, Principal, Principal, 1m,
(int)SwapEventTypeEnum., false, false, Principal, Principal,
Principal, Principal, 1m,
(int)SwapEventTypeEnum., false, Principal,
settment: false);
return interests.Count > 0 ? interests[0].InterestAmount : 0m;
}
@@ -0,0 +1,211 @@
using YLErp.Modules.SwapModule.Accrual;
using YLErp.Modules.SwapModule.Penalty;
namespace UnitTestProject.Modules.SwapModule.Penalty
{
/// <summary>
/// EQD-6977 罚息边界矩阵测试(全部断言金标准恒等式:全期 = 实结 + 罚息)。
///
/// 覆盖易错边界:
/// ① 平仓日恰为重置日(算尾/不算尾)——重置日快照基数还是上一段的,①须取 InterestIncomeSum
/// ② 重置日前一日平仓(② 几乎整段、窗口首段 0 天);
/// ③ 到期日恰为重置日(末段 [到期,到期] 1 天);
/// ④ 锚点偏离(td.StartDate=7/31 但腿 PosiStartDate=8/3 的延期/存续腿——重置网格整体不同);
/// ⑤ 起息日当天平仓(无 preEod)。
///
/// 一致性前提(与现实世界对齐):冻结利率 = 当前重置区间(含 unwind-1 的区间)的在役利率,
/// 即"历史末段利率 = 冻结利率";历史各段定盘不同(体现真实 FR007 利率历史)。
/// </summary>
[TestClass]
public class PenaltyBoundaryMatrixTest
{
private const decimal Notional = 100_000_000m;
private const int AnnualDays = 365;
private static readonly decimal[] Hist = { 0.0310m, 0.0420m, 0.0530m, 0.0225m }; // 7/31 / 8/7 / 8/14 / 8/21 段
private static readonly decimal Frozen = Hist[^1]; // 冻结 = 当前区间在役利率 = 历史末段
/// <summary>指定重置网格上的复利重放 [gridStart, end];超出所给历史段后沿用冻结利率。</summary>
private static decimal AccrueOnGrid(DateTime gridStart, DateTime end, AccrualBoundary boundary,
decimal[] histRates, decimal notional = Notional, int period = 7)
{
var frozen = histRates[^1];
var segs = new List<(DateTime, decimal)>();
var i = 0;
for (var d = gridStart; d <= end; d = d.AddDays(period))
segs.Add((d, i < histRates.Length ? histRates[i++] : frozen));
return CompoundInterestAccrual.AccruePeriod(
notional: notional, segmentRates: segs,
startDate: gridStart, endDate: end,
boundary: boundary, annualDays: AnnualDays, isAnnualized: true,
resetCarryInterest: 0m, realizedInterest: 0m, unwindFraction: 1m,
finalBasis: out _).Accrued;
}
private static trade CreateTrade(DateTime startDate, DateTime maturity)
=> new()
{
id = 1, TradeNumber = "UT-BOUNDARY", ClientId = 999998,
TradeType = "收益互换", TradeDate = startDate, StartDate = startDate,
ExerciseDate = maturity, TradeStatus = "确认成交", ValidState = "Valid"
};
private static swap_position CompoundLeg(DateTime posiStart, DateTime maturity, decimal spread, int periodDays = 7)
=> new()
{
id = 1001, SwapTradeId = 1, PosiDirection = 0, InterestDirection = 1,
InterestMode = (int)InterestModeEnum., InterestRateDefault = spread,
InterestPrincipalFix = Notional, PosiStartDate = posiStart, PosiMatuirityDate = maturity,
IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.,
IsAnnualized = true, interest_rest_days = periodDays, interest_rule = 0,
FloatRateUnderlyingCode = null, InterestSwapInterval = "[]"
};
/// <summary>日终快照:TdInterestPrincipal=当日实际滚动基数、InterestIncomeSum=截至当日待实现利息。</summary>
private static eod_swap_position Snap(DateTime valueDate, decimal rollingBasis, decimal incomeSum)
=> new() { id = 9, PositionId = 1001, ValueDate = valueDate,
TdInterestPrincipal = rollingBasis, InterestIncomeSum = incomeSum };
private static decimal RunFee(trade td, swap_position p, decimal settledAmount,
eod_swap_position? preEod, DateTime unwind, bool settled, decimal spread,
decimal interestPrincipal = 0m, bool maturityCalcLast = true)
{
var e = new swap_flow_event
{
PositionId = p.id, InterestAmount = settledAmount, InterestFee = 0m,
InterestDirection = 1, InterestClosePnL = settledAmount,
InterestPrincipal = interestPrincipal // 复利主路径下=重放末次并本金后基数(=被平份额本金+①)
};
PenaltyInterestFeeMerger.Merge(
td, new List<swap_position> { p }, new List<swap_flow_event> { e },
unwind, AnnualDays, settled, maturityCalcLast: maturityCalcLast,
posiNotionalValue: Notional, closePosiNotionalValue: Notional, closePercent: 1m,
getSpread: _ => spread, getPreEod: _ => preEod, tryGetFixing: (d, c) => spread);
return e.InterestFee;
}
[TestMethod]
public void _算尾_恒等式成立()
{
var start = new DateTime(2026, 7, 31); var unwind = new DateTime(2026, 8, 21); var maturity = new DateTime(2026, 8, 31);
var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.Both, Hist); // [7/31..8/21](末日=重置日,1 天)
var basisThru813 = AccrueOnGrid(start, new DateTime(2026, 8, 13), AccrualBoundary.Both, Hist); // 8/14 起段基数
var incomeSum = AccrueOnGrid(start, unwind.AddDays(-1), AccrualBoundary.Both, Hist); // 8/20 待实现
// 前提自检:重置日快照基数(8/14段)≠今日应并入额(8/20待实现),旧公式(basis−P)必错——用例有鉴别力
Assert.AreNotEqual((double)basisThru813, (double)incomeSum, 1000d, "快照基数与重置日应并入额应显著不同");
var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, Frozen), elapsed,
Snap(unwind.AddDays(-1), Notional + basisThru813, incomeSum), unwind, settled: true, spread: Frozen);
var full = AccrueOnGrid(start, maturity, AccrualBoundary.Both, Hist);
Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01,
"重置日当天平仓(算尾):① 须取 InterestIncomeSum,全期=实结+罚息");
}
[TestMethod]
public void _不算尾_恒等式成立()
{
var start = new DateTime(2026, 7, 31); var unwind = new DateTime(2026, 8, 21); var maturity = new DateTime(2026, 8, 31);
var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.StartOnly, Hist); // [7/31..8/20]
var incomeSum = elapsed; // 不算尾时实结=8/20待实现
var basisThru813 = AccrueOnGrid(start, new DateTime(2026, 8, 13), AccrualBoundary.Both, Hist);
var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, Frozen), elapsed,
Snap(unwind.AddDays(-1), Notional + basisThru813, incomeSum), unwind, settled: false, spread: Frozen);
var full = AccrueOnGrid(start, maturity, AccrualBoundary.Both, Hist);
Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01,
"重置日当天平仓(不算尾):②=0,罚息含平仓日,全期=实结+罚息");
}
[TestMethod]
public void _段内几乎整段承接_恒等式成立()
{
var start = new DateTime(2026, 7, 31); var unwind = new DateTime(2026, 8, 27); var maturity = new DateTime(2026, 8, 31);
var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.Both, Hist); // [7/31..8/27],段内已计 8/21..8/27
var basisThru820 = AccrueOnGrid(start, new DateTime(2026, 8, 20), AccrualBoundary.Both, Hist); // 8/21 起段基数
var incomeSum = AccrueOnGrid(start, unwind.AddDays(-1), AccrualBoundary.Both, Hist);
var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, Frozen), elapsed,
Snap(unwind.AddDays(-1), Notional + basisThru820, incomeSum), unwind, settled: true, spread: Frozen);
var full = AccrueOnGrid(start, maturity, AccrualBoundary.Both, Hist);
Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01,
"重置日前一日平仓:窗口首段 0 天、② 于 8/28 整段并入,全期=实结+罚息");
}
[TestMethod]
public void _末段一天_恒等式成立()
{
// 8/18 平仓:当前区间为 8/14 段(r3) → 冻结利率=r3=历史末段;到期 9/4 恰为重置日(末段 [9/4,9/4] 1 天)
var start = new DateTime(2026, 7, 31); var unwind = new DateTime(2026, 8, 18); var maturity = new DateTime(2026, 9, 4);
var hist = new decimal[] { 0.0310m, 0.0420m, 0.0530m }; // 7/31 / 8/7 / 8/14(=冻结 5.3%)
var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.Both, hist);
var basisThru813 = AccrueOnGrid(start, new DateTime(2026, 8, 13), AccrualBoundary.Both, hist);
var incomeSum = AccrueOnGrid(start, unwind.AddDays(-1), AccrualBoundary.Both, hist);
var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, hist[^1]), elapsed,
Snap(unwind.AddDays(-1), Notional + basisThru813, incomeSum), unwind, settled: true, spread: hist[^1]);
var full = AccrueOnGrid(start, maturity, AccrualBoundary.Both, hist);
Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01,
"到期日=重置日:末段 [9/4,9/4] 1 天收尾,全期=实结+罚息");
}
[TestMethod]
public void _延期腿按腿起息日网格_恒等式成立()
{
// 交易起始 7/31,但腿 PosiStartDate=8/3(延期/存续腿)→ 真实重置网格 8/10/8/17/8/24/8/31
var tradeStart = new DateTime(2026, 7, 31); var posiStart = new DateTime(2026, 8, 3);
var unwind = new DateTime(2026, 8, 19); var maturity = new DateTime(2026, 9, 3);
var hist = new decimal[] { 0.0300m, 0.0400m, 0.0225m }; // 8/3 / 8/10 / 8/17(=冻结) 三段历史
var elapsed = AccrueOnGrid(posiStart, unwind, AccrualBoundary.Both, hist);
var basisThru816 = AccrueOnGrid(posiStart, new DateTime(2026, 8, 16), AccrualBoundary.Both, hist); // 8/17 起段基数
var incomeSum = AccrueOnGrid(posiStart, unwind.AddDays(-1), AccrualBoundary.Both, hist);
var fee = RunFee(CreateTrade(tradeStart, maturity), CompoundLeg(posiStart, maturity, hist[^1]), elapsed,
Snap(unwind.AddDays(-1), Notional + basisThru816, incomeSum), unwind, settled: true, spread: hist[^1]);
var full = AccrueOnGrid(posiStart, maturity, AccrualBoundary.Both, hist);
Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01,
"锚点偏离:罚息分段/重置日判定必须用 position.PosiStartDate 网格(误用 td.StartDate 网格必挂)");
}
[TestMethod]
public void preEod且此前已有重置_经事件基数兜底_恒等式精确成立()
{
// UAT 实测场景(tradeId=2447):环境无日终快照、起息后已发生 8/19 重置并本。
// 兜底① = normalEvent.InterestPrincipal 本金(复利重放末次并本金后基数);
// 修复前 ①=0 少算 ≈3.17 元(并入额×冻结利率×段尾天数),本用例钉死兜底路径的精确性。
var start = new DateTime(2026, 8, 5); var unwind = new DateTime(2026, 8, 20); var maturity = new DateTime(2026, 9, 30);
var hist = new decimal[] { 0.0216m, 0.0144m }; // 8/5 段 2.16% / 8/19 段 1.44%(=冻结)14 天重置
var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.StartOnly, hist, period: 14); // 已结 [8/5..8/19]
var replayFinalBasis = Notional + AccrueOnGrid(start, new DateTime(2026, 8, 18), AccrualBoundary.Both, hist, period: 14); // 8/19 重置并本后基数
var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, hist[^1], periodDays: 14), elapsed,
preEod: null, unwind: unwind, settled: false, spread: hist[^1],
interestPrincipal: replayFinalBasis, maturityCalcLast: false); // 不算尾合约、14天重置(对应 UAT tradeId=2447 口径)
var full = AccrueOnGrid(start, maturity, AccrualBoundary.StartOnly, hist, period: 14);
Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01,
"无preEod+已有重置:兜底取事件基数后 ① 精确,全期=实结+罚息(修复前差≈3.17元)");
}
[TestMethod]
public void _无preEod_恒等式成立()
{
// 首日平仓:当前区间=首段(r1),无 preEod 时取价委托返回首段定盘 → 冻结利率=r1,全程恒率
var start = new DateTime(2026, 7, 31); var maturity = new DateTime(2026, 8, 31);
var hist = new decimal[] { 0.0310m };
var elapsed = AccrueOnGrid(start, start, AccrualBoundary.Both, hist); // 首日 1 天
var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, hist[^1]), elapsed,
preEod: null, unwind: start, settled: true, spread: hist[^1]);
var full = AccrueOnGrid(start, maturity, AccrualBoundary.Both, hist);
Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01,
"起息日当天平仓:①=0、②=首日利息于 8/7 并入,全期=实结+罚息");
}
}
}
@@ -0,0 +1,167 @@
using YLErp.Modules.SwapModule.Accrual;
using YLErp.Modules.SwapModule.Penalty;
namespace UnitTestProject.Modules.SwapModule.Penalty
{
/// <summary>
/// EQD-6977 罚息接缝 headless 测试(无 DBspread/preEod/取价 全部以委托注入)。
/// 锁定:Merge 把罚息金额并入既有利息事件的 InterestFee(不新增事件、不改 InterestAmount);
/// 承接量取实际计息状态(preEod 基数 + 事件实结金额)——含【多区间不同定盘】恒等式钉死,
/// 该用例在"冻结利率重放推导承接量"的旧实现下必挂(FR007 真实利率历史场景)。
/// </summary>
[TestClass]
public class PenaltyInterestFeeMergerTest
{
private const decimal Notional = 100_000_000m;
private const decimal Rate = 0.0225m; // 冻结 all-in 年化
private const int AnnualDays = 365;
private static readonly DateTime StartDate = new(2026, 7, 31);
private static readonly DateTime MaturityDate = new(2026, 8, 31);
private static readonly DateTime UnwindDate = new(2026, 8, 25);
private static readonly DateTime LastResetBeforeUnwind = new(2026, 8, 21);
private static trade CreateTrade()
=> new()
{
id = 1, TradeNumber = "UT-MERGE", ClientId = 999998,
TradeType = "收益互换", StartDate = StartDate, TradeDate = StartDate,
ExerciseDate = MaturityDate, TradeStatus = "确认成交", ValidState = "Valid"
};
private static swap_position Leg(InterestTypeEnum interestType)
=> new()
{
id = 1001, SwapTradeId = 1, PosiDirection = 0, InterestDirection = 1,
InterestMode = (int)InterestModeEnum., InterestRateDefault = Rate,
InterestPrincipalFix = Notional, PosiStartDate = StartDate, PosiMatuirityDate = MaturityDate,
IsInitial = true, Invalid = false, InterestType = (int)interestType,
IsAnnualized = true, interest_rest_days = 7, interest_rule = 0,
FloatRateUnderlyingCode = null, InterestSwapInterval = "[]"
};
/// <summary>正常平仓利息流(模拟 GetInterests 产出):InterestAmount=实结利息、InterestFee=0。</summary>
private static swap_flow_event NormalEvent(decimal settledAmount)
=> new() { PositionId = 1001, InterestAmount = settledAmount, InterestFee = 0m,
InterestDirection = 1, InterestClosePnL = settledAmount }; // 模拟 GetInterests 已算好的 PnL(收取=+1
private static eod_swap_position PreEod(decimal rollingBasis, decimal floatRate = 0m)
=> new() { id = 9, PositionId = 1001, ValueDate = UnwindDate.AddDays(-1),
TdInterestPrincipal = rollingBasis, FloatRate = floatRate };
private static void RunMerge(
swap_position p, swap_flow_event normalEvent, eod_swap_position? preEod,
Func<swap_position, decimal>? getSpread = null, Func<DateTime, string, decimal?>? tryGetFixing = null)
{
getSpread ??= _ => Rate;
tryGetFixing ??= (d, code) => Rate;
PenaltyInterestFeeMerger.Merge(
CreateTrade(), new List<swap_position> { p }, new List<swap_flow_event> { normalEvent },
UnwindDate, AnnualDays,
unwindDaySettled: true, maturityCalcLast: true,
posiNotionalValue: Notional, closePosiNotionalValue: Notional, closePercent: 1m,
getSpread: getSpread,
getPreEod: _ => preEod,
tryGetFixing: tryGetFixing);
}
/// <summary>复利重放 [StartDate, endDate],重置段=每 7 天;分段利率由 rates 决定(rates.Count=1 时为常率)。</summary>
private static decimal CompoundAccruedTo(DateTime endDate, AccrualBoundary boundary, params decimal[] rates)
{
var segs = new List<(DateTime, decimal)>();
var i = 0;
for (var d = StartDate; d <= endDate; d = d.AddDays(7))
// 超出所给历史段后沿用最后区间利率——即“未来段冻结为最后区间利率”的语义(勿循环回绕)
segs.Add((d, rates.Length == 1 ? rates[0] : i < rates.Length ? rates[i++] : rates[^1]));
return CompoundInterestAccrual.AccruePeriod(
notional: Notional, segmentRates: segs,
startDate: StartDate, endDate: endDate,
boundary: boundary, annualDays: AnnualDays, isAnnualized: true,
resetCarryInterest: 0m, realizedInterest: 0m, unwindFraction: 1m,
finalBasis: out _).Accrued;
}
[TestMethod]
public void _罚息并入InterestFee_不新增事件()
{
var e = NormalEvent(settledAmount: 50_000m);
RunMerge(Leg(InterestTypeEnum.), e, preEod: PreEod(Notional));
Assert.AreEqual(0d, (double)(e.InterestFee - Rate * Notional * 6m / AnnualDays), 0.0001,
"罚息=利率×本金×6天/基准(窗口 (8/25, 8/31]");
Assert.AreEqual(50_000d, (double)e.InterestAmount, 0.0001, "正常实结利息不受影响");
Assert.AreEqual((double)(50_000m + e.InterestFee), (double)e.InterestClosePnL, 0.0001, "PnL=(实结+罚息)×方向(收取=+1)");
}
[TestMethod]
public void _取价委托解析冻结率_并入费用()
{
var p = Leg(InterestTypeEnum.);
p.FloatRateUnderlyingCode = "FR007";
var e = NormalEvent(settledAmount: 50_000m);
// 无 preEod → 走取价委托:all-in = spread(0) + 定盘(Rate)
RunMerge(p, e, preEod: null, getSpread: _ => 0m, tryGetFixing: (d, code) => Rate);
Assert.AreEqual(0d, (double)(e.InterestFee - Rate * Notional * 6m / AnnualDays), 0.0001,
"浮动腿冻结率=取价委托值(零利差)");
}
[TestMethod]
public void _承接取实际状态_恒等式全期等于已结加罚息()
{
// 实际计息状态:preEod 滚动基数 = P + 已并入利息(截至 8/20);事件实结 = elapsed([7/31,8/25] Both)
var capitalized = CompoundAccruedTo(LastResetBeforeUnwind.AddDays(-1), AccrualBoundary.Both, Rate);
var elapsed = CompoundAccruedTo(UnwindDate, AccrualBoundary.Both, Rate);
var e = NormalEvent(elapsed);
RunMerge(Leg(InterestTypeEnum.), e, preEod: PreEod(Notional + capitalized));
var full = CompoundAccruedTo(MaturityDate, AccrualBoundary.Both, Rate);
Assert.AreEqual((double)full, (double)(elapsed + e.InterestFee), 0.0001,
"常率下 全期 = 已结(事件实结) + 罚息(InterestFee)");
}
[TestMethod]
public void _承接取实际状态_恒等式仍成立()
{
// 真实 FR007 世界:四个历史重置区间定盘各不相同,冻结利率=最后区间(2.25%)
var r1 = 0.0310m; var r2 = 0.0420m; var r3 = 0.0530m; var r4 = Rate; // r4=0.0225 冻结值
var rates = new[] { r1, r2, r3, r4 };
// 实际计息状态(与 GetInterests 重放同源):
var capitalized = CompoundAccruedTo(LastResetBeforeUnwind.AddDays(-1), AccrualBoundary.Both, rates); // 已并入 8/21 重置日
var elapsed = CompoundAccruedTo(UnwindDate, AccrualBoundary.Both, rates); // 实结(含 8/21..8/25 段内利息)
var e = NormalEvent(elapsed);
RunMerge(Leg(InterestTypeEnum.), e, preEod: PreEod(Notional + capitalized));
// 全期参照:历史段按各自真实定盘、8/28 起的未来段按冻结利率(=r4,恰好同段延续)
var full = CompoundAccruedTo(MaturityDate, AccrualBoundary.Both, rates);
Assert.AreEqual((double)full, (double)(elapsed + e.InterestFee), 0.01,
"多区间不同定盘下 全期(历史实率+未来冻结) = 实结 + 罚息——承接量必须来自实际状态");
// 反证旧缺陷:冻结重放推导的承接①(全程 r4)≠ 实际①(分段实率),差额显著
var frozenReplayCapitalized = CompoundAccruedTo(LastResetBeforeUnwind.AddDays(-1), AccrualBoundary.Both, Rate);
Assert.AreNotEqual((double)capitalized, (double)frozenReplayCapitalized, 1000d,
"前提自检:分段实率与冻结重放的已并入利息应显著不同(否则用例失去鉴别力)");
}
[TestMethod]
public void preEod_承接退化为实结全额_可计算不崩溃()
{
var elapsed = CompoundAccruedTo(UnwindDate, AccrualBoundary.Both, Rate);
var e = NormalEvent(elapsed);
RunMerge(Leg(InterestTypeEnum.), e, preEod: null);
Assert.IsTrue(e.InterestFee > 0m, "无 preEod(首日平仓等)仍可计算罚息");
}
[TestMethod]
public void _跳过该腿不阻断()
{
var p = Leg(InterestTypeEnum.);
p.FloatRateUnderlyingCode = "FR007";
var e = NormalEvent(settledAmount: 50_000m);
RunMerge(p, e, preEod: null, getSpread: _ => 0m, tryGetFixing: (d, code) => null);
Assert.AreEqual(0m, e.InterestFee, "缺价跳过:不加罚息、不抛异常");
Assert.AreEqual(50_000d, (double)e.InterestAmount, 0.0001, "正常平仓不受影响");
}
}
}
@@ -0,0 +1,81 @@
using YLErp.DBModels.Enums;
using YLErp.Modules.SwapModule;
using YLErp.Modules.SwapModule.Accrual;
using YLErp.Modules.SwapModule.Penalty;
namespace UnitTestProject.Modules.SwapModule.Penalty
{
/// <summary>
/// EQD-6977 罚息冻结利率解析契约测试。
/// 规则(需求 2.2.2):冻结为「最后一个重置区间」定盘;终止日为重置日也取上一区间。
/// </summary>
[TestClass]
public class PenaltyLegRateResolverTest
{
private const decimal Spread = 0.05m; // +500bp
private static readonly DateTime UnwindDate = new(2026, 8, 25);
private static swap_position CreateFloatPosition(int interestRule = 0)
=> new()
{
id = 1001, SwapTradeId = 1, PosiDirection = 0,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = (int)InterestModeEnum.,
InterestRateDefault = Spread,
PosiStartDate = new DateTime(2026, 7, 31),
interest_rest_days = 7, interest_rule = interestRule,
FloatRateUnderlyingCode = "FR007",
FloatRate = 0.0185m
};
[TestMethod]
public void _preEod快照优先_重置日下午仍取上一区间()
{
// 8/25 为重置日且下午已出新价的边缘场景:preEod.FloatRate(昨日区间定盘)仍优先,
// 解析器不做任何取价——「终止日取上一区间」由快照语义天然覆盖。
var p = CreateFloatPosition();
var rate = PenaltyLegRateResolver.ResolveFrozenRate(
p, spread: Spread, preEodFloatRate: 0.0210m,
unwindDate: UnwindDate, tryGetFixing: _ => throw new AssertFailedException("preEod 在场时不应取价"));
Assert.AreEqual(Spread + 0.0210m, rate.AllInRate, "冻结 all-in = 利差 + 上一区间定盘");
}
[TestMethod]
public void _无preEod_按前一营业日取价日取定盘()
{
var p = CreateFloatPosition(interestRule: 0); // 当前营业日规则
DateTime? askedDate = null;
var rate = PenaltyLegRateResolver.ResolveFrozenRate(
p, spread: Spread, preEodFloatRate: null,
unwindDate: UnwindDate,
tryGetFixing: d => { askedDate = d; return 0.0195m; });
Assert.AreEqual(new DateTime(2026, 8, 24), askedDate, "取价日 = GetFixingDate(8/24, rule=0)");
Assert.AreEqual(Spread + 0.0195m, rate.AllInRate);
}
[TestMethod]
public void _无preEod_缺价抛异常()
{
var p = CreateFloatPosition();
Assert.ThrowsException<Exception>(() =>
PenaltyLegRateResolver.ResolveFrozenRate(
p, spread: Spread, preEodFloatRate: null,
unwindDate: UnwindDate, tryGetFixing: _ => null));
}
[TestMethod]
public void _不取价_直接固定利率()
{
var p = CreateFloatPosition();
p.FloatRateUnderlyingCode = null;
var rate = PenaltyLegRateResolver.ResolveFrozenRate(
p, spread: Spread, preEodFloatRate: null,
unwindDate: UnwindDate, tryGetFixing: _ => throw new AssertFailedException("固定腿不应取价"));
Assert.AreEqual(Spread, rate.AllInRate);
}
}
}
@@ -0,0 +1,176 @@
using YLErp;
using YLErp.DBModels.Enums;
using YLErp.Modules.SwapModule;
using YLErp.Modules.SwapModule.Accrual;
using YLErp.Modules.SwapModule.Penalty;
namespace UnitTestProject.Modules.SwapModule.Penalty
{
/// <summary>
/// EQD-6977 平仓罚息计算器契约测试(返回罚息金额)。
///
/// 金标准恒等式(需求核心语义,2026-08-20 裁定的精确续接口径):
/// 全期利息 = 平仓日已结利息 + 罚息金额
/// 历史口径:7/31 起息、8/31 到期、7 天重置(8/7/8/14/8/21/8/28)、8/25 提前终止
/// (平仓日落在 8/21–8/28 重置段中间——复利承接两分量的关键场景)。
/// </summary>
[TestClass]
public class SwapPenaltyInterestCalculatorTest
{
private const decimal Notional = 100_000_000m;
private const decimal Rate = 0.0225m; // 冻结 all-in 年化
private const int AnnualDays = 365;
private static readonly DateTime StartDate = new(2026, 7, 31);
private static readonly DateTime MaturityDate = new(2026, 8, 31);
private static readonly DateTime UnwindDate = new(2026, 8, 25);
private static readonly DateTime LastResetBeforeUnwind = new(2026, 8, 21);
private static swap_position CreatePosition(InterestTypeEnum interestType, SwapDirectionEnum direction)
=> new()
{
id = 1001, SwapTradeId = 1, PosiDirection = 0,
InterestDirection = (int)direction,
InterestMode = (int)InterestModeEnum.,
InterestRateDefault = Rate,
InterestPrincipalFix = Notional,
PosiStartDate = StartDate, PosiMatuirityDate = MaturityDate,
IsInitial = true, Invalid = false,
InterestType = (int)interestType,
IsAnnualized = true, interest_rest_days = 7, interest_rule = 0,
FloatRateUnderlyingCode = null,
InterestSwapInterval = "[]"
};
private static AccrualPolicy Policy(swap_position p)
=> AccrualPolicy.BuildEod(p, AnnualDays, p.InterestType == (int)InterestTypeEnum.);
/// <summary>常率复利重放 [7/31, endDate],重置段 = 每 7 天。</summary>
private static decimal CompoundAccruedTo(DateTime endDate, AccrualBoundary boundary)
{
var segs = new List<(DateTime, decimal)>();
for (var d = StartDate; d <= endDate; d = d.AddDays(7)) segs.Add((d, Rate));
return CompoundInterestAccrual.AccruePeriod(
notional: Notional, segmentRates: segs,
startDate: StartDate, endDate: endDate,
boundary: boundary, annualDays: AnnualDays, isAnnualized: true,
resetCarryInterest: 0m, realizedInterest: 0m, unwindFraction: 1m,
finalBasis: out _).Accrued;
}
private static decimal CalcCompoundPenalty(
swap_position p, decimal closePrincipal, bool settled, decimal capitalized, decimal carryIn)
=> SwapPenaltyInterestCalculator.CalcPenaltyAmount(
p, closePrincipal,
unwindDate: UnwindDate, maturityDate: MaturityDate,
unwindDaySettled: settled, maturityCalcLast: true,
capitalizedInterest: capitalized, carryInInterest: carryIn,
frozenRate: FundingLegRate.Fixed(Rate),
policy: Policy(p), resetAnchor: StartDate);
[TestMethod]
public void _复利_全期等于已结加罚息()
{
var p = CreatePosition(InterestTypeEnum., SwapDirectionEnum.);
var elapsed = CompoundAccruedTo(UnwindDate, AccrualBoundary.Both);
var capitalized = CompoundAccruedTo(LastResetBeforeUnwind.AddDays(-1), AccrualBoundary.Both);
var carryIn = elapsed - capitalized;
var penalty = CalcCompoundPenalty(p, Notional, settled: true, capitalized, carryIn);
var full = CompoundAccruedTo(MaturityDate, AccrualBoundary.Both);
Assert.AreEqual((double)full, (double)(elapsed + penalty), 0.0001,
$"全期({full}) 应等于 已结({elapsed}) + 罚息({penalty});承接①={capitalized} ②={carryIn}");
}
[TestMethod]
public void _复利_不算尾平仓日()
{
// 不算尾:正常结算未计 8/25 → 罚息含 8/25IncludeStart=true),承接②少一天
var p = CreatePosition(InterestTypeEnum., SwapDirectionEnum.);
var elapsed = CompoundAccruedTo(UnwindDate, AccrualBoundary.StartOnly);
var capitalized = CompoundAccruedTo(LastResetBeforeUnwind.AddDays(-1), AccrualBoundary.Both);
var carryIn = elapsed - capitalized;
var penalty = CalcCompoundPenalty(p, Notional, settled: false, capitalized, carryIn);
var full = CompoundAccruedTo(MaturityDate, AccrualBoundary.Both);
Assert.AreEqual((double)full, (double)(elapsed + penalty), 0.0001,
"不算尾时罚息窗口须补回平仓日,恒等式仍成立");
}
[TestMethod]
public void _剩余期限利息等于公式()
{
// 需求 2.2.1:剩余利息 = 固定 × 名义本金 × 剩余天数 / 计息基准
// 算尾平仓日 + 到期算尾:窗口 (8/25, 8/31] = 6 天
var amount = SwapPenaltyInterestCalculator.CalcPenaltyAmount(
CreatePosition(InterestTypeEnum., SwapDirectionEnum.), Notional,
unwindDate: UnwindDate, maturityDate: MaturityDate,
unwindDaySettled: true, maturityCalcLast: true,
capitalizedInterest: 0m, carryInInterest: 0m,
frozenRate: FundingLegRate.Fixed(Rate),
policy: Policy(CreatePosition(InterestTypeEnum., SwapDirectionEnum.)),
resetAnchor: StartDate);
var expected = Rate * Notional * 6m / AnnualDays;
Assert.AreEqual((double)expected, (double)amount, 0.0001, "6 天 = 8/26..8/31");
}
[TestMethod]
public void _剩余天数口径正确()
{
var p = CreatePosition(InterestTypeEnum., SwapDirectionEnum.);
// 8/26..8/31 共 6 个计息日候选;IncludeStart 加 8/25、IncludeEnd 加 8/31 由约定裁剪
var cases = new (bool settled, bool calcLast, int days)[]
{
(true, true, 6), // (8/25, 8/31] 8/26..8/31
(true, false, 5), // (8/25, 8/31) 8/26..8/30
(false, true, 7), // [8/25, 8/31] 8/25..8/31
(false, false, 6), // [8/25, 8/31) 8/25..8/30
};
foreach (var (settled, calcLast, days) in cases)
{
var amount = SwapPenaltyInterestCalculator.CalcPenaltyAmount(
p, Notional,
unwindDate: UnwindDate, maturityDate: MaturityDate,
unwindDaySettled: settled, maturityCalcLast: calcLast,
capitalizedInterest: 0m, carryInInterest: 0m,
frozenRate: FundingLegRate.Fixed(Rate),
policy: Policy(p), resetAnchor: StartDate);
var expected = Rate * Notional * days / AnnualDays;
Assert.AreEqual((double)expected, (double)amount, 0.0001,
$"settled={settled}, calcLast={calcLast} → {days} 天");
}
}
[TestMethod]
public void _仅被平份额计罚息()
{
var p = CreatePosition(InterestTypeEnum., SwapDirectionEnum.);
var elapsed = CompoundAccruedTo(UnwindDate, AccrualBoundary.Both);
var capitalized = CompoundAccruedTo(LastResetBeforeUnwind.AddDays(-1), AccrualBoundary.Both);
var carryIn = elapsed - capitalized;
// 被平 30%:本金与两承接量同比缩放,罚息应恰为全额的 30%
var full = CalcCompoundPenalty(p, Notional, true, capitalized, carryIn);
var partial = CalcCompoundPenalty(p, Notional * 0.3m, true, capitalized * 0.3m, carryIn * 0.3m);
Assert.AreEqual((double)(full * 0.3m), (double)partial, 0.0001,
"被平 30%(本金与承接量同比)罚息应恰为全额的 30%");
}
[TestMethod]
public void _金额为零()
{
var amount = SwapPenaltyInterestCalculator.CalcPenaltyAmount(
CreatePosition(InterestTypeEnum., SwapDirectionEnum.), Notional,
unwindDate: MaturityDate, maturityDate: MaturityDate,
unwindDaySettled: true, maturityCalcLast: true,
capitalizedInterest: 90_000m, carryInInterest: 10_000m,
frozenRate: FundingLegRate.Fixed(Rate),
policy: Policy(CreatePosition(InterestTypeEnum., SwapDirectionEnum.)),
resetAnchor: StartDate);
Assert.AreEqual(0m, amount, "平仓日=到期日无剩余期限,罚息为 0(承接量不产生利息)");
}
}
}
@@ -103,8 +103,8 @@ namespace YLErp.Modules.SwapModule
SwapCalcTrace.Reset();
var eod = new List<eod_swap_position> { MakeEod(valueDate, PrepayRemaining, 0m) };
var fe = _svc.GetInterests(td, td.trade_extend, FullDate, FullDate, eod,
new List<swap_position> { pos }, PrepayFix, PrepayFix, PrepayFix, PrepayFix, 1m,
(int)SwapEventTypeEnum., false, false, 0, PrepayFix, false,
new List<swap_position> { pos }, PrepayFix, PrepayFix, 1m,
(int)SwapEventTypeEnum., false, PrepayFix, false,
settment: false, newCalcLast: calcLast, closeList: null)[0];
var trace = SwapCalcTrace.Dump();
Console.WriteLine(trace);
@@ -101,9 +101,9 @@ namespace YLErp.Modules.SwapModule
protected override List<swap_flow_event> CalcSwapInterests(
trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate,
List<eod_swap_position> eodPositions, List<swap_position> positions,
decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, bool needPrice,
decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false,
decimal posiNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose,
decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false,
List<swap_flow_event> closeList = null)
{
return positions.Select(p => new swap_flow_event
@@ -0,0 +1,270 @@
using YLErp;
using YLErp.DBModels;
using YLErp.DBModels.Enums;
using YLErp.Modules.EodModule;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Linq;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// GLMS-20260105-0006 端到端补充:EOD 分红引擎的票息归属须按【债权登记日 reg_date】判定,
/// 而非支付日(pay_date)。此前 DividendEodNoDoubleCountTest.EodSvcStub 把 CalcBondPayment 覆写成
/// 线性公式(DailyRatePerUnit*days*qty)**绕开了 reg_date 口径**——即没有真正验证"引擎按登记日计提"。
///
/// 本文件把 EOD stub 的 CalcBondPayment seam 重新桥接回【真实的 BondPaymentServicereg_date 口径)】,
/// 仅用内存 BondPayment 数据(不连库),使端到端流程(CopyEodPosition/UpdateEodPosition + GetPreEodDividendSum)
/// 真正跑生产日期逻辑:
/// ① EOD 引擎在登记日计提、支付日不计提(证明 reg_date 口径);
/// ② 登记日下一日(T+1)全平:经 GetPreEodDividendSum 读到登记日当日 EOD 分红(收盘在册→享有);
/// ③ 部分平仓 T+1DividendIn 为全量(非按比例缩放),剩余 PosiDividendSum 归 0(记录当前生产行为)。
/// </summary>
[TestClass]
public class RegDateDividendEodE2ETest
{
private const string BondCode = "230004.IB";
private const int TradeId = 7004;
private const long PositionId = 70041;
private const decimal Qty = 20_000_000m;
private const decimal PaymentPer100 = 0.1808m;
private const decimal ExpectedDividend = 36_160m; // 20,000,000 × 0.1808 / 100
private static readonly DateTime StartDate = new(2026, 4, 1);
private static readonly DateTime RegDate = new(2026, 4, 3); // 债权登记日
private static readonly DateTime PayDate = new(2026, 4, 6); // 实际支付日(与登记日差 3 天)
#region reg_date
private static List<BondPayment> BondPayments()
=> new List<BondPayment>
{
new BondPayment
{
underlyingCode = BondCode,
reg_date = RegDate, // 关键:分红归属按债权登记日判定
payment_date_pl = PayDate, // 理论付息日(非归属口径)
payment_date = PayDate, // 实际付息日(非归属口径)
payment_interest = PaymentPer100
}
};
#endregion
#region BondPaymentService seam reg_date
private sealed class RegDateBondPaymentService : BondPaymentService
{
private readonly List<BondPayment> _data;
public RegDateBondPaymentService(List<BondPayment> data, OptUserInfo userInfo) : base(userInfo) { _data = data; }
protected override IQueryable<BondPayment> QueryBondPayments(string underlyingCode)
=> _data.Where(x => x.underlyingCode == underlyingCode).AsQueryable();
}
#endregion
#region EOD stubCalcBondPayment BondPaymentService
private sealed class RegDateEodStub : TestableSwapEodPositionService
{
private readonly List<BondPayment> _bondPayments;
public RegDateEodStub(List<BondPayment> bondPayments) : base(nameof(RegDateDividendEodE2ETest)) { _bondPayments = bondPayments; }
protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio)
{
// 桥接真实生产口径:BondPaymentService.GetBondPayments 按 reg_date 过滤 + CalcPayment 累加
var svc = new RegDateBondPaymentService(_bondPayments, OptUserInfo.UnitTestUser);
return svc.CalcPayment(underlyingCode, fromDate, toDate, qty, shortRatio, directionRatio);
}
protected override underlying_manager GetUnderlyingData(string underlyingCode)
=> new underlying_manager { ValueAddedTax = 0m };
protected override decimal GetUnderlyingPrice(string code, DateTime settleDate, out decimal vobp)
{ vobp = 0m; return 1.00m; }
public eod_swap_position ExecuteCopyEodPosition(eod_swap_position eod, trade td, DateTime valueDate, DateTime preSettleDate)
=> CopyEodPosition(eod, null, td, valueDate, preSettleDate);
public eod_swap_position ExecuteUpdateEodPosition(swap_position swapPosition, eod_swap_position eod, trade td, DateTime valueDate, DateTime preSettleDate, List<swap_flow_event> unwindEvents)
=> UpdateEodPosition(swapPosition, eod, null, td, valueDate, preSettleDate, unwindEvents);
}
#endregion
#region Deal stubGetPreEodDividendSum EOD
private sealed class DealSvcStub : SwapDealService
{
private readonly List<eod_swap> _eodSwaps;
private readonly List<eod_swap_position> _eodPositions;
public DealSvcStub(List<eod_swap> eodSwaps, List<eod_swap_position> eodPositions)
: base(OptUserInfo.UnitTestUser) { _eodSwaps = eodSwaps; _eodPositions = eodPositions; }
public decimal ExposeGetPreEodDividendSum(int tradeId, long positionId, DateTime dealDate)
=> GetPreEodDividendSum(tradeId, positionId, dealDate);
protected override IQueryable<eod_swap> QueryPreEodSwaps(int tradeId)
=> _eodSwaps.Where(x => x.SwapTradeId == tradeId).AsQueryable();
protected override eod_swap_position QueryPreEodPosition(int tradeId, long positionId, DateTime valueDate)
=> _eodPositions.FirstOrDefault(x => x.SwapTradeId == tradeId && x.PositionId == positionId && x.ValueDate == valueDate);
}
#endregion
#region
private static trade CreateTrade() => new trade
{
id = TradeId, TradeNumber = "UT-REGDATE-E2E-001", ClientId = 999999,
TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate,
ExerciseDate = new DateTime(2027, 4, 1), TradeStatus = "确认成交", ValidState = "Valid",
StructureType = "单标的", QuoteCurrency = "CNY", SettlementCurrency = "CNY",
OriginalStockEqvNotional = (double)(Qty * 1.00m)
};
private static swap_position CreatePosition() => new swap_position
{
id = PositionId, SwapTradeId = TradeId,
PosiDirection = (int)SwapDirectionEnum., PositionType = (int)PositionTypeFlag.Long,
UnderlyingCode = BondCode, ContractSize = 1m,
PosiQuantity = Qty, PosiNotionalValue = Qty,
PosiNetPrice = 1.000m, PosiGrossPrice = 1.000m,
PosiNetFeePrice = 1.000m, PosiNetNoFeePrice = 1.000m,
IsInitial = true, Invalid = false,
PosiTradingFee = 0, PosiTradingFeePending = 0
};
private static eod_swap_position CreateInitialEod() => new eod_swap_position
{
id = 1, SwapTradeId = TradeId, PositionId = PositionId,
ValueDate = StartDate, PosiQuantity = Qty,
PosiDirection = (int)SwapDirectionEnum., PositionType = (int)PositionTypeFlag.Long,
UnderlyingCode = BondCode, ContractSize = 1m,
PosiNetPrice = 1.000m, PosiGrossPrice = 1.000m,
PosiNetFeePrice = 1.000m, PosiNetNoFeePrice = 1.000m,
PosiDividendSum = 0m, TdPosiDividend = 0m, TdCloseDividend = 0m,
RealizedDividend = 0m, PosiFeePending = 0m,
InterestProfitSum = 0m, Invalid = false
};
private static swap_flow_event CloseEvent(decimal qty, decimal dividendIn, DateTime eventDate) => new swap_flow_event
{
SwapTradeId = TradeId, EventType = (int)SwapFlowEventTypeEnum.,
PositionId = PositionId, Quantity = qty, DividendIn = dividendIn,
MarkClosePnl = 0m, CloseFee = 0m, TradingFeePending = 0m,
TradingAmount = qty * 1.000m,
UnwindDate = eventDate, EventDate = eventDate, PayDate = eventDate,
DataState = (int)SwapFlowDateStateEnum.
};
private static void AssertDecimalEqual(decimal expected, decimal actual, decimal tol, string msg)
=> Assert.IsTrue(System.Math.Abs(expected - actual) <= tol, $"{msg}: expected={expected} actual={actual}");
#endregion
/// <summary>
/// 端到端证 reg_date 口径:EOD 引擎(CopyEodPosition)逐日计提时,
/// 仅在【债权登记日】产生分红,【支付日】不产生(即便支付日与登记日相差数日)。
/// 这是线性 stub 无法覆盖的——线性公式按"天数"算,永远无法区分登记日 vs 支付日。
/// </summary>
[TestMethod]
public void _EOD引擎按reg_date计提_非pay_date()
{
var eodSvc = new RegDateEodStub(BondPayments());
var td = CreateTrade();
var initialEod = CreateInitialEod();
// D1=4/2(登记日前一日):窗口 (4/1,4/2] 无登记日 → 0
var r1 = eodSvc.ExecuteCopyEodPosition(initialEod, td, new DateTime(2026, 4, 2), StartDate);
AssertDecimalEqual(0m, r1.TdPosiDividend, 0.01m, "4/2 当日新计(无登记日)");
AssertDecimalEqual(0m, r1.PosiDividendSum, 0.01m, "4/2 累计(无登记日)");
// D2=4/3(登记日):窗口 (4/2,4/3] 命中 reg_date=4/3 → 36160
var r2 = eodSvc.ExecuteCopyEodPosition(r1, td, RegDate, StartDate);
AssertDecimalEqual(ExpectedDividend, r2.TdPosiDividend, 0.01m,
"4/3 登记日当日应计提 36160(按 reg_date 口径);若按支付日(pay_date=4/6)则此处为 0(漏计)。");
AssertDecimalEqual(ExpectedDividend, r2.PosiDividendSum, 0.01m, "4/3 累计=36160");
// D3=4/6(支付日,非登记日):窗口 (4/3,4/6] 不含任何 reg_date4/3 不>4/34/6 是支付日非登记日)→ 0
var r3 = eodSvc.ExecuteCopyEodPosition(r2, td, PayDate, StartDate);
AssertDecimalEqual(0m, r3.TdPosiDividend, 0.01m,
"4/6 支付日不应计提(分红归属按 reg_date,不是 pay_date);线性 stub 因按天数算会在此误计。");
AssertDecimalEqual(ExpectedDividend, r3.PosiDividendSum, 0.01m, "4/6 累计仍为 36160(支付日不重复计提)");
Console.WriteLine($"[reg_date 口径] 4/2={r1.PosiDividendSum}, 4/3={r2.PosiDividendSum}(登记日计提), 4/6={r3.PosiDividendSum}(支付日不计提)");
}
/// <summary>
/// 用户场景「登记日下一日(T+1)全平」:T日(登记日)收盘在册→享有T日分红;
/// T+1盘中全平,GetPreEodDividendSum(T+1) 应读到 T日 EOD(含当日分红)= 36160,而非漏读为 0。
/// 验证端到端:EOD 引擎算出 T日分红 → 快照 → 手动/互换读取正确取到。
/// </summary>
[TestMethod]
public void _经GetPreEodDividendSum读到登记日分红()
{
var eodSvc = new RegDateEodStub(BondPayments());
var td = CreateTrade();
var position = CreatePosition();
var initialEod = CreateInitialEod();
// T日=4/3(登记日)EOD:引擎算出分红 36160reg_date 口径)
var rReg = eodSvc.ExecuteCopyEodPosition(initialEod, td, RegDate, StartDate);
AssertDecimalEqual(ExpectedDividend, rReg.PosiDividendSum, 0.01m, "登记日 T日 EOD 累计分红=36160");
// T+1=4/4 盘中:注入 T日 EOD 快照,GetPreEodDividendSum 应读 T日(<=当日) → 36160
var dealSvc = new DealSvcStub(
new List<eod_swap> { new eod_swap { SwapTradeId = TradeId, ValueDate = RegDate } },
new List<eod_swap_position> { rReg });
decimal dividendIn = dealSvc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 4));
AssertDecimalEqual(ExpectedDividend, dividendIn, 0.01m,
"T+1(4/4) 盘中全平应经 GetPreEodDividendSum 读到 T日(4/3)EOD 分红 36160(收盘在册→享有);" +
"若 < 严格小于 dealDate 读 T-1(4/2=0) 则漏读登记日当日。");
Console.WriteLine($"[T+1 全平] DividendIn(读T日EOD)={dividendIn}");
// T+1=4/4 EOD 全平:PosiQuantity=0 → 不计提当日 + PosiDividendSum 归 0
var rT1 = eodSvc.ExecuteUpdateEodPosition(position, rReg, td, new DateTime(2026, 4, 4), RegDate,
new List<swap_flow_event> { CloseEvent(Qty, dividendIn, new DateTime(2026, 4, 4)) });
// 实拿 = DividendIn(本次落袋) + 末尾 PosiDividendSum(剩余挂账) = 应得(T日前待实现=持有至登记日)
decimal actualGot = dividendIn + rT1.PosiDividendSum;
AssertDecimalEqual(ExpectedDividend, actualGot, 0.01m, "实拿=应得(持有至登记日享有的 36160)");
AssertDecimalEqual(0m, rT1.TdPosiDividend, 0.01m, "T+1 非登记日,EOD 不计提当日");
AssertDecimalEqual(0m, rT1.PosiDividendSum, 0.01m, "全平后 PosiDividendSum=0");
Console.WriteLine($"[T+1 全平] 应得={ExpectedDividend}, 实拿={actualGot}, 末尾PosiDividendSum={rT1.PosiDividendSum}");
}
/// <summary>
/// 部分平仓 T+1:当前生产行为记录(非修复目标)。
/// T日(登记日)持有→T+1盘中部分平仓:GetPreEodDividendSum 返回的是【全量】待实现分红(非按平仓比例缩放),
/// 故 DividendIn=全量 36160T+1 EOD 部分平仓(PosiQuantity>0)后剩余 PosiDividendSum=前日-全量=0。
/// 注:此"DividendIn 不按平仓比例缩放"是当前生产行为,已与用户确认(潜在一致性议题,非本 bug 修复范围)。
/// </summary>
[TestMethod]
public void _T1_DividendIn为全量_剩余PosiDividendSum归0()
{
var eodSvc = new RegDateEodStub(BondPayments());
var td = CreateTrade();
var position = CreatePosition();
var initialEod = CreateInitialEod();
// T日=4/3(登记日)EOD:累计 36160
var rReg = eodSvc.ExecuteCopyEodPosition(initialEod, td, RegDate, StartDate);
AssertDecimalEqual(ExpectedDividend, rReg.PosiDividendSum, 0.01m, "登记日 T日 EOD 累计=36160");
// T+1=4/4 盘中部分平仓(50%)GetPreEodDividendSum 返回【全量】36160(不按比例缩放)
var dealSvc = new DealSvcStub(
new List<eod_swap> { new eod_swap { SwapTradeId = TradeId, ValueDate = RegDate } },
new List<eod_swap_position> { rReg });
decimal dividendIn = dealSvc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 4));
AssertDecimalEqual(ExpectedDividend, dividendIn, 0.01m, "部分平仓 T+1DividendIn 仍为全量 36160(非按 50% 缩放)");
// T+1=4/4 EOD 部分平仓(Quantity=Qty/2)PosiQuantity>0TdPosiDividend=0(非登记日)
// PosiDividendSum = 前日36160 + 0 - TdCloseDividend(全量36160) = 0
var rT1 = eodSvc.ExecuteUpdateEodPosition(position, rReg, td, new DateTime(2026, 4, 4), RegDate,
new List<swap_flow_event> { CloseEvent(Qty / 2, dividendIn, new DateTime(2026, 4, 4)) });
AssertDecimalEqual(ExpectedDividend, rT1.TdCloseDividend, 0.01m, "TdCloseDividend=全量 DividendIn(36160)");
AssertDecimalEqual(0m, rT1.PosiDividendSum, 0.01m,
"部分平仓后剩余 PosiDividendSum=前日36160 - 全量实现36160 = 0(当前生产行为:DividendIn 不按比例缩放)");
Console.WriteLine($"[部分平仓 T+1] DividendIn={dividendIn}(全量), 剩余PosiDividendSum={rT1.PosiDividendSum}");
}
}
}
@@ -80,9 +80,9 @@ namespace YLErp.Modules.SwapModule
var result = service.GetInterests(
trade, trade.trade_extend, closeCase.CloseDate, closeCase.CloseDate,
new List<eod_swap_position> { previousEod }, new List<swap_position> { position },
closeCase.RemainingNotional, closeCase.RemainingNotional, 0m,
closeCase.RemainingNotional,
closeCase.RemainingNotional, 1m, (int)SwapEventTypeEnum.,
false, false, 0m,
false,
closeCase.InterestType == 0 ? closeCase.RemainingNotional : closeCase.OriginalNotional,
add: false, settment: false, newCalcLast: false).Single();
@@ -1,3 +1,4 @@
using System.Linq;
using System.Reflection;
using YLErp.DBModels.Enums;
@@ -272,7 +273,16 @@ namespace YLErp.Modules.SwapModule
Console.WriteLine($" ✓ {scenario.Scenario}");
}
Assert.AreEqual(13, parameters.Length, "DealInterests应有13个参数");
// 校验参数集合(按名称,对参数增删/重排/改名均敏感,比裸数字更稳)
var expectedParamNames = new[]
{
"interestList", "eodPositions", "todyEodPositions", "settleDate",
"td", "flowEvents", "autoInterests", "lastEodSwap",
"posiTotalNotional", "closeNational", "grossPrice", "orginPv"
};
var actualParamNames = parameters.Select(p => p.Name).ToArray();
CollectionAssert.AreEquivalent(expectedParamNames, actualParamNames,
"DealInterests 参数集合应与预期一致(新增/重排/改名参数时请同步更新此列表)");
Console.WriteLine("✅ 分支覆盖分析完成");
}
}
@@ -0,0 +1,30 @@
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// FR007 界面手工录入落库精度契约(EQD-6968 测试期间发现):
/// FR007 官方发布为百分数下 4 位(1.4150%),前端 ÷100 转 6 位小数(0.014150)传后端;
/// 原 Math.Round(,4) 截成 0.0142(丢 0.5bp1 亿本金 7 天约 96 元)。修复后保留 6 位。
/// bond-sync 自动同步链(BigDecimal 全精度)不经此路径,无影响。
/// </summary>
[TestClass]
public class SwapFlowFr007EntryPrecisionTest
{
[TestMethod]
public void _六位小数全精度保留()
{
// 2026-08-19 官方发布 1.4150% —— 前端 1.4150/100 后的入参
Assert.AreEqual(0.01415, SwapFlowService.RoundFr007Price(1.4150 / 100.0), 1e-9,
"1.4150% 落库应保留 0.014150,不得截成 0.0142(丢 0.5bp");
Assert.AreEqual(0.021137, SwapFlowService.RoundFr007Price(2.1137 / 100.0), 1e-9,
"百分数下第3、4位(小数第5、6位)必须保留");
}
[TestMethod]
public void _行为不变()
{
// 历史常见形态(2.11% 等):修复前后结果一致
Assert.AreEqual(0.0211, SwapFlowService.RoundFr007Price(0.0211), 0d);
Assert.AreEqual(0.0142, SwapFlowService.RoundFr007Price(0.0142), 0d);
}
}
}
@@ -56,17 +56,17 @@ namespace UnitTestProject.Modules.SwapModule
protected override List<swap_flow_event> CalcSwapInterests(
trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate,
List<eod_swap_position> eodPositions, List<swap_position> positions,
decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, bool needPrice,
decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false,
decimal posiNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose,
decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false,
List<swap_flow_event> closeList = null)
{
var svc = new StubSwapDealService(
new OptUserInfo(0, nameof(SwapInterestScenario1And2Test), OptUserFrom.UnitTest), _floatRates);
return svc.GetInterests(td, tradeExtend, valueDate, unwindDate,
eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue,
closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice,
grossPrice, orginPv, add, settment, newCalcLast, closeList);
eodPositions, positions, posiNotionalValue,
closePosiNotionalValue, closePrecent, eventType, tdClose,
orginPv, add, settment, newCalcLast, closeList);
}
public eod_swap_position ExecuteClose(trade td, swap_position position, DateTime valueDate,
@@ -74,7 +74,7 @@ namespace UnitTestProject.Modules.SwapModule
List<swap_flow_event> flowEvents, decimal closeNotional, eod_swap_position prevEod)
{
SaveAutoEodWithCloseInterestPosition(prevEod, null, position, td, valueDate, null,
posiLongNotional, posiShortNotional, flowEvents, closeNotional, false, 1m,
posiLongNotional + posiShortNotional, flowEvents, closeNotional, false, 1m,
posiLongNotional + posiShortNotional);
return PersistedPositions.LastOrDefault();
}
@@ -211,9 +211,9 @@ namespace UnitTestProject.Modules.SwapModule
var interests = svc.GetInterests(
td, td.trade_extend, valueDate, valueDate,
prevEod, new List<swap_position> { position },
closeNotional, closeNotional, 0m, closeNotional, 1m,
closeNotional, closeNotional, 1m,
(int)SwapEventTypeEnum.,
false, false, 0m, closeNotional, false, settment: false, newCalcLast: isMaturity);
false, closeNotional, false, settment: false, newCalcLast: isMaturity);
Assert.AreEqual(1, interests.Count);
return interests[0];
}
@@ -1,12 +1,9 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Newtonsoft.Json;
using System.Globalization;
using YLErp;
using YLErp.DBModels;
using YLErp.DBModels.Enums;
using YLErp.Modules.SwapModule;
using YLErp.Modules.SwapModule.ReturnLegs;
namespace UnitTestProject.Modules.SwapModule
{
@@ -178,17 +175,17 @@ namespace UnitTestProject.Modules.SwapModule
protected override List<swap_flow_event> CalcSwapInterests(
trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate,
List<eod_swap_position> eodPositions, List<swap_position> positions,
decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, bool needPrice,
decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false,
decimal posiNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose,
decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false,
List<swap_flow_event> closeList = null)
{
var svc = new RealSwapDealService(
new OptUserInfo(0, nameof(SwapInterestScenario3And4FloatingTest), OptUserFrom.UnitTest), _floatRates, FlowEvents);
var interests = svc.GetInterests(td, tradeExtend, valueDate, unwindDate,
eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue,
closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice,
grossPrice, orginPv, add, settment, newCalcLast, closeList);
eodPositions, positions, posiNotionalValue,
closePosiNotionalValue, closePrecent, eventType, tdClose,
orginPv, add, settment, newCalcLast, closeList);
// 捕获 base InterestPrincipal= EOD:1406 行赋给 TdInterestPrincipal 的值,反推前),供 TdInterestPrincipal 断言镜像分叉。
LastBaseInterestPrincipal = interests.Count > 0 ? interests[0].InterestPrincipal : 0m;
return interests;
@@ -226,7 +223,7 @@ namespace UnitTestProject.Modules.SwapModule
List<swap_flow_event> flowEvents, decimal closeNotional, eod_swap_position prevEod)
{
SaveAutoEodWithCloseInterestPosition(prevEod, null, position, _td, valueDate, null,
posiLongNotional, posiShortNotional, flowEvents, closeNotional, false, 1m,
posiLongNotional + posiShortNotional, flowEvents, closeNotional, false, 1m,
posiLongNotional + posiShortNotional);
return PersistedPositions.LastOrDefault();
}
@@ -252,7 +249,7 @@ namespace UnitTestProject.Modules.SwapModule
private static void DebugCompare(string tag, decimal oracle, decimal actual, eod_swap_position eod = null)
{
var diff = actual - oracle;
var sb = new System.Text.StringBuilder();
var sb = new StringBuilder();
sb.AppendLine($"[DBG][{tag}] oracle={oracle:F4} actual={actual:F4} diff={diff:F4}");
if (eod != null)
{
@@ -394,9 +391,9 @@ namespace UnitTestProject.Modules.SwapModule
var interests = svc.GetInterests(
td, td.trade_extend, valueDate, valueDate,
prevEod, new List<swap_position> { position },
closeNotional, closeNotional, 0m, closeNotional, 1m,
closeNotional, closeNotional, 1m,
(int)SwapEventTypeEnum.,
false, false, 0m, closeNotional, false, settment: false, newCalcLast: isMaturity);
false, closeNotional, false, settment: false, newCalcLast: isMaturity);
Assert.AreEqual(1, interests.Count);
return interests[0];
}
@@ -421,8 +418,8 @@ namespace UnitTestProject.Modules.SwapModule
public void 3_3(string note, bool compound, bool calcFirst, bool calcLast,
int rule, int interestMode, string spreadStr, string oracleStr)
{
var spread = decimal.Parse(spreadStr, System.Globalization.CultureInfo.InvariantCulture);
var oracle = decimal.Parse(oracleStr, System.Globalization.CultureInfo.InvariantCulture);
var spread = decimal.Parse(spreadStr, CultureInfo.InvariantCulture);
var oracle = decimal.Parse(oracleStr, CultureInfo.InvariantCulture);
var mode = (calcFirst && calcLast) ? "11" : "10";
var type = compound ? InterestTypeEnum. : InterestTypeEnum.;
@@ -468,9 +465,9 @@ namespace UnitTestProject.Modules.SwapModule
public void 4_(string note, bool compound, bool calcFirst, bool calcLast,
int rule, int interestMode, string spreadStr, string oraclePartialStr, string oracleFinalStr)
{
var spread = decimal.Parse(spreadStr, System.Globalization.CultureInfo.InvariantCulture);
var oraclePartial = decimal.Parse(oraclePartialStr, System.Globalization.CultureInfo.InvariantCulture);
var oracleFinal = decimal.Parse(oracleFinalStr, System.Globalization.CultureInfo.InvariantCulture);
var spread = decimal.Parse(spreadStr, CultureInfo.InvariantCulture);
var oraclePartial = decimal.Parse(oraclePartialStr, CultureInfo.InvariantCulture);
var oracleFinal = decimal.Parse(oracleFinalStr, CultureInfo.InvariantCulture);
var mode = (calcFirst && calcLast) ? "11" : "10";
var type = compound ? InterestTypeEnum. : InterestTypeEnum.;
@@ -492,25 +489,61 @@ namespace UnitTestProject.Modules.SwapModule
_eod.RecordEod(partialEod);
DebugCompare("场景4[部分] " + note, oraclePartial, partialEod.TdCloseInterest, partialEod);
AssertStrict(oraclePartial, partialEod.TdCloseInterest, "场景4[部分] " + note);
// 覆盖 mode 2/9 分叉(SwapEodPositionService:1436-1469):部分平仓后 TdInterestPrincipal 的经济口径
// 必须 = 剩余动态本金(剩余名义本金 + 已并入本金的重置日待实现利息),mode2/9 应当一致。
// 单利:line 1491 直接取 posiNotionalValue = remainingNotional无累计利息。
// 复利:base = interests.First().InterestPrincipal(本服务 CalcSwapInterests 已捕获 LastBaseInterestPrincipal);
// mode2 仅在 calcLast 时于 1464 行反推剩余(× (1-cp)/cp)mode9 直取 baseGLMS-20260421-0004 禁止反推)。
// calcLast=false(如“算头不算尾”)或 mode9 被错误反推会膨胀 ~2.3 倍(494982903.27,下方断言精确拦截回归
// TdInterestPrincipal 独立经济不变量断言(取代原 reverseMode2 镜像布尔)。
// 生产线路(SwapEodPositionService ~:1400-1453):
// 单利:直接 TdInterestPrincipal = posiNotionalValue = remainingNotional无累计利息
// 复利:base = 计息器 CalcSwapInterests 返回的 InterestPrincipal,已由本桩捕获 LastBaseInterestPrincipal
// - mode9(标的期初全价):计息器已直接返回「剩余动态本金」,禁止任何 (1-cp)/cp 反推
// GLMS-20260421-0004误反推会 ~212135529.97 膨胀到 ~494982903.27)。
// - mode2(合约名义本金规模)仅 calcLast 时 InterestPrincipal 为已平部分,需反推剩余
// = base*(1-cp)/cp(经济恒等式 remaining = closed/cp closed,非代码镜像)。
// 下方用独立 if 锁死 mode9=base,不依赖 reverseMode2 布尔的拼写——
// 重基线时即便把 reverseMode2 错写成含 mode9mode9 仍走 base 分支,断言照红。
decimal expectedTdPrincipal;
if (!compound)
{
expectedTdPrincipal = remainingNotional;
}
else if (interestMode == (int)InterestModeEnum.)
{
// mode9 回归守卫(GLMS-20260421-0004):TdInterestPrincipal 必须等于反推前的 base
// 任何 (1-cp)/cp 反推都会把 ~212M 剩余本金膨胀到 ~495M,此断言立即红。
expectedTdPrincipal = _eod.LastBaseInterestPrincipal;
}
else
{
// mode2(合约名义本金规模):仅 calcLast 时 InterestPrincipal 为已平部分,需反推剩余
// = base*(1-cp)/cp(经济恒等式 remaining = closed/cp closed,非代码镜像)。
// !calcLast 时生产走 usesFullPreviousEodPrincipal 分支(*= (1-cp)),本测因部分平仓前一日 eod
// 差 3 天使 Days==1 不成立而跳过,故此处取 base。
var cp = partialCloseNotional / Notional; // = 0.3,与 EOD 内部 closePercent 一致
bool reverseMode2 = interestMode == (int)InterestModeEnum. && calcLast;
expectedTdPrincipal = reverseMode2
? _eod.LastBaseInterestPrincipal * (1m - cp) / cp
: _eod.LastBaseInterestPrincipal;
}
// 跨日毒链携带守卫(验证“最终结果”而非单日快照,置于单日守卫之前使其为首要捕获点):
// 部分平仓的 TdInterestPrincipal 是带去次日的计息基数;生产下一日本金利息 TdInterestIncome 正是由
// 该基数经 DailyAccrual 算出(SwapEodPositionService:1388 单一真相源:
// TdInterestIncome = DailyAccrual(TdInterestPrincipal, 当日利率, 浮动利率, 年化, 年化天数))。
// 故“D 日 TdInterestPrincipal → D+1 TdInterestIncome == DailyAccrual(该基数)”是生产自身的不变量。
// 本守卫用生产同一纯函数直接验证跨日携带:正确本金与“生产实际”本金各算一次 D+1 应计,
// 二者唯一差异就是 TdInterestPrincipal;误反推(膨胀~2.3x)会让 D+1 应计同步膨胀,差远超容差→红。
// (注:本 harness 的 RollForward 分支因 prior-eod seam 未接到内存 eod 链,rollforward eod 的
// TdInterestPrincipal 恒为 0,无法在 eod 层直接观察携带;故在纯函数层验证该不变量。)
var annualDays = td.trade_extend == null ? 365 : td.trade_extend.ExtendObj.AnnualDays;
var correctNextDayIncome = InterestIncomeCalc.DailyAccrual(
expectedTdPrincipal, partialEod.TdInterestRate, partialEod.FloatRate, partialEod.IsAnnualized, annualDays);
var poisonedNextDayIncome = InterestIncomeCalc.DailyAccrual(
partialEod.TdInterestPrincipal, partialEod.TdInterestRate, partialEod.FloatRate, partialEod.IsAnnualized, annualDays);
var carryTol = Math.Max(0.01m, Math.Abs(correctNextDayIncome) * 0.05m);
Assert.IsTrue(Math.Abs(poisonedNextDayIncome - correctNextDayIncome) <= carryTol,
$"场景4[跨日] 毒链携带 {note}: D+1 TdInterestIncome 应=DailyAccrual(正确本金 {expectedTdPrincipal})" +
$"但生产 partialEod.TdInterestPrincipal={partialEod.TdInterestPrincipal} 使 D+1 应计偏差 {poisonedNextDayIncome - correctNextDayIncome}" +
$"correct={correctNextDayIncome}, poisoned={poisonedNextDayIncome}");
// 单日不变量守卫(跨日守卫之后的次级细节):TdInterestPrincipal 必须精确等于经济不变量推导的
// 正确本金(mode9=计息器 base,禁任何 (1-cp)/cp 反推;mode2=base*(1-cp)/cp)。
AssertStrict(expectedTdPrincipal, partialEod.TdInterestPrincipal, "场景4[部分] TdInterestPrincipal " + note);
// 部分平仓后,剩余名义本金缩减为 70%(真实代码路径更新持仓口径)
@@ -521,6 +554,7 @@ namespace UnitTestProject.Modules.SwapModule
RunDailyEodFromStart(_eod, new DateTime(2026, 5, 12), new DateTime(2026, 5, 19));
var prevEodFull = _eod.LatestEodForPosition(position.id, new DateTime(2026, 5, 19));
// 第二步:2026-05-19 全部平仓剩余 70%consumedInterest 此时从真实累积的 flow event 读取,
// 真实扣除 5/11 部分平仓已结利息——绝无硬编码 0)
var fullFlow = CalcCloseFlow(td, position, new DateTime(2026, 5, 19), new List<eod_swap_position>(), remainingNotional, remainingNotional);
@@ -80,16 +80,16 @@ namespace YLErp.Modules.SwapModule
protected override List<swap_flow_event> CalcSwapInterests(
trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate,
List<eod_swap_position> eodPositions, List<swap_position> positions,
decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, bool needPrice,
decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false,
decimal posiNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose,
decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false,
List<swap_flow_event> closeList = null)
{
LastInterestCalculationPositions = positions;
return base.CalcSwapInterests(td, tradeExtend, valueDate, unwindDate,
eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue,
closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice,
grossPrice, orginPv, add, settment, newCalcLast, closeList);
eodPositions, positions, posiNotionalValue,
closePosiNotionalValue, closePrecent, eventType, tdClose,
orginPv, add, settment, newCalcLast, closeList);
}
public void ExecuteSwapPositionCompose(DateTime settleDate, DateTime preSettleDate)
@@ -59,17 +59,17 @@ namespace UnitTestProject.Modules.SwapModule
protected override List<swap_flow_event> CalcSwapInterests(
trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate,
List<eod_swap_position> eodPositions, List<swap_position> positions,
decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, bool needPrice,
decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false,
decimal posiNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose,
decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false,
List<swap_flow_event> closeList = null)
{
var svc = new StubSwapDealService(
new OptUserInfo(0, nameof(SwapSingleTradeVerificationTest), OptUserFrom.UnitTest), _floatRates);
return svc.GetInterests(td, tradeExtend, valueDate, unwindDate,
eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue,
closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice,
grossPrice, orginPv, add, settment, newCalcLast, closeList);
eodPositions, positions, posiNotionalValue,
closePosiNotionalValue, closePrecent, eventType, tdClose,
orginPv, add, settment, newCalcLast, closeList);
}
public eod_swap_position ExecuteClose(trade td, swap_position position, DateTime valueDate,
@@ -77,7 +77,7 @@ namespace UnitTestProject.Modules.SwapModule
List<swap_flow_event> flowEvents, decimal closeNotional, eod_swap_position prevEod)
{
SaveAutoEodWithCloseInterestPosition(prevEod, null, position, td, valueDate, null,
posiLongNotional, posiShortNotional, flowEvents, closeNotional, false, 1m,
posiLongNotional + posiShortNotional, flowEvents, closeNotional, false, 1m,
posiLongNotional + posiShortNotional);
return PersistedPositions.LastOrDefault();
}
@@ -213,9 +213,9 @@ namespace UnitTestProject.Modules.SwapModule
var interests = svc.GetInterests(
td, td.trade_extend, valueDate, valueDate,
prevEod, new List<swap_position> { position },
closeNotional, closeNotional, 0m, closeNotional, 1m,
closeNotional, closeNotional, 1m,
(int)SwapEventTypeEnum.,
false, false, 0m, closeNotional, false, settment: false, newCalcLast: isMaturity);
false, closeNotional, false, settment: false, newCalcLast: isMaturity);
Assert.AreEqual(1, interests.Count);
return interests[0];
}
@@ -5,12 +5,12 @@ using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 诊断测试:验证「浮动腿 fpositions 仍用 origPositions(orig 100M)」对本 deal 的
/// 预付金/返回预付金结果是否产生影响。结论预期:本 deal 利息腿只有 mode 9(标的期初全价)
/// 与 mode 5(初始预付金)CalcNotionalByMode 中 posiLong/posiShort 仅在「多头/空头存续名义本金」
/// 分支被消费(L709-716),故本 deal 即便 fpositions 用 orig 100M,预付金腿结果也不受其影响。
/// 本测试仅做诊断/验证,不改动任何生产代码;用反射调用 private CalcNotionalByMode 以直接证明
/// “mode 9 / mode 5 的 closePrincipal 不依赖 posiLong/posiShort”
/// 诊断测试骨架:针对 GLMS 双轨持仓(orig/real)构造预付金腿(mode 5)与标的期初全价腿(mode 9)
/// 用于验证“浮动腿 fpositions 用 origPositions 对预付金/标的端计息基数的影响”。
/// 计息基数现由 FundingLegStrategyFactory + 各 IFundingLegStrategy 策略类计算
/// (原 private CalcNotionalByMode 已重构移除);多空存续腿的 posiLong/posiShort 因界面禁用
/// 已从策略接口删除,故预付金/标的端计息基数不依赖多空头寸。
/// 注:当前仅含数据构造,反射诊断方法尚未实现(无 [TestMethod]
/// </summary>
[TestClass]
public class SwapUnwindFloatingLegDiagnosticTdd
@@ -93,9 +93,9 @@ namespace YLErp.Modules.SwapModule
var position = MakePrepayPosition();
var interests = _svc.GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
eodPositions, new List<swap_position> { position },
UnderlyingNotional, UnderlyingNotional, UnderlyingNotional, UnderlyingNotional, closePercent,
UnderlyingNotional, UnderlyingNotional, closePercent,
(int)SwapEventTypeEnum.,
false, false, 0, UnderlyingNotional, false, settment: false, newCalcLast: false, closeList: null);
false, UnderlyingNotional, false, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, interests.Count, "预付金腿应生成 1 条 flow_event");
return interests[0];
}
@@ -111,9 +111,9 @@ namespace YLErp.Modules.SwapModule
var position = MakePrepayPosition(fix, rate);
var interests = _svc.GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
eodPositions, new List<swap_position> { position },
notional, notional, notional, notional, closePercent,
notional, notional, closePercent,
(int)SwapEventTypeEnum.,
false, false, 0, notional, false, settment: false, newCalcLast: false, closeList: null);
false, notional, false, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, interests.Count, "预付金腿应生成 1 条 flow_event");
return interests[0];
}
@@ -279,9 +279,9 @@ namespace YLErp.Modules.SwapModule
};
var interests = _svc.GetInterests(td, td.trade_extend, ProdUnwindDate, ProdUnwindDate,
eod, new List<swap_position> { position },
fix, fix, fix, fix, closePercent,
fix, fix, closePercent,
(int)SwapEventTypeEnum.,
false, false, 0, fix, false, settment: false, newCalcLast: false, closeList: null);
false, fix, false, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, interests.Count, "预付金腿应生成 1 条 flow_event");
return interests[0];
}
@@ -373,9 +373,9 @@ namespace YLErp.Modules.SwapModule
// orginPv 传 notional:非预付金腿不走 877-881 的 Fix 对齐,dynomicPrincipal = notional + notional - notional = notional
var interests = _svc.GetInterests(td, td.trade_extend, ProdUnwindDate, ProdUnwindDate,
eod, new List<swap_position> { position },
notional, notional, notional, notional * closePercent, closePercent,
notional, notional * closePercent, closePercent,
(int)SwapEventTypeEnum.,
false, false, 0, notional, false, settment: false, newCalcLast: false, closeList: null);
false, notional, false, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, interests.Count, "非预付金腿应生成 1 条 flow_event");
return interests[0];
}
@@ -488,9 +488,9 @@ namespace YLErp.Modules.SwapModule
};
var interests = _svc.GetInterests(td, td.trade_extend, ProdUnwindDate, ProdUnwindDate,
eodPos, new List<swap_position> { position },
baseP, baseP, baseP, baseP * closePercent, closePercent,
baseP, baseP * closePercent, closePercent,
(int)SwapEventTypeEnum.,
false, false, 0, baseP, false, settment: eodPath, newCalcLast: false, closeList: null);
false, baseP, false, settment: eodPath, newCalcLast: false, closeList: null);
Assert.AreEqual(1, interests.Count, $"mode={mode} 应生成 1 条 flow_event");
return interests[0];
}
@@ -121,9 +121,9 @@ namespace YLErp.Modules.SwapModule
var position = MakePosition(currentNotional);
var interests = _svc.GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
MakeLastEod(), new List<swap_position> { position },
currentNotional, currentNotional, currentNotional, currentNotional * closePercent, closePercent,
currentNotional, currentNotional * closePercent, closePercent,
(int)SwapEventTypeEnum.,
false, false, 0, N, false, settment: false, newCalcLast: false, closeList: null);
false, N, false, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, interests.Count, "标的期初全价腿应生成 1 条 flow_event");
return interests[0];
}
@@ -68,7 +68,7 @@ namespace YLErp.Modules.SwapModule
/// 捕获真实收盘产生的 swap_flow_event(生产写 DbContext.swap_flow_event)。
/// 与 PersistEodSwapPosition 同理,这里只收集不写库,供 GetConsumedInterest 真实计算。
/// </summary>
protected void PersistFlowEvent(swap_flow_event flowEvent)
protected override void PersistFlowEvent(swap_flow_event flowEvent)
{
if (flowEvent.id == 0) flowEvent.id = _nextId++;
FlowEvents.Add(flowEvent);
@@ -111,5 +111,16 @@ namespace YLErp.Modules.SwapModule
ClientCashCalls.Add((amount, action));
return _nextId++;
}
/// <summary>
/// AddClientCashInCashOut 生产实现会查 DataCacheProvider.GetClientDataSource().GetData(ClientId)
/// 纯内存测试无客户缓存会抛"客户信息未找到"。与 AddClientCash 同构 no-op
/// 仅捕获调用记录,供断言使用。
/// </summary>
public override int AddClientCashInCashOut(OtcTradeBase td, double amount, string action, DateTime valueDate)
{
ClientCashCalls.Add((amount, action));
return _nextId++;
}
}
}
+18 -3
View File
@@ -30,9 +30,24 @@ namespace YLErp
YLServiceLocator.SetServiceCollection(services);
AppManager.Initialize(YLErp.Enums.SubSystemName.UnitTest, configuration);
DataCacheManager.UpdateOnce();
// P0 容错(2026-08-16):初始化段(AppManager.Initialize 内部 InitializePsConfig 读 AppConfig 表、
// DataCacheManager.UpdateOnce 预热)在测试库不可达时不再让 ModuleInitializer 抛异常连坐全部 903 个
// 测试——降级为醒目警告,纯内存测试照常可跑;依赖配置/缓存/库的测试将以各自的连接错误失败
// (与降级前表现一致,只是不再全红归因到"类创建失败")。完全跳过初始化仍用
// YLErp_UNIT_TEST_SKIP_INITIALIZATION=1。实测触发链:M1→AppManager.Initialize→ConfigDic→
// ServerVersion.AutoDetect→MySQL 不可达(Program.cs:332026-08-16 栈实证)。
try
{
AppManager.Initialize(YLErp.Enums.SubSystemName.UnitTest, configuration);
DataCacheManager.UpdateOnce();
}
catch (Exception ex)
{
var warn = $"[UnitTest初始化降级] 初始化段失败(测试库不可达?):{ex.GetType().Name}: {ex.Message}。" +
"纯内存测试继续;依赖配置/缓存/数据库的测试将失败——这是网络问题不是代码问题。";
Console.WriteLine(warn);
logger.Warn(warn);
}
services.AddHttpClient("")
.ConfigurePrimaryHttpMessageHandler(messageHandler =>
+3 -1
View File
@@ -19,6 +19,8 @@ namespace BaseOUDAL
public DbSet<client_black> client_black { get; set; }
public DbSet<ClientBlackLog> client_blacklog { get; set; }
public DbSet<client_file> client_file { get; set; }
public DbSet<client_file_audit> client_file_audit { get; set; }
@@ -57,4 +59,4 @@ namespace BaseOUDAL
public DbSet<ClientCustomerManage> client_customer_manage { get; set; }
}
}
}
@@ -0,0 +1,20 @@
namespace YLErp.Model
{
public class ClientBlackApprovalQueryRes
{
public int id { get; set; }
public string EncryptId { get; set; }
public string ProcessStatus { get; set; }
public int ProcessOrderId { get; set; }
public string ProcessRoleName { get; set; }
public string ClientName { get; set; }
public int ProcessRoleId { get; set; }
public string Comments { get; set; }
public string ApprovalOptName { get; set; }
public DateTime? ApprovalOptDate { get; set; }
public string State { get; set; }
public int? creator_id { get; set; }
public string creator_name { get; set; }
public DateTime? creator_time { get; set; }
}
}
+18
View File
@@ -0,0 +1,18 @@
using YLErp.Helpers;
namespace YLErp.Model
{
/// <summary>
/// 黑名单审批请求。
/// </summary>
public class ClientBlackAuditReq
{
public string enid { get; set; }
public int id => DataProtectHelper.DecryptInt(enid);
public string status { get; set; }
public string auditComment { get; set; }
}
}
@@ -1,67 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace YLErp.Model.HengTaiModel
{
public class SwapUnwindReq
{
public SwapUnwindReq() {
ACCTSWAP_TERMINATE = new SwapUnwindData();
}
public SwapUnwindData ACCTSWAP_TERMINATE {get;set;}
}
public class SwapUnwindData
{
/// <summary>
/// 客户交易号
/// </summary>
public string CUSTORDID { get; set; }
/// <summary>
/// 返回的时候EXT_NO 对应推送的CUSTORDID
/// </summary>
public string EXT_NO { get; set; }
/// <summary>
/// 合约编号,推送不需要给,返回对应推送的EXT_NO
/// </summary>
public string CONTRACT_CODE { get; set; }
/// <summary>
/// 终止类型 全部终止 1 部分终止 0
/// </summary>
public string TERMINATE_TYPE { get; set; }
/// <summary>
/// 终止数量
/// </summary>
public string TERMINATE_COUNT { get; set; }
/// <summary>
/// 终止日期
/// </summary>
public string TERMINATE_DAY { get; set; }
/// <summary>
/// 支付日期
/// </summary>
public string PAY_DAY { get; set; }
/// <summary>
/// 资产端终止金额 不可为空
/// </summary>
public string ZCD_AMOUNT { get; set; }
/// <summary>
/// 固定端终止金额 不可为空
/// </summary>
public string GDD_AMOUNT { get; set; }
/// <summary>
/// 交易状态 不可为空 0新建,1审批中
/// </summary>
public string ORDSTATUS { get; set; }
/// <summary>
/// 固定端费用
/// </summary>
public string FIX_FEE { get; set; }
/// <summary>
/// 资产端费用
/// </summary>
public string ASSET_FEE { get; set;}
}
}
+6
View File
@@ -14,6 +14,12 @@ namespace YLErp.Model
/// </summary>
public string Name { get; set; }
public DateTime? DateFromOptDate { get; set; }
public DateTime? DateToOptDate { get; set; }
public string ClientBlackStates { get; set; }
}
}
@@ -0,0 +1,94 @@
using YLErp.Model;
namespace YLErp.Modules.ClientModule
{
public static class ClientBlackApprovalPolicy
{
public static readonly string[] EffectiveStates =
{
client_black.,
client_black.,
client_black.
};
public static bool IsEffective(string state)
{
return EffectiveStates.Contains(state);
}
public static bool CanSubmitAddition(string state)
{
return state == client_black. || state == client_black.;
}
public static ClientBlackAdditionResult GetAdditionResult(bool hasApprovalProcess)
{
return hasApprovalProcess
? new ClientBlackAdditionResult(client_black., 0, false)
: new ClientBlackAdditionResult(client_black., -2, true);
}
public static bool CanRequestRemoval(string state)
{
return state == client_black. || state == client_black.;
}
public static bool CanDeleteDraft(string state)
{
return state == client_black. || state == client_black.;
}
public static ClientBlackRemovalResult GetRemovalResult(bool hasApprovalProcess)
{
return hasApprovalProcess
? new ClientBlackRemovalResult(client_black., 1, false)
: new ClientBlackRemovalResult(client_black., -2, true);
}
public static bool CanReplaceRemarks(string state)
{
return state != client_black. && state != client_black.;
}
public static bool CanWithdraw(string state, int approvalProcess)
{
return approvalProcess == 1 &&
(state == client_black. || state == client_black.);
}
public static string GetRejectedState(string state)
{
return state switch
{
client_black. => client_black.,
client_black. => client_black.,
_ => throw new ArgumentException("当前状态不允许拒绝审批", nameof(state))
};
}
public static ClientBlackWithdrawResult GetWithdrawResult(string state)
{
return state switch
{
client_black. => new ClientBlackWithdrawResult(client_black., 0),
client_black. => new ClientBlackWithdrawResult(client_black., -2),
_ => throw new ArgumentException("当前状态不允许撤回审批", nameof(state))
};
}
public static ClientBlackFinalResult GetFinalResult(string state)
{
return state switch
{
client_black. => new ClientBlackFinalResult(client_black., false),
client_black. => new ClientBlackFinalResult(null, true),
_ => throw new ArgumentException("当前状态不允许完成审批", nameof(state))
};
}
}
public readonly record struct ClientBlackWithdrawResult(string State, int ApprovalProcess);
public readonly record struct ClientBlackFinalResult(string State, bool ShouldDelete);
public readonly record struct ClientBlackAdditionResult(string State, int ApprovalProcess, bool IsEffective);
public readonly record struct ClientBlackRemovalResult(string State, int ApprovalProcess, bool ShouldDelete);
}
@@ -41,6 +41,19 @@ namespace YLErp.Modules.ClientModule
{
predicate = predicate.And(d => d.Name.Contains(req.Name));
}
if (!string.IsNullOrEmpty(req.ClientBlackStates))
{
var states = req.ClientBlackStates.Split(',', StringSplitOptions.RemoveEmptyEntries);
predicate = predicate.And(d => states.Contains(d.State));
}
if (req.DateFromOptDate.HasValue)
{
predicate = predicate.And(d => d.OptDate >= req.DateFromOptDate.Value);
}
if (req.DateToOptDate.HasValue)
{
predicate = predicate.And(d => d.OptDate < req.DateToOptDate.Value.AddDays(1));
}
}
var query = DbContext.client_black.AsNoTracking().Where(predicate);
@@ -93,6 +106,324 @@ namespace YLErp.Modules.ClientModule
return retListResult;
}
public List<approvalprocess> ProcessList()
{
return DbContextFactory.GetYLDbContext().approvalprocess
.Where(s => s.processType == "ClientBlackProcess")
.OrderBy(s => s.order)
.ToList();
}
public int DeleteClientBlack(IEnumerable<int> ids)
{
var idList = ids?.Distinct().ToList() ?? new List<int>();
if (idList.Count == 0)
{
throw new ServiceException("请选择要移出的黑名单客户");
}
var rows = DbContext.client_black.Where(x => idList.Contains(x.id)).ToList();
if (rows.Count != idList.Count)
{
throw new ServiceException("未找到要删除的数据");
}
var hasProcess = ProcessList().Any();
var submitRemovalApprovalCount = 0;
foreach (var row in rows)
{
if (ClientBlackApprovalPolicy.CanDeleteDraft(row.State))
{
DbContext.client_black.Remove(row);
ClientBlackCategoryLog(row.id, "已删除");
continue;
}
if (!ClientBlackApprovalPolicy.CanRequestRemoval(row.State))
{
throw new ServiceException($"黑名单客户{row.Name}当前状态不允许移出");
}
if (hasProcess)
{
var result = ClientBlackApprovalPolicy.GetRemovalResult(true);
row.State = result.State;
row.ApprovalProcess = result.ApprovalProcess;
row.ApprovalOptName = UserName;
row.ApprovalOptDate = DateTime.Now;
ClientBlackCategoryLog(row.id, client_black.);
submitRemovalApprovalCount++;
}
else
{
RemoveEffectiveBlack(row);
}
}
DbContext.SaveChanges();
return submitRemovalApprovalCount;
}
public void WithdrawApprovalClientBlack(List<int> ids, out int withdrawCount, out string msg)
{
withdrawCount = 0;
msg = "";
var rows = DbContext.client_black.Where(x => ids.Contains(x.id)).ToList();
foreach (var row in rows)
{
if (!ClientBlackApprovalPolicy.CanWithdraw(row.State, row.ApprovalProcess))
{
if (row.ApprovalProcess > 1)
{
msg += row.Name + ",";
}
continue;
}
var result = ClientBlackApprovalPolicy.GetWithdrawResult(row.State);
row.State = result.State;
row.ApprovalProcess = result.ApprovalProcess;
row.ApprovalOptName = null;
row.ApprovalOptDate = null;
ClientBlackCategoryLog(row.id, row.State);
withdrawCount++;
}
DbContext.SaveChanges();
}
public void SubmitApprovalClientBlack(List<int> ids)
{
var rows = DbContext.client_black.Where(x => ids.Contains(x.id)).ToList();
var process = ProcessList();
foreach (var row in rows)
{
if (!ClientBlackApprovalPolicy.CanSubmitAddition(row.State))
{
continue;
}
if (process.Count == 0)
{
row.State = client_black.;
row.ApprovalProcess = -2;
row.ApprovalOptName = UserName;
row.ApprovalOptDate = DateTime.Now;
var notifications = new List<(Client oldClient, Client newClient)>();
ApplyEffectiveAddition(row.Name, notifications);
ClientBlackCategoryLog(row.id, client_black., "未设置审批流程,直接通过");
DbContext.SaveChanges();
SendClientNotifications(notifications);
continue;
}
row.State = client_black.;
row.ApprovalProcess = 1;
row.ApprovalOptName = UserName;
row.ApprovalOptDate = DateTime.Now;
ClientBlackCategoryLog(row.id, client_black.);
}
DbContext.SaveChanges();
}
public string AuditClientBlack(ClientBlackAuditReq req, bool isBatch = false, string optType = "")
{
var row = DbContext.client_black.Find(req.id);
if (row == null)
{
throw new ServiceException("审批失败,系统中没有该黑名单记录");
}
if (row.State != client_black. && row.State != client_black.)
{
throw new ServiceException("当前黑名单不在审批中");
}
var process = ProcessList();
var currentNode = process.FirstOrDefault(x => x.order == row.ApprovalProcess);
if (currentNode == null || !UserBLL.GetRolesByUserId(UserId).Any(x => x.Id == currentNode.roleId))
{
throw new ServiceException("当前用户无权审批该节点");
}
if (req.status == "reject")
{
row.State = ClientBlackApprovalPolicy.GetRejectedState(row.State);
row.ApprovalProcess = -1;
row.ApprovalOptDate = DateTime.Now;
row.OptId = UserId;
row.OptName = UserName;
row.OptDate = DateTime.Now;
ClientBlackCategoryLog(row.id, row.State, req.auditComment);
DbContext.SaveChanges();
return "提交成功";
}
if (req.status != "pass")
{
throw new ServiceException("status参数不支持:" + req.status);
}
var nextNode = process.FirstOrDefault(x => x.order > row.ApprovalProcess);
if (nextNode != null)
{
row.ApprovalProcess = nextNode.order;
row.ApprovalOptDate = DateTime.Now;
row.OptId = UserId;
row.OptName = UserName;
row.OptDate = DateTime.Now;
ClientBlackCategoryLog(row.id, row.State, req.auditComment);
DbContext.SaveChanges();
return "提交成功";
}
var final = ClientBlackApprovalPolicy.GetFinalResult(row.State);
if (final.ShouldDelete)
{
RemoveEffectiveBlack(row, req.auditComment, isBatch ? optType : null);
DbContext.SaveChanges();
}
else
{
row.State = final.State;
row.ApprovalProcess = -2;
row.ApprovalOptDate = DateTime.Now;
var notifications = new List<(Client oldClient, Client newClient)>();
ApplyEffectiveAddition(row.Name, notifications);
ClientBlackCategoryLog(row.id, isBatch ? optType : row.State, req.auditComment);
DbContext.SaveChanges();
SendClientNotifications(notifications);
}
return "提交成功";
}
public SearchListResult<ClientBlackApprovalQueryRes> ClientBlackApprovalQuery(ClientBlackReq req)
{
var process = ProcessList();
var predicate = PredicateBuilder.Create<client_black>(x => x.ApprovalProcess > 0);
if (!string.IsNullOrWhiteSpace(req.Name))
{
predicate = predicate.And(x => x.Name.Contains(req.Name));
}
var query = from row in DbContext.client_black.AsNoTracking().Where(predicate)
select new ClientBlackApprovalQueryRes
{
id = row.id,
EncryptId = row.EncryptId,
ProcessOrderId = row.ApprovalProcess,
ProcessRoleId = 0,
ProcessStatus = "审批中 流程" + (row.ApprovalProcess - 1) + "/" + process.Count,
State = row.State,
ClientName = row.Name,
Comments = row.Remarks,
ApprovalOptName = row.ApprovalOptName,
ApprovalOptDate = row.ApprovalOptDate,
creator_id = row.creator_id,
creator_name = row.creator_name,
creator_time = row.creator_time
};
if (string.IsNullOrEmpty(req.sidx))
{
req.sidx = "ApprovalOptDate";
req.sord = "desc";
}
var result = query.OrderByDescending(x => x.ApprovalOptDate).ToSearchList(req);
var roles = new ErpBaseContext().Roles
.Select(x => new { x.Id, x.Name })
.ToDictionary(x => x.Id, x => x.Name);
foreach (var item in result.rows)
{
var node = process.FirstOrDefault(x => x.order == item.ProcessOrderId);
if (node == null)
{
continue;
}
item.ProcessRoleId = node.roleId;
item.ProcessRoleName = roles.TryGetValue(node.roleId, out var roleName) ? roleName : string.Empty;
}
return result;
}
private void ApplyEffectiveAddition(string name, List<(Client oldClient, Client newClient)> notifications)
{
var client = DbContext.client.FirstOrDefault(c => c.Name == name);
if (client == null)
{
return;
}
var dt = DateTime.Now;
var oldClient = client.Clone();
if (client.ProcessStatus == "已开户")
{
client.ProcessOrderId = -4;
client.ProcessStatus = "已休眠";
client.OptId = UserId;
client.OptName = UserName;
client.OptDate = dt;
DbContext.ClientAuditLog.Add(new ClientAuditLog
{
ClientId = client.id,
OptType = "休眠",
Changes = string.Empty,
DataType = "00",
OptId = UserId,
OptName = UserName,
OptDate = dt
});
notifications.Add((oldClient, client));
}
DbContext.ClientAuditLog.Add(new ClientAuditLog
{
ClientId = client.id,
OptType = "加入黑名单",
Changes = string.Empty,
DataType = "00",
OptId = UserId,
OptName = UserName,
OptDate = dt
});
}
private void RemoveEffectiveBlack(client_black row, string changes = null, string optType = null)
{
var client = DbContext.client.FirstOrDefault(c => c.Name == row.Name);
if (client != null)
{
DbContext.ClientAuditLog.Add(new ClientAuditLog
{
ClientId = client.id,
OptType = "移除黑名单",
Changes = string.Empty,
DataType = "00",
OptId = UserId,
OptName = UserName,
OptDate = DateTime.Now
});
}
DbContext.client_black.Remove(row);
ClientBlackCategoryLog(row.id, optType ?? "已删除", changes);
}
private void SendClientNotifications(List<(Client oldClient, Client newClient)> notifications)
{
foreach (var (oldClient, newClient) in notifications)
{
new ClientKafkaService(_kafkaProduce).Send(newClient, oldClient);
}
}
public void ClientBlackCategoryLog(int clientblackId, string optType, string changes = null)
{
DbContext.client_blacklog.Add(new ClientBlackLog
{
ClientBlackId = clientblackId,
OptType = optType,
Changes = changes,
DataType = "00",
OptId = UserId,
OptName = UserName,
OptDate = DateTime.Now
});
}
/// <summary>
/// 客户黑名单导入
/// </summary>
@@ -165,37 +496,47 @@ namespace YLErp.Modules.ClientModule
public void AddClientBlack(IEnumerable<client_black> list, bool checkStatus)
{
var inputList = list?.ToList() ?? new List<client_black>();
var errMsgList = new List<string>();
var nameList = list.Select(O => O.Name);
var dbList = DbContext.client_black.Where(O => nameList.Contains(O.Name));
var nameList = inputList.Select(O => O.Name).ToList();
var dbList = DbContext.client_black.Where(O => nameList.Contains(O.Name)).ToList();
foreach (var item in dbList)
{
var obj = inputList.FirstOrDefault(O => O.Name.Equals(item.Name, StringComparison.OrdinalIgnoreCase));
if (obj == null)
{
continue;
}
if (!ClientBlackApprovalPolicy.CanReplaceRemarks(item.State))
{
throw new ServiceException("黑名单客户在审批中无法修改!");
}
if (checkStatus && !string.IsNullOrWhiteSpace(item.Remarks) && item.Remarks != obj.Remarks)
{
errMsgList.Add($"{item.Name}");
}
}
if (checkStatus)
{
foreach (var item in dbList)
{
var obj = list.First(O => O.Name.Equals(item.Name, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(item.Remarks) && item.Remarks != obj.Remarks)
{
errMsgList.Add($"{item.Name}");
continue;
}
}
if (errMsgList.Count > 0)
{
var msg = "";
if (errMsgList.Count <= 5)
{
msg = $"客户:{string.Join(",", errMsgList)},备注已存在,是否替换?";
msg = $"客户:{string.Join(",", errMsgList)}当前已在黑名单中,本次将修改备注,备注已存在是否确认?";
}
else
{
msg = $"{string.Join(",", errMsgList.Take(5))} 等{errMsgList.Count}个客户,备注已存在,是否替换?";
msg = $"{string.Join(",", errMsgList.Take(5))} 等{errMsgList.Count}个客户当前已在黑名单中,本次将修改备注,备注已存在是否确认?";
}
throw new ServiceException(msg);
}
}
// 在外部定义列表来保存需要通知的客户对
var clientsToNotify = new List<(Client oldClient, Client newClient)>();
foreach (var item in list)
var newItems = new List<client_black>();
var processList = ProcessList();
foreach (var item in inputList)
{
if (string.IsNullOrWhiteSpace(item.Name))
{
@@ -206,61 +547,45 @@ namespace YLErp.Modules.ClientModule
item.OptId = UserId;
item.OptName = UserName;
item.OptDate = DateTime.Now;
var clientexistence = DbContext.client.FirstOrDefault(c => c.Name == item.Name);
if (clientexistence != null)
var existing = dbList.FirstOrDefault(x => x.Name.Equals(item.Name, StringComparison.OrdinalIgnoreCase));
if (existing != null)
{
var dt = DateTime.Now;
if (clientexistence.ProcessStatus == "已开户")
var oldRemarks = existing.Remarks;
existing.Remarks = item.Remarks;
existing.OptId = UserId;
existing.OptName = UserName;
existing.OptDate = DateTime.Now;
if (oldRemarks != existing.Remarks)
{
var oldClient= clientexistence.Clone();
clientexistence.ProcessOrderId = -4;
clientexistence.ProcessStatus = "已休眠";
clientexistence.OptId = UserId;
clientexistence.OptName = UserName;
clientexistence.OptDate = dt;
DbContext.ClientAuditLog.Add(new ClientAuditLog
{
ClientId = clientexistence.id,
OptType = "休眠",
Changes = string.Empty,
DataType = "00",
OptId = UserId,
OptName = UserName,
OptDate = dt
});
// 如果原有状态是已开户,添加到通知列表
if (oldClient != null)
{
clientsToNotify.Add((oldClient, clientexistence));
}
ClientBlackCategoryLog(existing.id, "修改备注", $"备注:{oldRemarks ?? string.Empty} -> {existing.Remarks ?? string.Empty}");
}
///日志记录
DbContext.ClientAuditLog.Add(new ClientAuditLog
{
ClientId = clientexistence.id,
OptType = "加入黑名单",
Changes = string.Empty,
DataType = "00",
OptId = UserId,
OptName = UserName,
OptDate = dt
});
continue;
}
var additionResult = ClientBlackApprovalPolicy.GetAdditionResult(processList.Any());
item.State = additionResult.State;
item.ApprovalProcess = additionResult.ApprovalProcess;
item.creator_id = UserId;
item.creator_name = UserName;
item.creator_time = DateTime.Now;
if (additionResult.IsEffective)
{
ApplyEffectiveAddition(item.Name, clientsToNotify);
}
newItems.Add(item);
}
if (dbList.Any())
DbContext.client_black.AddRange(newItems);
DbContext.SaveChanges();
foreach (var item in newItems)
{
DbContext.client_black.RemoveRange(dbList);
DbContext.SaveChanges();
ClientBlackCategoryLog(item.id, item.State);
}
DbContext.client_black.AddRange(list);
DbContext.SaveChanges();
// 发送Kafka消息
foreach (var (oldClient, newClient) in clientsToNotify)
{
new ClientKafkaService(_kafkaProduce).Send(newClient, oldClient);
}
var importHasTagClientNames = list.Where(p => p.Tags != null && p.Tags.Count > 0).Select(p => p.Name).Distinct().ToList();
var importHasTagClientNames = inputList.Where(p => p.Tags != null && p.Tags.Count > 0).Select(p => p.Name).Distinct().ToList();
if (importHasTagClientNames != null && importHasTagClientNames.Count > 0)
{
var dbClients = DbContext.client.AsNoTracking().Where(p => importHasTagClientNames.Contains(p.Name)).Select(p => new ClientSimpleDto
@@ -273,7 +598,7 @@ namespace YLErp.Modules.ClientModule
var tagService = new TagService(OptUser);
dbClients.ForEach(p =>
{
var importInfo = list.FirstOrDefault(d => d.Name.Equals(p.Name));
var importInfo = inputList.FirstOrDefault(d => d.Name.Equals(p.Name));
if (importInfo != null)
{
tagService.SetClientTagForClientImport(new TagModule.Dto.SetClientTagForClientEditRequest { ClientId = p.id, Tags = importInfo.Tags });
@@ -317,7 +317,7 @@ namespace YLErp.Modules.ClientModule
{
return "第" + rowNum + "行客户类别,机构属性,客户性质关联性质有误,导入失败";
}
if (DbContext.client_black.Any(c => c.Name == Name))
if (DbContext.client_black.Any(c => c.Name == Name && ClientBlackApprovalPolicy.EffectiveStates.Contains(c.State)))
{
return $"客户'{Name}'已经存在于黑名单中”";
}
@@ -1070,7 +1070,7 @@ namespace YLErp.Modules.ClientModule
}
}
}
if (DbContext.client_black.Any(c => c.Name == Name))
if (DbContext.client_black.Any(c => c.Name == Name && ClientBlackApprovalPolicy.EffectiveStates.Contains(c.State)))
{
return "" + Name + "客户已经存在于黑名单中”";
}
@@ -1733,7 +1733,7 @@ namespace YLErp.Modules.ClientModule
//默认为1
IsReceiveEmail = 1;
if (DbContext.client_black.Any(c => c.Name == Name))
if (DbContext.client_black.Any(c => c.Name == Name && ClientBlackApprovalPolicy.EffectiveStates.Contains(c.State)))
{
return "" + Name + "客户已经存在于黑名单中";
}
@@ -130,7 +130,7 @@ namespace YLErp.Modules.ClientModule
try
{
if (DbContext.client_black.Any(c => c.Name == client.Name))
if (DbContext.client_black.Any(c => c.Name == client.Name && ClientBlackApprovalPolicy.EffectiveStates.Contains(c.State)))
{
client.RejectOrderId = client.ApprovalOrderId;
client.ApprovalOrderId = -1;
@@ -158,7 +158,7 @@ namespace YLErp.Modules.ClientModule
throw new ServiceException("客户名称 必须填写");
}
if (DbContext.client_black.Any(c => c.Name == req.Name))
if (DbContext.client_black.Any(c => c.Name == req.Name && ClientBlackApprovalPolicy.EffectiveStates.Contains(c.State)))
{
throw new ServiceException("该客户为黑名单客户,无法进行下一步操作");
}
@@ -703,7 +703,7 @@ namespace YLErp.Modules.ClientModule
//新增时,新的客户名如果在黑名单里,不允许新增
//修改时,旧的客户名如果在黑名单里,不允许修改
if (!isAddNew && !req.Name.Equals(blackNameForCheck) && DbContext.client_black.Any(x => x.Name == blackNameForCheck))
if (!isAddNew && !req.Name.Equals(blackNameForCheck) && DbContext.client_black.Any(x => x.Name == blackNameForCheck && ClientBlackApprovalPolicy.EffectiveStates.Contains(x.State)))
{
throw new ServiceException("该客户为黑名单客户," + (req.id > 0 ? "不允许修改客户名称" : "不允许新增"));
}
@@ -149,8 +149,14 @@ namespace YLErp.Modules.DataProviderModule
price = 0;
valueDate = valueDate.Date;
// FR007 走整表快照缓存(预载全历史,O(1) 命中零查询)。miss=行尚不存在(当日未发布/补录),
// 继续直查库兜底——miss 永不进快照(负缓存防线,详见 Fr007FixingCache 注释)。
if (underlyingCode == Fr007FixingCache.UnderlyingCode && Fr007FixingCache.TryGet(valueDate, out price))
return true;
using var db = DbContextFactory.GetYLDbContext();
var data = db.eod_commodity_future_price.Where(x => x.ValueDate == valueDate && x.UnderlyingCode == underlyingCode).OrderByDescending(o => o.ValueDate).FirstOrDefault();
var data = db.eod_commodity_future_price.Where(x => x.ValueDate == valueDate && x.UnderlyingCode == underlyingCode).FirstOrDefault();
if (data != null)
{
@@ -158,6 +164,11 @@ namespace YLErp.Modules.DataProviderModule
// 利息腿计算直接作为 floatRate 参与 principal*(fixedRate+floatRate)/annualDays,无需再 ÷100。
price = data.ReferencePrice ?? 0;
// 快照漏掉的行(当日新发布/补录历史)直查命中——失效快照,下次访问整表重载后回到 O(1),
// 否则该键在 TTL 窗口内每次读取都退化为点查。
if (underlyingCode == Fr007FixingCache.UnderlyingCode)
Fr007FixingCache.Invalidate();
return true;
}
@@ -0,0 +1,109 @@
using YLErp.Modules;
namespace YLErp.Modules.DataProviderModule
{
/// <summary>
/// FR007 定盘快照缓存。定盘是"每工作日一个数、发布后不变"的小表(一年约 250 行,全历史数千行),
/// 故采用整表快照而非逐键缓存:
///
/// <para>① <b>预载</b>:首次访问一次性 SELECT 全历史进字典,此后读取 O(1)、零查询;</para>
/// <para>② <b>miss 不进快照</b>(负缓存防线):当日定盘 ~11:15 后才出现、历史可补录——
/// 字典查不到必须回退直查库,绝不能把"查不到"缓存住,否则发布后仍沿用旧利率;</para>
/// <para>③ <b>写侧版本失效</b>:经应用的 FR007 写入(SwapFlowService 新增/修改/删除)立即失效,
/// 下次访问整表重载一次;</para>
/// <para>④ <b>TTL 1 分钟兜底</b>:覆盖直改库与其他通用价格写入口(导入/复制/合成等),
/// 可见性窗口 ≤1 分钟,重启无需。</para>
///
/// <para>快照以引用替换发布(Volatile.Write),读侧要么看到旧快照要么看到新快照,无撕裂;
/// 重载加锁去抖,避免并发下重复整表查询。</para>
/// </summary>
public static class Fr007FixingCache
{
public const string UnderlyingCode = "FR007";
/// <summary>TTL:兜底"直接改库/通用写入口"的可见性窗口(FR007 专属写入口走版本失效,不受此限)。</summary>
internal static TimeSpan Ttl = TimeSpan.FromMinutes(1);
/// <summary>测试接缝:时钟(默认 UtcNow Ticks)。</summary>
internal static Func<long> NowTicks = () => DateTime.UtcNow.Ticks;
/// <summary>测试接缝:快照装载器(默认直查库)。返回 日期→ReferencePrice 全量映射。</summary>
internal static Func<Dictionary<DateTime, double>> LoadSnapshot = LoadFromDb;
private static Dictionary<DateTime, double> _snapshot = new();
private static bool _loaded; // 是否已完成首次装载
private static long _loadedAt; // 快照装载时刻(NowTicks 口径)
private static long _writeVersion; // 写侧版本(Invalidate 递增)
private static long _snapVersion; // 装载时的写侧版本
private static readonly object _reloadLock = new();
private static Dictionary<DateTime, double> LoadFromDb()
{
using var db = DbContextFactory.GetYLDbContext();
return db.eod_commodity_future_price
.Where(x => x.UnderlyingCode == UnderlyingCode && x.ReferencePrice != null)
.Select(x => new { x.ValueDate, Price = x.ReferencePrice!.Value })
.AsEnumerable()
.GroupBy(x => x.ValueDate.Date)
.ToDictionary(g => g.Key, g => g.First().Price);
}
/// <summary>命中返回 true。miss 仅代表"快照里没有",调用方须直查库兜底(当日新发布/补录)。</summary>
public static bool TryGet(DateTime valueDate, out double price)
{
EnsureFresh();
return Volatile.Read(ref _snapshot).TryGetValue(valueDate.Date, out price);
}
/// <summary>写侧失效:FR007 行经应用新增/修改/删除后调用,下次访问整表重载。</summary>
public static void Invalidate() => Interlocked.Increment(ref _writeVersion);
/// <summary>重置为未装载状态并恢复默认接缝(仅测试用)。</summary>
internal static void ResetForTest()
{
lock (_reloadLock)
{
_snapshot = new Dictionary<DateTime, double>();
_loaded = false;
_loadedAt = 0;
_writeVersion = 0;
_snapVersion = 0;
Ttl = TimeSpan.FromMinutes(1);
NowTicks = () => DateTime.UtcNow.Ticks;
LoadSnapshot = LoadFromDb;
}
}
private static void EnsureFresh()
{
if (IsFresh()) return;
lock (_reloadLock)
{
if (IsFresh()) return;
try
{
var snap = LoadSnapshot();
Volatile.Write(ref _snapshot, snap);
Volatile.Write(ref _loaded, true);
Volatile.Write(ref _loadedAt, NowTicks());
Volatile.Write(ref _snapVersion, Volatile.Read(ref _writeVersion));
}
catch (Exception ex)
{
// 重载失败(如DB抖动):保留旧快照并重置TTL时钟——历史定盘不可变,旧值依旧正确;
// 同时防止每次读取都重试全表SELECT(重试风暴)。不抛:命中继续走旧快照,
// miss照旧回退直查库,新数据取不到仍会在直查处响亮报错。
// 注:版本失效(Invalidate)后的重载失败不会被TTL掩盖——版本不相等会持续重试直到成功。
Volatile.Write(ref _loaded, true);
Volatile.Write(ref _loadedAt, NowTicks());
LogFactory.GetLogger("Fr007FixingCache").Error("FR007定盘缓存重载失败,沿用旧快照", ex);
}
}
}
private static bool IsFresh()
=> Volatile.Read(ref _loaded)
&& Volatile.Read(ref _snapVersion) == Volatile.Read(ref _writeVersion)
&& NowTicks() - Volatile.Read(ref _loadedAt) <= Ttl.Ticks;
}
}
@@ -98,8 +98,13 @@ namespace YLErp.Modules.EodModule
/// <returns></returns>
public List<BondPayment> GetBondPayments(string underlyingCode, DateTime startDate, DateTime endDate)
{
var result = DbContext.bondPayment.Where(x => x.underlyingCode == underlyingCode
&& x.payment_date > startDate && x.payment_date <= endDate).AsNoTracking().ToList();
// GLMS-20260105-0006:票息归属按债权登记日(reg_date)判定,而非支付日(pay_date_PL/pay_date_act)。
// 登记日当天 EOD 即应计提;原按支付日口径会让"登记日≠支付日"的债券漏计(二者恰差一工作日时缺陷被掩盖)。
var result = QueryBondPayments(underlyingCode)
.Where(x => x.reg_date > startDate && x.reg_date <= endDate)
.AsNoTracking().ToList();
Log.Info($"[分红-登记日口径] GetBondPayments underlyingCode={underlyingCode} 区间=({startDate:yyyy-MM-dd},{endDate:yyyy-MM-dd}] 按reg_date过滤, 命中 {result.Count} 条: " +
string.Join(",", result.Select(r => r.reg_date?.ToString("yyyy-MM-dd"))));
// 让 Copy/Update EOD 始终只依赖 BondPaymentService,而不必在收盘链路直接累加 ex_dividend_info。
// 口径约定:bond_payment_info.payment_interest 对 Stock/Fund 统一按“每 10 份派现金额”存储,
@@ -149,6 +154,13 @@ namespace YLErp.Modules.EodModule
return result;
}
/// <summary>
/// 可测性 seam:返回某债券的全部付息记录(未做日期过滤)。测试可 override 注入内存数据,
/// 以验证日期口径(GLMS-20260105-0006:应按债权登记日 reg_date 而非支付日 pay_date_PL/pay_date_act 判定)。
/// </summary>
protected virtual IQueryable<BondPayment> QueryBondPayments(string underlyingCode)
=> DbContext.bondPayment.Where(x => x.underlyingCode == underlyingCode);
public List<BondPayment> GetTargetDatePayments(string underlyingCode, DateTime targetDate)
{
+17 -9
View File
@@ -52,11 +52,7 @@ SwapModule/
├── Margin/ 保证金(mode 5/6
│ ├── MarginModes mode 判断(含 ForLinq for EF Core
── MarginBalance 保证金余额(值对象
│ ├── MarginAccount 余额管理 + AccrueInterest 计息入口
│ ├── MarginCalc 纯函数(PreviousBalance/FlipDirection/AccumulateSettlement
│ ├── IMarginResolver 保证金形态接口
│ └── Cash/Credit/Guarantee 三种形态实现
── MarginCalc 纯函数(PreviousBalance/FlipDirection/AccumulateSettlement
├── ReturnLegs/ 标的端
│ ├── ReturnLegSummary 标的端汇总值
@@ -66,6 +62,14 @@ SwapModule/
│ ├── DirectionRatio 方向因子(LongShort + ReceivePay
│ └── PositionValueCalc 持仓价值汇总(利息端 + 浮动端)
├── Accrual/ 计息(生产实现,自洽域)
│ ├── InterestMath 共用数学:Round/AccrualDays/FundingLegPrecision + AccrualBoundary/InterestResult
│ ├── SimpleInterestAccrual 单利纯函数(AccrueEod 单日 + AccruePeriod 多日)
│ ├── CompoundInterestAccrual 复利纯函数(EodBasis/AccrueEod/AccruePeriod
│ ├── AccrualPolicy 计息政策(算头算尾/单复利/重置周期/年化)
│ ├── AccrualTrace 计息 trace 收集器(SwapCalcTrace.Write 常驻落盘)
│ └── FundingLegRate all-in 利率值对象
├── SwapDealService.cs 盘中平仓/互换主逻辑
├── SwapEodPositionService.cs EOD 日终归档主逻辑
├── SwapDealIndexFixer.cs SwapDealService 专用取价器(委托 TryGetFloatRate
@@ -76,12 +80,16 @@ SwapModule/
```
Interest/
├── SwapInterest.cs 纯函数库(AccrueSimple/AccrueCompound/ApplyUnwind
├── IIndexFixer.cs 取价接口
── IndexFixerBase.cs 取价日计算工具
└── Fr007IndexFixer.cs FR007 取价生产实现(调 EodPriceQueryService
── IndexFixerBase.cs 取价日计算工具
```
> 注:① `Fr007IndexFixer.cs`(FR007 取价生产实现)在 SwapModule 下,不在本目录。
> ② 2026-08 计息类型(InterestMath/AccrualBoundary/InterestResult/AccrualTrace)已整体迁至 SwapModule/Accrual/
> Core 不再持有计息实现。原 Core 层 SwapInterest 的算法方法(AccrueSimple/AccrueCompoundInArrears/ApplyUnwind/
> AccrueUnrealized/ToInterestRate)与 AccrualContext/InterestRate 从未接线(生产走 Accrual/ 目录),作为孤儿死代码删除——
> 其舍入/rollover 口径与生产实现已分叉,若将来重建须先补对账测试,勿凭记忆复原。
## InterestModeEnum(显式赋值,DB 契约)
```
@@ -132,7 +140,7 @@ Unknown = 0
|---|---|---|
| 公司行为(送股/拆股) | QtyRollforward.corpActionDeltaQty | ✅ |
| 公司行为(登记日快照) | DividendCalc + BondPayment | 见 corp-action-refactor-proposal.md |
| 保证金配置/规则/占用 | MarginAccount + MarginCalc | ✅ |
| 保证金配置/规则/占用 | MarginCalc | ✅ |
| RecordMarginCashFlow 迁入 Margin | AddClientCash 加 virtual | 待做 |
| EOD 编排拆分 | SwapPositionCompose | 待业务需求驱动 |
```
@@ -1,5 +1,3 @@
using YLErp.Derivatives.Interest;
namespace YLErp.Modules.SwapModule.Accrual;
/// <summary>
@@ -11,7 +9,7 @@ namespace YLErp.Modules.SwapModule.Accrual;
/// </summary>
public sealed class AccrualPolicy
{
/// <summary>算头算尾约定(复用 SwapInterest 已有的 AccrualBoundary,物理上杜绝 calcFirst/calcLast 传反)。</summary>
/// <summary>算头算尾约定(AccrualBoundary,物理上杜绝 calcFirst/calcLast 传反)。</summary>
public AccrualBoundary Convention { get; }
/// <summary>是否复利(利滚利)。来自 DB 的 InterestTypeEnum;单利=false,复利=true。</summary>
@@ -29,4 +27,8 @@ public sealed class AccrualPolicy
public AccrualPolicy(AccrualBoundary convention, bool isCompound, int resetPeriodDays, int annualDays, bool isAnnualized = false)
=> (Convention, IsCompound, ResetPeriodDays, AnnualDays, IsAnnualized) = (convention, isCompound, resetPeriodDays, annualDays, isAnnualized);
/// <summary>从 swap_position 构造 EOD 计息政策(算头算尾,重置周期取 interest_rest_days)。</summary>
public static AccrualPolicy BuildEod(DBModels.swap_position position, int annualDays, bool isCompound)
=> new(AccrualBoundary.Both, isCompound, position.interest_rest_days ?? 1, annualDays, position.IsAnnualized);
}
@@ -1,49 +0,0 @@
using YLErp.DBModels;
namespace YLErp.Modules.SwapModule.Accrual;
/// <summary>
/// 融资腿逐日计息的跨日状态(不可变值对象)。
/// 这是"待实现利息"在日间滚动的快照,区别于已落库的 <c>swap_flow_event</c>。
///
/// 旧字段 → 领域命名映射(DB 列不可改,仅在边界处适配;本类内部一律用下列自描述名):
/// <list type="table">
/// <item><term>TdInterestPrincipal</term><description>逐日滚动的计息本金 → <see cref="AccrualPrincipal"/></description></item>
/// <item><term>InterestIncomeSum</term><description>累计待实现利息 → <see cref="UnrealizedInterest"/></description></item>
/// <item><term>consumedInterest</term><description>历史已实现利息(legacy) → <see cref="RealizedInterest"/></description></item>
/// <item><term>ValueDate</term><description>快照截至日 → <see cref="ValueDate"/>EOD 续接起算日,Bug C / 5-11 跳过需据此判断从哪天接续)。</description></item>
/// </list>
/// </summary>
public readonly struct AccrualState
{
/// <summary>用于计算当日利息的计息本金。单利=名义本金基数;复利=本金+累计利息。</summary>
public decimal AccrualPrincipal { get; }
/// <summary>累计待实现(未平仓)利息。</summary>
public decimal UnrealizedInterest { get; }
/// <summary>历史各次平仓已确认的已实现利息,从剩余待实现中扣除。</summary>
public decimal RealizedInterest { get; }
/// <summary>快照截至日(来自 eod_swap_position.ValueDate)。编排层据此判断计息区间起点,避免 5-11 等"跳过日"误重算。</summary>
public DateTime ValueDate { get; }
public AccrualState(decimal accrualPrincipal, decimal unrealizedInterest, decimal realizedInterest, DateTime valueDate)
=> (AccrualPrincipal, UnrealizedInterest, RealizedInterest, ValueDate) = (accrualPrincipal, unrealizedInterest, realizedInterest, valueDate);
/// <summary>向后兼容:未携带快照日期时(如纯内存构造)用默认日。</summary>
public AccrualState(decimal accrualPrincipal, decimal unrealizedInterest, decimal realizedInterest)
: this(accrualPrincipal, unrealizedInterest, realizedInterest, default) { }
/// <summary>空状态(新开仓首个计息日之前)。</summary>
public static readonly AccrualState Zero = new(0m, 0m, 0m);
/// <summary>
/// 从上一日日终归档 <see cref="eod_swap_position"/> 适配(边界适配:DB 列名 → 领域名)。
/// 仅映射计息状态;名义本金基数 / 平仓比例 / 已实现利息等由调用方另行传入。
/// </summary>
public static AccrualState FromPreviousEod(eod_swap_position previousEod)
=> previousEod == null || previousEod.id == 0
? Zero
: new AccrualState(previousEod.TdInterestPrincipal, previousEod.InterestIncomeSum, 0m, previousEod.ValueDate);
}
@@ -1,14 +1,10 @@
using System;
using System.Collections.Generic;
using System.Linq;
using YLErp.Derivatives.Interest;
namespace YLErp.Core.Interest;
namespace YLErp.Modules.SwapModule.Accrual;
/// <summary>
/// 计息过程追踪收集器(值对象,非日志)。
/// 计息过程追踪收集器(值对象,非日志)。2026-08 自 Core 层(YLErp.Core.Interest)迁入 DAL
/// 与 Simple/CompoundInterestAccrual、AccrualBoundary 同处一域,Core 不再持有计息类型。
///
/// <para><b>为什么是收集器而不是日志调用</b>:计息数学(SwapInterest / FundingLegAccrual)必须保持纯函数、
/// <para><b>为什么是收集器而不是日志调用</b>:计息数学(Simple/CompoundInterestAccrual)必须保持纯函数、
/// 可单测、不依赖 NLog;但按工程铁律,关键路径日志须<b>无条件常驻落盘</b>(出问题时事后翻日志定位,不能依赖开关)。
/// 折中:纯函数把"发生了什么"记录为结构化条目写入本收集器,由<b>适配器(IO 边界)</b>统一经
/// <c>SwapCalcTrace.Write</c> 常驻落盘。落盘职责归一处,计息代码零日志依赖、保持干净。</para>
@@ -71,6 +67,10 @@ public sealed class AccrualTrace
=> Add(AccrualTraceEvent.End, default,
$"END accrued={totalAccrued:F6} today={totalToday:F6}");
/// <summary>自由文本注解(如罚息接缝的诊断行),不绑定特定计息语义。</summary>
public void Note(string message)
=> Add(AccrualTraceEvent.Note, default, message);
private void Add(AccrualTraceEvent step, DateTime date, string line)
=> _entries.Add(new AccrualTraceEntry(step, date, line));
@@ -82,7 +82,7 @@ public sealed class AccrualTrace
/// <summary>追踪条目的语义类别(对应 QuantLib/Strata 的"事件"概念),便于程序化筛选(如"只看重置日")。</summary>
public enum AccrualTraceEvent
{
Start, DayAccrual, ResetBefore, ResetAfter, Rollover, Unwind, End
Start, DayAccrual, ResetBefore, ResetAfter, Rollover, Unwind, End, Note
}
/// <summary>单条追踪记录:类别 + 日期 + 已渲染文本。</summary>
@@ -1,6 +1,3 @@
using YLErp.Core.Interest;
using YLErp.Derivatives.Interest;
namespace YLErp.Modules.SwapModule.Accrual;
/// <summary>
@@ -9,7 +6,7 @@ namespace YLErp.Modules.SwapModule.Accrual;
/// </summary>
public static class CompoundInterestAccrual
{
private const int Precision = SwapInterest.FundingLegPrecision;
private const int Precision = InterestMath.FundingLegPrecision;
/// <summary>复利日终计息基数(单一真相源,纯函数与调用方共用):
/// 重置日 = notional + 累计利息×剩余比例(利息并入本金);非重置日 = priorNotional(昨日滚动基数)。
@@ -52,8 +49,8 @@ public static class CompoundInterestAccrual
var totalAccrued = priorAccrued * unwindFraction + dayInterest;
var result = new InterestResult(
SwapInterest.Round(totalAccrued, Precision),
SwapInterest.Round(tdInterest, Precision));
InterestMath.Round(totalAccrued, Precision),
InterestMath.Round(tdInterest, Precision));
trace?.Day(0, eodDate, allInRate, displayBasis, dayInterest, totalAccrued);
trace?.MarkEnd(result.Accrued, result.AccruedToday);
@@ -64,6 +61,11 @@ public static class CompoundInterestAccrual
/// 复利多日计息(替换 CalcDailyCompoundInterest 的纯数学部分)。
/// 从 startDate 到 endDate 全程重放,每个重置日把累计利息并入本金。
/// </summary>
/// <param name="carryInInterest">
/// 窗口前已计未结转利息(EQD-6977 罚息承接):在【首个重置日】并入计息基数——
/// 与"持有至到期"全期轨迹严格对齐(恒等式:全期复利 = 平仓日已结利息 + 罚息窗口利息)。
/// 默认 0 时与旧行为逐位一致。与 resetCarryInterest 互斥使用(后者是全平重放的末段存量替代)。
/// </param>
public static InterestResult AccruePeriod(
decimal notional,
IReadOnlyList<(DateTime StartDate, decimal Rate)> segmentRates,
@@ -76,10 +78,14 @@ public static class CompoundInterestAccrual
decimal realizedInterest,
decimal unwindFraction,
out decimal finalBasis,
AccrualTrace? trace = null)
AccrualTrace? trace = null,
decimal carryInInterest = 0m)
{
decimal accrualBasis = notional;
decimal accrued = 0m;
// carryInInterest 是"窗口前已计未结转利息":先并入 accrued,随首个重置日的
// basis = notional + accrued 一并资本化,并在其后每个重置日持续留在基数里
//(与全窗口重放时 accrued 含全部历史利息的轨迹严格一致);最终报告时扣除。
decimal accrued = carryInInterest;
trace?.MarkStart(startDate, endDate, boundary, annualDays, isAnnualized);
@@ -107,7 +113,7 @@ public static class CompoundInterestAccrual
var segIncludeStart = (si == 0) ? boundary.IncludeStart : true;
var segIncludeEnd = isLastSegment ? boundary.IncludeEnd : false;
var days = SwapInterest.AccrualDays(segmentRates[si].StartDate, segEnd,
var days = InterestMath.AccrualDays(segmentRates[si].StartDate, segEnd,
AccrualBoundary.Of(segIncludeStart, segIncludeEnd));
if (days <= 0) continue;
@@ -119,13 +125,16 @@ public static class CompoundInterestAccrual
finalBasis = accrualBasis;
// 报告口径只含窗口内增量(carryIn 是窗口前已结利息,由正常平仓流单独结算)
accrued -= carryInInterest;
if (realizedInterest != 0m)
trace?.Unwind(endDate, unwindFraction, realizedInterest * unwindFraction, accrued - realizedInterest * unwindFraction);
accrued -= realizedInterest * unwindFraction;
var result = new InterestResult(
SwapInterest.Round(accrued, Precision),
SwapInterest.Round(accrued, Precision));
InterestMath.Round(accrued, Precision),
InterestMath.Round(accrued, Precision));
trace?.MarkEnd(result.Accrued, result.AccruedToday);
return result;
}
@@ -1,3 +1,5 @@
using YLErp.DBModels;
namespace YLErp.Modules.SwapModule.Accrual;
/// <summary>
@@ -26,4 +28,10 @@ public readonly struct FundingLegRate
/// <summary>构造浮动腿利率(all-in = 加点利差 + 指数定盘)。</summary>
public static FundingLegRate Floating(decimal spread, decimal indexFixing)
=> new(spread + indexFixing);
/// <summary>从 swap_position 构造:固定腿→Fixed(spread),浮动腿→Floating(spread+fixing)。</summary>
public static FundingLegRate Build(swap_position position, decimal spread, decimal effectiveFloat)
=> string.IsNullOrEmpty(position.FloatRateUnderlyingCode)
? Fixed(spread)
: Floating(spread, effectiveFloat);
}
@@ -0,0 +1,104 @@
namespace YLErp.Modules.SwapModule.Accrual;
// ─────────────────────────────────────────────────────────────────────────────
// 词汇表(本文件只允许出现下列用词,同一概念不得出现第二种叫法)
//
// 概念 唯一用词 与既有代码的对应
// ───────────────────────────────────────────────────────────────────
// 区间起点/终点 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>
/// 利息腿共用数学工具:舍入、应计天数、精度常量。
///
/// <para><b>沿革</b>2026-08 自 Core 层 SwapInterest 迁入 DAL(生产消费面整体搬家)。
/// 原 SwapInterest 的算法方法(AccrueSimple/AccrueCompoundInArrears/ApplyUnwind/AccrueUnrealized
/// 与 AccrualContext/InterestRate 始终未接线(生产计息走本目录 Simple/CompoundInterestAccrual
/// 两者舍入与 rollover 口径已分叉),作为孤儿死代码删除——接线前须先补对账,勿凭记忆重建。</para>
///
/// <para>为何不复用 Qdp 的 IDayCount
/// a. 语义——Qdp 的 DaysInPeriod = end start 是写死的半开区间,只能表达四种算头算尾中的一种;
/// b. 精度——Qdp 返回 double 年化系数,本系统 decimal 对账;
/// c. 依赖方向——Qdp 用自有 Date 类型,引入会让本模块反向依赖定价库。</para>
/// </summary>
public static class InterestMath
{
/// <summary>资金腿与保证金腿的生产计息精度(落库/对账均以 12 位为准)。
/// 提升至公共常量,消除 SwapDealService 与 SimpleInterestAccrual 的重复定义。</summary>
public const int FundingLegPrecision = 12;
/// <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>统一舍入:MidpointRounding.AwayFromZero。所有计息路径收口到此处,避免散落的 Math.Round 不一致。</summary>
public static decimal Round(decimal value, int precision)
=> Math.Round(value, precision, MidpointRounding.AwayFromZero);
}
@@ -1,6 +1,3 @@
using YLErp.Core.Interest;
using YLErp.Derivatives.Interest;
namespace YLErp.Modules.SwapModule.Accrual;
/// <summary>
@@ -9,7 +6,7 @@ namespace YLErp.Modules.SwapModule.Accrual;
/// </summary>
public static class SimpleInterestAccrual
{
private const int Precision = SwapInterest.FundingLegPrecision;
private const int Precision = InterestMath.FundingLegPrecision;
/// <summary>
/// 单利日终计息(替换 CalcDailySimpleInterestByEod 的纯数学部分)。
@@ -38,8 +35,8 @@ public static class SimpleInterestAccrual
var totalAccrued = priorAccrued + dayInterest;
var result = new InterestResult(
SwapInterest.Round(totalAccrued, Precision),
SwapInterest.Round(tdInterest, Precision));
InterestMath.Round(totalAccrued, Precision),
InterestMath.Round(tdInterest, Precision));
trace?.Day(0, eodDate, allInRate, displayBasis, dayInterest, totalAccrued);
trace?.MarkEnd(result.Accrued, result.AccruedToday);
@@ -85,7 +82,7 @@ public static class SimpleInterestAccrual
var includeStart = effectiveStart == startDate ? boundary.IncludeStart : true;
var isLastSegment = si == segmentRates.Count - 1;
var segBoundary = AccrualBoundary.Of(includeStart, isLastSegment && boundary.IncludeEnd);
var days = SwapInterest.AccrualDays(effectiveStart, segEnd, segBoundary);
var days = InterestMath.AccrualDays(effectiveStart, segEnd, segBoundary);
if (days <= 0) { segStart = segEnd; continue; }
var dailyRate = isAnnualized ? segmentRates[si].Rate / annualDays : segmentRates[si].Rate;
@@ -98,8 +95,8 @@ public static class SimpleInterestAccrual
}
var result = new InterestResult(
SwapInterest.Round(accrued, Precision),
SwapInterest.Round(accruedUnscaled, Precision));
InterestMath.Round(accrued, Precision),
InterestMath.Round(accruedUnscaled, Precision));
trace?.MarkEnd(result.Accrued, result.AccruedToday);
return result;
}
@@ -0,0 +1,65 @@
using YLErp.DBModels;
namespace YLErp.Modules.SwapModule;
/// <summary>
/// 平仓比例(ClosePercent) 数学——占期初(A) / 占剩余(B) 两种口径的转换。
/// 从 SwapDealService 提取为共享模块,两个 service 均可引用。
/// </summary>
public static class ClosePercentMath
{
/// <summary>
/// 取上一日终的浮动端名义本金(orginPv 的来源)。
/// 优先取浮动腿 PosiNotionalValue 之和,取不到用 eod_swap 多空绝对值之和,都没有用 currentNotional 兜底。
/// </summary>
public static decimal ResolveUnwindPreviousNotional(
eod_swap lastEod,
IEnumerable<eod_swap_position> lastEodPositions,
decimal currentNotional)
{
var floatingPositions = lastEodPositions?.Where(x => x.PosiDirection > 0).ToList();
decimal previousNotional;
if (floatingPositions?.Count > 0)
{
previousNotional = floatingPositions.Sum(x => x.PosiNotionalValue);
}
else
{
previousNotional = lastEod == null
? currentNotional
: Math.Abs(lastEod.NotionalValueLong) + Math.Abs(lastEod.NotionalValueShort);
}
return previousNotional == 0m && currentNotional != 0m
? currentNotional
: previousNotional;
}
/// <summary>
/// A(占期初) → B(占剩余),用于把前端传入的占期初比例换算成后端计算用的占剩余比例。
/// </summary>
public static decimal ToRemainingClosePercent(decimal originalClosePercent, decimal notionalValue, decimal posiNotionalValue)
{
if (posiNotionalValue <= 0) return originalClosePercent;
var remaining = originalClosePercent * notionalValue / posiNotionalValue;
return remaining > 1 ? 1 : remaining;
}
/// <summary>
/// B(占剩余) → A(占期初),用于落库 / 事件列表展示还原。见 ToRemainingClosePercent。
/// </summary>
public static decimal ToOriginalClosePercent(decimal remainingClosePercent, decimal notionalValue, decimal posiNotionalValue)
{
if (notionalValue <= 0) return remainingClosePercent;
return remainingClosePercent * posiNotionalValue / notionalValue;
}
/// <summary>
/// 计算 InitUnwind 默认占期初(A)平仓比例 = PosiNotionalValue / NotionalValue。
/// 未平仓时 =1(平100%);部分平仓后自动变为剩余比例。
/// </summary>
public static decimal CalcDefaultInitClosePercent(decimal notionalValue, decimal posiNotionalValue)
{
return notionalValue > 0 ? posiNotionalValue / notionalValue : 1;
}
}
@@ -0,0 +1,183 @@
using System;
using System.Collections.Generic;
using YLErp;
using YLErp.DBModels;
using YLErp.DBModels.Enums;
using YLErp.Modules.SwapModule.ReturnLegs;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 互换日终盈亏/精度计算纯函数集合。
/// 自 SwapEodPositionService 抽出,支持无库单测;同类内部调用无需前缀。
/// </summary>
public static class EodPnlCalculator
{
// 日终利息待实现需跨日累计,按表设计保留 12 位;已实现结算仍按金额两位处理。
private const int EodInterestStoragePrecision = 12;
internal static decimal RoundMoney(decimal value)
{
return Math.Round(value, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
}
internal static decimal RoundEodInterest(decimal value)
{
return Math.Round(value, EodInterestStoragePrecision, MidpointRounding.AwayFromZero);
}
/// <summary>
/// 仅在写入 eod_swap_position 前统一快照精度。
/// 浮动腿收益最终以金额两位展示和存储;利息腿的待实现、计息基数及利率保留 12 位,
/// 使部分结算后的尾差可继续参与后续计息。
/// </summary>
internal static void NormalizeEodPositionForStorage(eod_swap_position position)
{
if (string.IsNullOrEmpty(position.UnderlyingCode))
{
// 利息腿没有标的代码:待实现字段保留高精度,已实现结算字段收敛到金额两位。
position.InterestPrincipalFix = RoundEodInterest(position.InterestPrincipalFix);
position.InterestRateDefault = RoundEodInterest(position.InterestRateDefault);
position.InterestFeePending = RoundEodInterest(position.InterestFeePending);
position.TdInterestPrincipal = RoundEodInterest(position.TdInterestPrincipal);
position.TdInterestRate = RoundEodInterest(position.TdInterestRate);
position.TdInterestIncome = RoundEodInterest(position.TdInterestIncome);
position.TdInterestFee = RoundEodInterest(position.TdInterestFee);
position.InterestIncomeSum = RoundEodInterest(position.InterestIncomeSum);
position.InterestFeeSum = RoundEodInterest(position.InterestFeeSum);
position.InterestProfitSum = RoundEodInterest(position.InterestProfitSum);
position.FloatRate = RoundEodInterest(position.FloatRate);
position.SwapPositionValue = RoundEodInterest(position.SwapPositionValue);
position.TdCloseInterest = RoundMoney(position.TdCloseInterest);
position.TdCloseInterestFee = RoundMoney(position.TdCloseInterestFee);
position.RealizedInterest = RoundMoney(position.RealizedInterest);
position.RealizedInterestFee = RoundMoney(position.RealizedInterestFee);
}
else
{
// 浮动腿有标的代码:其损益作为金额结果落库,统一按两位四舍五入。
position.TdPosiDividend = RoundMoney(position.TdPosiDividend);
position.PosiMtmPnL = RoundMoney(position.PosiMtmPnL);
position.PosiDividendSum = RoundMoney(position.PosiDividendSum);
position.PosiFeePending = RoundMoney(position.PosiFeePending);
position.PosiProfitSum = RoundMoney(position.PosiProfitSum);
position.TdCloseMtmPnl = RoundMoney(position.TdCloseMtmPnl);
position.TdCloseDividend = RoundMoney(position.TdCloseDividend);
position.TdCloseFee = RoundMoney(position.TdCloseFee);
position.RealizedMtmPnL = RoundMoney(position.RealizedMtmPnL);
position.RealizedDividend = RoundMoney(position.RealizedDividend);
position.RealizedFee = RoundMoney(position.RealizedFee);
position.SwapPositionValue = RoundMoney(position.SwapPositionValue);
}
position.RealizedPnl = RoundMoney(position.RealizedPnl);
}
/// <summary>
/// 浮动腿累计已实现盈亏由盯市、分红和费用三个已实现组成项汇总。
/// 各组成项已经按本方视角落库,此处不再额外转换方向。
/// </summary>
internal static void SetFloatingRealizedPnl(eod_swap_position position)
{
position.RealizedPnl = position.RealizedMtmPnL
+ position.RealizedDividend
+ position.RealizedFee;
}
/// <summary>
/// 汇总单条日终腿的我方已实现收益。
/// 浮动腿及普通利息腿维持数据库记录的方向;初始/追加预付金腿的利息
/// 则与保证金本金方向相反。这样“收取对手方保证金”产生的利息会作为
/// 我方支付给对手方的成本计入,而不会错误增加框架合约已实现收益。
/// 抽为静态纯函数以支持无库单测(marginTypes 等价于 ConsTrade.InterestMarginModels)。
/// </summary>
public static decimal CalculateSwapRealizedPnl(eod_swap_position position)
{
var interestRatio = DirectionRatio.InterestLegPnl(position.InterestDirection, position.InterestMode);
return position.RealizedMtmPnL
+ position.RealizedDividend
+ position.RealizedFee
+ position.RealizedInterest * interestRatio
+ position.RealizedInterestFee;
}
/// <summary>填充框架合约的持仓腿汇总字段(多空名义本金/市值/浮动盈亏/dv01/平仓量)。
/// SaveEodSwap 与 UpdateEodSwap 共用,消除 ~10 行重复。</summary>
internal static void FillPositionLegSummary(eod_swap eod_Swap, List<eod_swap_position> positions)
{
eod_Swap.NotionalValueLong = Math.Round(positions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.PosiNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
eod_Swap.NotionalValueShort = Math.Round(-Math.Abs(positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue)), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
eod_Swap.MarketValueLong = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.UnderlyingMarketValue);
eod_Swap.MarketValueShort = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.UnderlyingMarketValue);
eod_Swap.FloatingPnL = positions.Sum(s => s.PosiProfitSum);
eod_Swap.dv01 = positions.Sum(s => s.dv01 ?? 0);
eod_Swap.TdCloseQty = positions.Sum(s => s.TdCloseQty);
}
/// <summary>利息腿 PnL 汇总(按方向比例 + 保证金翻转)。原 SaveEodSwap/UpdateEodSwap 各一段 ForEach。</summary>
internal static decimal SumInterestPnL(List<eod_swap_position> interestPositions)
{
decimal interestPnL = 0;
foreach (var x in interestPositions)
interestPnL += x.InterestProfitSum * DirectionRatio.InterestLegPnl(x.InterestDirection, x.InterestMode);
return interestPnL;
}
/// <summary>
/// 风险报表符号归一化:把历史两种符号口径的 TdCloseInterest/RealizedInterest
/// 统一按"绝对金额 × 业务方向"重写。普通利息腿收取为正、支付为负;
/// 预付金腿利息方向与保证金本金方向相反。随后重算 RealizedPnl。
/// 抽为 public static 纯函数以支持无库单测(见 SwapReportInterestSignNormalizeTest)。
/// 仅当 InterestDirection > 0 时执行(与原内联逻辑等价)。
/// </summary>
public static void NormalizeInterestSignForReport(eod_swap_position position)
{
if (position.InterestDirection <= 0) return;
if (position.InterestMode == (int)InterestModeEnum.)
{
return;
}
var interestRatio = DirectionRatio.InterestLegPnl(position.InterestDirection, position.InterestMode);
position.TdCloseInterest = Math.Abs(position.TdCloseInterest) * interestRatio;
position.RealizedInterest = Math.Abs(position.RealizedInterest) * interestRatio;
// 兼容修复前已落库的利息腿:当时只累计了明细字段,未同步写入 RealizedPnl。
position.RealizedPnl = position.RealizedInterest + position.RealizedInterestFee;
}
/// <summary>
/// 计算预付金利率。多条初始/追加预付金腿按本金绝对值加权,
/// 不按收付方向轧差,避免相反方向本金抵消后放大利率。
/// </summary>
internal static decimal CalculateWeightedMarginRate(IEnumerable<swap_position> margins)
{
var marginList = margins.ToList();
var totalWeight = marginList.Sum(x => Math.Abs(x.InterestPrincipalFix));
return totalWeight == 0
? 0
: marginList.Sum(x => x.InterestRateDefault * Math.Abs(x.InterestPrincipalFix)) / totalWeight;
}
/// <summary>
/// 计算预付金利息金额。InterestIncomeSum 已是各腿利息金额,
/// 按收取为正、支付为负直接轧差求和,不做本金加权。
/// 抽为 public static 纯函数以支持无库单测(见 SwapWeightedMarginInterestTest)。
/// </summary>
public static decimal CalculateWeightedMarginInterest(IEnumerable<eod_swap_position> margins)
{
return margins.Sum(x =>
x.InterestIncomeSum * DirectionRatio.ReceivePay(x.InterestDirection));
}
/// <summary>
/// 固定利息腿的累计已实现盈亏 = 累计已实现利息 + 累计已实现利息费用。
/// 4 处 SaveAutoEodInterestPosition/SaveEodInterestPosition 路径口径一致,
/// 抽为 public static 纯函数以支持无库单测(见 SwapFixedLegRealizedPnlTest),
/// 并消除复制粘贴带来的笔误风险(如 L1296 历史双分号)。
/// </summary>
public static void SetFixedLegRealizedPnl(eod_swap_position position)
{
position.RealizedPnl = position.RealizedInterest + position.RealizedInterestFee;
}
}
}
@@ -0,0 +1,19 @@
using System;
using System.Linq;
using YLErp.DBModels;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// eod_swap_position 查询收口(Query Object)。
/// 规则"某交易某日日终的有效持仓 = SwapTradeId 匹配 + ValueDate 匹配 + 未作废(!Invalid)"集中于此,
/// 避免多处复制同一谓词导致语义漂移(漏写 !Invalid 即静默出 bug)。
/// 仅返回 IQueryable,不调用 SaveChanges,不破坏跟踪/Include/事务边界。
/// </summary>
public static class EodSwapPositionQueries
{
public static IQueryable<eod_swap_position> ActiveByTradeAndDate(
this IQueryable<eod_swap_position> query, int tradeId, DateTime valueDate)
=> query.Where(x => x.SwapTradeId == tradeId && x.ValueDate == valueDate && !x.Invalid);
}
}
@@ -11,6 +11,6 @@ public sealed class ContractNotionalLeg : IFundingLegStrategy
{
public InterestModeEnum Mode => InterestModeEnum.;
public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal posiLong, decimal posiShort, decimal closePercent)
public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal closePercent)
=> new(posiNotional * closePercent, posiNotional, closePercent);
}
@@ -12,6 +12,6 @@ public sealed class FixedAmountLeg : IFundingLegStrategy
{
public InterestModeEnum Mode => InterestModeEnum.;
public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal posiLong, decimal posiShort, decimal closePercent)
public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal closePercent)
=> new(fix, fix, 1m);
}
@@ -22,10 +22,8 @@ public interface IFundingLegStrategy
/// </summary>
/// <param name="fix">合约固定本金(固定值/预付金腿用;其余腿忽略)。</param>
/// <param name="posiNotional">当前剩余名义本金(数量 × 全价)。</param>
/// <param name="posiLong">多头剩余名义本金(多空存续腿用,当前界面已禁用)。</param>
/// <param name="posiShort">空头剩余名义本金。</param>
/// <param name="closePercent">平仓比例(占剩余,0~1)。</param>
NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal posiLong, decimal posiShort, decimal closePercent);
NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal closePercent);
}
/// <summary>
@@ -1,4 +1,4 @@
using YLErp.DBModels;
using YLErp.DBModels;
namespace YLErp.Modules.SwapModule.FundingLegs;
@@ -7,13 +7,13 @@ namespace YLErp.Modules.SwapModule.FundingLegs;
/// 计息基数 = 标的期初含费全价(PosiGrossPrice/EntryDirtyPrice) × 数量。
/// "期初(Entry)"是关键——建仓时点的全价,非当前估值全价。
/// 主路径 CalcNotionalByMode 公式与合约名义本金规模(2)相同;
/// 差异在衡泰路径会乘 grossPrice 折算(SwapDealService.GetUnwindInterestsByHT),
/// 衡泰回执折算路径(原 SwapDealService.GetUnwindInterestsByHT 乘 grossPrice 折算)已随死链清理移除;
/// 以及 EOD 复利部分平仓后直接返回剩余本金(禁止反推,SwapEodPositionService:1458-1465)。
/// </summary>
public sealed class UnderlyingEntryFullPriceLeg : IFundingLegStrategy
{
public InterestModeEnum Mode => InterestModeEnum.;
public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal posiLong, decimal posiShort, decimal closePercent)
public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal closePercent)
=> new(posiNotional * closePercent, posiNotional, closePercent);
}
@@ -0,0 +1,91 @@
namespace YLErp.Modules.SwapModule;
/// <summary>
/// GetInterests 参数对象(2026-08 参数显式化)。
///
/// 动机:原 GetInterests 20 个位置参数中,名义本金簇(posiNotionalValue/closePosiNotionalValue/closePercent
/// 在【盘中平仓】与【EOD 平仓后收盘】两类场景下语义相反(详见 GetInterests "根因位置"注释与
/// GetInterestsEntrySemanticsTest 的口径留档),位置参数无法表达该约束。
///
/// 用法:只能经两个场景工厂构造——工厂形参名即该场景语义(平仓前剩余 / 平仓后剩余 / 实际平掉额),
/// 物理上防止两套语义混传。needPrice/grossPrice(原方法死参数)与 posiLong/posiShortNotionalValue
/// (多空组合子系统删除后计息链零消费的管道死参数)均不承载。
/// </summary>
public sealed class InterestCalcRequest
{
public trade Td { get; }
public trade_extend TradeExtend { get; }
public DateTime ValueDate { get; }
public DateTime UnwindDate { get; }
public List<eod_swap_position> EodPositions { get; }
public List<swap_position> Positions { get; }
/// <summary>当日适用名义本金。语义随场景:盘中=平仓【前】剩余;EOD平仓后收盘=平仓【后】剩余;EOD增量=当前剩余。</summary>
public decimal PosiNotionalValue { get; }
/// <summary>本次实际平掉本金(两场景恒同义)。mode2 无条件覆盖 / mode9 全平兜底的输入。</summary>
public decimal ClosePosiNotionalValue { get; }
/// <summary>平仓比例。语义随场景:盘中=实际比例(B 占剩余);EOD平仓后收盘=恒1(全额结息)。</summary>
public decimal ClosePercent { get; }
public int EventType { get; }
public bool TdClose { get; }
public decimal OrginPv { get; }
public bool Add { get; }
public bool NewCalcLast { get; }
public List<swap_flow_event> CloseList { get; }
/// <summary>是否计罚息(EQD-6977):利息端按持有至到期计息。由平仓页下拉经 UnwindData 透传;默认 false。</summary>
public bool IsPenaltyInterest { get; }
private InterestCalcRequest(
trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate,
List<eod_swap_position> eodPositions, List<swap_position> positions,
decimal posiNotionalValue,
decimal closePosiNotionalValue, decimal closePercent,
int eventType, bool tdClose, decimal orginPv,
bool add, bool newCalcLast, List<swap_flow_event> closeList,
bool isPenaltyInterest = false)
{
Td = td; TradeExtend = tradeExtend; ValueDate = valueDate; UnwindDate = unwindDate;
EodPositions = eodPositions; Positions = positions;
PosiNotionalValue = posiNotionalValue; ClosePosiNotionalValue = closePosiNotionalValue;
ClosePercent = closePercent; EventType = eventType; TdClose = tdClose; OrginPv = orginPv;
Add = add; NewCalcLast = newCalcLast; CloseList = closeList;
IsPenaltyInterest = isPenaltyInterest;
}
/// <summary>
/// 【盘中平仓/互换结息】场景(→ GetIntradayUnwindInterestssettment:false 盘中重放)。
/// </summary>
/// <param name="preCloseNotional">平仓【前】实时剩余本金(原 GetUnwindInterests.stockEqvNotional)。</param>
/// <param name="closedNotional">本次实际平掉本金(= preCloseNotional × closePercentRemaining)。</param>
/// <param name="closePercentRemaining">平仓比例,B 语义【占剩余】(前端传 A 占期初须先经 ToRemainingClosePercent 转换)。</param>
public static InterestCalcRequest IntradayUnwind(
trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate,
List<eod_swap_position> eodPositions, List<swap_position> positions,
decimal preCloseNotional, decimal closedNotional, decimal closePercentRemaining,
int eventType, bool tdClose, decimal orginPv,
bool add, bool newCalcLast, List<swap_flow_event> closeList,
bool isPenaltyInterest = false)
=> new(td, tradeExtend, valueDate, unwindDate, eodPositions, positions,
preCloseNotional, closedNotional, closePercentRemaining,
eventType, tdClose, orginPv, add, newCalcLast, closeList, isPenaltyInterest);
/// <summary>
/// 【EOD 当日有平仓后的收盘结息】场景(→ CalcEodPostCloseSettleInterestssettment:false 全额结息)。
/// 该场景触发 GetInterests 内 mode2 无条件覆盖 / mode9 全平兜底(见其"根因位置"注释,勿删)。
/// </summary>
/// <param name="remainingNotionalAfterClose">平仓【后】剩余本金(GetInterests.posiNotionalValue 形参位)。</param>
/// <param name="closedNotional">本次实际平掉本金。</param>
public static InterestCalcRequest EodPostCloseSettle(
trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate,
List<eod_swap_position> eodPositions, List<swap_position> positions,
decimal remainingNotionalAfterClose, decimal closedNotional,
int eventType, bool tdClose, decimal orginPv,
bool add, bool newCalcLast)
=> new(td, tradeExtend, valueDate, unwindDate, eodPositions, positions,
remainingNotionalAfterClose, closedNotional, 1m, // 恒1:本次事件全额结息(非 closeNational / 期初比例)
eventType, tdClose, orginPv, add, newCalcLast, closeList: null);
}
@@ -0,0 +1,47 @@
using YLErp.Models;
namespace YLErp.Modules.SwapModule;
/// <summary>
/// 利息腿 EOD 归档场景分派规格(纯函数集;表驱动单测见 InterestEodScenarioDispatchTest)。
/// 优先级链(承重业务语义,不能乱序):
/// ① hasSwap(手工互换) → 按事件流水重新生成,压制观察日自动结息
/// ② observationInterval(观察日) 存在 → 自动结息;同日有平仓 → autoSwap=true
/// ③ hasClose(纯平仓, 非观察日) → autoSwap=false
/// ④ 普通日 → 复制上一日终并计提当日新增
/// 注意:hasSwap/hasClose 是交易级标志(整笔合约当天有无事件),
/// observationInterval 是腿级(本条利息腿当天是否观察日)——粒度不同,分派依赖此区分。
/// 生产分派点:SwapEodPositionService.DealInterests(分派结构接线前为影子规格,见其分派处注释)。
/// </summary>
public enum InterestEodScenario
{
ManualSwap, // ① 手工互换:压制观察日自动结息
AutoSettleWithClose, // ② 观察日 + 当日平仓 (autoSwap=true)
AutoSettle, // ② 观察日 + 当日无平仓
CloseOnly, // ③ 非观察日 + 当日平仓 (autoSwap=false)
RollForward, // ④ 普通日滚动
}
public static class InterestEodScenarioDispatch
{
/// <summary>三分量布尔 → 场景(8 组合表驱动见 InterestEodScenarioDispatchTest)。</summary>
public static InterestEodScenario ResolveInterestScenario(bool hasInterval, bool hasSwap, bool hasClose)
{
if (hasSwap)
return InterestEodScenario.ManualSwap;
if (hasInterval)
return hasClose ? InterestEodScenario.AutoSettleWithClose : InterestEodScenario.AutoSettle;
if (hasClose)
return InterestEodScenario.CloseOnly;
return InterestEodScenario.RollForward;
}
/// <summary>
/// 查找利息腿在指定结算日的观察日信息(SwapIntervalList 中 Date==settleDate 且 Settlement==1 的记录)。
/// 观察日即自动结息触发日;返回 null 表示当日非观察日。分派见 ResolveInterestScenario。
/// </summary>
public static IntervalModel FindObservationInterval(swap_position interest, DateTime settleDate)
{
return interest.SwapIntervalList.FirstOrDefault(x => x.Date == settleDate && x.Settlement == 1);
}
}
@@ -1,10 +0,0 @@
namespace YLErp.Modules.SwapModule.Margin;
/// <summary>现金保证金:余额 = 现金余额。</summary>
public sealed class CashMargin : IMarginResolver
{
public MarginForm Form => MarginForm.Cash;
public MarginBalance Resolve(decimal postedAmount)
=> new(postedAmount);
}
@@ -1,10 +0,0 @@
namespace YLErp.Modules.SwapModule.Margin;
/// <summary>授信保证:余额 = 已用授信额度。</summary>
public sealed class CreditMargin : IMarginResolver
{
public MarginForm Form => MarginForm.Credit;
public MarginBalance Resolve(decimal postedAmount)
=> new(postedAmount);
}
@@ -1,10 +0,0 @@
namespace YLErp.Modules.SwapModule.Margin;
/// <summary>担保品:余额 = 担保品市值。</summary>
public sealed class GuaranteeMargin : IMarginResolver
{
public MarginForm Form => MarginForm.Guarantee;
public MarginBalance Resolve(decimal postedAmount)
=> new(postedAmount);
}

Some files were not shown because too many files have changed in this diff Show More