diff --git a/Framework/YLErp.Core/Interest/AccrualContext.cs b/Framework/YLErp.Core/Interest/AccrualContext.cs
deleted file mode 100644
index 2a1f6273..00000000
--- a/Framework/YLErp.Core/Interest/AccrualContext.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-namespace YLErp.Core.Interest;
-
-///
-/// 计息执行上下文:把"与具体金额/利率无关"的横向参数(年化天数、精度、trace 收集器)
-/// 打包成一个只读值对象,避免每个计息方法都重复携带这些参数。
-///
-/// 为何 trace 是"成员"而非散落参数:利息纯函数(AccrueSimple / AccrueCompoundInArrears)
-/// 的核心职责是算账,trace 只是可观测性的旁路。把 trace 作为上下文的成员传入,
-/// 调用点只需传一个 ctx,签名更干净;同时 ctx 是只读值对象,不破坏纯函数
-/// (无共享可变状态 → 线程安全、可重入、可测)。切勿把 trace 设成类的实例/静态字段,
-/// 那会让并发的两笔交易共用同一 trace、并使函数带隐藏状态。
-///
-/// 与 AccrualState(跨日滚动本金状态)/ AccrualPolicy(EOD 会计政策)正交:
-/// 本上下文只描述"如何算 + 往哪记",不持有任何交易进度。
-///
-public readonly struct AccrualContext
-{
- /// 年化天数(365 / 360)。
- public int AnnualDays { get; }
-
- /// 舍入精度位数。默认 11(生产融资腿/保证金腿均显式传入 FundingLegPrecision=12)。
- public int Precision { get; }
-
- /// 可选 trace 收集器;为 null 时不记录(纯计算场景直接传 null,与开关无关)。
- public AccrualTrace? Trace { get; }
-
- public AccrualContext(int annualDays, int precision = 11, AccrualTrace? trace = null)
- => (AnnualDays, Precision, Trace) = (annualDays, precision, trace);
-}
diff --git a/Framework/YLErp.Core/Interest/InterestRate.cs b/Framework/YLErp.Core/Interest/InterestRate.cs
deleted file mode 100644
index b5a6def1..00000000
--- a/Framework/YLErp.Core/Interest/InterestRate.cs
+++ /dev/null
@@ -1,71 +0,0 @@
-using System;
-
-namespace YLErp.Core.Interest;
-
-///
-/// 利率 + 计息方式(单利 / 复利 / 连续复利)。
-///
-/// 通用金融原语,与互换、衍生品、任何具体业务均无耦合——谁需要算利息都能用。
-/// 利息计算不是互换特有的,所以它不住在 SwapModule,也不带任何 swap 词汇。
-///
-/// 用法(年化时间 t,如 30天/365):
-///
-/// - 计息因子 = ;含息额 = 本金 × 因子;
-/// - 利息 = 本金 × (因子 − 1) = 。
-///
-///
-/// 与 QuantLib 模型一致:单利 / 复利 / 连续复利只是 的一个分支,
-/// 不是三套独立方法。TRS 的"重置日并本金"属于离散复利,用
-/// 按段计息、段末把利息滚入本金即可(见 SwapInterest.AccrueCompoundInArrears),无需 Pow/Exp,decimal 精度无损。
-///
-/// 互换特有的会计态(每日先舍入再乘天数、平仓缩放、跨日滚动本金)不属于本原语,
-/// 请在各自的 accrual 层处理。
-///
-public enum Compounding
-{
- /// 单利:因子 = 1 + r·t。
- Simple,
- /// 复利(理想化闭式):因子 = (1 + r/f)^(f·t),f 为年复利频次。
- Compounded,
- /// 连续复利:因子 = e^(r·t)。
- Continuous
-}
-
-///
-/// 不可变利率值对象。构造即完整,无副作用。
-///
-public readonly struct InterestRate
-{
- /// 年化利率 r。
- public decimal Rate { get; }
-
- /// 计息方式。
- public Compounding Compounding { get; }
-
- /// 年复利频次(仅 使用,其余忽略,默认 1)。
- public int Frequency { get; }
-
- public InterestRate(decimal rate, Compounding compounding, int frequency = 1)
- => (Rate, Compounding, Frequency) = (rate, compounding, frequency);
-
- ///
- /// 计息因子(输入年化时间 t)。
- ///
- /// - :decimal 精确运算。
- /// - / :闭式(double 计算后回 decimal),
- /// 满足通用定价;若要 decimal 精度的离散重置日复利,请用 Simple 按段计息并滚动本金。
- ///
- ///
- 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))
- };
-
- /// 利息 = 本金 × (因子 − 1)。
- public decimal Interest(decimal principal, decimal t)
- => principal * (CompoundFactor(t) - 1m);
-}
diff --git a/Framework/YLErp.Core/Interest/SwapInterest.cs b/Framework/YLErp.Core/Interest/SwapInterest.cs
deleted file mode 100644
index ddec05bf..00000000
--- a/Framework/YLErp.Core/Interest/SwapInterest.cs
+++ /dev/null
@@ -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 realizedInterest(legacy 字段 consumedInterest)
-// 待实现收益 Unrealized 预付金模式下的待实现收益余额
-// 计息基数 principal principal / dynomicPrincipal
-// 年化天数 annualDays tradeExtend.ExtendObj.AnnualDays
-//
-// 入参一律沿用既有代码的字段名,调用点两边读起来同名,不产生心智翻译成本。
-// 出参改用自描述名(Accrued / AccruedToday),因为 "Td" 对新读者是黑话。
-// ─────────────────────────────────────────────────────────────────────────────
-
-///
-/// 计息区间边界(算头 / 算尾)。
-/// 用具名值取代两个相邻 bool,物理上杜绝 calcFirst / calcLast 传反这一类历史缺陷。
-///
-public readonly struct AccrualBoundary
-{
- /// 算头:含 startDate。
- public bool IncludeStart { get; }
-
- /// 算尾:含 endDate。
- public bool IncludeEnd { get; }
-
- private AccrualBoundary(bool includeStart, bool includeEnd)
- => (IncludeStart, IncludeEnd) = (includeStart, includeEnd);
-
- /// 算头算尾 [start, end]。
- public static readonly AccrualBoundary Both = new(true, true);
-
- /// 算头不算尾 [start, end)。
- public static readonly AccrualBoundary StartOnly = new(true, false);
-
- /// 不算头算尾 (start, end]。
- public static readonly AccrualBoundary EndOnly = new(false, true);
-
- /// 不算头不算尾 (start, end)。
- public static readonly AccrualBoundary None = new(false, false);
-
- /// 由既有 calcFirst / calcLast 布尔对构造,供旧调用方渐进迁移。
- public static AccrualBoundary Of(bool includeStart, bool includeEnd) => new(includeStart, includeEnd);
-
- public override string ToString()
- => $"{(IncludeStart ? "算头" : "不算头")}{(IncludeEnd ? "算尾" : "不算尾")}";
-}
-
-///
-/// 计息结果。Accrued → 记账字段 InterestAmount / InterestProfitSum;AccruedToday → TdInterestAmount。
-///
-public readonly struct InterestResult
-{
- /// 区间累计应计利息。
- public decimal Accrued { get; }
-
- /// 末日(当日)应计利息。
- 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}";
-}
-
-///
-/// 收益互换(TRS)利息腿计算——纯函数。
-///
-/// 层级关系:计息数学(单利/复利/连续复利)是通用金融原语,已抽到
-/// (YLErp.Core.Interest,与互换无关,谁都能用)。
-/// 本类只负责 TRS 特有的会计态:每日先舍入再乘天数的对账口径、平仓缩放、
-/// 跨日滚动本金、预付金/授信模式——这些不是"利率数学",不应塞进通用原语。
-///
-/// 设计约束:
-/// 1. 无副作用——不读写 flowEvent、不取利率、不连库、不碰任何共享可变状态;
-/// 2. 同 input → 同 output,结果仅通过返回值流出;
-/// 3. 正交轴(算头算尾 / 单利复利 / 平仓 / 待实现收益)各自独立,互不耦合;
-/// 4. 调用方负责「取利率 + 构造日期区间 + 落库」,本类只算账。
-/// 由此,corp action 调整价格 / 数量时只需把新的 principal 与 rate 喂入,计息逻辑一行不动。
-///
-/// 领域口径:本系统利息腿是单边融资腿,任一时点只有一个生效利率(见 SwapDealService 的
-/// floateRate 单一入参),不存在 IRS 那种 fixedRate − floatingRate 轧差;
-/// 权益腿盈亏与平仓费用属三腿汇总层,不在本类职责内。
-///
-/// TRS 的"复利"是离散重置日复利:按重置日切段,每段用
-/// 计息、段末把利息滚入本金——本质就是单利按段叠加,decimal 精度无损,无需 Pow/Exp
-/// (见 )。所以本类不另立复利方法,计息只有一种,区别在于"是否滚动本金"。
-///
-/// 为何不复用 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 反向依赖定价库。
-///
-public static class SwapInterest
-{
- /// 默认舍入精度位数(历史值;生产融资腿与保证金腿均用 FundingLegPrecision=12)。
- public const int Precision = 11;
-
- /// 资金腿与保证金腿的生产计息精度(落库/对账均以 12 位为准)。
- /// 提升至公共常量,消除 SwapDealService 与 SimpleInterestAccrual 的重复定义。
- public const int FundingLegPrecision = 12;
-
- /// 年化天数常量(合约字段存的是 int,故不用 enum)。
- public const int Act365 = 365;
-
- public const int Act360 = 360;
-
- /// 应计天数。边界规则由日期区间表达,计息函数内不再出现 flag 分支。
- 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;
- }
-
- /// 把 TRS 年化利率收敛为通用利率原语。
- /// TRS 计息按段均为单利——离散重置日复利靠"段末把利息滚入本金"实现,不引入 Compounded 闭式。
- public static InterestRate ToInterestRate(decimal annualRate)
- => new(annualRate, Compounding.Simple);
-
- /// 单利:计息基数固定,每日利息相同,无逐日循环。
- 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);
- }
-
- ///
- /// 离散重置日复利(compounded-in-arrears):按重置日切段,段间把累计利息并入计息基数(滚动本金)。
- /// 每段计息即 得到的 (无逐日循环);
- /// 重置日是唯一并本金的地方。复利与单利只有"是否滚动本金"这一个区别。
- ///
- /// 此模型即 OIS / SOFR / FR007 的 compounded-in-arrears:每个子区间取一次定盘 rᵢ、增长因子
- /// 1 + rᵢ·yfᵢ,段末把 accrued 折进下一期本金——比闭式
- /// 更贴合 FR007 约定且 decimal 无损。注意:它不是 InterestRate 的 Compounded 闭式分支(TRS 下该分支为死路径)。
- ///
- /// 每段可有独立利率(FR007 浮动逐段不同),由适配器按段取定盘后封装为
- /// 传入——取价永远在编排层,原语只吃一个数(与 QuantLib/Strata 同范)。
- /// 必须含一条 ResetDate ≤ startDate 的起始利率。
- ///
- /// trace:经 发射 Start / ResetBefore·ResetAfter(利率切换时) /
- /// Rollover(段末并本金) / End,完整记录"重置日前后、利率切换、本金增加前后"。纯函数保持无日志依赖。
- ///
- /// 重置日 → 该段生效利率(段起点 = 重置日)。
- 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;
- }
-
- ///
- /// 固定利率复利便捷重载(每段同一 rate),向后兼容旧调用方。
- /// 内部把 resetDates 展平为"每段同率"的 schedule 后委托主方法。
- ///
- public static InterestResult AccrueCompoundInArrears(
- AccrualContext ctx,
- decimal principal,
- decimal rate,
- DateTime startDate,
- DateTime endDate,
- AccrualBoundary boundary,
- IReadOnlyList? 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);
- }
-
- ///
- /// 平仓(Unwind)缩放——全仓唯一缩放点,物理上杜绝 unwindPercent 被重复相乘。
- /// 全平即 unwindPercent = 1,不另设方法。
- ///
- /// 已实现 / 未实现边界:传入的 是平仓前仍「未实现(unrealized)」的
- /// 累计应计利息;本方法按比例缩放后返回「平仓后剩余未实现」部分,并扣除历史累计「已实现(realized)」
- /// 的 。被平仓比例 unwindPercent 对应的那一份 accrued,
- /// 即在此刻「实现(realized)」,由调用方记入 realizedInterest。
- ///
- /// 平仓前累计应计利息(未实现)。
- ///
- /// 平仓比例(0~1,实为 ratio 非百分数)。
- /// 对应既有字段 closePercent;分母口径必须与传入 所依据的持仓数量一致——
- /// 是「本次计算依据的持仓」而非「初始建仓」,历史缺陷正来自这个歧义。
- ///
- /// 已实现利息累计(legacy 字段 consumedInterest):历史各次 unwind 已确认、应从剩余未实现中扣除的部分。
- /// 舍入精度。⚠️ 默认 11(Precision),资金腿务必显式传 =12。
- 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));
- }
-
- /// 待实现收益余额滚动(预付金 / 授信模式)。
- /// 上期待实现收益余额。
- /// 本期新增。
- /// 本期 unwind 应扣减(即本期实现的份额)。
- public static decimal AccrueUnrealized(
- decimal openingUnrealized,
- decimal todayIncome,
- decimal unwindDeduction,
- int precision = Precision)
- => Round(openingUnrealized + todayIncome - unwindDeduction, precision);
-
- /// 统一舍入:MidpointRounding.AwayFromZero。所有计息路径收口到此处,避免散落的 Math.Round 不一致。
- public static decimal Round(decimal value, int precision)
- => Math.Round(value, precision, MidpointRounding.AwayFromZero);
-}
diff --git a/UnitTestProject/Modules/EodModule/DividendBasketQueryTranslationTest.cs b/UnitTestProject/Modules/EodModule/DividendBasketQueryTranslationTest.cs
new file mode 100644
index 00000000..babd8e9e
--- /dev/null
+++ b/UnitTestProject/Modules/EodModule/DividendBasketQueryTranslationTest.cs
@@ -0,0 +1,68 @@
+using System;
+using System.IO;
+
+namespace YLErp.Modules.EodModule
+{
+ [TestClass]
+ public class DividendBasketQueryTranslationTest
+ {
+ [TestMethod]
+ public void DividendBasketQueriesUseEfTranslatableCommodityCondition()
+ {
+ var source = ReadDividendServiceSource();
+ var addDividendQuery = ExtractQuery(
+ source,
+ "var basketList =",
+ "IEnumerable priceList = null;");
+ var executeStatusQuery = ExtractQuery(
+ source,
+ "var umList = DataCacheProvider.GetUnderlyingDataSource().AsQueryable(",
+ ").Select(O => O.UnderlyingCode).ToArray();");
+
+ AssertQueryUsesCommodityCondition(addDividendQuery, "AddDividendInfos");
+ AssertQueryUsesCommodityCondition(executeStatusQuery, "checkDividendInfoExecuteStatus");
+ }
+
+ private static void AssertQueryUsesCommodityCondition(string query, string methodName)
+ {
+ Assert.IsFalse(
+ query.Contains("IsBasket()", StringComparison.Ordinal),
+ $"{methodName} must not put IsBasket() in an IQueryable predicate.");
+ Assert.IsTrue(
+ query.Contains("O.CommodityCode == \"篮子标的\"", StringComparison.Ordinal),
+ $"{methodName} must filter baskets with the EF-translatable CommodityCode condition.");
+ }
+
+ private static string ExtractQuery(string source, string startMarker, string endMarker)
+ {
+ var start = source.IndexOf(startMarker, StringComparison.Ordinal);
+ Assert.IsTrue(start >= 0, $"Could not find query marker: {startMarker}");
+ var end = source.IndexOf(endMarker, start + startMarker.Length, StringComparison.Ordinal);
+ Assert.IsTrue(end >= 0, $"Could not find query end marker: {endMarker}");
+ return source.Substring(start, end + endMarker.Length - start);
+ }
+
+ private static string ReadDividendServiceSource()
+ {
+ var directory = new DirectoryInfo(AppContext.BaseDirectory);
+ while (directory != null)
+ {
+ var path = Path.Combine(
+ directory.FullName,
+ "YLErpDAL",
+ "Modules",
+ "TradeModule",
+ "DealModule",
+ "DividendService.cs");
+ if (File.Exists(path))
+ {
+ return File.ReadAllText(path);
+ }
+ directory = directory.Parent;
+ }
+
+ Assert.Fail("Could not locate DividendService.cs from the test output directory.");
+ return string.Empty;
+ }
+ }
+}
diff --git a/UnitTestProject/Modules/EodModule/EodSettlementTaskTest.cs b/UnitTestProject/Modules/EodModule/EodSettlementTaskTest.cs
index aaec3654..95b4d343 100644
--- a/UnitTestProject/Modules/EodModule/EodSettlementTaskTest.cs
+++ b/UnitTestProject/Modules/EodModule/EodSettlementTaskTest.cs
@@ -123,7 +123,7 @@ namespace YLErp.Modules.EodModule
{
UnderlyingCode = "002043.SZ",
ExDividendDate = new DateTime(2020, 7, 6),
- GiveCashAmount = 2.5,
+ GiveCashAmount = 2.5m,
GiveShareAmount = 0,
RationedSharesAmount = 0,
RationedSharesPrice = 0,
@@ -135,7 +135,7 @@ namespace YLErp.Modules.EodModule
{
UnderlyingCode = "600406.SH",
ExDividendDate = new DateTime(2020, 7, 8),
- GiveCashAmount = 2.9,
+ GiveCashAmount = 2.9m,
GiveShareAmount = 0,
RationedSharesAmount = 0,
RationedSharesPrice = 0,
@@ -147,7 +147,7 @@ namespace YLErp.Modules.EodModule
{
UnderlyingCode = "600406.SH",
ExDividendDate = new DateTime(2020, 7, 8),
- GiveCashAmount = 2.9,
+ GiveCashAmount = 2.9m,
GiveShareAmount = 0,
RationedSharesAmount = 0,
RationedSharesPrice = 0,
@@ -159,7 +159,7 @@ namespace YLErp.Modules.EodModule
{
UnderlyingCode = "601021.SH",
ExDividendDate = new DateTime(2020, 7, 8),
- GiveCashAmount = 2.0006,
+ GiveCashAmount = 2.0006m,
GiveShareAmount = 0,
RationedSharesAmount = 0,
RationedSharesPrice = 0,
@@ -171,7 +171,7 @@ namespace YLErp.Modules.EodModule
{
UnderlyingCode = "300001.SZ",
ExDividendDate = new DateTime(2020, 7, 13),
- GiveCashAmount = 0.2,
+ GiveCashAmount = 0.2m,
GiveShareAmount = 0,
RationedSharesAmount = 0,
RationedSharesPrice = 0,
diff --git a/UnitTestProject/Modules/SwapModule/Accrual/CompoundEodShadowTest.cs b/UnitTestProject/Modules/SwapModule/Accrual/CompoundEodShadowTest.cs
index 625d2254..6a1d8984 100644
--- a/UnitTestProject/Modules/SwapModule/Accrual/CompoundEodShadowTest.cs
+++ b/UnitTestProject/Modules/SwapModule/Accrual/CompoundEodShadowTest.cs
@@ -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
{
diff --git a/UnitTestProject/Modules/SwapModule/Accrual/CompoundPeriodShadowTest.cs b/UnitTestProject/Modules/SwapModule/Accrual/CompoundPeriodShadowTest.cs
index 305fe802..2f6aed6e 100644
--- a/UnitTestProject/Modules/SwapModule/Accrual/CompoundPeriodShadowTest.cs
+++ b/UnitTestProject/Modules/SwapModule/Accrual/CompoundPeriodShadowTest.cs
@@ -1,6 +1,5 @@
using Newtonsoft.Json;
using YLErp;
-using YLErp.Derivatives.Interest;
using YLErp.Modules.SwapModule;
using YLErp.Modules.SwapModule.Accrual;
diff --git a/UnitTestProject/Modules/SwapModule/Accrual/SwapInterest_CompoundInArrears_RolloverTimingTests.cs b/UnitTestProject/Modules/SwapModule/Accrual/SwapInterest_CompoundInArrears_RolloverTimingTests.cs
deleted file mode 100644
index 816be349..00000000
--- a/UnitTestProject/Modules/SwapModule/Accrual/SwapInterest_CompoundInArrears_RolloverTimingTests.cs
+++ /dev/null
@@ -1,144 +0,0 @@
-using System.Text.RegularExpressions;
-using YLErp.Core.Interest;
-using YLErp.Derivatives.Interest;
-
-namespace UnitTestProject.Modules.SwapModule.Accrual
-{
- ///
- /// 聚焦测试:AccrueCompoundInArrears 的「本金滚存时机」必须符合确认书规定。
- /// 核心不变量:本金只允许在重置日/段末滚入利息,非重置日不得资本化。
- ///
- /// 与原草稿的关键区别:本版直接通过 AccrualTrace 断言不变量。
- /// 真实实现在每次段末会发出 ROLLOVER 事件并记录 newBasis(见 SwapInterest.cs:215 /
- /// AccrualTrace.Rollover),因此「非重置日是否发生资本化」是可程序化验证的,
- /// 无需仅靠总利息回归来保护(原草稿的自我怀疑"无法断言计息基数"已不成立)。
- ///
- [TestClass]
- public class SwapInterest_CompoundInArrears_RolloverTimingTests
- {
- private const int FundingLegPrecision = 12;
- private const int AnnualDays = 365;
-
- ///
- /// 场景: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段计息基数恒为原始本金、段内未提前资本化。
- ///
- [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 { 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,
- "重置日滚入的本金应为原始本金 + 前段利息,证明段内未提前资本化");
- }
-
- ///
- /// 极端场景:startDate = endDate(1天),无重置日。
- /// 期望利息 = 本金 × 日利率 = 1,000,000 × 0.0365/365 = 100。
- /// 且唯一 ROLLOVER 必须落在窗口终点(=startDate),无任何内部重置滚存。
- ///
- [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());
- }
-
- ///
- /// 段内无重置日:验证整段等同于单利,且不发生任何内部滚存。
- /// 6天窗口(01-01..01-06)在7天重置周期内,Both 边界含两端 = 6 个计息日,
- /// 期望利息 = 本金 × 日利率 × 6 = 600。
- ///
- [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);
- }
- }
-}
diff --git a/UnitTestProject/Modules/SwapModule/AutoUnwindMultiPartialDividendTest.cs b/UnitTestProject/Modules/SwapModule/AutoUnwindMultiPartialDividendTest.cs
new file mode 100644
index 00000000..ea5035a9
--- /dev/null
+++ b/UnitTestProject/Modules/SwapModule/AutoUnwindMultiPartialDividendTest.cs
@@ -0,0 +1,69 @@
+using YLErp.Modules.EodModule;
+
+namespace YLErp.Modules.SwapModule
+{
+ ///
+ /// 自动平仓路径(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。)
+ ///
+ [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 QueryBondPayments(string underlyingCode)
+ => new List
+ {
+ 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),多算方是手动路径而非自动路径");
+ }
+ }
+}
diff --git a/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs b/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs
index 85052b9c..e42c8de3 100644
--- a/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs
+++ b/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs
@@ -145,9 +145,9 @@ namespace YLErp.Modules.SwapModule
protected override List CalcSwapInterests(
trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate,
List eodPositions, List 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 closeList = null)
{
return positions.Select(p => new swap_flow_event
diff --git a/UnitTestProject/Modules/SwapModule/ConsumedInterestScenarioTest.cs b/UnitTestProject/Modules/SwapModule/ConsumedInterestScenarioTest.cs
index 866b4f3a..457de583 100644
--- a/UnitTestProject/Modules/SwapModule/ConsumedInterestScenarioTest.cs
+++ b/UnitTestProject/Modules/SwapModule/ConsumedInterestScenarioTest.cs
@@ -125,8 +125,8 @@ namespace YLErp.Modules.SwapModule
var position = CreateCompoundPosition();
var interests = service.GetInterests(td, td.trade_extend, unwindDate, unwindDate,
new List(), new List { 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];
@@ -356,8 +356,8 @@ namespace YLErp.Modules.SwapModule
var interests = ServiceByDate().GetInterests(td, td.trade_extend, unwindDate, unwindDate,
new List(), new List { 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);
@@ -420,8 +420,8 @@ namespace YLErp.Modules.SwapModule
var result = service.GetInterests(td, td.trade_extend, resetDate, resetDate,
new List { preEod }, new List { 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;
@@ -466,8 +466,8 @@ namespace YLErp.Modules.SwapModule
var result = service.GetInterests(td, td.trade_extend, unwindDate, unwindDate,
new List { preEod }, new List { 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,
diff --git a/UnitTestProject/Modules/SwapModule/DealInterestsGoldenReplayTest.cs b/UnitTestProject/Modules/SwapModule/DealInterestsGoldenReplayTest.cs
index dafec3db..cc86d5be 100644
--- a/UnitTestProject/Modules/SwapModule/DealInterestsGoldenReplayTest.cs
+++ b/UnitTestProject/Modules/SwapModule/DealInterestsGoldenReplayTest.cs
@@ -47,7 +47,7 @@ namespace YLErp.Modules.SwapModule
{
DealInterests(interestList, eodPositions, new List(),
settleDate, td, new List(), new List(), null,
- posiLongNational, 0m, 0m, grossPrice, orginPv);
+ posiLongNational + 0m, 0m, grossPrice, orginPv);
}
}
diff --git a/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs b/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs
index 07f9b289..079a6ba7 100644
--- a/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs
+++ b/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs
@@ -63,10 +63,10 @@ namespace YLErp.Modules.SwapModule
trade td, trade_extend tradeExtend,
DateTime valueDate, DateTime unwindDate,
List eodPositions, List 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 closeList = null)
{
@@ -77,9 +77,9 @@ namespace YLErp.Modules.SwapModule
}
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 +99,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 +110,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 +121,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 +134,7 @@ namespace YLErp.Modules.SwapModule
{
DealInterests(interestList, eodPositions, new List(),
settleDate, td, flowEvents, new List(), null,
- posiLongNational, posiShortNational, closeNational, grossPrice, orginPv);
+ posiLongNational + posiShortNational, closeNational, grossPrice, orginPv);
}
}
@@ -1229,8 +1229,8 @@ namespace YLErp.Modules.SwapModule
var result = new SwapDealService(service).GetInterests(
td, td.trade_extend, closeDate, closeDate,
new List { previousEod }, new List { 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 +1268,8 @@ namespace YLErp.Modules.SwapModule
var firstCloseInterest = dealService.GetInterests(
td, td.trade_extend, firstCloseDate, firstCloseDate,
new List(), new List { 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 +1286,14 @@ namespace YLErp.Modules.SwapModule
var replayAtPreviousEod = dealService.GetInterests(
td, td.trade_extend, firstCloseDate, firstCloseDate,
new List(), new List { 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(), new List { 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 +1306,8 @@ namespace YLErp.Modules.SwapModule
var finalCloseInterest = dealService.GetInterests(
td, td.trade_extend, finalCloseDate, finalCloseDate,
new List { firstCloseEod }, new List { 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 +1434,8 @@ namespace YLErp.Modules.SwapModule
var partial = service.GetInterests(
td, td.trade_extend, partialCloseDate, partialCloseDate,
new List { previousEod }, new List { 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 +1444,8 @@ namespace YLErp.Modules.SwapModule
var final = service.GetInterests(
td, td.trade_extend, maturityDate, maturityDate,
new List(), new List { 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 +1575,8 @@ namespace YLErp.Modules.SwapModule
var intermediateInterest = dealService.GetInterests(
td, td.trade_extend, intermediateDate, intermediateDate,
new List { partialEod }, new List { 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 +1706,8 @@ namespace YLErp.Modules.SwapModule
var intermediateInterest = dealService.GetInterests(
td, td.trade_extend, intermediateDate, intermediateDate,
new List { partialEod }, new List { 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}");
@@ -1739,8 +1739,8 @@ namespace YLErp.Modules.SwapModule
var finalInterest = dealService.GetInterests(
td, td.trade_extend, finalCloseDate, finalCloseDate,
new List { intermediateEod }, new List { 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 +1829,8 @@ namespace YLErp.Modules.SwapModule
var result = dealService.GetInterests(
td, td.trade_extend, finalCloseDate, finalCloseDate,
new List { previousEod }, new List { 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 +1927,8 @@ namespace YLErp.Modules.SwapModule
var partialInterest = dealService.GetInterests(
td, td.trade_extend, partialCloseDate, partialCloseDate,
new List { preCloseEod }, new List { 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 +1976,8 @@ namespace YLErp.Modules.SwapModule
var finalInterest = dealService.GetInterests(
td, td.trade_extend, finalCloseDate, finalCloseDate,
new List { finalPreEod }, new List { 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 列");
diff --git a/UnitTestProject/Modules/SwapModule/DividendEodNoDoubleCountTest.cs b/UnitTestProject/Modules/SwapModule/DividendEodNoDoubleCountTest.cs
index 36e8484b..ef5a8622 100644
--- a/UnitTestProject/Modules/SwapModule/DividendEodNoDoubleCountTest.cs
+++ b/UnitTestProject/Modules/SwapModule/DividendEodNoDoubleCountTest.cs
@@ -1,5 +1,7 @@
+using YLErp;
using YLErp.DBModels;
using YLErp.DBModels.Enums;
+using YLErp.Modules.EodModule;
namespace YLErp.Modules.SwapModule
{
@@ -17,9 +19,21 @@ namespace YLErp.Modules.SwapModule
private const int SwapTradeId = 9200;
private const long PositionId = 9201;
private const decimal InitialQty = 1000m;
- private const decimal DailyRatePerUnit = 0.01m; // 每单位每天 0.01,便于手算
+ 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 BondPayments() => new List
+ {
+ // 登记日 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
/// SwapDealService stub:暴露 GetPreEodDividendSum,注入 EOD 数据(不连库)。
@@ -37,14 +51,25 @@ namespace YLErp.Modules.SwapModule
=> _eodPositions.FirstOrDefault(x => x.SwapTradeId == tradeId && x.PositionId == positionId && x.ValueDate == valueDate);
}
- /// SwapEodPositionService stub:暴露 UpdateEodPosition/CopyEodPosition + 线性 CalcBondPayment。
+ /// 真实 BondPaymentService(reg_date 口径)seam:仅注入内存 BondPayment 数据,票息计算走生产 GetBondPayments+CalcPayment。
+ private sealed class RealBondPaymentService : BondPaymentService
+ {
+ private readonly List _data;
+ public RealBondPaymentService(List data, OptUserInfo userInfo) : base(userInfo) { _data = data; }
+ protected override IQueryable QueryBondPayments(string underlyingCode)
+ => _data.Where(x => x.underlyingCode == underlyingCode).AsQueryable();
+ }
+
+ /// SwapEodPositionService stub:暴露 UpdateEodPosition/CopyEodPosition;CalcBondPayment 桥接真实 BondPaymentService(reg_date 口径,不再用线性假公式)。
private sealed class EodSvcStub : TestableSwapEodPositionService
{
- public EodSvcStub() : base(nameof(DividendEodNoDoubleCountTest)) { }
+ private readonly List _bondPayments;
+ public EodSvcStub(List bondPayments) : base(nameof(DividendEodNoDoubleCountTest)) { _bondPayments = bondPayments; }
protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio)
{
- int days = Math.Max(0, (int)(toDate - fromDate).TotalDays);
- return DailyRatePerUnit * days * qty * shortRatio * 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 };
@@ -122,15 +147,15 @@ namespace YLErp.Modules.SwapModule
/// 盘中收益互换:DividendIn 由 GetPreEodDividendSum 真实算(读 T-1 EOD)→ 保存 → EOD。
/// 验证:不重复(EOD TdCloseDividend 扣 DividendIn)+ 不丢失(当日新计进 PosiDividendSum)+ 守恒。
///
- /// 序列(StartDate=1/5,每日 0.01×1000=10):
- /// D1=1/6 无事件 Copy:PosiDividendSum = 0 + 10 = 10
- /// D2=1/7 盘中互换:GetPreEodDividendSum(读 D1) → DividendIn=10;保存 swap_event;EOD:新计 10 - 实现 10 → PosiDividendSum=10
+ /// 序列(StartDate=1/5,reg_date 1/6、1/7 各一期,每期 = qty×per100/100 = 10):
+ /// D1=1/6 无事件 Copy:窗口(1/5,1/6] 命中 reg_date 1/6 → TdPosiDividend=10,PosiDividendSum=10
+ /// D2=1/7 盘中互换:GetPreEodDividendSum(读 D1) → DividendIn=10;保存 swap_event;EOD 窗口(1/6,1/7] 命中 reg_date 1/7 → 新计 10 - 实现 10 → PosiDividendSum=10
/// 守恒:全程新计(10+10) - 全程实现(10) = 末尾 PosiDividendSum(10)
///
[TestMethod]
public void 盘中收益互换_DividendIn真实算_保存后EOD_不重复不丢失()
{
- var eodSvc = new EodSvcStub();
+ var eodSvc = new EodSvcStub(BondPayments());
var td = CreateTrade();
var position = CreatePosition();
var initialEod = CreateInitialEod();
@@ -174,15 +199,15 @@ namespace YLErp.Modules.SwapModule
/// 登记日当日全平(盘中平仓→收盘持仓 0):按各交易场所规定,不享有登记日当日的分红
/// (股权登记日以收盘在册为准;盘中全平→收盘不在册)。验证系统行为符合该规定。
///
- /// 系统行为:①盘中 DividendIn=GetPreEodDividendSum 读 T-1(=T日前待实现,正确不含登记日当日);
- /// ②EOD 全平 PosiQuantity=0 → TdPosiDividend=0(不计提登记日当日)+ PosiDividendSum=0。
- /// 即登记日当日分红既不进 DividendIn、也不进 PosiDividendSum = 正确不享有。
- /// 应得 = T日前待实现累计(r1.PosiDividendSum);实拿 = DividendIn → 相等,无丢失(不享有当日是正确的)。
+ /// 系统行为:①盘中 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 → 相等,无丢失(不享有当日是正确的)。
///
[TestMethod]
public void 登记日全平_按交易场所规定不享有当日分红()
{
- var eodSvc = new EodSvcStub();
+ var eodSvc = new EodSvcStub(BondPayments());
var td = CreateTrade();
var position = CreatePosition();
var initialEod = CreateInitialEod();
diff --git a/UnitTestProject/Modules/SwapModule/GLMS20260703CloseInterestTest.cs b/UnitTestProject/Modules/SwapModule/GLMS20260703CloseInterestTest.cs
index bb29f270..398c8b3a 100644
--- a/UnitTestProject/Modules/SwapModule/GLMS20260703CloseInterestTest.cs
+++ b/UnitTestProject/Modules/SwapModule/GLMS20260703CloseInterestTest.cs
@@ -209,10 +209,10 @@ namespace YLErp.Modules.SwapModule
CloseDate, CloseDate, // valueDate / unwindDate
new List(), // eodPositions(空)
new List { 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];
diff --git a/UnitTestProject/Modules/SwapModule/GetInterestsEntrySemanticsTest.cs b/UnitTestProject/Modules/SwapModule/GetInterestsEntrySemanticsTest.cs
new file mode 100644
index 00000000..b3d1ed58
--- /dev/null
+++ b/UnitTestProject/Modules/SwapModule/GetInterestsEntrySemanticsTest.cs
@@ -0,0 +1,307 @@
+using Newtonsoft.Json;
+using YLErp.DBModels.Enums;
+
+namespace YLErp.Modules.SwapModule
+{
+ ///
+ /// GetInterests 双显式入口语义字符化测试(Step3"特判降级"的前置钉子)。
+ ///
+ /// 背景:GetIntradayUnwindInterests(盘中:平仓前剩余×实际比例)与
+ /// CalcEodPostCloseSettleInterests(EOD平仓后收盘:平仓后剩余×恒1)是同一经济事件
+ /// (部分平仓)的两套传参语义,靠 GetInterests 内 mode2 无条件覆盖 / mode9 全平兜底粘合。
+ /// 本测试钉死当前行为,使后续特判降级/语义重构有回归网:
+ /// ① 复利×mode2:closePrincipal(特判产物)是 CalcDailyCompoundInterest 的重放本金——
+ /// 两入口 closePosiNotionalValue 均为实际平掉额 → InterestAmount 必须相等;
+ /// ② 单利×mode2:CalcDailySimpleInterest 消费的是 posiPrincipal×closePercent——
+ /// 盘中(平仓前×比例) vs EOD(剩余×1) 数值口径可能不同,本测试【记录现状】(见各断言注释);
+ /// ③ mode9 全平(posi=0):兜底覆盖生效,结息额非零。
+ ///
+ /// 数据基建复用 GetInterestsUnitTest_T0 的构建器口径(T+0,4/27起息,"11"算头算尾)。
+ ///
+ [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 _floatRates;
+ public StubSwapDealService(OptUserInfo optUser, IReadOnlyDictionary 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;
+ }
+ }
+
+ private static SwapDealService CreateService() => new StubSwapDealService(
+ new OptUserInfo(0, nameof(GetInterestsEntrySemanticsTest), OptUserFrom.UnitTest),
+ new Dictionary
+ {
+ [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
+ {
+ 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
+
+ ///
+ /// 复利×mode2×部分平仓30%:钉住两入口【当前】结息口径(2026-08-14 实测,字符化)。
+ ///
+ /// 实测(closePrincipal 特判两边均=平掉额300,但消费路径不同):
+ /// 盘中 = 0.036164835616 —— CalcDailyCompoundInterest 以 closePosi(300) 全程重放 [4/27,4/30];
+ /// EOD = 0.059041913305 —— InitSwapDealInterest closePercent==1 分支:
+ /// preEod.InterestIncomeSum(0.05 全腿待实现) + amountAtEnd(0.036165) - amountAtPrevEod(0.027123)。
+ ///
+ /// ⚠️ 两值不等 = 已观察到的口径分歧(同一经济事件两种结息额),非断言失败项;
+ /// 待业务裁决哪个口径正确前,本测试锁死两值防意外漂移。裁决后改断言为"相等"或删除错方。
+ ///
+ [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 { preEod };
+ var positions = new List { 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));
+
+ var eodPostClose = 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, intraday.Count);
+ Assert.AreEqual(1, eodPostClose.Count);
+ Console.WriteLine($"[复利mode2] 盘中 InterestAmount={intraday[0].InterestAmount} / EOD={eodPostClose[0].InterestAmount}");
+
+ // 钉住两入口各自的当前值(容差 1e-9 级,防任何实现漂移)
+ Assert.AreEqual(0.036164835616m, intraday[0].InterestAmount, 0.000000001m,
+ "盘中口径:closePosi(平掉额300) 全程重放利息。此值变化=盘中复利口径漂移");
+ Assert.AreEqual(0.059041913305m, eodPostClose[0].InterestAmount, 0.000000001m,
+ "EOD口径:preEod待实现(0.05) + 平掉额末段增量(0.009042)。此值变化=EOD平仓后收盘复利口径漂移");
+ }
+
+ ///
+ /// 单利×mode2×部分平仓30%:记录两入口当前口径(快照×比例 vs 重放基数差异面)。
+ /// 单利消费 posiPrincipal×closePercent:盘中 1000×0.3 vs EOD 700×1 —— 若两值不等,
+ /// 这是当前系统的已知口径差异面(非断言失败项),数值以 Console 留档,供特判降级时对照。
+ ///
+ [TestMethod]
+ public void 单利_mode2_部分平仓_双入口口径留档()
+ {
+ var td = CreateTrade();
+ var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.单利);
+ var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose);
+ var eodPositions = new List { preEod };
+ var positions = new List { 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));
+
+ var eodPostClose = 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, 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.IsTrue(intraday[0].InterestAmount != 0m, "盘中单利结息额不应为0");
+ Assert.IsTrue(eodPostClose[0].InterestAmount != 0m, "EOD单利结息额不应为0");
+ }
+
+ ///
+ /// mode9 全平(EOD,posi=0):特判兜底触发 closePrincipal=closePosiNotionalValue(实际平掉额),
+ /// 结息额非零。若兜底被删,closePrincipal=0×1=0 → 结息额归零 → 本断言红。
+ ///
+ [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 { preEod };
+ var positions = new List { position };
+
+ // 全平:剩余=0,平掉=全部 1000
+ var result = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
+ eodPositions, positions, 0m, 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 全平时 posi=0,兜底必须以 closePosiNotionalValue(实际平掉额) 为结息本金,结息额非零(兜底钉子)");
+ }
+
+ #region CalcEodPostCloseSettleInterests 接缝映射钉子
+
+ ///
+ /// 参数捕获 stub:拦下 CalcSwapInterests 的全部实参,不触库、不真算。
+ ///
+ private sealed class CalcSwapInterestsCapture : TestableSwapEodPositionService
+ {
+ public CalcSwapInterestsCapture() : base(nameof(GetInterestsEntrySemanticsTest)) { }
+
+ public List 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 CalcSwapInterests(
+ trade td, trade_extend tradeExtend,
+ DateTime valueDate, DateTime unwindDate,
+ List eodPositions, List positions,
+ decimal posiNotionalValue,
+ decimal closePosiNotionalValue, decimal closePrecent,
+ int eventType, bool tdClose,
+ decimal orginPv,
+ bool add = false, bool settment = true, bool newCalcLast = false,
+ List 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();
+ }
+
+ public List ExposedEodPostCloseSettle(InterestCalcRequest req)
+ => CalcEodPostCloseSettleInterests(req);
+ }
+
+ ///
+ /// 钉死 InterestCalcRequest.EodPostCloseSettle 工厂 → CalcEodPostCloseSettleInterests →
+ /// CalcSwapInterests 的位置参数转发契约。这段转发是位置传参最易错位的环节
+ /// (posiNotionalValue/closePosiNotionalValue/orginPv 三个相邻同型 decimal,编译器不查错位),
+ /// 任何映射改动(含将来删 needPrice/grossPrice 死参数)都必须保持本断言绿。
+ ///
+ [TestMethod]
+ public void EOD平仓后收盘_工厂到接缝_参数映射钉死()
+ {
+ var td = CreateTrade();
+ var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.单利);
+ var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose);
+ var positions = new List { position };
+
+ var stub = new CalcSwapInterestsCapture();
+ var req = InterestCalcRequest.EodPostCloseSettle(
+ td, td.trade_extend, UnwindDate, UnwindDate,
+ new List { 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
+ }
+}
diff --git a/UnitTestProject/Modules/SwapModule/GetInterestsUnitTest_T0.cs b/UnitTestProject/Modules/SwapModule/GetInterestsUnitTest_T0.cs
index cb664f25..7a590d08 100644
--- a/UnitTestProject/Modules/SwapModule/GetInterestsUnitTest_T0.cs
+++ b/UnitTestProject/Modules/SwapModule/GetInterestsUnitTest_T0.cs
@@ -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 { 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 { 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 { 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 { 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];
}
diff --git a/UnitTestProject/Modules/SwapModule/GetInterestsUnitTest_T1.cs b/UnitTestProject/Modules/SwapModule/GetInterestsUnitTest_T1.cs
index 4d074d8d..93a26203 100644
--- a/UnitTestProject/Modules/SwapModule/GetInterestsUnitTest_T1.cs
+++ b/UnitTestProject/Modules/SwapModule/GetInterestsUnitTest_T1.cs
@@ -322,9 +322,9 @@ namespace YLErp.Modules.SwapModule
valueDate, unwindDate,
eodPositions,
new List { 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 { 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 { 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 { 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 { 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 { 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 { 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];
diff --git a/UnitTestProject/Modules/SwapModule/Margin/MarginInterestGoldenReplayTest.cs b/UnitTestProject/Modules/SwapModule/Margin/MarginInterestGoldenReplayTest.cs
index bb9a65fa..e9816011 100644
--- a/UnitTestProject/Modules/SwapModule/Margin/MarginInterestGoldenReplayTest.cs
+++ b/UnitTestProject/Modules/SwapModule/Margin/MarginInterestGoldenReplayTest.cs
@@ -99,9 +99,9 @@ namespace UnitTestProject.Modules.SwapModule.Margin
{
oldList = svc.GetInterests(td, extend, valueDate, valueDate,
preEods, marginPositions,
- 0m, 0m, 0m, 0m, 1.0m,
- (int)SwapEventTypeEnum.自动互换, tdClose: false, needPrice: false,
- grossPrice: 0m, orginPv: 0m,
+ 0m, 0m, 1.0m,
+ (int)SwapEventTypeEnum.自动互换, tdClose: false,
+ orginPv: 0m,
add: false, settment: true, newCalcLast: false, closeList: null);
}
catch (Exception ex)
diff --git a/UnitTestProject/Modules/SwapModule/Margin/MarginInterestShadowTest.cs b/UnitTestProject/Modules/SwapModule/Margin/MarginInterestShadowTest.cs
index 34740fc2..2ff0f5ad 100644
--- a/UnitTestProject/Modules/SwapModule/Margin/MarginInterestShadowTest.cs
+++ b/UnitTestProject/Modules/SwapModule/Margin/MarginInterestShadowTest.cs
@@ -8,7 +8,6 @@ using YLErp.DBModels.Enums;
using YLErp.Modules.SwapModule;
using YLErp.Modules.SwapModule.Accrual;
using YLErp.Modules.SwapModule.Margin;
-using YLErp.Derivatives.Interest;
namespace UnitTestProject.Modules.SwapModule.Margin
{
diff --git a/UnitTestProject/Modules/SwapModule/MultiStepConservationTest.cs b/UnitTestProject/Modules/SwapModule/MultiStepConservationTest.cs
index 1dfb1d64..8ea6d60b 100644
--- a/UnitTestProject/Modules/SwapModule/MultiStepConservationTest.cs
+++ b/UnitTestProject/Modules/SwapModule/MultiStepConservationTest.cs
@@ -119,8 +119,8 @@ namespace YLErp.Modules.SwapModule
var position = CreateInterestPosition();
var interests = service.GetInterests(td, td.trade_extend, unwindDate, unwindDate,
new List(), new List { 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 { preEod }, new List { 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(), new List { 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(), new List { 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(), new List { 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(), new List { 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;
}
diff --git a/UnitTestProject/Modules/SwapModule/PrepaidPrincipalCloseTraceTest.cs b/UnitTestProject/Modules/SwapModule/PrepaidPrincipalCloseTraceTest.cs
index 45e60bef..37c50207 100644
--- a/UnitTestProject/Modules/SwapModule/PrepaidPrincipalCloseTraceTest.cs
+++ b/UnitTestProject/Modules/SwapModule/PrepaidPrincipalCloseTraceTest.cs
@@ -103,8 +103,8 @@ namespace YLErp.Modules.SwapModule
SwapCalcTrace.Reset();
var eod = new List { MakeEod(valueDate, PrepayRemaining, 0m) };
var fe = _svc.GetInterests(td, td.trade_extend, FullDate, FullDate, eod,
- new List { pos }, PrepayFix, PrepayFix, PrepayFix, PrepayFix, 1m,
- (int)SwapEventTypeEnum.平仓, false, false, 0, PrepayFix, false,
+ new List { pos }, PrepayFix, PrepayFix, 1m,
+ (int)SwapEventTypeEnum.平仓, false, PrepayFix, false,
settment: false, newCalcLast: calcLast, closeList: null)[0];
var trace = SwapCalcTrace.Dump();
Console.WriteLine(trace);
diff --git a/UnitTestProject/Modules/SwapModule/PrepaidPrincipalClosingChainTraceTest.cs b/UnitTestProject/Modules/SwapModule/PrepaidPrincipalClosingChainTraceTest.cs
index 39746577..ad3a7182 100644
--- a/UnitTestProject/Modules/SwapModule/PrepaidPrincipalClosingChainTraceTest.cs
+++ b/UnitTestProject/Modules/SwapModule/PrepaidPrincipalClosingChainTraceTest.cs
@@ -101,9 +101,9 @@ namespace YLErp.Modules.SwapModule
protected override List CalcSwapInterests(
trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate,
List eodPositions, List 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 closeList = null)
{
return positions.Select(p => new swap_flow_event
diff --git a/UnitTestProject/Modules/SwapModule/RegDateDividendEodE2ETest.cs b/UnitTestProject/Modules/SwapModule/RegDateDividendEodE2ETest.cs
new file mode 100644
index 00000000..38b58df9
--- /dev/null
+++ b/UnitTestProject/Modules/SwapModule/RegDateDividendEodE2ETest.cs
@@ -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
+{
+ ///
+ /// GLMS-20260105-0006 端到端补充:EOD 分红引擎的票息归属须按【债权登记日 reg_date】判定,
+ /// 而非支付日(pay_date)。此前 DividendEodNoDoubleCountTest.EodSvcStub 把 CalcBondPayment 覆写成
+ /// 线性公式(DailyRatePerUnit*days*qty),**绕开了 reg_date 口径**——即没有真正验证"引擎按登记日计提"。
+ ///
+ /// 本文件把 EOD stub 的 CalcBondPayment seam 重新桥接回【真实的 BondPaymentService(reg_date 口径)】,
+ /// 仅用内存 BondPayment 数据(不连库),使端到端流程(CopyEodPosition/UpdateEodPosition + GetPreEodDividendSum)
+ /// 真正跑生产日期逻辑:
+ /// ① EOD 引擎在登记日计提、支付日不计提(证明 reg_date 口径);
+ /// ② 登记日下一日(T+1)全平:经 GetPreEodDividendSum 读到登记日当日 EOD 分红(收盘在册→享有);
+ /// ③ 部分平仓 T+1:DividendIn 为全量(非按比例缩放),剩余 PosiDividendSum 归 0(记录当前生产行为)。
+ ///
+ [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 BondPayments()
+ => new List
+ {
+ 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 _data;
+ public RegDateBondPaymentService(List data, OptUserInfo userInfo) : base(userInfo) { _data = data; }
+ protected override IQueryable QueryBondPayments(string underlyingCode)
+ => _data.Where(x => x.underlyingCode == underlyingCode).AsQueryable();
+ }
+
+ #endregion
+
+ #region EOD stub(CalcBondPayment 桥接真实 BondPaymentService)
+
+ private sealed class RegDateEodStub : TestableSwapEodPositionService
+ {
+ private readonly List _bondPayments;
+ public RegDateEodStub(List 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 unwindEvents)
+ => UpdateEodPosition(swapPosition, eod, null, td, valueDate, preSettleDate, unwindEvents);
+ }
+
+ #endregion
+
+ #region Deal stub(GetPreEodDividendSum,注入 EOD 快照)
+
+ private sealed class DealSvcStub : SwapDealService
+ {
+ private readonly List _eodSwaps;
+ private readonly List _eodPositions;
+ public DealSvcStub(List eodSwaps, List eodPositions)
+ : base(OptUserInfo.UnitTestUser) { _eodSwaps = eodSwaps; _eodPositions = eodPositions; }
+ public decimal ExposeGetPreEodDividendSum(int tradeId, long positionId, DateTime dealDate)
+ => GetPreEodDividendSum(tradeId, positionId, dealDate);
+ protected override IQueryable 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
+
+ ///
+ /// 端到端证 reg_date 口径:EOD 引擎(CopyEodPosition)逐日计提时,
+ /// 仅在【债权登记日】产生分红,【支付日】不产生(即便支付日与登记日相差数日)。
+ /// 这是线性 stub 无法覆盖的——线性公式按"天数"算,永远无法区分登记日 vs 支付日。
+ ///
+ [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_date(4/3 不>4/3;4/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}(支付日不计提)");
+ }
+
+ ///
+ /// 用户场景「登记日下一日(T+1)全平」:T日(登记日)收盘在册→享有T日分红;
+ /// T+1盘中全平,GetPreEodDividendSum(T+1) 应读到 T日 EOD(含当日分红)= 36160,而非漏读为 0。
+ /// 验证端到端:EOD 引擎算出 T日分红 → 快照 → 手动/互换读取正确取到。
+ ///
+ [TestMethod]
+ public void 登记日下一日全平_经GetPreEodDividendSum读到登记日分红()
+ {
+ var eodSvc = new RegDateEodStub(BondPayments());
+ var td = CreateTrade();
+ var position = CreatePosition();
+ var initialEod = CreateInitialEod();
+
+ // T日=4/3(登记日)EOD:引擎算出分红 36160(reg_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 { new eod_swap { SwapTradeId = TradeId, ValueDate = RegDate } },
+ new List { 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 { 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}");
+ }
+
+ ///
+ /// 部分平仓 T+1:当前生产行为记录(非修复目标)。
+ /// T日(登记日)持有→T+1盘中部分平仓:GetPreEodDividendSum 返回的是【全量】待实现分红(非按平仓比例缩放),
+ /// 故 DividendIn=全量 36160;T+1 EOD 部分平仓(PosiQuantity>0)后剩余 PosiDividendSum=前日-全量=0。
+ /// 注:此"DividendIn 不按平仓比例缩放"是当前生产行为,已与用户确认(潜在一致性议题,非本 bug 修复范围)。
+ ///
+ [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 { new eod_swap { SwapTradeId = TradeId, ValueDate = RegDate } },
+ new List { rReg });
+ decimal dividendIn = dealSvc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 4));
+ AssertDecimalEqual(ExpectedDividend, dividendIn, 0.01m, "部分平仓 T+1:DividendIn 仍为全量 36160(非按 50% 缩放)");
+
+ // T+1=4/4 EOD 部分平仓(Quantity=Qty/2):PosiQuantity>0;TdPosiDividend=0(非登记日),
+ // PosiDividendSum = 前日36160 + 0 - TdCloseDividend(全量36160) = 0
+ var rT1 = eodSvc.ExecuteUpdateEodPosition(position, rReg, td, new DateTime(2026, 4, 4), RegDate,
+ new List { 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}");
+ }
+ }
+}
diff --git a/UnitTestProject/Modules/SwapModule/SwapCloseConversationCasesRegressionTest.cs b/UnitTestProject/Modules/SwapModule/SwapCloseConversationCasesRegressionTest.cs
index f10e3439..0d6afc17 100644
--- a/UnitTestProject/Modules/SwapModule/SwapCloseConversationCasesRegressionTest.cs
+++ b/UnitTestProject/Modules/SwapModule/SwapCloseConversationCasesRegressionTest.cs
@@ -80,9 +80,9 @@ namespace YLErp.Modules.SwapModule
var result = service.GetInterests(
trade, trade.trade_extend, closeCase.CloseDate, closeCase.CloseDate,
new List { previousEod }, new List { 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();
diff --git a/UnitTestProject/Modules/SwapModule/SwapEodPositionServiceIntegrationTest.cs b/UnitTestProject/Modules/SwapModule/SwapEodPositionServiceIntegrationTest.cs
index ee7235e3..b4ab66bf 100644
--- a/UnitTestProject/Modules/SwapModule/SwapEodPositionServiceIntegrationTest.cs
+++ b/UnitTestProject/Modules/SwapModule/SwapEodPositionServiceIntegrationTest.cs
@@ -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("✅ 分支覆盖分析完成");
}
}
diff --git a/UnitTestProject/Modules/SwapModule/SwapInterestScenario1And2Test.cs b/UnitTestProject/Modules/SwapModule/SwapInterestScenario1And2Test.cs
index bf3f2b0a..9d1e4c59 100644
--- a/UnitTestProject/Modules/SwapModule/SwapInterestScenario1And2Test.cs
+++ b/UnitTestProject/Modules/SwapModule/SwapInterestScenario1And2Test.cs
@@ -56,17 +56,17 @@ namespace UnitTestProject.Modules.SwapModule
protected override List CalcSwapInterests(
trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate,
List eodPositions, List 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 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 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 { 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];
}
diff --git a/UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs b/UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs
index 4d396b0a..5bc338e8 100644
--- a/UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs
+++ b/UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs
@@ -178,17 +178,17 @@ namespace UnitTestProject.Modules.SwapModule
protected override List CalcSwapInterests(
trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate,
List eodPositions, List 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 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 +226,7 @@ namespace UnitTestProject.Modules.SwapModule
List 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();
}
@@ -394,9 +394,9 @@ namespace UnitTestProject.Modules.SwapModule
var interests = svc.GetInterests(
td, td.trade_extend, valueDate, valueDate,
prevEod, new List { 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];
}
diff --git a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs
index 3749384a..708ac5cf 100644
--- a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs
+++ b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs
@@ -79,16 +79,16 @@ namespace YLErp.Modules.SwapModule
protected override List CalcSwapInterests(
trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate,
List eodPositions, List 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 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)
diff --git a/UnitTestProject/Modules/SwapModule/SwapSingleTradeVerificationTest.cs b/UnitTestProject/Modules/SwapModule/SwapSingleTradeVerificationTest.cs
index bdcfd67e..ceb2976d 100644
--- a/UnitTestProject/Modules/SwapModule/SwapSingleTradeVerificationTest.cs
+++ b/UnitTestProject/Modules/SwapModule/SwapSingleTradeVerificationTest.cs
@@ -59,17 +59,17 @@ namespace UnitTestProject.Modules.SwapModule
protected override List CalcSwapInterests(
trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate,
List eodPositions, List 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 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 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 { 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];
}
diff --git a/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs
index ee29d70d..cf01b63b 100644
--- a/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs
+++ b/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs
@@ -93,9 +93,9 @@ namespace YLErp.Modules.SwapModule
var position = MakePrepayPosition();
var interests = _svc.GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
eodPositions, new List { 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 { 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 { 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 { 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 { 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];
}
diff --git a/UnitTestProject/Modules/SwapModule/SwapUnwindSameDayDoublePartialTest.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindSameDayDoublePartialTest.cs
index 5e18c5d1..5e03e9c6 100644
--- a/UnitTestProject/Modules/SwapModule/SwapUnwindSameDayDoublePartialTest.cs
+++ b/UnitTestProject/Modules/SwapModule/SwapUnwindSameDayDoublePartialTest.cs
@@ -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 { 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];
}
diff --git a/YLErpDAL/Model/ExDividendInfo.cs b/YLErpDAL/Model/ExDividendInfo.cs
index ae77cd50..bbf76a41 100644
--- a/YLErpDAL/Model/ExDividendInfo.cs
+++ b/YLErpDAL/Model/ExDividendInfo.cs
@@ -5,6 +5,12 @@ using System.ComponentModel.DataAnnotations.Schema;
namespace YLErp.DBModels
{
+ public static class ExDividendDataSources
+ {
+ public const string Manual = "Manual";
+ public const string MarketData = "MarketData";
+ }
+
[Table("ex_dividend_info")]
public class ex_dividend_info : DBModelWithOperator
{
@@ -35,29 +41,41 @@ namespace YLErp.DBModels
/// 派息金额
///
[DisplayName("派息金额")]
- public double GiveCashAmount { get; set; }
+ public decimal GiveCashAmount { get; set; }
///
/// 送股手数
///
[DisplayName("送股股数")]
- public double GiveShareAmount { get; set; }
+ public decimal GiveShareAmount { get; set; }
///
/// 配股手数
///
[DisplayName("配股股数")]
- public double RationedSharesAmount { get; set; }
+ public decimal RationedSharesAmount { get; set; }
///
/// 配股手数
///
[DisplayName("配股价")]
- public double RationedSharesPrice { get; set; }
+ public decimal RationedSharesPrice { get; set; }
///
/// 是否有效
///
public bool ValidStatus { get; set; }
+
+ ///
+ /// Ownership of the record. Manual records always take precedence over imports.
+ ///
+ [DisplayName("数据来源"), Required, MaxLength(32)]
+ public string DataSource { get; set; } = ExDividendDataSources.Manual;
+
+ ///
+ /// Last update timestamp supplied by the market-data provider.
+ ///
+ [DisplayName("来源更新时间")]
+ public DateTime? SourceUpdatedAt { get; set; }
}
public class ex_dividend_infoReq : BaseSearchReq
diff --git a/YLErpDAL/Model/HengTaiModel/SwapUnwindReq.cs b/YLErpDAL/Model/HengTaiModel/SwapUnwindReq.cs
deleted file mode 100644
index 40aea13e..00000000
--- a/YLErpDAL/Model/HengTaiModel/SwapUnwindReq.cs
+++ /dev/null
@@ -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
- {
- ///
- /// 客户交易号
- ///
- public string CUSTORDID { get; set; }
- ///
- /// 返回的时候EXT_NO 对应推送的CUSTORDID
- ///
- public string EXT_NO { get; set; }
- ///
- /// 合约编号,推送不需要给,返回对应推送的EXT_NO
- ///
- public string CONTRACT_CODE { get; set; }
- ///
- /// 终止类型 全部终止 1 部分终止 0
- ///
- public string TERMINATE_TYPE { get; set; }
- ///
- /// 终止数量
- ///
- public string TERMINATE_COUNT { get; set; }
- ///
- /// 终止日期
- ///
- public string TERMINATE_DAY { get; set; }
- ///
- /// 支付日期
- ///
- public string PAY_DAY { get; set; }
- ///
- /// 资产端终止金额 不可为空
- ///
- public string ZCD_AMOUNT { get; set; }
- ///
- /// 固定端终止金额 不可为空
- ///
- public string GDD_AMOUNT { get; set; }
- ///
- /// 交易状态 不可为空 0新建,1审批中
- ///
- public string ORDSTATUS { get; set; }
- ///
- /// 固定端费用
- ///
- public string FIX_FEE { get; set; }
- ///
- /// 资产端费用
- ///
- public string ASSET_FEE { get; set;}
- }
-}
diff --git a/YLErpDAL/Modules/EodModule/BondPaymentService.cs b/YLErpDAL/Modules/EodModule/BondPaymentService.cs
index d601e628..549af3ad 100644
--- a/YLErpDAL/Modules/EodModule/BondPaymentService.cs
+++ b/YLErpDAL/Modules/EodModule/BondPaymentService.cs
@@ -103,7 +103,7 @@ namespace YLErp.Modules.EodModule
var result = QueryBondPayments(underlyingCode)
.Where(x => x.reg_date > startDate && x.reg_date <= endDate)
.AsNoTracking().ToList();
- Log.Debug($"[分红-登记日口径] GetBondPayments underlyingCode={underlyingCode} 区间=({startDate:yyyy-MM-dd},{endDate:yyyy-MM-dd}] 按reg_date过滤, 命中 {result.Count} 条: " +
+ 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"))));
return result;
}
diff --git a/YLErpDAL/Modules/EodModule/SettlementModule/EodSettlementService.cs b/YLErpDAL/Modules/EodModule/SettlementModule/EodSettlementService.cs
index 83a6007e..26f22bfa 100644
--- a/YLErpDAL/Modules/EodModule/SettlementModule/EodSettlementService.cs
+++ b/YLErpDAL/Modules/EodModule/SettlementModule/EodSettlementService.cs
@@ -39,14 +39,15 @@ namespace YLErp.Modules.EodModule
predicate = PredicateBuilder.Create(n => n.ValueDate == settleDate).And(predicate);
}
+ // 除权数据不在这里做 SQL 左连接:同一标的一天只允许一条有效除权记录,
+ // 但历史脏数据可能存在重复行。左连接会把一条 EOD 持仓扩成多行,进而重复
+ // 参与后续风险/结算计算。先取得 EOD+BOD 的唯一持仓结果,再按标的代码匹配
+ // 除权记录,可以把重复业务键暴露为 ToDictionary 异常,而不是静默扩行。
var query = from eod in DbContext.Set().Where(predicate)
join bod in DbContext.BodTradePosition.Where(n => n.ValueDate == bodDate)
on new { eod.BookId, eod.TradeType, eod.PositionType, eod.UnderlyingCode, ExchangeOptionCode = eod.ExchangeOptionCode ?? string.Empty }
equals new { bod.BookId, bod.TradeType, bod.PositionType, bod.UnderlyingCode, ExchangeOptionCode = bod.ExchangeOptionCode ?? string.Empty } into t_bod
from bod in t_bod.DefaultIfEmpty()
- join dividend in DbContext.ex_dividend_info.Where(O => O.ExDividendDate == settleDate && O.ValidStatus)
- on eod.UnderlyingCode equals dividend.UnderlyingCode into t_dividend
- from dividend in t_dividend.DefaultIfEmpty()
select new
{
eod,
@@ -55,24 +56,31 @@ namespace YLErp.Modules.EodModule
bod.Amount,
bod.Cost,
//bod.AveragePrice
- },
- dividend
+ }
};
var datas = query.ToArray();
var diviService = new TradeModule.DealModule.DividendService(OptUser);
+ // 除权查询集中复用 DividendService 的有效记录条件。字典使用不区分大小写的
+ // UnderlyingCode 匹配,兼容 EOD 与除权表代码大小写差异;如果同日同代码仍有
+ // 多条有效记录,ToDictionary 会失败,提示迁移/结算前先清理重复数据。
+ var dividendDict = diviService.GetExDividendQuery(settleDate)
+ .ToDictionary(O => O.UnderlyingCode, O => O, StringComparer.OrdinalIgnoreCase);
var eodPriceProvider = new EodPriceProvider(settleDate);
return datas.Select(data =>
{
var eod = data.eod;
var bod = data.bod;
- if (data.dividend != null)
+ // 命中除权数据后仍沿用原有股票结算分支:只重算除权后的收盘价和数量,
+ // 并保留原 Pv 的正负方向。其他 TradeType 当前不进入该分支,避免扩大
+ // 本次查询重构的业务范围。
+ if (dividendDict.TryGetValue(eod.UnderlyingCode, out var dividend))
{
if (data.eod.TradeType == "股票")
{
var SettlePrice = eodPriceProvider.GetPrice(data.eod.UnderlyingCode, SettlementTypeEnum.ClosePrice);
- SettlePrice = diviService.GetPrice(SettlePrice, data.dividend);
- var amount = diviService.GetPositionAmount(data.eod.Amount, data.dividend);
+ SettlePrice = diviService.GetPrice(SettlePrice, dividend);
+ var amount = diviService.GetPositionAmount(data.eod.Amount, dividend);
eod.Pv = eod.Pv > 0 ? Math.Abs(amount * SettlePrice) : -Math.Abs(amount * SettlePrice);
}
}
@@ -104,14 +112,14 @@ namespace YLErp.Modules.EodModule
predicate = PredicateBuilder.Create(n => n.ValueDate == settleDate).And(predicate);
}
+ // 带风险数据的重载与上面的持仓重载采用相同策略:除权记录不参与 SQL 左连接,
+ // 先完成 EOD、BOD、Risk 的行级关联,再在内存中按标的代码查找唯一除权记录,
+ // 防止除权表重复行复制风险记录。
var query = from eod in DbContext.Set().AsNoTracking().Where(predicate)
join bod in DbContext.BodTradePosition.Where(n => n.ValueDate == bodDate)
on new { eod.BookId, eod.TradeType, eod.PositionType, eod.UnderlyingCode, ExchangeOptionCode = eod.ExchangeOptionCode ?? string.Empty }
equals new { bod.BookId, bod.TradeType, bod.PositionType, bod.UnderlyingCode, ExchangeOptionCode = bod.ExchangeOptionCode ?? string.Empty } into t_bod
from bod in t_bod.DefaultIfEmpty()
- join dividend in DbContext.ex_dividend_info.AsNoTracking().Where(O => O.ExDividendDate == settleDate && O.ValidStatus)
- on eod.UnderlyingCode equals dividend.UnderlyingCode into t_dividend
- from dividend in t_dividend.DefaultIfEmpty()
join risk in DbContext.Set().AsNoTracking().Where(n => n.ValueDate == settleDate && n.TradeId > 0) on new { eod.ValueDate, eod.TradeId } equals new { risk.ValueDate, risk.TradeId } into risk_t
from risk in risk_t.DefaultIfEmpty()
select new
@@ -123,24 +131,28 @@ namespace YLErp.Modules.EodModule
bod.Cost,
//bod.AveragePrice
},
- dividend,
risk
};
var datas = query.ToArray();
var diviService = new TradeModule.DealModule.DividendService(OptUser);
+ // 与无风险重载保持同一数据来源、日期条件和大小写无关的代码匹配规则;重复
+ // 有效记录会在这里显式失败,而不是让一条持仓对应多条风险结果。
+ var dividendDict = diviService.GetExDividendQuery(settleDate)
+ .ToDictionary(O => O.UnderlyingCode, O => O, StringComparer.OrdinalIgnoreCase);
var eodPriceProvider = new EodPriceProvider(settleDate);
return datas.Select(data =>
{
var pos = data.eod;
var bod = data.bod;
- if (data.dividend != null)
+ // 风险对象的除权 Pv 重算规则与上一个重载保持一致,仅在股票交易类型下执行。
+ if (dividendDict.TryGetValue(pos.UnderlyingCode, out var dividend))
{
if (data.eod.TradeType == "股票")
{
var settlePrice = eodPriceProvider.GetPrice(data.eod.UnderlyingCode, SettlementTypeEnum.ClosePrice);
- settlePrice = diviService.GetPrice(settlePrice, data.dividend);
- var amount = diviService.GetPositionAmount(data.eod.Amount, data.dividend);
+ settlePrice = diviService.GetPrice(settlePrice, dividend);
+ var amount = diviService.GetPositionAmount(data.eod.Amount, dividend);
pos.Pv = pos.Pv > 0 ? Math.Abs(amount * settlePrice) : -Math.Abs(amount * settlePrice);
}
}
diff --git a/YLErpDAL/Modules/SwapModule/ARCHITECTURE.md b/YLErpDAL/Modules/SwapModule/ARCHITECTURE.md
index 1ddf3913..669e64ff 100644
--- a/YLErpDAL/Modules/SwapModule/ARCHITECTURE.md
+++ b/YLErpDAL/Modules/SwapModule/ARCHITECTURE.md
@@ -62,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)
@@ -72,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 契约)
```
diff --git a/YLErpDAL/Modules/SwapModule/Accrual/AccrualPolicy.cs b/YLErpDAL/Modules/SwapModule/Accrual/AccrualPolicy.cs
index 9f7e4ec4..324a373b 100644
--- a/YLErpDAL/Modules/SwapModule/Accrual/AccrualPolicy.cs
+++ b/YLErpDAL/Modules/SwapModule/Accrual/AccrualPolicy.cs
@@ -1,5 +1,3 @@
-using YLErp.Derivatives.Interest;
-
namespace YLErp.Modules.SwapModule.Accrual;
///
@@ -11,7 +9,7 @@ namespace YLErp.Modules.SwapModule.Accrual;
///
public sealed class AccrualPolicy
{
- /// 算头算尾约定(复用 SwapInterest 已有的 AccrualBoundary,物理上杜绝 calcFirst/calcLast 传反)。
+ /// 算头算尾约定(AccrualBoundary,物理上杜绝 calcFirst/calcLast 传反)。
public AccrualBoundary Convention { get; }
/// 是否复利(利滚利)。来自 DB 的 InterestTypeEnum;单利=false,复利=true。
diff --git a/YLErpDAL/Modules/SwapModule/Accrual/AccrualState.cs b/YLErpDAL/Modules/SwapModule/Accrual/AccrualState.cs
deleted file mode 100644
index 3ba2c6fb..00000000
--- a/YLErpDAL/Modules/SwapModule/Accrual/AccrualState.cs
+++ /dev/null
@@ -1,49 +0,0 @@
-using YLErp.DBModels;
-
-namespace YLErp.Modules.SwapModule.Accrual;
-
-///
-/// 融资腿逐日计息的跨日状态(不可变值对象)。
-/// 这是"待实现利息"在日间滚动的快照,区别于已落库的 swap_flow_event。
-///
-/// 旧字段 → 领域命名映射(DB 列不可改,仅在边界处适配;本类内部一律用下列自描述名):
-///
-/// - TdInterestPrincipal逐日滚动的计息本金 →
-/// - InterestIncomeSum累计待实现利息 →
-/// - consumedInterest历史已实现利息(legacy) →
-/// - ValueDate快照截至日 → (EOD 续接起算日,Bug C / 5-11 跳过需据此判断从哪天接续)。
-///
-///
-public readonly struct AccrualState
-{
- /// 用于计算当日利息的计息本金。单利=名义本金基数;复利=本金+累计利息。
- public decimal AccrualPrincipal { get; }
-
- /// 累计待实现(未平仓)利息。
- public decimal UnrealizedInterest { get; }
-
- /// 历史各次平仓已确认的已实现利息,从剩余待实现中扣除。
- public decimal RealizedInterest { get; }
-
- /// 快照截至日(来自 eod_swap_position.ValueDate)。编排层据此判断计息区间起点,避免 5-11 等"跳过日"误重算。
- public DateTime ValueDate { get; }
-
- public AccrualState(decimal accrualPrincipal, decimal unrealizedInterest, decimal realizedInterest, DateTime valueDate)
- => (AccrualPrincipal, UnrealizedInterest, RealizedInterest, ValueDate) = (accrualPrincipal, unrealizedInterest, realizedInterest, valueDate);
-
- /// 向后兼容:未携带快照日期时(如纯内存构造)用默认日。
- public AccrualState(decimal accrualPrincipal, decimal unrealizedInterest, decimal realizedInterest)
- : this(accrualPrincipal, unrealizedInterest, realizedInterest, default) { }
-
- /// 空状态(新开仓首个计息日之前)。
- public static readonly AccrualState Zero = new(0m, 0m, 0m);
-
- ///
- /// 从上一日日终归档 适配(边界适配:DB 列名 → 领域名)。
- /// 仅映射计息状态;名义本金基数 / 平仓比例 / 已实现利息等由调用方另行传入。
- ///
- public static AccrualState FromPreviousEod(eod_swap_position previousEod)
- => previousEod == null || previousEod.id == 0
- ? Zero
- : new AccrualState(previousEod.TdInterestPrincipal, previousEod.InterestIncomeSum, 0m, previousEod.ValueDate);
-}
diff --git a/Framework/YLErp.Core/Interest/AccrualTrace.cs b/YLErpDAL/Modules/SwapModule/Accrual/AccrualTrace.cs
similarity index 94%
rename from Framework/YLErp.Core/Interest/AccrualTrace.cs
rename to YLErpDAL/Modules/SwapModule/Accrual/AccrualTrace.cs
index 108b2692..271a0bc4 100644
--- a/Framework/YLErp.Core/Interest/AccrualTrace.cs
+++ b/YLErpDAL/Modules/SwapModule/Accrual/AccrualTrace.cs
@@ -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;
///
-/// 计息过程追踪收集器(值对象,非日志)。
+/// 计息过程追踪收集器(值对象,非日志)。2026-08 自 Core 层(YLErp.Core.Interest)迁入 DAL,
+/// 与 Simple/CompoundInterestAccrual、AccrualBoundary 同处一域,Core 不再持有计息类型。
///
-/// 为什么是收集器而不是日志调用:计息数学(SwapInterest / FundingLegAccrual)必须保持纯函数、
+/// 为什么是收集器而不是日志调用:计息数学(Simple/CompoundInterestAccrual)必须保持纯函数、
/// 可单测、不依赖 NLog;但按工程铁律,关键路径日志须无条件常驻落盘(出问题时事后翻日志定位,不能依赖开关)。
/// 折中:纯函数把"发生了什么"记录为结构化条目写入本收集器,由适配器(IO 边界)统一经
/// SwapCalcTrace.Write 常驻落盘。落盘职责归一处,计息代码零日志依赖、保持干净。
diff --git a/YLErpDAL/Modules/SwapModule/Accrual/CompoundInterestAccrual.cs b/YLErpDAL/Modules/SwapModule/Accrual/CompoundInterestAccrual.cs
index 9418aa46..eddbb07a 100644
--- a/YLErpDAL/Modules/SwapModule/Accrual/CompoundInterestAccrual.cs
+++ b/YLErpDAL/Modules/SwapModule/Accrual/CompoundInterestAccrual.cs
@@ -1,6 +1,3 @@
-using YLErp.Core.Interest;
-using YLErp.Derivatives.Interest;
-
namespace YLErp.Modules.SwapModule.Accrual;
///
@@ -9,7 +6,7 @@ namespace YLErp.Modules.SwapModule.Accrual;
///
public static class CompoundInterestAccrual
{
- private const int Precision = SwapInterest.FundingLegPrecision;
+ private const int Precision = InterestMath.FundingLegPrecision;
/// 复利日终计息基数(单一真相源,纯函数与调用方共用):
/// 重置日 = 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);
@@ -107,7 +104,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;
@@ -124,8 +121,8 @@ public static class CompoundInterestAccrual
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;
}
diff --git a/YLErpDAL/Modules/SwapModule/Accrual/InterestMath.cs b/YLErpDAL/Modules/SwapModule/Accrual/InterestMath.cs
new file mode 100644
index 00000000..54b5de29
--- /dev/null
+++ b/YLErpDAL/Modules/SwapModule/Accrual/InterestMath.cs
@@ -0,0 +1,104 @@
+namespace YLErp.Modules.SwapModule.Accrual;
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 词汇表(本文件只允许出现下列用词,同一概念不得出现第二种叫法)
+//
+// 概念 唯一用词 与既有代码的对应
+// ───────────────────────────────────────────────────────────────────
+// 区间起点/终点 Start / End startDate / endDate
+// 计息 Accrue CalcDailySimpleInterest / CalcDailyCompoundInterest
+// 平仓 Unwind unwindPercent(既有字段 closePercent)
+// 已实现利息 Realized realizedInterest(legacy 字段 consumedInterest)
+// 待实现收益 Unrealized 预付金模式下的待实现收益余额
+// 计息基数 principal principal / dynomicPrincipal
+// 年化天数 annualDays tradeExtend.ExtendObj.AnnualDays
+//
+// 入参一律沿用既有代码的字段名,调用点两边读起来同名,不产生心智翻译成本。
+// 出参改用自描述名(Accrued / AccruedToday),因为 "Td" 对新读者是黑话。
+// ─────────────────────────────────────────────────────────────────────────────
+
+///
+/// 计息区间边界(算头 / 算尾)。
+/// 用具名值取代两个相邻 bool,物理上杜绝 calcFirst / calcLast 传反这一类历史缺陷。
+///
+public readonly struct AccrualBoundary
+{
+ /// 算头:含 startDate。
+ public bool IncludeStart { get; }
+
+ /// 算尾:含 endDate。
+ public bool IncludeEnd { get; }
+
+ private AccrualBoundary(bool includeStart, bool includeEnd)
+ => (IncludeStart, IncludeEnd) = (includeStart, includeEnd);
+
+ /// 算头算尾 [start, end]。
+ public static readonly AccrualBoundary Both = new(true, true);
+
+ /// 算头不算尾 [start, end)。
+ public static readonly AccrualBoundary StartOnly = new(true, false);
+
+ /// 不算头算尾 (start, end]。
+ public static readonly AccrualBoundary EndOnly = new(false, true);
+
+ /// 不算头不算尾 (start, end)。
+ public static readonly AccrualBoundary None = new(false, false);
+
+ /// 由既有 calcFirst / calcLast 布尔对构造,供旧调用方渐进迁移。
+ public static AccrualBoundary Of(bool includeStart, bool includeEnd) => new(includeStart, includeEnd);
+
+ public override string ToString()
+ => $"{(IncludeStart ? "算头" : "不算头")}{(IncludeEnd ? "算尾" : "不算尾")}";
+}
+
+///
+/// 计息结果。Accrued → 记账字段 InterestAmount / InterestProfitSum;AccruedToday → TdInterestAmount。
+///
+public readonly struct InterestResult
+{
+ /// 区间累计应计利息。
+ public decimal Accrued { get; }
+
+ /// 末日(当日)应计利息。
+ 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}";
+}
+
+///
+/// 利息腿共用数学工具:舍入、应计天数、精度常量。
+///
+/// 沿革:2026-08 自 Core 层 SwapInterest 迁入 DAL(生产消费面整体搬家)。
+/// 原 SwapInterest 的算法方法(AccrueSimple/AccrueCompoundInArrears/ApplyUnwind/AccrueUnrealized)
+/// 与 AccrualContext/InterestRate 始终未接线(生产计息走本目录 Simple/CompoundInterestAccrual,
+/// 两者舍入与 rollover 口径已分叉),作为孤儿死代码删除——接线前须先补对账,勿凭记忆重建。
+///
+/// 为何不复用 Qdp 的 IDayCount:
+/// a. 语义——Qdp 的 DaysInPeriod = end − start 是写死的半开区间,只能表达四种算头算尾中的一种;
+/// b. 精度——Qdp 返回 double 年化系数,本系统 decimal 对账;
+/// c. 依赖方向——Qdp 用自有 Date 类型,引入会让本模块反向依赖定价库。
+///
+public static class InterestMath
+{
+ /// 资金腿与保证金腿的生产计息精度(落库/对账均以 12 位为准)。
+ /// 提升至公共常量,消除 SwapDealService 与 SimpleInterestAccrual 的重复定义。
+ public const int FundingLegPrecision = 12;
+
+ /// 应计天数。边界规则由日期区间表达,计息函数内不再出现 flag 分支。
+ 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;
+ }
+
+ /// 统一舍入:MidpointRounding.AwayFromZero。所有计息路径收口到此处,避免散落的 Math.Round 不一致。
+ public static decimal Round(decimal value, int precision)
+ => Math.Round(value, precision, MidpointRounding.AwayFromZero);
+}
diff --git a/YLErpDAL/Modules/SwapModule/Accrual/SimpleInterestAccrual.cs b/YLErpDAL/Modules/SwapModule/Accrual/SimpleInterestAccrual.cs
index fb1378ab..ac184645 100644
--- a/YLErpDAL/Modules/SwapModule/Accrual/SimpleInterestAccrual.cs
+++ b/YLErpDAL/Modules/SwapModule/Accrual/SimpleInterestAccrual.cs
@@ -1,6 +1,3 @@
-using YLErp.Core.Interest;
-using YLErp.Derivatives.Interest;
-
namespace YLErp.Modules.SwapModule.Accrual;
///
@@ -9,7 +6,7 @@ namespace YLErp.Modules.SwapModule.Accrual;
///
public static class SimpleInterestAccrual
{
- private const int Precision = SwapInterest.FundingLegPrecision;
+ private const int Precision = InterestMath.FundingLegPrecision;
///
/// 单利日终计息(替换 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;
}
diff --git a/YLErpDAL/Modules/SwapModule/EodSwapPositionQueries.cs b/YLErpDAL/Modules/SwapModule/EodSwapPositionQueries.cs
new file mode 100644
index 00000000..7041def4
--- /dev/null
+++ b/YLErpDAL/Modules/SwapModule/EodSwapPositionQueries.cs
@@ -0,0 +1,19 @@
+using System;
+using System.Linq;
+using YLErp.DBModels;
+
+namespace YLErp.Modules.SwapModule
+{
+ ///
+ /// eod_swap_position 查询收口(Query Object)。
+ /// 规则"某交易某日日终的有效持仓 = SwapTradeId 匹配 + ValueDate 匹配 + 未作废(!Invalid)"集中于此,
+ /// 避免多处复制同一谓词导致语义漂移(漏写 !Invalid 即静默出 bug)。
+ /// 仅返回 IQueryable,不调用 SaveChanges,不破坏跟踪/Include/事务边界。
+ ///
+ public static class EodSwapPositionQueries
+ {
+ public static IQueryable ActiveByTradeAndDate(
+ this IQueryable query, int tradeId, DateTime valueDate)
+ => query.Where(x => x.SwapTradeId == tradeId && x.ValueDate == valueDate && !x.Invalid);
+ }
+}
diff --git a/YLErpDAL/Modules/SwapModule/FundingLegs/UnderlyingEntryFullPriceLeg.cs b/YLErpDAL/Modules/SwapModule/FundingLegs/UnderlyingEntryFullPriceLeg.cs
index 6838f7e0..a1294649 100644
--- a/YLErpDAL/Modules/SwapModule/FundingLegs/UnderlyingEntryFullPriceLeg.cs
+++ b/YLErpDAL/Modules/SwapModule/FundingLegs/UnderlyingEntryFullPriceLeg.cs
@@ -1,4 +1,4 @@
-using YLErp.DBModels;
+using YLErp.DBModels;
namespace YLErp.Modules.SwapModule.FundingLegs;
@@ -7,7 +7,7 @@ namespace YLErp.Modules.SwapModule.FundingLegs;
/// 计息基数 = 标的期初含费全价(PosiGrossPrice/EntryDirtyPrice) × 数量。
/// "期初(Entry)"是关键——建仓时点的全价,非当前估值全价。
/// 主路径 CalcNotionalByMode 公式与合约名义本金规模(2)相同;
-/// 差异在衡泰路径会乘 grossPrice 折算(SwapDealService.GetUnwindInterestsByHT),
+/// 衡泰回执折算路径(原 SwapDealService.GetUnwindInterestsByHT 乘 grossPrice 折算)已随死链清理移除;
/// 以及 EOD 复利部分平仓后直接返回剩余本金(禁止反推,SwapEodPositionService:1458-1465)。
///
public sealed class UnderlyingEntryFullPriceLeg : IFundingLegStrategy
diff --git a/YLErpDAL/Modules/SwapModule/InterestCalcRequest.cs b/YLErpDAL/Modules/SwapModule/InterestCalcRequest.cs
new file mode 100644
index 00000000..fcaff397
--- /dev/null
+++ b/YLErpDAL/Modules/SwapModule/InterestCalcRequest.cs
@@ -0,0 +1,85 @@
+namespace YLErp.Modules.SwapModule;
+
+///
+/// GetInterests 参数对象(2026-08 参数显式化)。
+///
+/// 动机:原 GetInterests 20 个位置参数中,名义本金簇(posiNotionalValue/closePosiNotionalValue/closePercent)
+/// 在【盘中平仓】与【EOD 平仓后收盘】两类场景下语义相反(详见 GetInterests "根因位置"注释与
+/// GetInterestsEntrySemanticsTest 的口径留档),位置参数无法表达该约束。
+///
+/// 用法:只能经两个场景工厂构造——工厂形参名即该场景语义(平仓前剩余 / 平仓后剩余 / 实际平掉额),
+/// 物理上防止两套语义混传。needPrice/grossPrice(原方法死参数)与 posiLong/posiShortNotionalValue
+/// (多空组合子系统删除后计息链零消费的管道死参数)均不承载。
+///
+public sealed class InterestCalcRequest
+{
+ public trade Td { get; }
+ public trade_extend TradeExtend { get; }
+ public DateTime ValueDate { get; }
+ public DateTime UnwindDate { get; }
+ public List EodPositions { get; }
+ public List Positions { get; }
+
+ /// 当日适用名义本金。语义随场景:盘中=平仓【前】剩余;EOD平仓后收盘=平仓【后】剩余;EOD增量=当前剩余。
+ public decimal PosiNotionalValue { get; }
+
+ /// 本次实际平掉本金(两场景恒同义)。mode2 无条件覆盖 / mode9 全平兜底的输入。
+ public decimal ClosePosiNotionalValue { get; }
+
+ /// 平仓比例。语义随场景:盘中=实际比例(B 占剩余);EOD平仓后收盘=恒1(全额结息)。
+ 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 CloseList { get; }
+
+ private InterestCalcRequest(
+ trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate,
+ List eodPositions, List positions,
+ decimal posiNotionalValue,
+ decimal closePosiNotionalValue, decimal closePercent,
+ int eventType, bool tdClose, decimal orginPv,
+ bool add, bool newCalcLast, List closeList)
+ {
+ 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;
+ }
+
+ ///
+ /// 【盘中平仓/互换结息】场景(→ GetIntradayUnwindInterests,settment:false 盘中重放)。
+ ///
+ /// 平仓【前】实时剩余本金(原 GetUnwindInterests.stockEqvNotional)。
+ /// 本次实际平掉本金(= preCloseNotional × closePercentRemaining)。
+ /// 平仓比例,B 语义【占剩余】(前端传 A 占期初须先经 ToRemainingClosePercent 转换)。
+ public static InterestCalcRequest IntradayUnwind(
+ trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate,
+ List eodPositions, List positions,
+ decimal preCloseNotional, decimal closedNotional, decimal closePercentRemaining,
+ int eventType, bool tdClose, decimal orginPv,
+ bool add, bool newCalcLast, List closeList)
+ => new(td, tradeExtend, valueDate, unwindDate, eodPositions, positions,
+ preCloseNotional, closedNotional, closePercentRemaining,
+ eventType, tdClose, orginPv, add, newCalcLast, closeList);
+
+ ///
+ /// 【EOD 当日有平仓后的收盘结息】场景(→ CalcEodPostCloseSettleInterests,settment:false 全额结息)。
+ /// 该场景触发 GetInterests 内 mode2 无条件覆盖 / mode9 全平兜底(见其"根因位置"注释,勿删)。
+ ///
+ /// 平仓【后】剩余本金(GetInterests.posiNotionalValue 形参位)。
+ /// 本次实际平掉本金。
+ public static InterestCalcRequest EodPostCloseSettle(
+ trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate,
+ List eodPositions, List 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);
+}
diff --git a/YLErpDAL/Modules/SwapModule/SwapCalcTrace.cs b/YLErpDAL/Modules/SwapModule/SwapCalcTrace.cs
index f6e1a1ec..40bc9571 100644
--- a/YLErpDAL/Modules/SwapModule/SwapCalcTrace.cs
+++ b/YLErpDAL/Modules/SwapModule/SwapCalcTrace.cs
@@ -1,8 +1,5 @@
-using System;
-using System.Collections.Generic;
using System.Text;
-using YLErp.Core.Interest;
-using YLErp.Helpers;
+using YLErp.Modules.SwapModule.Accrual;
namespace YLErp.Modules.SwapModule
{
diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs
index 21eca92b..0aab88a2 100644
--- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs
+++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs
@@ -1,9 +1,8 @@
-using MoreLinq.Extensions;
+using MoreLinq.Extensions;
using Newtonsoft.Json;
using YLErp.BLL;
using YLErp.BLL.Eod;
using YLErp.DBModels.Enums;
-using YLErp.Core.Interest;
using YLErp.Derivatives.Interest;
using YLErp.Helpers;
using YLErp.Modules.DataProviderModule;
@@ -50,8 +49,8 @@ namespace YLErp.Modules.SwapModule
return SaveSwapDealInternal(unwindData, eventType, clientCashId, eventResason, approve);
}
- // 待实现利息会进入 decimal(30,12) 日终快照;精度常量统一引用 SwapInterest.FundingLegPrecision,消除重复定义。
- private const int InterestCalculationPrecision = SwapInterest.FundingLegPrecision;
+ // 待实现利息会进入 decimal(30,12) 日终快照;精度常量统一引用 InterestMath.FundingLegPrecision,消除重复定义。
+ private const int InterestCalculationPrecision = InterestMath.FundingLegPrecision;
// 客户现金在 SaveSwapDeal 之前创建,手工结算必须先收敛流水并重算汇总金额。
private void NormalizeManualSettlementAmounts(UnwindData unwindData, int eventType, string eventReason)
@@ -211,7 +210,7 @@ namespace YLErp.Modules.SwapModule
public UnwindData InitUnwind(int tradeId)
{
var td = DbContext.trade.Find(tradeId);
- var positions = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && !x.Invalid);
+ var positions = DbContext.swap_position.ActiveByTrade(tradeId);
var um = DataCacheProvider.GetUnderlyingDataSource().GetData(td.UnderlyingCode);
bool commodity = ConsGlobal.InstrumentType.CalcTypeIsFutures(um.UnderlyingInstrumentType);
List eventTyps = new List() { (int)SwapEventTypeEnum.自动互换, (int)SwapEventTypeEnum.互换 };
@@ -277,7 +276,7 @@ namespace YLErp.Modules.SwapModule
// DividendPending = "待结算分红收益"(仍挂在账上、未来才结的存量 = PosiDividendSum 全量口径,
// 见 GetPreEodDividendSum 注释的口径论证;切勿改回硬0或分摊,会落库回归)
decimal preEodDividendSum = GetPreEodDividendSum(tradeId, position.PositionId, dealDate);
- Logger.Debug($"[分红-平仓预览] 方案C DividendIn=DividendPending=PosiDividendSum全量 tradeId={tradeId} positionId={position.PositionId} dealDate={dealDate:yyyy-MM-dd} 值={preEodDividendSum}");
+ Logger.Info($"[分红-平仓预览] 方案C DividendIn=DividendPending=PosiDividendSum全量 tradeId={tradeId} positionId={position.PositionId} dealDate={dealDate:yyyy-MM-dd} 值={preEodDividendSum}");
floatEvent.DividendIn = preEodDividendSum;
floatEvent.DividendPending = preEodDividendSum;
floatEvent.UnderlyingCode = position.UnderlyingCode;
@@ -356,7 +355,7 @@ namespace YLErp.Modules.SwapModule
{
var checkEventTypes = new List() { (int)SwapEventTypeEnum.互换, (int)SwapEventTypeEnum.自动互换 };
var td = DbContext.trade.Find(tradeId);
- var positions = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && !x.Invalid);
+ var positions = DbContext.swap_position.ActiveByTrade(tradeId);
var um = DataCacheProvider.GetUnderlyingDataSource().GetData(td.UnderlyingCode);
List eventTypes = new List() { (int)SwapFlowEventTypeEnum.互换, (int)SwapFlowEventTypeEnum.自动互换 };
var maxIncomeValueDate = GetMaxIncomeValueDate(td);
@@ -412,7 +411,7 @@ namespace YLErp.Modules.SwapModule
// 方案C:分红收益改由上一收盘日 EOD PosiDividendSum 提供(单一可信源),
// 前端 getDivindIn 不再覆盖;消除"期初持仓×totalInterest"对已平仓部分的重复计入。
decimal preEodDividendSum = GetPreEodDividendSum(tradeId, position.PositionId, dealDate);
- Logger.Debug($"[分红-收益结算] DividendIn=PosiDividendSum全量 tradeId={tradeId} positionId={position.PositionId} dealDate={dealDate:yyyy-MM-dd} 值={preEodDividendSum}");
+ Logger.Info($"[分红-收益结算] DividendIn=PosiDividendSum全量 tradeId={tradeId} positionId={position.PositionId} dealDate={dealDate:yyyy-MM-dd} 值={preEodDividendSum}");
floatEvent.DividendIn = preEodDividendSum;
floatEvent.UnderlyingCode = position.UnderlyingCode;
floatEvent.UnderlyingInstrumentType = position.UnderlyingInstrumentType;
@@ -461,23 +460,18 @@ namespace YLErp.Modules.SwapModule
{
throw new ServiceException("未找到交易信息");
}
- var allpositions = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && !x.Invalid).ToList();
+ var allpositions = DbContext.swap_position.ActiveByTrade(tradeId).ToList();
var origPositions = allpositions.Where(x => x.IsInitial).ToList();
var realPostitions = allpositions.Where(x => !x.IsInitial).ToList();
// 根因修复(多次部分平仓预付金返还错误):见 ResolveInterestLegPositions 注释。
// 迭代源仍用 origPositions(保留 orig.id → eod_swap_position.PositionId 的日终匹配),
// 仅对预付金腿以实时腿的剩余本金克隆覆盖,故此处不改任何日终匹配行为。
var positions = ResolveInterestLegPositions(origPositions, realPostitions);
- var fpositions = origPositions.Where(x => x.PosiDirection > 0).ToList();
- var longPositions = fpositions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).ToList();
- var shortPositions = fpositions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).ToList();
var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == tradeId);
List eventTypes = new List() { (int)SwapEventTypeEnum.平仓, (int)SwapEventTypeEnum.互换, (int)SwapEventTypeEnum.自动互换 };
var lastEod = DbContext.eod_swap.Where(x => x.ValueDate < unwindDate && x.SwapTradeId == tradeId).OrderByDescending(o => o.ValueDate).FirstOrDefault();
var _preSetteDate = lastEod == null ? unwindDate.AddDays(-1) : lastEod.ValueDate;
List lastEodPositions = new SwapEodPositionService(this).GetPreEodPositions(tradeId, _preSetteDate);//上一交易数据
- var posiLongNotionalValue = longPositions.Sum(s => s.PosiNotionalValue);// 剩余名义本金
- var posiShortNotionalValue = shortPositions.Sum(s => s.PosiNotionalValue);// 剩余名义本金
var stockEqvNotional = realPostitions.Where(x => x.PosiDirection > 0).Sum(s => s.PosiNotionalValue); // 当前平仓前的实时剩余本金
var posiNotionalValue = stockEqvNotional * closePercent;// 本次平仓名义本金
var orginPv = ResolveUnwindPreviousNotional(lastEod, lastEodPositions, stockEqvNotional); // 上一日终的浮动端本金
@@ -487,7 +481,11 @@ namespace YLErp.Modules.SwapModule
&& eventTypes.Contains(x.EventType)
&& x.DataState == (int)SwapFlowDateStateEnum.完成).ToList();
bool tdClose = closeList.Count > 0;
- interests = GetInterests(td, tradeExtend, valueDate, unwindDate, lastEodPositions, positions, stockEqvNotional, posiLongNotionalValue, posiShortNotionalValue, posiNotionalValue, closePercent, eventType, tdClose, false, grossPrice ?? 0, orginPv, true, false,false, closeList);
+ // 显式入口:平仓前剩余本金 + 实际平掉额 + B语义比例,盘中重放(语义见 InterestCalcRequest.IntradayUnwind)
+ interests = GetIntradayUnwindInterests(InterestCalcRequest.IntradayUnwind(
+ td, tradeExtend, valueDate, unwindDate, lastEodPositions, positions,
+ stockEqvNotional, posiNotionalValue,
+ closePercent, eventType, tdClose, orginPv, add: true, newCalcLast: false, closeList));
return interests;
}
@@ -609,14 +607,22 @@ namespace YLErp.Modules.SwapModule
/// 上一日终持仓
/// 期初利率端
/// 持仓名义本金
- /// 多头持仓名义本金
- /// 空头持仓名义本金
/// 平仓名义本金
///
///
///
///
///
+ ///
+ /// 【盘中平仓/互换结息】显式入口——GetInterests(settment:false) 盘中语义的具名封装(2026-08 显式化重构)。
+ /// 语义契约见 InterestCalcRequest.IntradayUnwind 工厂注释;计息走 CalcUnwindInterest 全区间重放。
+ ///
+ public List GetIntradayUnwindInterests(InterestCalcRequest req)
+ => GetInterests(req.Td, req.TradeExtend, req.ValueDate, req.UnwindDate, req.EodPositions, req.Positions,
+ req.PosiNotionalValue, req.ClosePosiNotionalValue,
+ req.ClosePercent, req.EventType, req.TdClose,
+ req.OrginPv, req.Add, settment: false, req.NewCalcLast, req.CloseList);
+
public List GetInterests(
trade td,
trade_extend tradeExtend,
@@ -625,14 +631,10 @@ namespace YLErp.Modules.SwapModule
List eodPositions,
List 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,
@@ -811,7 +813,7 @@ namespace YLErp.Modules.SwapModule
{
var preEod = GetPreEodPositionByDate(tradeId, positionId, dealDate);
var sum = preEod == null ? 0m : preEod.PosiDividendSum;
- Logger.Debug($"[分红-读取] GetPreEodDividendSum tradeId={tradeId} positionId={positionId} dealDate={dealDate:yyyy-MM-dd} 取EOD日期={(preEod?.ValueDate):yyyy-MM-dd} PosiDividendSum={sum}");
+ Logger.Info($"[分红-读取] GetPreEodDividendSum tradeId={tradeId} positionId={positionId} dealDate={dealDate:yyyy-MM-dd} 取EOD日期={(preEod?.ValueDate):yyyy-MM-dd} PosiDividendSum={sum}");
return sum;
}
@@ -826,7 +828,7 @@ namespace YLErp.Modules.SwapModule
.Where(x => x.ValueDate <= dealDate)
.OrderByDescending(o => o.ValueDate).FirstOrDefault();
var preEodDate = lastEod == null ? dealDate.AddDays(-1) : lastEod.ValueDate;
- Logger.Debug($"[分红-快照定位] GetPreEodPositionByDate tradeId={tradeId} positionId={positionId} dealDate={dealDate:yyyy-MM-dd} 取<=当日EOD, 命中日期={(lastEod?.ValueDate):yyyy-MM-dd}, 回退={lastEod == null}");
+ Logger.Info($"[分红-快照定位] GetPreEodPositionByDate tradeId={tradeId} positionId={positionId} dealDate={dealDate:yyyy-MM-dd} 取<=当日EOD, 命中日期={(lastEod?.ValueDate):yyyy-MM-dd}, 回退={lastEod == null}");
return QueryPreEodPosition(tradeId, positionId, preEodDate);
}
@@ -1097,7 +1099,7 @@ namespace YLErp.Modules.SwapModule
}
return InitSwapDealInterest(td, valueDate, endDate, rate, position, add, swap, posiPrincipal,
- closePrincipal, closePercent, annualDays, eventType, preEod, false,
+ closePrincipal, closePercent, annualDays, eventType, preEod,
orginPv, calcFirst, calcLast, consumedInterest);
}
///
@@ -1152,7 +1154,6 @@ namespace YLErp.Modules.SwapModule
int annualDays,
int eventType,
eod_swap_position preEodPosition,
- bool needPrice,
decimal orginPv,
bool calcFirst,
bool calcLast,
@@ -1456,7 +1457,7 @@ namespace YLErp.Modules.SwapModule
SwapCalcTrace.Write(interestTrace);
// flowEvent.InterestPrincipal:当日计息基数(已按平仓比例缩放)——下游 EOD 用它播种次日 TdInterestPrincipal。
- // 复用 CompoundEodBasis 单一真相源(与 AccrueCompoundEod 内部同一公式)。
+ // 复用 CompoundEodBasis 单一真相源(与 CompoundInterestAccrual.AccrueEod 内部同一公式,见其 EodBasis 调用)。
flowEvent.InterestPrincipal = CompoundInterestAccrual.EodBasis(
isResetDay, posiPrincipal, preEodPosition.InterestProfitSum, remainingFraction,
preEodPosition.TdInterestPrincipal) * closePercent;
@@ -1570,7 +1571,7 @@ namespace YLErp.Modules.SwapModule
{
unwindPriceFee = decimal.Parse(unwindPriceFee.ToString("F10"));
var td = DbContext.trade.Find(tradeid);
- var positions = DbContext.swap_position.Where(x => x.SwapTradeId == td.id && !x.Invalid);
+ var positions = DbContext.swap_position.ActiveByTrade(td.id);
List eventTypes = new List() { (int)SwapEventTypeEnum.平仓, (int)SwapEventTypeEnum.互换 };
var dealDate = valueDate;
var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == td.id);
@@ -1696,153 +1697,6 @@ namespace YLErp.Modules.SwapModule
return data.ValueAddedTax ?? 0;
}
- ///
- /// 衡泰新增平仓事件
- ///
- ///
- ///
- ///
- ///
- ///
- public void AutoSwapUnwindFromConsumer(trade td, DateTime valueDate, DateTime payDate, decimal markClosePnl, decimal tradeinfFee, decimal interestAmount, decimal fee, decimal unwindQty, bool allClose)
- {
- List eventTypes = new List() { (int)SwapEventTypeEnum.平仓, (int)SwapEventTypeEnum.互换 };
- var dealDate = valueDate;
- var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == td.id);
- td.trade_extend = tradeExtend;
- var position = DbContext.swap_position.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && x.IsInitial && !x.Invalid).FirstOrDefault();
- var preDealDate = GetPreDealDate(td.id, dealDate, eventTypes);
- swap_flow_event floatEvent = new swap_flow_event();
- UnwindData unwindData = new UnwindData();
- unwindData.CloseType = 2;
- unwindData.StartDate = td.TradeDate.Value;
- if (preDealDate.HasValue)
- {
- unwindData.StartDate = preDealDate.Value;
- }
- unwindData.ValueDate = dealDate;
- floatEvent.EventDate = dealDate;
- unwindData.UnwindDate = QdpCalendarHelper.GetNonHoliday(dealDate.AddDays(1));
- floatEvent.UnwindDate = unwindData.UnwindDate;
- floatEvent.PayDate = payDate;
- unwindData.PayDate = floatEvent.PayDate;
- floatEvent.SwapTradeId = td.id;
- floatEvent.SwapTradeNo = td.TradeNumber;
- unwindData.SwapTradeId = td.id;
- unwindData.StructureType = td.StructureType;
- unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0);
- unwindData.NotionalQty = position.PosiQuantity;
- unwindData.PosiNotionalValue = Convert.ToDecimal(td.StockEqvNotional);
- unwindData.PositionQty = Convert.ToDecimal(td.TradeAmount);
- unwindData.AnnualDays = tradeExtend == null ? 365 : tradeExtend.ExtendObj.AnnualDays;
- unwindData.CloseMethod = allClose ? (int)CloseMethodEnum.全部平仓 : (int)CloseMethodEnum.部分平仓;
- unwindData.ClosePercent = allClose ? 1 : unwindQty / unwindData.NotionalQty;
- unwindData.CloseNotionalValue = allClose ? unwindData.PosiNotionalValue : unwindQty;
- unwindData.CloseQty = allClose ? unwindData.PositionQty : unwindQty;
- if (position != null)
- {
- decimal floatRatio = position.PosiDirection == 1 ? 1m : -1m;
- floatEvent.PositionId = position.id;
- floatEvent.EventType = (int)SwapEventTypeEnum.平仓;
- floatEvent.EventReason = "接口合约终止交易";
- floatEvent.DividendIn = 0;
- floatEvent.UnderlyingCode = position.UnderlyingCode;
- floatEvent.UnderlyingInstrumentType = position.UnderlyingInstrumentType;
- floatEvent.CloseFee = 0;
- floatEvent.BeforeCloseFee = position.PosiTradingFee + position.PosiTradingFeePending;
- floatEvent.PayDirection = position.PosiDirection;
- floatEvent.PosiGrossPrice = position.PosiGrossPrice;
- floatEvent.PosiNetPrice = position.PosiNetPrice;
- floatEvent.TradingAmountNetAvg = position.PosiNetNoFeePrice;
- floatEvent.TradingFeePending = position.PosiTradingFeePending * unwindData.ClosePercent;
- floatEvent.TradingFee = tradeinfFee - floatEvent.TradingFeePending;
- floatEvent.MarkClosePnl = markClosePnl;
- floatEvent.TradingAmount = floatEvent.Quantity * floatEvent.ContractSize;
- floatEvent.PositionType = position.PositionType;
- floatEvent.Quantity = position.PosiQuantity;
- floatEvent.PositionQty = 0;
- floatEvent.ContractSize = position.ContractSize;
- floatEvent.DataState = (int)SwapFlowDateStateEnum.完成;
- floatEvent.InterestMode = position.InterestMode;
- floatEvent.TradingAmount = unwindData.CloseQty;
- floatEvent.ClientId = td.ClientId;
- floatEvent.OptLog = "衡泰同步";
- floatEvent.SetOpt(UserInfo);
- }
- unwindData.FlowEvents.Add(floatEvent);
- var interestPositions = GetUnwindInterestsByHT(unwindData, td, interestAmount, fee);
- unwindData.FlowEvents.AddRange(interestPositions);
- CalcCloseAmount(unwindData);
- DealUnwind(unwindData, td, "合约终止接口回执");
- }
- private List GetUnwindInterestsByHT(UnwindData unwindData, trade td, decimal interestAmount, decimal fee)
- {
- List interests = new List();
- var allpositions = DbContext.swap_position.Where(x => x.SwapTradeId == unwindData.SwapTradeId && !x.Invalid && x.IsInitial && x.PosiDirection > 0).ToList();
- var position = allpositions.Where(x => ConsTrade.InterestModels.Contains(x.InterestMode)).FirstOrDefault();
- if (position == null)
- {
- return interests;
- }
- var grossPrice = allpositions.Where(x => x.PosiDirection > 0).FirstOrDefault()?.PosiGrossPrice ?? 0;
- var _closePosiNotionalValue = unwindData.CloseNotionalValue;
- var _posiNotionalValue = unwindData.PosiNotionalValue;
- var newClosePercent = unwindData.ClosePercent;
- foreach (var item in allpositions)
- {
- var positionClone = item.Clone();
- var swapIntervalToday = position.SwapIntervalList.OrderByDescending(o => o.Date).FirstOrDefault();
- if (item.InterestMode == (int)InterestModeEnum.固定值)
- {
- _closePosiNotionalValue = item.InterestPrincipalFix;
- _posiNotionalValue = item.InterestPrincipalFix;
- newClosePercent = 1m;
- }
- else if (item.InterestMode == (int)InterestModeEnum.标的期初全价)
- {
- _closePosiNotionalValue = _posiNotionalValue * grossPrice * newClosePercent;
- _posiNotionalValue = _posiNotionalValue * grossPrice;
- }
- else if (MarginModes.Contains(item.InterestMode))
- {
- _closePosiNotionalValue = 0;
- positionClone.InterestDirection = MarginCalc.FlipDirection(position.InterestDirection);
- }
- decimal rate = item.InterestRateDefault;
- if (swapIntervalToday != null)//当日无适用观察日
- {
- rate = swapIntervalToday.Rate;
- }
- swap_flow_event interest = new swap_flow_event();
- interest.SwapTradeId = td.id;
- interest.SwapTradeNo = td.TradeNumber;
- interest.EventType = (int)SwapEventTypeEnum.平仓;
- interest.EventReason = "衡泰同步平仓";
- interest.EventDate = unwindData.ValueDate;
- interest.PositionId = item.id;
- interest.InterestDirection = positionClone.InterestDirection;
- interest.InterestRate = rate;
- interest.InterestPrincipal = _closePosiNotionalValue;
- interest.InterestSwapInterval = item.InterestSwapInterval;
- interest.InterestMode = item.InterestMode;
- interest.FloatRate = item.FloatRate;
- interest.DataState = (int)SwapFlowDateStateEnum.完成;
- interest.ClientId = td.ClientId;
- interest.UnwindDate = unwindData.ValueDate;
- interest.PayDate = unwindData.PayDate;
- if (position != null && item.id == position.id)
- {
- interest.InterestAmount = interestAmount;
- interest.TdInterestAmount = interestAmount;
- interest.InterestClosePnL = interestAmount;
- interest.InterestFee = fee;
- }
- UpdateDbOption(interest);
- interests.Add(interest);
- }
-
- return interests;
- }
private void DealUnwind(UnwindData unwindData, trade td, string actionMsg = "系统操作_自动平仓")
{
int clientCashId = AddClientCashInCashOut(td, Convert.ToDouble(-unwindData.SwapRealizedPnL), ClientCashInCashOut.系统操作_平仓费, unwindData.ValueDate);
diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs
index be097caf..d6fa2a48 100644
--- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs
+++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs
@@ -82,26 +82,40 @@ namespace YLErp.Modules.SwapModule
}
///
- /// 计算利息腿利息明细(生产: new SwapDealService(this).GetInterests;测试: 用StubSwapDealService内存算)
+ /// 计算利息腿利息明细(生产: new SwapDealService(this).GetInterests;测试: 用StubSwapDealService内存算)。
/// 参数与 SwapDealService.GetInterests 完全一致,保证行为不变。
+ /// (needPrice/grossPrice 死参数已随 2026-08 收口删除,两侧同步。)
///
protected virtual List CalcSwapInterests(
trade td, trade_extend tradeExtend,
DateTime valueDate, DateTime unwindDate,
List eodPositions, List 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 closeList = null)
{
return 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);
}
+ ///
+ /// 【EOD 当日有平仓后的收盘结息】显式入口——原 SaveAutoEodWithCloseInterestPosition 直调
+ /// CalcSwapInterests(settment:false) 的具名封装(2026-08 显式化重构)。
+ /// 语义契约见 InterestCalcRequest.EodPostCloseSettle 工厂注释(平仓后剩余 + 实际平掉额 + 恒1全额结息,
+ /// 触发 GetInterests 内 mode2/mode9 本金修正)。计息走 CalcUnwindInterest 全区间重放。
+ /// 默认实现仍经 CalcSwapInterests 转发,保持既有测试替身对该虚接缝的拦截不变。
+ ///
+ protected virtual List CalcEodPostCloseSettleInterests(InterestCalcRequest req)
+ => CalcSwapInterests(req.Td, req.TradeExtend, req.ValueDate, req.UnwindDate, req.EodPositions, req.Positions,
+ req.PosiNotionalValue,
+ req.ClosePosiNotionalValue, req.ClosePercent, req.EventType, req.TdClose,
+ req.OrginPv, req.Add, settment: false, req.NewCalcLast, req.CloseList);
+
// FindTrade 已上提到基类 SwapTradeBaseService(三子类实现一致,消除重复)
/// 查找交易扩展(生产: DbContext.trade_extend;测试: 内存字典)
@@ -119,7 +133,7 @@ namespace YLErp.Modules.SwapModule
/// 查找交易持仓(生产: DbContext.swap_position.Where;测试: 内存列表)
protected virtual List FindSwapPositions(int swapTradeId)
{
- return DbContext.swap_position.Where(x => x.SwapTradeId == swapTradeId && !x.Invalid).ToList();
+ return DbContext.swap_position.ActiveByTrade(swapTradeId).ToList();
}
/// 查找框架合约日终汇总(生产: DbContext.eod_swap.FirstOrDefault;测试: 内存字典)
@@ -355,7 +369,7 @@ namespace YLErp.Modules.SwapModule
var closePosiNotional = curEodPosis.Where(s => s.TdCloseQty > 0).Sum(s => s.TdCloseQty * s.ContractSize * s.PosiGrossPrice);
var grossPrice = curEodPosis.Where(x => x.PosiDirection > 0).FirstOrDefault()?.PosiGrossPrice ?? 0;
//处理利息腿
- DealInterests(interestList, eodPositions, todyEodPositions, settleDate, td, flowEvents, autoInterests, lastEodSwap, posiLongNotional, posiShortNotional, closePosiNotional, grossPrice, orginPv);
+ DealInterests(interestList, eodPositions, todyEodPositions, settleDate, td, flowEvents, autoInterests, lastEodSwap, posiLongNotional + posiShortNotional, closePosiNotional, grossPrice, orginPv);
//获取自动互换的 interval 信息,用于确定结算日期
IntervalModel autoInterval = null;
foreach (var interest in interestList)
@@ -427,8 +441,7 @@ namespace YLErp.Modules.SwapModule
List flowEvents,
List autoInterests,
eod_swap lastEodSwap,
- decimal posiLongNational,
- decimal posiShortNational,
+ decimal posiTotalNotional,
decimal closeNational,
decimal grossPrice,
decimal orginPv)
@@ -439,7 +452,7 @@ namespace YLErp.Modules.SwapModule
Log.Info($"[DealInterests] 参数验证 - settleDate: {settleDate:yyyy-MM-dd}, td.id: {td?.id}, td.TradeNumber: {td?.TradeNumber}");
Log.Info($"[DealInterests] 参数验证 - interestList.Count: {interestList?.Count ?? 0}, eodPositions.Count: {eodPositions?.Count ?? 0}, todyEodPositions.Count: {todyEodPositions?.Count ?? 0}");
Log.Info($"[DealInterests] 参数验证 - flowEvents.Count: {flowEvents?.Count ?? 0}, autoInterests.Count: {autoInterests?.Count ?? 0}");
- Log.Info($"[DealInterests] 参数验证 - posiLongNational: {posiLongNational}, posiShortNational: {posiShortNational}, closeNational: {closeNational}, grossPrice: {grossPrice}, orginPv: {orginPv}");
+ Log.Info($"[DealInterests] 参数验证 - posiTotalNotional: {posiTotalNotional}, closeNational: {closeNational}, grossPrice: {grossPrice}, orginPv: {orginPv}");
// 验证关键参数
if (td == null)
@@ -487,7 +500,7 @@ namespace YLErp.Modules.SwapModule
{
if (!hasClose)//当日无平仓
{
- var _autoInterests = SaveAutoEodInterestPosition(eodPosition, tdEodPosition, interest, td, settleDate, insterval, lastEodSwap, posiLongNational, posiShortNational, grossPrice, orginPv);
+ var _autoInterests = SaveAutoEodInterestPosition(eodPosition, tdEodPosition, interest, td, settleDate, insterval, lastEodSwap, posiTotalNotional, grossPrice, orginPv);
if (_autoInterests.Count > 0)
{
autoInterests.AddRange(_autoInterests);
@@ -495,7 +508,7 @@ namespace YLErp.Modules.SwapModule
}
else
{
- var _autoInterests = SaveAutoEodWithCloseInterestPosition(eodPosition, tdEodPosition, interest, td, settleDate, insterval, posiLongNational, posiShortNational, swapEvents, closeNational, true, grossPrice, orginPv);
+ var _autoInterests = SaveAutoEodWithCloseInterestPosition(eodPosition, tdEodPosition, interest, td, settleDate, insterval, posiTotalNotional, swapEvents, closeNational, true, grossPrice, orginPv);
if (_autoInterests.Count > 0)
{
autoInterests.AddRange(_autoInterests);
@@ -508,11 +521,11 @@ namespace YLErp.Modules.SwapModule
}
else if (hasClose)
{
- SaveAutoEodWithCloseInterestPosition(eodPosition, tdEodPosition, interest, td, settleDate, insterval, posiLongNational, posiShortNational, swapEvents, closeNational, false, grossPrice, orginPv);
+ SaveAutoEodWithCloseInterestPosition(eodPosition, tdEodPosition, interest, td, settleDate, insterval, posiTotalNotional, swapEvents, closeNational, false, grossPrice, orginPv);
}
else//无自动互换、互换/平仓,复制上一日终信息,并计算当日新增利息
{
- SaveEodInterestPositionCopy(eodPosition, tdEodPosition, settleDate, td, interest, lastEodSwap, true, posiLongNational, posiShortNational, grossPrice, orginPv);
+ SaveEodInterestPositionCopy(eodPosition, tdEodPosition, settleDate, td, interest, lastEodSwap, true, posiTotalNotional, grossPrice, orginPv);
}
}
}
@@ -1079,7 +1092,7 @@ namespace YLErp.Modules.SwapModule
/// 上一平仓/互换日期
/// 当日平仓金额
/// 上一日终框架合约估值
- protected List SaveAutoEodInterestPosition(eod_swap_position eodPayPosition, eod_swap_position newEodPayPosition, swap_position position, trade td, DateTime valueDate, IntervalModel interval, eod_swap lastEodSwap, decimal posiLongNotional, decimal posiShortNational, decimal grossPrice, decimal orginPv)
+ protected List SaveAutoEodInterestPosition(eod_swap_position eodPayPosition, eod_swap_position newEodPayPosition, swap_position position, trade td, DateTime valueDate, IntervalModel interval, eod_swap lastEodSwap, decimal posiTotalNotional, decimal grossPrice, decimal orginPv)
{
Log.Info($"[SaveAutoEodInterestPosition] 开始执行 - valueDate: {valueDate:yyyy-MM-dd}, td.id: {td?.id}, position.id: {position?.id}");
@@ -1118,7 +1131,7 @@ namespace YLErp.Modules.SwapModule
}
var tradeExtend = td.trade_extend.ExtendObj;
- decimal posiNotionalValue = posiLongNotional + posiShortNational;
+ decimal posiNotionalValue = posiTotalNotional;
decimal closePercent = 1;
var ratio = DirectionRatio.InterestLegPnl(position.InterestDirection, position.InterestMode);
if (eodPayPosition == null)
@@ -1145,7 +1158,7 @@ namespace YLErp.Modules.SwapModule
{
orginPv = eodPayPosition.InterestPrincipalFix;
}
- var interests = CalcSwapInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, posiNotionalValue, posiLongNotional, posiShortNational, posiNotionalValue, closePercent, (int)SwapEventTypeEnum.自动互换, false, true, grossPrice, orginPv, true);
+ var interests = CalcSwapInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, posiNotionalValue, posiNotionalValue, closePercent, (int)SwapEventTypeEnum.自动互换, false, orginPv, true);
decimal interestAmountBeforeSettlement = interests.Sum(x => x.InterestAmount);
decimal tdInterestAmount = interests.Sum(x => x.TdInterestAmount);
@@ -1230,7 +1243,7 @@ namespace YLErp.Modules.SwapModule
/// 当日平仓金额
/// 上一日终框架合约估值
/// 平仓主信息
- protected List SaveAutoEodWithCloseInterestPosition(eod_swap_position eodPayPosition, eod_swap_position newEodPayPosition, swap_position position, trade td, DateTime valueDate, IntervalModel interval, decimal posiLongNotional, decimal posiShortNational, List flowEvents, decimal closeNational, bool autoSwap, decimal grossPrice, decimal orginPv)
+ protected List SaveAutoEodWithCloseInterestPosition(eod_swap_position eodPayPosition, eod_swap_position newEodPayPosition, swap_position position, trade td, DateTime valueDate, IntervalModel interval, decimal posiTotalNotional, List flowEvents, decimal closeNational, bool autoSwap, decimal grossPrice, decimal orginPv)
{
Log.Info($"eodPayPosition is {JsonHelper.Serialize(eodPayPosition, false)},newEodPayPosition is {JsonHelper.Serialize(newEodPayPosition, false)}");
var tradeExtend = td.trade_extend.ExtendObj;
@@ -1241,8 +1254,8 @@ namespace YLErp.Modules.SwapModule
// 调用共享计息器。因此策略的 "posiNotional × closePercent" 在本例会得到 212197382.46,
// 而本次实际应结的平仓本金是 closeNational=90941735.34。该语义错位由
// SwapDealService.GetInterests 的模式2无条件修正、模式9全平零值兜底分流处理,不能删除。
- decimal oriPosiNotionalValue = posiLongNotional + posiShortNational + closeNational;
- decimal posiNotionalValue = posiLongNotional + posiShortNational;
+ decimal oriPosiNotionalValue = posiTotalNotional + closeNational;
+ decimal posiNotionalValue = posiTotalNotional;
// ratio 只负责把腿内原始金额转换为本方盈亏方向,不参与计息金额本身的计算。
var ratio = DirectionRatio.InterestLegPnl(position.InterestDirection, position.InterestMode);
// 首次日终结算可能包含当日收盘,因此尚无先前的日终利息持仓。
@@ -1306,9 +1319,12 @@ namespace YLErp.Modules.SwapModule
List preEodPositions = new List();
preEodPositions.Add(eodPayPosition);
var calcLast = tradeExtend?.InterestCalcMode?.EndsWith("1") ?? true;
- // 此处 closePercent=1 表示 EOD 计算本次事件时走全额结息;它不是 closeNational / oriPosiNotionalValue。
- // 与上方“收盘后剩余本金”同时传入会触发共享计息器的模式2/9本金修正,见 GetInterests。
- var interests = CalcSwapInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, posiNotionalValue, posiLongNotional, posiShortNational, closeNational, 1, eventType, false, true, grossPrice, orginPv, true, settment: false, newCalcLast: autoSwap || calcLast);
+ // 显式入口:平仓后剩余本金 + 实际平掉额 + 恒1全额结息(语义见 InterestCalcRequest.EodPostCloseSettle)。
+ // 该组合触发 GetInterests 内共享计息器的模式2/9本金修正(见其"根因位置"注释,勿删)。
+ var interests = CalcEodPostCloseSettleInterests(InterestCalcRequest.EodPostCloseSettle(
+ td, td.trade_extend, valueDate, valueDate, preEodPositions, positions,
+ posiNotionalValue, closeNational,
+ eventType, tdClose: false, orginPv, add: true, newCalcLast: autoSwap || calcLast));
// TdInterestAmount:计息器返回的全腿当日/累计参考值,用于拆出 EOD 的当日新增。
// interestAmountBeforeSettlement:本次事件发生前理论应结的高精度利息。
// manualSettledInterestAmount:swap_flow_event 实际落库的手工结息,金额已按分处理。
@@ -1488,7 +1504,7 @@ namespace YLErp.Modules.SwapModule
/// 上一交易日
/// 当前结算日
/// 互换交易主干
- protected void SaveEodInterestPositionCopy(eod_swap_position eodPayPosition, eod_swap_position newEodPayPosition, DateTime valueDate, trade td, swap_position position, eod_swap lastEodSwap, bool needPrice, decimal posiLongNational, decimal posiShortNational, decimal grossPrice, decimal orginPv)
+ protected void SaveEodInterestPositionCopy(eod_swap_position eodPayPosition, eod_swap_position newEodPayPosition, DateTime valueDate, trade td, swap_position position, eod_swap lastEodSwap, bool needPrice, decimal posiTotalNotional, decimal grossPrice, decimal orginPv)
{
Log.Info($"eodPayPosition is {JsonHelper.Serialize(eodPayPosition, false)},newEodPayPosition is {JsonHelper.Serialize(newEodPayPosition, false)}");
List intervals = position.SwapIntervalList;
@@ -1512,7 +1528,7 @@ namespace YLErp.Modules.SwapModule
eodPayPosition.InterestPrincipalFix = position.InterestPrincipalFix;
eodPayPosition.InterestRateDefault = position.InterestRateDefault;
eodPayPosition.InterestSwapInterval = position.InterestSwapInterval;
- eodPayPosition.TdInterestPrincipal = interestModes.Contains(position.InterestMode) ? eodPayPosition.InterestPrincipalFix : posiLongNational + posiShortNational;
+ eodPayPosition.TdInterestPrincipal = interestModes.Contains(position.InterestMode) ? eodPayPosition.InterestPrincipalFix : posiTotalNotional;
eodPayPosition.PosiStartDate = td.StartDate.Value;
eodPayPosition.PosiMatuirityDate = td.ExerciseDate.Value;
eodPayPosition.IsAnnualized = position.IsAnnualized;
@@ -1535,7 +1551,7 @@ namespace YLErp.Modules.SwapModule
orginPv = eodPayPosition.InterestPrincipalFix;
}
bool longShort = td.StructureType == ClientMarginTypeEnum.多空组合.ToString();
- decimal oriPosiNotionalValue = posiLongNational + posiShortNational;
+ decimal oriPosiNotionalValue = posiTotalNotional;
decimal posiNotionalValue = oriPosiNotionalValue;
if (lastEodSwap == null)
{
@@ -1560,7 +1576,7 @@ namespace YLErp.Modules.SwapModule
{
preEodPositions.Add(eodPayPosition);
}
- var interests = CalcSwapInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, posiNotionalValue, posiLongNational, posiShortNational, posiNotionalValue, closePercent, 0, false, needPrice, grossPrice, orginPv);
+ var interests = CalcSwapInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, posiNotionalValue, posiNotionalValue, closePercent, 0, false, orginPv);
UpdateDbOption(newEodPayPosition);
newEodPayPosition.PosiStatus = 0;
@@ -2114,7 +2130,7 @@ namespace YLErp.Modules.SwapModule
var tradeSpan = DbContext.trade_span.FirstOrDefault(x => x.TradeId == td.id && x.ValueDate == settleDate);
// eod_swap 是交易级汇总;eod_swap_position 是浮动腿、利息腿和保证金腿的明细。
// 以下先按日终明细拆腿,再按框架合约展示口径汇总。
- var eodSwapPositions = DbContext.eod_swap_position.Where(x => x.SwapTradeId == td.id && x.ValueDate == settleDate && !x.Invalid).ToList();
+ var eodSwapPositions = DbContext.eod_swap_position.ActiveByTradeAndDate(td.id, settleDate).ToList();
var interestPositions = eodSwapPositions.Where(x => string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//利息腿
var positions = eodSwapPositions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//持仓腿
// 框架合约的方向约定:多头为正、空头为负;总名义本金取交易原始规模,
@@ -2176,7 +2192,7 @@ namespace YLErp.Modules.SwapModule
DbContext.eod_swap.Add(eod_Swap);
}
// 单标的调整与首次归档使用同一套框架合约汇总口径,避免重算后多空和名义本金展示不一致。
- var eodSwapPositions = DbContext.eod_swap_position.Where(x => x.SwapTradeId == td.id && x.ValueDate == settleDate && !x.Invalid).ToList();
+ var eodSwapPositions = DbContext.eod_swap_position.ActiveByTradeAndDate(td.id, settleDate).ToList();
var interestPositions = eodSwapPositions.Where(x => string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//利息腿
var positions = eodSwapPositions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//持仓腿
eod_Swap.NotionalValue = Math.Round(Convert.ToDecimal(td.OriginalStockEqvNotional ?? td.StockEqvNotional), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
@@ -2227,7 +2243,7 @@ namespace YLErp.Modules.SwapModule
public SwapLongShortCloseModel GetCloseDetails(int tradeId, DateTime valueDate)
{
SwapLongShortCloseModel closeModel = new SwapLongShortCloseModel();
- var eodPositions = DbContext.eod_swap_position.Where(x => x.SwapTradeId == tradeId && x.ValueDate == valueDate && !x.Invalid).ToList();
+ var eodPositions = DbContext.eod_swap_position.ActiveByTradeAndDate(tradeId, valueDate).ToList();
var flowEvents = DbContext.swap_flow_event.Where(x => x.SwapTradeId == tradeId && x.EventDate == valueDate && x.DataState == (int)SwapFlowDateStateEnum.完成 && x.EventType == (int)SwapEventTypeEnum.平仓 && string.IsNullOrEmpty(x.UnderlyingCode)).ToList();
closeModel.DealPositions = eodPositions.Where(x => x.TdCloseQty != 0).ToList();
closeModel.DealInterests = flowEvents;
@@ -2468,7 +2484,7 @@ namespace YLErp.Modules.SwapModule
///
public List GetPreEodPositions(int tradeId, DateTime valueDate)
{
- return DbContext.eod_swap_position.Where(x => x.SwapTradeId == tradeId && x.ValueDate == valueDate && !x.Invalid).ToList();
+ return DbContext.eod_swap_position.ActiveByTradeAndDate(tradeId, valueDate).ToList();
}
///
/// 获取互换交易日终持仓数据集合
diff --git a/YLErpDAL/Modules/SwapModule/SwapPositionQueries.cs b/YLErpDAL/Modules/SwapModule/SwapPositionQueries.cs
new file mode 100644
index 00000000..7c86a39a
--- /dev/null
+++ b/YLErpDAL/Modules/SwapModule/SwapPositionQueries.cs
@@ -0,0 +1,18 @@
+using System.Linq;
+using YLErp.DBModels;
+
+namespace YLErp.Modules.SwapModule
+{
+ ///
+ /// swap_position 查询收口(Query Object)。
+ /// 规则"有效持仓 = SwapTradeId 匹配且未作废(!Invalid)"集中于此,
+ /// 避免多处复制同一谓词导致语义漂移(漏写 !Invalid 即静默出 bug)。
+ /// 仅返回 IQueryable,不调用 SaveChanges,不破坏跟踪/Include/事务边界。
+ ///
+ public static class SwapPositionQueries
+ {
+ public static IQueryable ActiveByTrade(
+ this IQueryable query, int tradeId)
+ => query.Where(x => x.SwapTradeId == tradeId && !x.Invalid);
+ }
+}
diff --git a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs
index 26a57cee..17adb85f 100644
--- a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs
+++ b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs
@@ -1188,7 +1188,7 @@ namespace YLErp.Modules.SwapModule
tradeObj.trade_Initial_Margin = new trade_initial_margin();
}
tradeObj.trade_extend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == intid);
- tradeObj.swap_positions = DbContext.swap_position.Where(x => x.SwapTradeId == intid && !x.Invalid).ToList();
+ tradeObj.swap_positions = DbContext.swap_position.ActiveByTrade(intid).ToList();
tradeObj.swap_positions = tradeObj.swap_positions.Where(x => x.PosiQuantity > 0 || x.InterestDirection > 0).ToList();
var intervalPositions = tradeObj.swap_positions.Where(x => string.IsNullOrEmpty(x.UnderlyingCode) && x.IsInitial).ToList();
var intervalPositionIds = intervalPositions.Select(s => s.id).ToList();
@@ -1517,7 +1517,7 @@ namespace YLErp.Modules.SwapModule
throw new ServiceException("交易不存在");
}
bool backToBegin = td.TradeDate == valueDate;
- var swapPositions = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && !x.Invalid).ToList();
+ var swapPositions = DbContext.swap_position.ActiveByTrade(tradeId).ToList();
td.trade_extend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == td.id);
//展期
diff --git a/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs b/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs
index 7242618e..ce9a9714 100644
--- a/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs
+++ b/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs
@@ -28,7 +28,8 @@ namespace YLErp.Modules.TradeModule.DealModule
public List Execute(DateTime settleDate, IEnumerable positions)
{
var result = new List();
- var dict = DbContext.ex_dividend_info.Where(O => O.ValidStatus && O.ExDividendDate == settleDate).ToDictionary(K => K.UnderlyingId, V => V);
+ var dict = GetExDividendQuery(settleDate)
+ .ToDictionary(K => K.UnderlyingId, V => V);
foreach (var item in positions)
{
double cost = item.Cost,
@@ -76,7 +77,8 @@ namespace YLErp.Modules.TradeModule.DealModule
useSaveTrades = new List();
useSaveUndedrlyings = new List();
var result = new List();
- var dict = DbContext.ex_dividend_info.Where(O => O.ValidStatus && O.ExDividendDate == settleDate).ToDictionary(K => K.UnderlyingId, V => V);
+ var dict = GetExDividendQuery(settleDate)
+ .ToDictionary(K => K.UnderlyingId, V => V);
var tradeIds = trades.Select(O => O.id);
var dividendRatioDict = new DbRecordChangesService(this).GetValue(ConsInfoChangeType.UserChange, tradeIds, nameof(trade.DividendRatio), settleDate).ToDictionary(K => K.RecordId, V => { return double.TryParse(V.NewValue, out var temp) ? (double?)temp : null; });
foreach (var t in trades)
@@ -713,9 +715,15 @@ namespace YLErp.Modules.TradeModule.DealModule
{
return 0;
}
- var ratio = overrideDividendRatio != null ? overrideDividendRatio.Value : GetRatio(info);
- double? result = price / ratio;
- return Math.Round(result ?? 0, 4, MidpointRounding.AwayFromZero);
+ var decimalRatio = overrideDividendRatio.HasValue
+ ? (decimal)overrideDividendRatio.Value
+ : GetRatioDecimal(info);
+ if (decimalRatio == 0)
+ {
+ return 0;
+ }
+ var result = (decimal)price / decimalRatio;
+ return (double)Math.Round(result, 4, MidpointRounding.AwayFromZero);
}
///
@@ -725,10 +733,17 @@ namespace YLErp.Modules.TradeModule.DealModule
///
public double GetRatio(ex_dividend_info info)
{
- var dividendRate = valuedateBLL.SystemDate.DividendRate / 100;
+ return (double)GetRatioDecimal(info);
+ }
+
+ private decimal GetRatioDecimal(ex_dividend_info info)
+ {
+ var dividendRate = (decimal)valuedateBLL.SystemDate.DividendRate / 100m;
var closePrice = new EodPriceProvider(info.ExDividendDate.Value).GetPrice(info.UnderlyingCode, SettlementTypeEnum.ClosePrice);
- var cDivdPrice = (closePrice * 10.0 - (info.GiveCashAmount * (1 - dividendRate)) + info.RationedSharesAmount * info.RationedSharesPrice) / (10 + info.GiveShareAmount + info.RationedSharesAmount);
- return closePrice / cDivdPrice;
+ var decimalClosePrice = (decimal)closePrice;
+ var cDivdPrice = (decimalClosePrice * 10m - (info.GiveCashAmount * (1m - dividendRate)) + info.RationedSharesAmount * info.RationedSharesPrice) /
+ (10m + info.GiveShareAmount + info.RationedSharesAmount);
+ return cDivdPrice == 0 ? 0 : decimalClosePrice / cDivdPrice;
}
///
@@ -751,18 +766,20 @@ namespace YLErp.Modules.TradeModule.DealModule
///
public double GetPositionAmount(double amount, ex_dividend_info info)
{
- double? result = amount * (1 + info.GiveShareAmount / 10.0);
- return Math.Round(result ?? 0, 12);
+ var result = (decimal)amount * (1m + info.GiveShareAmount / 10m);
+ return (double)Math.Round(result, 12, MidpointRounding.AwayFromZero);
}
public IQueryable GetExDividendQuery(DateTime valueDate)
{
- return DbContext.ex_dividend_info.Where(O => O.ValidStatus && O.ExDividendDate == valueDate);
+ return DbContext.ex_dividend_info
+ .Where(O => O.ValidStatus && O.ExDividendDate == valueDate);
}
public IQueryable GetExDividendQuery(DateTime dateStart, DateTime dateEnd)
{
- return DbContext.ex_dividend_info.Where(O => O.ValidStatus && O.ExDividendDate >= dateStart && O.ExDividendDate <= dateEnd);
+ return DbContext.ex_dividend_info
+ .Where(O => O.ValidStatus && O.ExDividendDate >= dateStart && O.ExDividendDate <= dateEnd);
}
public IEnumerable GetExDividends(DateTime valueDate, params int[] underlyingIds)
@@ -772,7 +789,7 @@ namespace YLErp.Modules.TradeModule.DealModule
{
query = query.Where(n => underlyingIds.Contains(n.UnderlyingId));
}
- return query.ToArray();
+ return query;
}
public IQueryable GetExDividendInfos(string underlyingCode)
@@ -797,17 +814,17 @@ namespace YLErp.Modules.TradeModule.DealModule
{
throw new ServiceException("请使用正确的模板上传");
}
- var dict = new Dictionary();
+ var dividendInfos = new List();
for (var i = 0; i < dt.Rows.Count; i++)
{
var info = new ex_dividend_info
{
UnderlyingCode = dt.Rows[i]["股票代码"]?.ToString(),
ExDividendDate = DateTime.TryParse(getColValueFromTable(dt.Rows[i], "股权登记日"), out var date) ? date : DateTime.MinValue,
- GiveCashAmount = double.TryParse(getColValueFromTable(dt.Rows[i], "派息金额"), out var value) ? value : 0,
- GiveShareAmount = double.TryParse(getColValueFromTable(dt.Rows[i], "送股股数"), out value) ? value : 0,
- RationedSharesAmount = double.TryParse(getColValueFromTable(dt.Rows[i], "配股股数"), out value) ? value : 0,
- RationedSharesPrice = double.TryParse(getColValueFromTable(dt.Rows[i], "配股股价"), out value) ? value : 0,
+ GiveCashAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "派息金额"), out var value) ? value : 0,
+ GiveShareAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "送股股数"), out value) ? value : 0,
+ RationedSharesAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "配股股数"), out value) ? value : 0,
+ RationedSharesPrice = decimal.TryParse(getColValueFromTable(dt.Rows[i], "配股股价"), out value) ? value : 0,
OptId = OptUser.UserId,
OptName = OptUser.UserName,
OptDate = DateTime.Now
@@ -824,9 +841,9 @@ namespace YLErp.Modules.TradeModule.DealModule
{
throw new ServiceException($"第{i + 1}行股权登记日不正确");
}
- dict[$"{info.ExDividendDate}{info.UnderlyingCode}"] = info;
+ dividendInfos.Add(info);
}
- if (!AddDividendInfos(dict.Values, out var errMsg))
+ if (!AddDividendInfos(dividendInfos, out var errMsg))
{
throw new ServiceException(errMsg);
}
@@ -841,48 +858,163 @@ namespace YLErp.Modules.TradeModule.DealModule
return "";
}
+ private ex_dividend_info FindExDividendByBusinessKey(int underlyingId, DateTime exDividendDate, int excludedId = 0)
+ {
+ // 业务唯一键按“标的 + 自然日”定义,而不是按完整 DateTime 定义。
+ // 因此这里使用 [当天 00:00, 次日 00:00) 查询,兼容历史数据中可能存在的时分秒。
+ // excludedId 用于编辑已有记录时排除自身,避免把当前记录误判为重复记录。
+ return DbContext.ex_dividend_info.FirstOrDefault(O => O.UnderlyingId == underlyingId
+ && O.ExDividendDate >= exDividendDate
+ && O.ExDividendDate < exDividendDate.AddDays(1)
+ && (excludedId <= 0 || O.id != excludedId));
+ }
+
+ private static void MergeNonZeroDividendValues(ex_dividend_info target, ex_dividend_info source)
+ {
+ if (target == null)
+ {
+ throw new ArgumentNullException(nameof(target));
+ }
+ if (source == null)
+ {
+ throw new ArgumentNullException(nameof(source));
+ }
+
+ // 同一业务键可能分别来自多行导入,或来自“数据库旧记录 + 当前导入记录”。
+ // 每个字段独立合并:当前值非零时覆盖旧值,当前值为零时保留旧值,
+ // 这样派息、送股、配股数量、配股价格可以从不同来源补齐到同一行。
+ // 该约定将零解释为“未提供”,因此不能通过普通导入把已有字段显式清零。
+ if (source.GiveCashAmount != 0m)
+ {
+ target.GiveCashAmount = source.GiveCashAmount;
+ }
+ if (source.GiveShareAmount != 0m)
+ {
+ target.GiveShareAmount = source.GiveShareAmount;
+ }
+ if (source.RationedSharesAmount != 0m)
+ {
+ target.RationedSharesAmount = source.RationedSharesAmount;
+ }
+ if (source.RationedSharesPrice != 0m)
+ {
+ target.RationedSharesPrice = source.RationedSharesPrice;
+ }
+ }
+
public bool AddDividendInfos(IEnumerable infos, out string errMsg)
{
try
{
- var keys = infos.Select(O => $"{O.ExDividendDate?.ToString("yyyy-MM-dd")}{O.UnderlyingCode}");
- var ids = infos.Select(O => O.id).ToHashSet();
-
- var data = from dividendDb in DbContext.ex_dividend_info.Where(O => keys.Contains(O.ExDividendDate + O.UnderlyingCode) && O.ValidStatus)
- where !ids.Contains(dividendDb.id)
- select dividendDb;
- if (data.Any())
+ var dividendInfos = infos?.ToList();
+ if (dividendInfos == null || dividendInfos.Count == 0)
{
- var dd = data.Select(O => O.UnderlyingCode + "_" + O.ExDividendDate).ToArray();
- errMsg = string.Join(",", dd) + "已存在除息信息,请修改原数据";
+ errMsg = "没有可保存的除权除息信息";
return false;
}
- var basketList =
- DataCacheProvider.GetUnderlyingDataSource()
- .AsQueryable().Where(O => O.IsBasket() && O.SubData != null)
- .Select(O => new { O.UnderlyingCode, O.SubData });
- IEnumerable priceList = null;
- foreach (var item in infos)
+ var preparedInfos = new List<(ex_dividend_info Item, underlying_manager Underlying, DateTime ExDividendDate)>();
+ var preparedIndexes = new Dictionary<(int UnderlyingId, DateTime ExDividendDate), int>();
+ var recordKeys = new Dictionary();
+ foreach (var item in dividendInfos)
{
+ if (item == null || string.IsNullOrWhiteSpace(item.UnderlyingCode))
+ {
+ errMsg = "标的代码信息不存在";
+ return false;
+ }
+
var underlying = underlying_managerBLL.GetByCode(item.UnderlyingCode);
if (underlying == null)
{
errMsg = $"{item.UnderlyingCode} 标的信息不存在";
return false;
}
+ if (!item.ExDividendDate.HasValue)
+ {
+ errMsg = "股权登记日信息不存在";
+ return false;
+ }
+
+ // 保存前统一截断时间部分,确保 Excel/接口传入的同一天不同时间
+ // 能命中同一个自然日业务键,也与数据库的一行模型保持一致。
+ var exDividendDate = item.ExDividendDate.Value.Date;
+ var businessKey = (underlying.id, exDividendDate);
+ if (item.id > 0
+ && recordKeys.TryGetValue(item.id, out var existingRecordKey)
+ && existingRecordKey != businessKey)
+ {
+ errMsg = "同一除权信息不能重复保存";
+ return false;
+ }
+
item.UnderlyingId = underlying.id;
- item.GiveCashAmount = item.GiveCashAmount.FormatValue(6);
- item.RationedSharesAmount = item.RationedSharesAmount.FormatValue(6);
- item.RationedSharesPrice = item.RationedSharesPrice.FormatValue(6);
- item.GiveShareAmount = item.GiveShareAmount.FormatValue(6);
- item.ValidStatus = true;
- item.OptId = OptUser.UserId;
- item.OptName = OptUser.UserName;
- item.OptDate = DateTime.Now;
- var dividend = item.id > 0 ? DbContext.ex_dividend_info.Where(O => O.id == item.id).FirstOrDefault() : null;
+ item.ExDividendDate = exDividendDate;
+ item.GiveCashAmount = OtcFormatHelper.FormatValue(item.GiveCashAmount, 6);
+ item.RationedSharesAmount = OtcFormatHelper.FormatValue(item.RationedSharesAmount, 6);
+ item.RationedSharesPrice = OtcFormatHelper.FormatValue(item.RationedSharesPrice, 6);
+ item.GiveShareAmount = OtcFormatHelper.FormatValue(item.GiveShareAmount, 6);
+
+ // 先在当前批次内按业务键归并。第一条记录作为待保存目标,后续记录
+ // 只补充/覆盖非零字段,不会因为重复行而生成多条数据库记录。
+ if (preparedIndexes.TryGetValue(businessKey, out var preparedIndex))
+ {
+ var preparedItem = preparedInfos[preparedIndex].Item;
+ // 同一业务键下允许重复的是同一条记录(两个新对象都为 id=0,
+ // 或两个对象的 id 相同);不同 id 代表不同存量记录,不能静默合并。
+ if ((preparedItem.id == 0) != (item.id == 0)
+ || preparedItem.id > 0 && item.id > 0 && preparedItem.id != item.id)
+ {
+ errMsg = $"{item.UnderlyingCode} {exDividendDate:yyyy-MM-dd}除权信息不能合并不同记录";
+ return false;
+ }
+
+ MergeNonZeroDividendValues(preparedItem, item);
+ if (item.id > 0)
+ {
+ recordKeys[item.id] = businessKey;
+ }
+ continue;
+ }
+
+ if (item.id > 0)
+ {
+ recordKeys[item.id] = businessKey;
+ }
+ preparedIndexes.Add(businessKey, preparedInfos.Count);
+ preparedInfos.Add((item, underlying, exDividendDate));
+ }
+
+ var basketList =
+ DataCacheProvider.GetUnderlyingDataSource()
+ .AsQueryable().Where(O => O.CommodityCode == "篮子标的" && O.SubData != null)
+ .Select(O => new { O.UnderlyingCode, O.SubData });
+ IEnumerable priceList = null;
+ foreach (var prepared in preparedInfos)
+ {
+ var item = prepared.Item;
+ var underlying = prepared.Underlying;
+ var itemDate = prepared.ExDividendDate;
+ // id>0 表示前端正在编辑指定的存量记录;id=0 时先按自然日业务键
+ // 查找数据库旧记录,使“新增导入”也能与已有记录合并,而不是重复插入。
+ var dividend = item.id > 0
+ ? DbContext.ex_dividend_info.FirstOrDefault(O => O.id == item.id)
+ : FindExDividendByBusinessKey(underlying.id, itemDate);
if (dividend == null)
- { DbContext.ex_dividend_info.Add(item); }
+ {
+ if (item.id > 0)
+ {
+ errMsg = "未找到要修改的除权除息信息";
+ return false;
+ }
+ item.DataSource = ExDividendDataSources.Manual;
+ item.SourceUpdatedAt = null;
+ item.ValidStatus = true;
+ item.OptId = OptUser.UserId;
+ item.OptName = OptUser.UserName;
+ item.OptDate = DateTime.Now;
+ DbContext.ex_dividend_info.Add(item);
+ }
else
{
if (checkDividendInfoExecuteStatus(dividend))
@@ -890,28 +1022,37 @@ namespace YLErp.Modules.TradeModule.DealModule
errMsg = $"{dividend.UnderlyingCode} {dividend.ExDividendDate?.ToString("yyyy-MM-dd")}除权信息保存失败,该信息已被执行,不允许修改!";
return false;
}
+ var conflictingDividend = FindExDividendByBusinessKey(underlying.id, itemDate, dividend.id);
+ if (conflictingDividend != null)
+ {
+ errMsg = $"{item.UnderlyingCode} {itemDate:yyyy-MM-dd}除权信息已存在,不能修改为该业务键";
+ return false;
+ }
+ var sourceUpdatedAt = dividend.SourceUpdatedAt;
dividend.UnderlyingCode = item.UnderlyingCode;
dividend.UnderlyingId = item.UnderlyingId;
dividend.ExDividendDate = item.ExDividendDate;
- dividend.GiveCashAmount = item.GiveCashAmount;
- dividend.RationedSharesAmount = item.RationedSharesAmount;
- dividend.RationedSharesPrice = item.RationedSharesPrice;
- dividend.GiveShareAmount = item.GiveShareAmount;
- dividend.ValidStatus = item.ValidStatus;
- dividend.OptId = item.OptId;
- dividend.OptName = item.OptName;
- dividend.OptDate = item.OptDate;
+ // 数据库已有记录也必须走与批次内重复行相同的合并规则:导入字段非零
+ // 才覆盖旧值,导入字段为零则保留数据库存量值,避免一次不完整导入
+ // 把旧的派息/送股/配股信息误清零。
+ MergeNonZeroDividendValues(dividend, item);
+ dividend.ValidStatus = true;
+ dividend.DataSource = ExDividendDataSources.Manual;
+ dividend.SourceUpdatedAt = sourceUpdatedAt;
+ dividend.OptId = OptUser.UserId;
+ dividend.OptName = OptUser.UserName;
+ dividend.OptDate = DateTime.Now;
}
if (!basketList.Any())
{
continue;
}
- var codes = basketList.Where(O => O.SubData.Contains(item.UnderlyingCode)).Select(O => O.UnderlyingCode);
- if (!codes.Any())
+ var basketCodes = basketList.Where(O => O.SubData.Contains(item.UnderlyingCode)).Select(O => O.UnderlyingCode);
+ if (!basketCodes.Any())
{
continue;
}
- var removePriceList = DbContext.eod_stock_price.Where(O => codes.Contains(O.UnderlyingCode) && O.ValueDate > item.ExDividendDate);
+ var removePriceList = DbContext.eod_stock_price.Where(O => basketCodes.Contains(O.UnderlyingCode) && O.ValueDate > item.ExDividendDate);
if (!removePriceList.Any())
{
continue;
@@ -954,7 +1095,7 @@ namespace YLErp.Modules.TradeModule.DealModule
return true;
}
//查询篮子标的对应交易是否执行过收盘操作;
- var umList = DataCacheProvider.GetUnderlyingDataSource().AsQueryable(O => O.IsBasket() && O.SubData != null && O.SubData.Contains(info.UnderlyingCode)).Select(O => O.UnderlyingCode).ToArray();
+ var umList = DataCacheProvider.GetUnderlyingDataSource().AsQueryable(O => O.CommodityCode == "篮子标的" && O.SubData != null && O.SubData.Contains(info.UnderlyingCode)).Select(O => O.UnderlyingCode).ToArray();
tradeQuery = from t in DbContext.trade.Where(O => umList.Contains(O.UnderlyingCode) && O.TradeDate <= info.ExDividendDate && O.ExerciseDate >= info.ExDividendDate && O.DividendDate >= O.TradeDate)
join et in DbContext.eod_trade.Where(O => ConsTrade.LiveTradeStatusList.Contains(O.TradeStatus))
on new { t.id, ValueDate = t.TradeDate.Value } equals new { id = et.TradeId, et.ValueDate }
diff --git a/YLErpDAL/Modules/TradeModule/DocGenerateModule/ConfirmationGenerateContext.cs b/YLErpDAL/Modules/TradeModule/DocGenerateModule/ConfirmationGenerateContext.cs
index 2ac4c1ac..319889ac 100644
--- a/YLErpDAL/Modules/TradeModule/DocGenerateModule/ConfirmationGenerateContext.cs
+++ b/YLErpDAL/Modules/TradeModule/DocGenerateModule/ConfirmationGenerateContext.cs
@@ -34,6 +34,7 @@ using YLErp.Office.Converters;
using YLErp.Plugins.TradeDocGenerator;
using YLErp.Plugins.TradeDocGenerator.Abstracts;
using YLErp.QdpModule;
+using YLErp.Modules.SwapModule;
namespace YLErp.Modules.TradeModule.DocGenerateModule
{
@@ -2864,7 +2865,7 @@ namespace YLErp.Modules.TradeModule.DocGenerateModule
}
public List GetEodPositions(int tradeId, DateTime valueDate)
{
- return DbContext.eod_swap_position.Where(x => x.SwapTradeId == tradeId && !x.Invalid && x.ValueDate == valueDate).AsNoTracking().ToList();
+ return DbContext.eod_swap_position.ActiveByTradeAndDate(tradeId, valueDate).AsNoTracking().ToList();
}
public List GetSwapFlowDeals(int tradeId)
diff --git a/YLErpWeb/Controllers/ex_dividend_infoController.cs b/YLErpWeb/Controllers/ex_dividend_infoController.cs
index 0783d33a..a366b8ae 100644
--- a/YLErpWeb/Controllers/ex_dividend_infoController.cs
+++ b/YLErpWeb/Controllers/ex_dividend_infoController.cs
@@ -98,6 +98,10 @@ namespace YLErp.Web.Controllers
else
{
r.ValidStatus = false;
+ r.DataSource = ExDividendDataSources.Manual;
+ r.OptId = CurUser.UserId;
+ r.OptName = CurUser.UserName;
+ r.OptDate = DateTime.Now;
yldb.SaveChanges();
return JsonSuccess("删除成功");
}
diff --git a/YLErpWeb/Views/Pricing/Structure_DZ.cshtml b/YLErpWeb/Views/Pricing/Structure_DZ.cshtml
index 236f4f8c..18c7d47e 100644
--- a/YLErpWeb/Views/Pricing/Structure_DZ.cshtml
+++ b/YLErpWeb/Views/Pricing/Structure_DZ.cshtml
@@ -85,7 +85,7 @@
-
+
-
+