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 @@ - + - +