diff --git a/.runsettings b/.runsettings new file mode 100644 index 00000000..e822241a --- /dev/null +++ b/.runsettings @@ -0,0 +1,15 @@ + + + + + + 0 + TestClass + + + diff --git a/Framework/YLErp.Core/DBModels/BondPayment.cs b/Framework/YLErp.Core/DBModels/BondPayment.cs index aae29465..3de640b4 100644 --- a/Framework/YLErp.Core/DBModels/BondPayment.cs +++ b/Framework/YLErp.Core/DBModels/BondPayment.cs @@ -65,6 +65,13 @@ namespace YLErp.DBModels [DisplayName("实际付息(兑付)日")] [Column("pay_date_act")] public DateTime? payment_date { get; set; } + + /// + /// 债权登记日(除息/归属截止日)——票息归属按此判定,而非支付日 + /// + [DisplayName("债权登记日")] + [Column("reg_date")] + public DateTime? reg_date { get; set; } /// /// 每张兑付利息额 /// diff --git a/Framework/YLErp.Core/DBModels/ClientBlackLog.cs b/Framework/YLErp.Core/DBModels/ClientBlackLog.cs new file mode 100644 index 00000000..54bcf093 --- /dev/null +++ b/Framework/YLErp.Core/DBModels/ClientBlackLog.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace YLErp.DBModels +{ + /// + /// 客户黑名单审批及操作日志。 + /// + [Table("client_blacklog")] + public class ClientBlackLog + { + public long id { get; set; } + + public int ClientBlackId { get; set; } + + public string Changes { get; set; } + + public string OptType { get; set; } + + public string DataType { get; set; } + + public int OptId { get; set; } + + public string OptName { get; set; } + + public DateTime OptDate { get; set; } + } + + [NotMapped] + public class ClientBlackLogDto : ClientBlackLog + { + } +} diff --git a/Framework/YLErp.Core/DBModels/Client_Black.cs b/Framework/YLErp.Core/DBModels/Client_Black.cs index 306d3cdd..7fb31970 100644 --- a/Framework/YLErp.Core/DBModels/Client_Black.cs +++ b/Framework/YLErp.Core/DBModels/Client_Black.cs @@ -10,6 +10,13 @@ namespace YLErp.Model [Table("client_black")] public class client_black : DBModelWithOperator, IDataEntity, IDataTraceV2, IClonable { + public const string 未提交 = "未提交"; + public const string 新增审批中 = "新增审批中"; + public const string 新增已拒绝 = "新增已拒绝"; + public const string 已加入 = "已加入"; + public const string 删除审批中 = "删除审批中"; + public const string 删除已拒绝 = "删除已拒绝"; + /// /// 客户名称 /// @@ -25,6 +32,26 @@ namespace YLErp.Model [DataChange] public string Remarks { get; set; } + [DisplayName("提交审批时间")] + public DateTime? ApprovalOptDate { get; set; } + + [DisplayName("提交审批人")] + public string ApprovalOptName { get; set; } + + public int ApprovalProcess { get; set; } + + [DisplayName("状态")] + public string State { get; set; } = ""; + + [DisplayName("创建人")] + public int? creator_id { get; set; } + + [DisplayName("创建人")] + public string creator_name { get; set; } + + [DisplayName("创建时间")] + public DateTime? creator_time { get; set; } + public client_black Clone() { return (client_black)MemberwiseClone(); diff --git a/Framework/YLErp.Core/DBModels/EodSwapPosition.cs b/Framework/YLErp.Core/DBModels/EodSwapPosition.cs index f4c8f745..77b3dbbf 100644 --- a/Framework/YLErp.Core/DBModels/EodSwapPosition.cs +++ b/Framework/YLErp.Core/DBModels/EodSwapPosition.cs @@ -205,7 +205,7 @@ namespace YLErp.DBModels [DataChange] public int InterestDirection { get; set; } /// - /// 计息基本类型 1:固定值,2:合约名义本金规模,3:持仓名义本金,4:持仓市值,5:初始预付金,6:追加预付金 + /// 计息基本类型 1:固定值,2:合约名义本金规模,5:初始预付金,6:追加预付金,9:标的期初全价 /// [DisplayName("计息基本类型")] [DataChange] diff --git a/Framework/YLErp.Core/DBModels/SwapEvent.cs b/Framework/YLErp.Core/DBModels/SwapEvent.cs index 9501c4e4..a57996d5 100644 --- a/Framework/YLErp.Core/DBModels/SwapEvent.cs +++ b/Framework/YLErp.Core/DBModels/SwapEvent.cs @@ -149,6 +149,11 @@ namespace YLErp.DBModels /// public decimal ClosePercent { get; set; } /// + /// 是否计罚息(EQD-6977):提前终止平仓时利息端按持有至到期计息。默认 false=否。 + /// 由平仓页“是否罚息”下拉写入,经 GetUnwindInterests 透传至罚息接缝层。 + /// + public bool IsPenaltyInterest { get; set; } + /// /// 平仓名义本金 /// public decimal CloseNotionalValue { get; set; } diff --git a/Framework/YLErp.Core/DBModels/SwapFlowEvent.cs b/Framework/YLErp.Core/DBModels/SwapFlowEvent.cs index 632ed8c3..b635b672 100644 --- a/Framework/YLErp.Core/DBModels/SwapFlowEvent.cs +++ b/Framework/YLErp.Core/DBModels/SwapFlowEvent.cs @@ -280,7 +280,7 @@ namespace YLErp.DBModels } } /// - /// 计息方式 1:固定值,2:合约名义本金规模,3:持仓名义本金,4:持仓市值 + /// 计息方式 1:固定值,2:合约名义本金规模,5:初始预付金,6:追加预付金,9:标的期初全价 /// [DisplayName("计息方式")] [DataChange] diff --git a/Framework/YLErp.Core/DBModels/SwapPosition.cs b/Framework/YLErp.Core/DBModels/SwapPosition.cs index 04a306f9..a5d66d81 100644 --- a/Framework/YLErp.Core/DBModels/SwapPosition.cs +++ b/Framework/YLErp.Core/DBModels/SwapPosition.cs @@ -163,7 +163,7 @@ namespace YLErp.DBModels /// public string FloatRateUnderlyingCode { get; set; } /// - /// 计息基本类型 1:固定值,2:合约名义本金规模,3:持仓名义本金,4:持仓市值,5:初始预付金,6:追加预付金 + /// 计息基本类型 1:固定值,2:合约名义本金规模,5:初始预付金,6:追加预付金,9:标的期初全价 /// [DisplayName("计息基本类型")] [DataChange] diff --git a/Framework/YLErp.Core/DBModels/TradeExtend.cs b/Framework/YLErp.Core/DBModels/TradeExtend.cs index 72772775..15829dd4 100644 --- a/Framework/YLErp.Core/DBModels/TradeExtend.cs +++ b/Framework/YLErp.Core/DBModels/TradeExtend.cs @@ -89,6 +89,21 @@ namespace YLErp.DBModels /// public string InterestCalcMode { get; set; } = "11"; + /// 算头:InterestCalcMode 首位为 1;null/缺省视为算头。收口原 GetInterests/InitInterestDate/EOD三处 StartsWith 解析。 + [JsonIgnore] + public bool CalcFirst => InterestCalcMode?.StartsWith("1") ?? true; + + /// 算尾:InterestCalcMode 末位为 1;null/缺省视为算尾(EQD-6968 取价依赖判定的核心开关之一)。 + [JsonIgnore] + public bool CalcLast => InterestCalcMode?.EndsWith("1") ?? true; + + /// + /// 是否罚息(EQD-6977):提前终止平仓时利息端按持有至到期计息的簿记默认值。 + /// 平仓页默认带出此值、可修改,以平仓时选择为准(当次选择经 UnwindData.IsPenaltyInterest 走请求,不回写)。 + /// 存量 JSON 无此键 → 反序列化默认 false("否"),零回填。 + /// + public bool IsPenaltyInterest { get; set; } + /// ///多空组合浮动端 收取方向 1:收取,2:支付 /// diff --git a/Framework/YLErp.Core/Interest/AccrualContext.cs b/Framework/YLErp.Core/Interest/AccrualContext.cs deleted file mode 100644 index 3cdbf5fb..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 716d48b7..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 d66ce7c4..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 -{ - /// 系统统一价格精度位数(保证金腿)。 - public const int Precision = 11; - - /// 资金腿计息精度(生产口径)。资金腿所有落库/对账均以 12 位为准, - /// 与保证金腿的 Precision=11 不同。提升至公共常量,消除 SwapDealService 与 FundingLegAccrual 的重复定义。 - 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/ClientModule/ClientBlackApprovalPolicyTests.cs b/UnitTestProject/Modules/ClientModule/ClientBlackApprovalPolicyTests.cs new file mode 100644 index 00000000..60e83b3a --- /dev/null +++ b/UnitTestProject/Modules/ClientModule/ClientBlackApprovalPolicyTests.cs @@ -0,0 +1,133 @@ +using YLErp.Model; + +namespace YLErp.Modules.ClientModule.Tests +{ + [TestClass] + public class ClientBlackApprovalPolicyTests + { + [DataTestMethod] + [DataRow(client_black.未提交, false)] + [DataRow(client_black.新增审批中, false)] + [DataRow(client_black.新增已拒绝, false)] + [DataRow(client_black.已加入, true)] + [DataRow(client_black.删除审批中, true)] + [DataRow(client_black.删除已拒绝, true)] + public void IsEffective_OnlyAppliedOrPendingRemovalStatesAreEffective(string state, bool expected) + { + Assert.AreEqual(expected, ClientBlackApprovalPolicy.IsEffective(state)); + } + + [DataTestMethod] + [DataRow(client_black.未提交, true)] + [DataRow(client_black.新增已拒绝, true)] + [DataRow(client_black.新增审批中, false)] + [DataRow(client_black.已加入, false)] + [DataRow(client_black.删除审批中, false)] + [DataRow(client_black.删除已拒绝, false)] + public void CanSubmitAddition_OnlyDraftOrRejectedAdditionCanSubmit(string state, bool expected) + { + Assert.AreEqual(expected, ClientBlackApprovalPolicy.CanSubmitAddition(state)); + } + + [DataTestMethod] + [DataRow(client_black.已加入, true)] + [DataRow(client_black.删除已拒绝, true)] + [DataRow(client_black.未提交, false)] + [DataRow(client_black.新增审批中, false)] + [DataRow(client_black.新增已拒绝, false)] + [DataRow(client_black.删除审批中, false)] + public void CanRequestRemoval_OnlyEffectiveNonPendingRemovalStatesCanRequest(string state, bool expected) + { + Assert.AreEqual(expected, ClientBlackApprovalPolicy.CanRequestRemoval(state)); + } + + [DataTestMethod] + [DataRow(client_black.未提交, true)] + [DataRow(client_black.新增已拒绝, true)] + [DataRow(client_black.新增审批中, false)] + [DataRow(client_black.已加入, false)] + [DataRow(client_black.删除审批中, false)] + [DataRow(client_black.删除已拒绝, false)] + public void CanDeleteDraft_OnlyNeverEffectiveStatesCanDeleteDirectly(string state, bool expected) + { + Assert.AreEqual(expected, ClientBlackApprovalPolicy.CanDeleteDraft(state)); + } + + [DataTestMethod] + [DataRow(client_black.新增审批中, 1, true)] + [DataRow(client_black.删除审批中, 1, true)] + [DataRow(client_black.新增审批中, 2, false)] + [DataRow(client_black.删除审批中, 2, false)] + [DataRow(client_black.未提交, 0, false)] + public void CanWithdraw_OnlyFirstApprovalNodeCanWithdraw(string state, int approvalProcess, bool expected) + { + Assert.AreEqual(expected, ClientBlackApprovalPolicy.CanWithdraw(state, approvalProcess)); + } + + [DataTestMethod] + [DataRow(client_black.新增审批中, client_black.新增已拒绝)] + [DataRow(client_black.删除审批中, client_black.删除已拒绝)] + public void RejectedState_DistinguishesAdditionAndRemoval(string state, string expected) + { + Assert.AreEqual(expected, ClientBlackApprovalPolicy.GetRejectedState(state)); + } + + [DataTestMethod] + [DataRow(client_black.新增审批中, client_black.未提交, 0)] + [DataRow(client_black.删除审批中, client_black.已加入, -2)] + public void WithdrawState_RestoresStateBeforeSubmission(string state, string expectedState, int expectedProcess) + { + var result = ClientBlackApprovalPolicy.GetWithdrawResult(state); + + Assert.AreEqual(expectedState, result.State); + Assert.AreEqual(expectedProcess, result.ApprovalProcess); + } + + [DataTestMethod] + [DataRow(client_black.新增审批中, client_black.已加入, false)] + [DataRow(client_black.删除审批中, null, true)] + public void GetFinalResult_AdditionAppliesAndRemovalDeletes(string state, string expectedState, bool expectedDelete) + { + var result = ClientBlackApprovalPolicy.GetFinalResult(state); + + Assert.AreEqual(expectedState, result.State); + Assert.AreEqual(expectedDelete, result.ShouldDelete); + } + + [DataTestMethod] + [DataRow(false, client_black.已加入, -2, true)] + [DataRow(true, client_black.未提交, 0, false)] + public void GetAdditionResult_OnlyEffectiveWithoutApprovalProcess(bool hasApprovalProcess, string expectedState, int expectedProcess, bool expectedEffective) + { + var result = ClientBlackApprovalPolicy.GetAdditionResult(hasApprovalProcess); + + Assert.AreEqual(expectedState, result.State); + Assert.AreEqual(expectedProcess, result.ApprovalProcess); + Assert.AreEqual(expectedEffective, result.IsEffective); + } + + [DataTestMethod] + [DataRow(false, client_black.已加入, -2, true)] + [DataRow(true, client_black.删除审批中, 1, false)] + public void GetRemovalResult_OnlyDeletesImmediatelyWithoutApprovalProcess(bool hasApprovalProcess, string expectedState, int expectedProcess, bool expectedDelete) + { + var result = ClientBlackApprovalPolicy.GetRemovalResult(hasApprovalProcess); + + Assert.AreEqual(expectedState, result.State); + Assert.AreEqual(expectedProcess, result.ApprovalProcess); + Assert.AreEqual(expectedDelete, result.ShouldDelete); + } + + [DataTestMethod] + [DataRow(client_black.未提交, true)] + [DataRow(client_black.新增已拒绝, true)] + [DataRow(client_black.新增审批中, false)] + [DataRow(client_black.删除审批中, false)] + [DataRow(client_black.已加入, true)] + [DataRow(client_black.删除已拒绝, true)] + public void CanReplaceRemarks_ApprovalPendingRowsCannotBeOverwritten(string state, bool expected) + { + Assert.AreEqual(expected, ClientBlackApprovalPolicy.CanReplaceRemarks(state)); + } + } +} diff --git a/UnitTestProject/Modules/DataProviderModule/Fr007FixingCacheTest.cs b/UnitTestProject/Modules/DataProviderModule/Fr007FixingCacheTest.cs new file mode 100644 index 00000000..f4784bb2 --- /dev/null +++ b/UnitTestProject/Modules/DataProviderModule/Fr007FixingCacheTest.cs @@ -0,0 +1,137 @@ +using YLErp.Modules.DataProviderModule; + +namespace YLErp.Modules.DataProviderModule +{ + /// + /// Fr007FixingCache 快照缓存行为契约(纯内存,不连库;Loader/时钟均为注入接缝): + /// ① 首次访问批量预载、命中 O(1);② miss 不进快照(负缓存防线——当日发布前 miss、发布后须能查到); + /// ③ 写侧版本失效:Invalidate 后立即重载取到新值(不等 TTL);④ TTL 过期自动重载(直改库兜底); + /// ⑤ TTL 窗口内无写入不重载(零查询稳态)。 + /// + [TestClass] + public class Fr007FixingCacheTest + { + private static readonly DateTime D1 = new(2026, 7, 6); + private static readonly DateTime D2 = new(2026, 7, 13); + private int _loadCount; + private Dictionary _market; + + [TestInitialize] + public void Init() + { + _loadCount = 0; + _market = new Dictionary { [D1] = 0.0142, [D2] = 0.01425 }; + Fr007FixingCache.ResetForTest(); + Fr007FixingCache.LoadSnapshot = () => { _loadCount++; return new Dictionary(_market); }; + } + + [TestCleanup] + public void Cleanup() => Fr007FixingCache.ResetForTest(); + + private static void AdvanceClock(long ticks) => Fr007FixingCache.NowTicks = () => ticks; + + [TestMethod] + public void 首次访问预载并命中() + { + Fr007FixingCache.NowTicks = () => 1_000_000L; + Assert.IsTrue(Fr007FixingCache.TryGet(D1, out var p1), "预载后历史定盘应命中"); + Assert.AreEqual(0.0142, p1, 1e-12); + Assert.AreEqual(1, _loadCount, "首次访问恰好装载一次"); + Assert.IsTrue(Fr007FixingCache.TryGet(D2, out var p2), "同快照内多次命中"); + Assert.AreEqual(0.01425, p2, 1e-12); + Assert.AreEqual(1, _loadCount, "命中不应重复装载"); + } + + [TestMethod] + public void miss不进快照_当日新发布经直查兜底后TTL内可见() + { + Fr007FixingCache.NowTicks = () => 1_000_000L; + var today = new DateTime(2026, 8, 18); + Assert.IsFalse(Fr007FixingCache.TryGet(today, out _), "快照无该行应 miss(调用方直查库兜底)"); + Assert.AreEqual(1, _loadCount); + + // 直查库发现了新发布的当日行 → 写侧失效(SwapFlowService 场景)→ 重载后可见 + _market[today] = 0.0143; + Fr007FixingCache.Invalidate(); + Assert.IsTrue(Fr007FixingCache.TryGet(today, out var p), "写侧失效重载后当日行应可见"); + Assert.AreEqual(0.0143, p, 1e-12); + Assert.AreEqual(2, _loadCount); + } + + [TestMethod] + public void 写侧失效立即重载取到修正值_不等TTL() + { + Fr007FixingCache.NowTicks = () => 1_000_000L; + Fr007FixingCache.TryGet(D1, out _); + Assert.AreEqual(1, _loadCount); + + _market[D1] = 0.0150; // 界面修正错价 + Fr007FixingCache.Invalidate(); + + // 时钟只走了 1 tick(远小于 TTL),仍必须重载 + Fr007FixingCache.NowTicks = () => 1_000_001L; + Assert.IsTrue(Fr007FixingCache.TryGet(D1, out var p), "修正后仍应命中"); + Assert.AreEqual(0.0150, p, 1e-12, "TTL 未到也必须看到写侧修正值"); + Assert.AreEqual(2, _loadCount, "版本失效应立即触发重载"); + } + + [TestMethod] + public void TTL过期自动重载_直改库兜底() + { + Fr007FixingCache.NowTicks = () => 1_000_000L; + Fr007FixingCache.TryGet(D1, out _); + Assert.AreEqual(1, _loadCount); + + _market[D1] = 0.0160; // 直改库(无 Invalidate) + Fr007FixingCache.NowTicks = () => 1_000_000L + TimeSpan.FromMinutes(1).Ticks + 1; // TTL+1 tick + + Assert.IsTrue(Fr007FixingCache.TryGet(D1, out var p)); + Assert.AreEqual(0.0160, p, 1e-12, "TTL 过期后应重载并看到直改库的新值"); + Assert.AreEqual(2, _loadCount); + } + + [TestMethod] + public void TTL窗口内无写入零重载() + { + Fr007FixingCache.NowTicks = () => 1_000_000L; + Fr007FixingCache.TryGet(D1, out _); + + Fr007FixingCache.NowTicks = () => 1_000_000L + TimeSpan.FromMinutes(1).Ticks - 1; // TTL-1 tick:稳态窗口 + for (int i = 0; i < 10; i++) Fr007FixingCache.TryGet(D1, out _); + + Assert.AreEqual(1, _loadCount, "稳态窗口内多次读取零重载(零查询)"); + } + + [TestMethod] + public void TTL过期重载失败_保留旧快照不抛_且窗口内不重试() + { + Fr007FixingCache.NowTicks = () => 1_000_000L; + Assert.IsTrue(Fr007FixingCache.TryGet(D1, out _), "先成功装载一次"); + Assert.AreEqual(1, _loadCount); + + Fr007FixingCache.LoadSnapshot = () => { _loadCount++; throw new InvalidOperationException("db down"); }; + Fr007FixingCache.NowTicks = () => 1_000_000L + TimeSpan.FromMinutes(1).Ticks + 1; // TTL 过期 → 触发重载 → 失败 + + // 不抛:历史定盘不可变,命中继续走旧快照(异常会直接 fail 本用例) + Assert.IsTrue(Fr007FixingCache.TryGet(D1, out var p), "重载失败应保留旧快照继续命中"); + Assert.AreEqual(0.0142, p, 1e-12, "旧快照值不变"); + Assert.AreEqual(2, _loadCount, "失败的重载尝试恰好一次"); + + Fr007FixingCache.TryGet(D2, out _); // TTL 时钟已被重置:窗口内不再重试(防重试风暴) + Assert.AreEqual(2, _loadCount, "失败后TTL窗口内不得反复重试全表SELECT"); + } + + [TestMethod] + public void 首次装载失败_miss不抛_窗口内不重试() + { + Fr007FixingCache.LoadSnapshot = () => { _loadCount++; throw new InvalidOperationException("db down"); }; + Fr007FixingCache.NowTicks = () => 1_000_000L; + + Assert.IsFalse(Fr007FixingCache.TryGet(D1, out _), "装载失败=空快照miss(调用方直查库兜底,新数据该响仍响)"); + Assert.AreEqual(1, _loadCount); + + Fr007FixingCache.TryGet(D1, out _); + Assert.AreEqual(1, _loadCount, "失败后TTL窗口内不重试"); + } + } +} diff --git a/UnitTestProject/Modules/DbDiagnoseGuard.cs b/UnitTestProject/Modules/DbDiagnoseGuard.cs new file mode 100644 index 00000000..0b6c2d4b --- /dev/null +++ b/UnitTestProject/Modules/DbDiagnoseGuard.cs @@ -0,0 +1,33 @@ +namespace YLErp.Modules +{ + /// + /// DbDiagnose 用例自守卫:真实探测一次测试库可达性并缓存结论(整个测试进程共享)。 + /// 不可达 → Assert.Inconclusive:裸跑全量不再出假红,且多个用例只付一次连接超时。 + /// 原先各用例把 try 挂在 GetYLDbContext() 上是无效守卫——EF context 构造不连库, + /// Connect Timeout 发生在首个查询执行时,守卫永远打不中。 + /// + public static class DbDiagnoseGuard + { + private static int _state; // 0=未探测 1=可达 2=不可达 + + public static void RequireTestDb() + { + var state = Volatile.Read(ref _state); + if (state == 1) return; + if (state == 2) Assert.Inconclusive("测试库不可达(结论已缓存),跳过 DbDiagnose 用例"); + + try + { + using var db = DbContextFactory.GetYLDbContext(); + if (!db.Database.CanConnect()) + throw new InvalidOperationException("CanConnect=false"); + Volatile.Write(ref _state, 1); + } + catch (Exception ex) + { + Volatile.Write(ref _state, 2); + Assert.Inconclusive($"无法连接测试库,跳过 DbDiagnose 用例:{ex.Message}"); + } + } + } +} diff --git a/UnitTestProject/Modules/EodModule/GLMS20260805FR007UnderlyingIdDiagnoseTest.cs b/UnitTestProject/Modules/EodModule/GLMS20260805FR007UnderlyingIdDiagnoseTest.cs index 1a8020cf..80da3633 100644 --- a/UnitTestProject/Modules/EodModule/GLMS20260805FR007UnderlyingIdDiagnoseTest.cs +++ b/UnitTestProject/Modules/EodModule/GLMS20260805FR007UnderlyingIdDiagnoseTest.cs @@ -31,6 +31,7 @@ namespace YLErp.Modules.EodModule [TestCategory("DbDiagnose")] public void Diagnose_FR007_UnderlyingIdMismatch() { + DbDiagnoseGuard.RequireTestDb(); YLContext db; try { db = DbContextFactory.GetYLDbContext(); } catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; } @@ -164,6 +165,7 @@ namespace YLErp.Modules.EodModule public void Diagnose_Trade_FR007_ResetDays() { const string TradeNumber = "GLMS-JIATT-20260805-FICC-01-2180120IB"; + DbDiagnoseGuard.RequireTestDb(); YLContext db; try { db = DbContextFactory.GetYLDbContext(); } catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; } @@ -256,6 +258,7 @@ namespace YLErp.Modules.EodModule public void Diagnose_EOD_vs_Unwind_Compound_DailyCompare() { const string TradeNumber = "GLMS-JIATT-20260805-FICC-01-2180120IB"; + DbDiagnoseGuard.RequireTestDb(); YLContext db; try { db = DbContextFactory.GetYLDbContext(); } catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; } @@ -367,6 +370,7 @@ namespace YLErp.Modules.EodModule { const string TradeNumber = "GLMS-JIATT-20260805-FICC-01-2180120IB"; const long CompoundPositionId = 38122; + DbDiagnoseGuard.RequireTestDb(); YLContext db; try { db = DbContextFactory.GetYLDbContext(); } catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; } @@ -431,6 +435,7 @@ namespace YLErp.Modules.EodModule public void Diagnose_Trade_804_ResetDay_FloatRate_Trace() { const string TradeNumber = "GLMS-JIATT-20260805-FICC-01-2180120IB"; + DbDiagnoseGuard.RequireTestDb(); YLContext db; try { db = DbContextFactory.GetYLDbContext(); } catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; } @@ -528,6 +533,7 @@ namespace YLErp.Modules.EodModule public void Diagnose_Trade_AllLegs_And_EodMapping() { const string TradeNumber = "GLMS-JIATT-20260805-FICC-01-2180120IB"; + DbDiagnoseGuard.RequireTestDb(); YLContext db; try { db = DbContextFactory.GetYLDbContext(); } catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; } diff --git a/UnitTestProject/Modules/SwapModule/Accrual/CompoundCarryInTest.cs b/UnitTestProject/Modules/SwapModule/Accrual/CompoundCarryInTest.cs new file mode 100644 index 00000000..8c4af6da --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/Accrual/CompoundCarryInTest.cs @@ -0,0 +1,107 @@ +using Newtonsoft.Json; +using YLErp; +using YLErp.Modules.SwapModule; +using YLErp.Modules.SwapModule.Accrual; + +namespace UnitTestProject.Modules.SwapModule.Accrual +{ + /// + /// EQD-6977 carryInInterest 契约测试: + /// 1) 默认 0 与旧逐日循环逐位一致(加参零行为变化的安全证明); + /// 2) carry-in 仅在【首个重置日】并入计息基数(非窗口首日起息)—— + /// 与"持有至到期"全期轨迹对齐的数学不变量:增量 = carryIn × 后续段日利率 × 后续段天数。 + /// + [TestClass] + public class CompoundCarryInTest + { + private const decimal Notional = 100_000_000m; + private const decimal Spread = 0.0025m; + private const int AnnualDays = 365; + private static readonly DateTime StartDate = new(2026, 4, 21); + private static readonly DateTime EndDate = new(2026, 5, 11); // 21天 = 3×7,末日是重置日 + + private static swap_position CreatePosition() + { + return new swap_position + { + id = 1001, SwapTradeId = 1, PosiDirection = 0, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.标的期初全价, + InterestRateDefault = Spread, + InterestPrincipalFix = Notional, + PosiStartDate = StartDate, PosiMatuirityDate = StartDate.AddYears(1), + IsInitial = true, Invalid = false, + InterestType = (int)InterestTypeEnum.复利, + IsAnnualized = true, interest_rest_days = 7, interest_rule = 0, + FloatRateUnderlyingCode = null, + InterestSwapInterval = "[]" + }; + } + + private static List<(DateTime, decimal)> Segments() + => new() + { + (StartDate, Spread), + (StartDate.AddDays(7), Spread), + (StartDate.AddDays(14), Spread), + }; + + private sealed class StubSvc : SwapDealService + { + public StubSvc() : base(new OptUserInfo(0, nameof(CompoundCarryInTest), OptUserFrom.UnitTest)) { } + } + + [TestMethod] + public void carryIn_默认省略_与旧逐日循环一致() + { + var position = CreatePosition(); + var flowEvent = new swap_flow_event { InterestRate = Spread }; + + decimal oldI = 0, oldTd = 0; + new StubSvc().CalcDailyCompoundInterest(EndDate, position, Notional, flowEvent, + AnnualDays, 0m, 1m, true, false, ref oldI, ref oldTd); + + // 省略 carryInInterest(默认 0) + var r1 = CompoundInterestAccrual.AccruePeriod( + notional: Notional, segmentRates: Segments(), + startDate: StartDate, endDate: EndDate, + boundary: AccrualBoundary.StartOnly, annualDays: AnnualDays, isAnnualized: true, + resetCarryInterest: 0m, realizedInterest: 0m, unwindFraction: 1m, + finalBasis: out _); + // 显式传 0 与省略等价 + var r2 = CompoundInterestAccrual.AccruePeriod( + notional: Notional, segmentRates: Segments(), + startDate: StartDate, endDate: EndDate, + boundary: AccrualBoundary.StartOnly, annualDays: AnnualDays, isAnnualized: true, + resetCarryInterest: 0m, realizedInterest: 0m, unwindFraction: 1m, + finalBasis: out _, trace: null, carryInInterest: 0m); + + Assert.AreEqual((double)oldI, (double)r1.Accrued, 0.0000001, "省略 carryIn 与旧实现一致"); + Assert.AreEqual((double)r1.Accrued, (double)r2.Accrued, 0.0000001, "省略与显式0一致"); + } + + [TestMethod] + public void carryIn_仅在首重置日起息_增量等于后续两段复利() + { + const decimal carryIn = 1_000_000m; + + decimal Accrued(decimal c) + => CompoundInterestAccrual.AccruePeriod( + notional: Notional, segmentRates: Segments(), + startDate: StartDate, endDate: EndDate, + boundary: AccrualBoundary.Both, annualDays: AnnualDays, isAnnualized: true, + resetCarryInterest: 0m, realizedInterest: 0m, unwindFraction: 1m, + finalBasis: out _, trace: null, carryInInterest: c).Accrued; + + var delta = Accrued(carryIn) - Accrued(0m); + + // Both 边界下三段各 7 天。carryIn 于 4/28(首个重置日)并入基数: + // 首段 [4/21,4/28] 不含 carryIn;其后两段 carryIn 自身起息且其首段利息再复利。 + // 精确增量 = c×d + (c + c×d)×d = c×(2d + d²) = c×((1+d)² − 1),d = 7天利率因子。 + var d = Spread * 7m / AnnualDays; + var expected = carryIn * (2m * d + d * d); + Assert.AreEqual((double)expected, (double)delta, 0.001, + "carryIn 增量 = 首个重置日起息的两段复利,首段不含 carryIn"); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/Accrual/CompoundEodShadowTest.cs b/UnitTestProject/Modules/SwapModule/Accrual/CompoundEodShadowTest.cs index a904af3a..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 { @@ -92,7 +90,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual decimal oldInterest = 0, oldTd = 0; var svc = new StubSvc(); svc.CalcDailyCompoundInterestByEod(preEod, EodDate, TradeDate, position, - Notional, Notional, flowEvent, AnnualDays, false, 0m, 1m, + Notional, Notional, flowEvent, AnnualDays, 0m, 1m, ref oldInterest, ref oldTd); // 新方法 @@ -125,7 +123,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual decimal oldInterest = 0, oldTd = 0; var svc = new StubSvc(); svc.CalcDailyCompoundInterestByEod(preEod, nonResetDate, TradeDate, position, - Notional, Notional, flowEvent, AnnualDays, false, 0m, 1m, + Notional, Notional, flowEvent, AnnualDays, 0m, 1m, ref oldInterest, ref oldTd); // 新方法 diff --git a/UnitTestProject/Modules/SwapModule/Accrual/CompoundPeriodShadowTest.cs b/UnitTestProject/Modules/SwapModule/Accrual/CompoundPeriodShadowTest.cs index 855419c5..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; @@ -74,7 +73,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual decimal oldI = 0, oldTd = 0; var svc = new StubSvc(); svc.CalcDailyCompoundInterest(EndDate, position, Notional, flowEvent, - AnnualDays, false, 0m, 1m, true, false, + AnnualDays, 0m, 1m, true, false, ref oldI, ref oldTd); // 新方法:固定利率全段相同,分段点 = PosiStartDate + k×7 @@ -122,7 +121,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual decimal oldI = 0, oldTd = 0; var svc = new StubSvc(); svc.CalcDailyCompoundInterest(EndDate, position, Notional * closePct, flowEvent, - AnnualDays, false, 0m, closePct, true, false, + AnnualDays, 0m, closePct, true, false, ref oldI, ref oldTd, consumedInterest: consumed, resetCarryInterest: carry); // 新方法 @@ -166,7 +165,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual decimal oldI = 0, oldTd = 0; var svc = new StubSvc(); svc.CalcDailyCompoundInterest(EndDate, position, Notional, flowEvent, - AnnualDays, false, 0m, 1m, true, true, + AnnualDays, 0m, 1m, true, true, ref oldI, ref oldTd); // 新方法 diff --git a/UnitTestProject/Modules/SwapModule/Accrual/ContractReferenceCalc.cs b/UnitTestProject/Modules/SwapModule/Accrual/ContractReferenceCalc.cs new file mode 100644 index 00000000..abf233dd --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/Accrual/ContractReferenceCalc.cs @@ -0,0 +1,72 @@ +namespace UnitTestProject.Modules.SwapModule.Accrual +{ + /// + /// 契约参考实现(确认书公式,TEST-MATRIX §8a)——全矩阵统一 oracle 供给。 + /// + /// 【独立性约束·勿破坏】本类只实现确认书公式原文,禁止引用任何生产计息引擎类 + /// (YLErp.Modules.SwapModule.Accrual.* / SwapDealService),否则 oracle 与被测对象同源, + /// 失去"独立参考"资格(oracle 分级第一级,见 TEST-MATRIX §7.4)。 + /// + /// 确认书公式(国联民生收益互换确认书-现券/ETF 四份一致): + /// 参考利率(绝对) = ∏[i=1..k] ( 1 + (FR007i + 利差) × di / 365 ) − 1 + /// 结息额(平仓部分) = 实际平掉额 × 参考利率(绝对) + /// - k = 计息期包含的重置期个数;完整重置期 di = 重置频率(生产 7 天),末段不足按实际日历日 + /// - 重置期自计息期首日按重置频率依次推算;首个重置期始于计息期首日;末段收口到计息期最后一日 + /// - 利率确定日 = 每个重置期首日(重置日)的上一个营业日,取该日 FR007 + /// - 计息期 = 自起始日(含)至到期日(不含)——即算头不算尾 "10"(生产主力条款) + /// - 计息基准 A/365 + /// + /// 营业日准则:本参考实现按周末近似(周六/周日非营业日);法定节假日历由调用方通过 + /// 取价委托自行吸收(如按确定日提供同一利率)。测试与生产参数对齐(§8):重置 7 天 / 365。 + /// + public static class ContractReferenceCalc + { + /// + /// 参考利率(绝对) = ∏(1 + (FR007i+利差)×di/annualDays) − 1。 + /// + /// 计息期首日(含) + /// 计息期末日("10"不含/"11"含,由 calcLast 决定) + /// 重置频率天数(生产 7) + /// 利差(InterestRateDefault,如 +0.25% = 0.0025) + /// 取价委托:入参=利率确定日(重置日上一营业日),返回该日 FR007 + /// 算头(生产 "10"/"11" 为 true) + /// 算尾(生产 "10" 为 false) + /// 计息基准(生产 365) + public static decimal ReferenceRateAbsolute( + DateTime startDate, DateTime endDate, + int resetDays, decimal spread, + Func fixing, + bool calcFirst = true, bool calcLast = false, + int annualDays = 365) + { + var totalDays = (endDate - startDate).Days + (calcFirst ? 0 : -1) + (calcLast ? 1 : 0); + if (totalDays <= 0) return 0m; + + decimal factor = 1m; + var resetDate = startDate; // 首个重置期始于计息期首日 + var remaining = totalDays; + while (remaining > 0) + { + var di = Math.Min(resetDays, remaining); // 完整期 di=resetDays,末段按实际日历日 + var fixingDate = PreviousBusinessDay(resetDate); + var allIn = fixing(fixingDate) + spread; + factor *= 1m + allIn * di / annualDays; + remaining -= di; + resetDate = resetDate.AddDays(di); + } + return factor - 1m; + } + + /// 结息额(平仓部分)= 实际平掉额 × 参考利率(绝对)。 + public static decimal ClosedInterest(decimal closedNotional, decimal referenceRate) + => closedNotional * referenceRate; + + /// 利率确定日 = 重置日的上一营业日(周末近似)。 + public static DateTime PreviousBusinessDay(DateTime date) + { + do { date = date.AddDays(-1); } + while (date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday); + return date; + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/Accrual/ContractReferenceOracleTest.cs b/UnitTestProject/Modules/SwapModule/Accrual/ContractReferenceOracleTest.cs new file mode 100644 index 00000000..52fb404f --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/Accrual/ContractReferenceOracleTest.cs @@ -0,0 +1,196 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; +using YLErp; +using YLErp.DBModels.Enums; +using YLErp.Modules.SwapModule; + +namespace UnitTestProject.Modules.SwapModule.Accrual +{ + /// + /// 契约参考实现 oracle 落地(TEST-MATRIX §7 第 5 步)——两段式: + /// + /// ① oracle 自验证:手算锚点直接钉 ContractReferenceCalc(独立于生产引擎,公式正确性 + /// 由手算锚点保证——真实规模 5000 万/2.05%/90 天 与玩具 4 天。Excel 金标准期望值亦符合本公式(2026-08-18 复核)。 + /// ② 引擎对照:主力族(mode9 标的期初全价 / mode2 合约名义本金规模 × FR007 × 复利 × "10") + /// 盘中 T+0 部分平仓 30%,GetInterests 重放结果 必须 == 契约 oracle(容差 0.01 元,§7.4)。 + /// 这是本矩阵第一个"契约公式独立参考实现"级 oracle 的引擎对照用例(此前仅有 Excel 手算/工单值)。 + /// + /// 引擎对照用恒定 FR007 利率表——刻意免疫"利率确定日=重置日上一营业日 vs 当日"的取价日 + /// 约定差异(任何确定日取到的都是同一利率),单独验证 ∏ 公式/重置期切分/算头不算尾/末段收口; + /// 取价日维度(E 维,66a97e03)由变利率用例在 oracle 侧钉住(§①第 4 例),引擎侧后续补。 + /// + /// 坐标登记:mode9/mode2 × 复利 × "10" × T+0 × 部分平仓30% × B=跨12个完整重置期+末段 × E=恒定利率。 + /// + [TestClass] + public class ContractReferenceOracleTest + { + // ── 生产参数(TEST-MATRIX §8:7 天重置 / A365 / 真实点差 +0.25% / 千万级名义)── + private const decimal Spread = 0.0025m; // 点差 +0.25%(确认书真实点差) + private const decimal Fr007 = 0.018m; // FR007 示意水平 1.8% → all-in 2.05% + private const int ResetDays = 7; + private const int AnnualDaysConst = 365; + private const decimal Notional = 50_000_000m; // 名义 5000 万 + private const decimal ClosedNotional = 15_000_000m; // 平掉 30% = 1500 万 + private const decimal ClosePercent = 0.3m; + + private static readonly DateTime StartDate = new(2026, 4, 27); // 周一,起息日 + private static readonly DateTime Unwind90 = new(2026, 7, 26); // 90 天 = 12×7 + 6 末段 + private static readonly DateTime Unwind89 = new(2026, 7, 25); // 89 天 = 12×7 + 5 末段 + private static readonly DateTime ExerciseDate = new(2027, 4, 27); + + #region ① oracle 自验证(手算锚点) + + [TestMethod] + public void 契约公式_恒定利率_90天12整期加6天末段_等于手算() + { + var rate = ContractReferenceCalc.ReferenceRateAbsolute( + StartDate, Unwind90, ResetDays, Spread, _ => Fr007, + calcFirst: true, calcLast: false, annualDays: AnnualDaysConst); + // 手算:(1+0.0205×7/365)^12 × (1+0.0205×6/365) − 1(python 高精度复核) + Assert.AreEqual(0.0050666026m, rate, 0.0000000009m, "90 天参考利率(绝对)必须等于 ∏ 公式手算值"); + + var interest = ContractReferenceCalc.ClosedInterest(ClosedNotional, rate); + Assert.AreEqual(75999.04m, interest, 0.01m, "平掉 1500 万 × 参考利率 = 确认书公式应结值"); + } + + [TestMethod] + public void 契约公式_恒定利率_89天末段5天_等于手算() + { + var rate = ContractReferenceCalc.ReferenceRateAbsolute( + StartDate, Unwind89, ResetDays, Spread, _ => Fr007, + calcFirst: true, calcLast: false, annualDays: AnnualDaysConst); + Assert.AreEqual(0.0050101727m, rate, 0.0000000009m, "89 天参考利率(绝对)手算值"); + + var interest = ContractReferenceCalc.ClosedInterest(ClosedNotional, rate); + Assert.AreEqual(75152.59m, interest, 0.01m); + } + + [TestMethod] + public void 契约公式_玩具参数_算头算尾4天_等于手算锚点() + { + // 手算:300×[(1+0.011×3/365)×(1+0.011×1/365)−1] = 0.0361652(重置 3 天,利差 1%,FR 0.1%) + var rate = ContractReferenceCalc.ReferenceRateAbsolute( + new DateTime(2026, 4, 27), new DateTime(2026, 4, 30), resetDays: 3, + spread: 0.01m, fixing: _ => 0.001m, + calcFirst: true, calcLast: true, annualDays: 365); + var interest = ContractReferenceCalc.ClosedInterest(300m, rate); + Assert.AreEqual(0.0361652m, interest, 0.000001m); + } + + [TestMethod] + public void 契约公式_分段变利率_利率确定日为重置日上一营业日() + { + // 计息期 [5/4(一), 5/15(五)) "10" → 11 天 = 7 + 4 末段;重置日 5/4、5/11(均为周一) + // 契约:利率确定日 = 重置日上一营业日 → 5/1(五)、5/8(五) + Assert.AreEqual(new DateTime(2026, 5, 1), ContractReferenceCalc.PreviousBusinessDay(new DateTime(2026, 5, 4)), "5/4(一)的上一营业日是 5/1(五)"); + Assert.AreEqual(new DateTime(2026, 5, 8), ContractReferenceCalc.PreviousBusinessDay(new DateTime(2026, 5, 11)), "5/11(一)的上一营业日是 5/8(五)"); + + var fixings = new Dictionary + { + [new DateTime(2026, 5, 1)] = 0.02m, // 第一段 FR007 2.0% → all-in 2.25% + [new DateTime(2026, 5, 8)] = 0.03m, // 第二段 FR007 3.0% → all-in 3.25% + }; + var rate = ContractReferenceCalc.ReferenceRateAbsolute( + new DateTime(2026, 5, 4), new DateTime(2026, 5, 15), ResetDays, Spread, + d => fixings[d], calcFirst: true, calcLast: false, annualDays: AnnualDaysConst); + // 手算:(1+0.0225×7/365)×(1+0.0325×4/365)−1 = 0.0007878249 + Assert.AreEqual(0.0007878249m, rate, 0.0000000009m, + "分段变利率下每段必须用各自确定日的 FR007(E 维:取价日=重置日上一营业日)"); + } + + #endregion + + #region ② 引擎对照(恒定 FR007,免疫取价日约定) + + private sealed class StubSwapDealService : SwapDealService + { + public StubSwapDealService() : base( + new OptUserInfo(0, nameof(ContractReferenceOracleTest), OptUserFrom.UnitTest)) { } + + protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate) + { + if (!string.Equals(underlyingCode, "FR007", StringComparison.OrdinalIgnoreCase)) { rate = 0; return false; } + rate = (double)Fr007; + return true; + } + + /// fresh 重放无历史已结利息,覆写掉 DB 查询(本场景语义即 0)。 + public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate) => 0m; + } + + private static trade CreateTrade() + { + var extend = new trade_extend + { + TradeId = 1, + ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { + AnnualDays = AnnualDaysConst, + InterestCalcMode = "10", // 算头不算尾(生产主力条款) + SettlementRules = 0 + }) + }; + return new trade + { + id = 1, TradeNumber = "UT-CONTRACT-REF-ORACLE", ClientId = 999998, + TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate, + ExerciseDate = ExerciseDate, TradeStatus = "确认成交", ValidState = "Valid", + trade_extend = extend + }; + } + + private static swap_position CreatePosition(InterestModeEnum mode) => + new() + { + id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown, + InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)mode, + InterestRateDefault = Spread, InterestPrincipalFix = Notional, + PosiStartDate = StartDate, PosiMatuirityDate = ExerciseDate, + IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.复利, + IsAnnualized = true, interest_rest_days = ResetDays, interest_rule = 0, + FloatRateUnderlyingCode = "FR007", + InterestSwapInterval = JsonConvert.SerializeObject( + new List { new() { Date = ExerciseDate, Rate = Spread, Settlement = 0 } }) + }; + + /// 引擎盘中重放(T+0 fresh 持仓,T0 形状)vs 契约 oracle,容差 0.01 元。 + private static void AssertEngineMatchesOracle( + InterestModeEnum mode, DateTime unwindDate, decimal posi, decimal closePosi, + decimal expectedOracleInterest) + { + var td = CreateTrade(); + var position = CreatePosition(mode); + var interests = new StubSwapDealService().GetInterests( + td, td.trade_extend, unwindDate, unwindDate, + new List(), new List { position }, + posi, closePosi, ClosePercent, + (int)SwapEventTypeEnum.平仓, + tdClose: false, orginPv: posi, add: false, settment: false, newCalcLast: false, closeList: null); + + Assert.AreEqual(1, interests.Count); + Assert.IsTrue(Math.Abs(interests[0].InterestAmount - expectedOracleInterest) <= 0.01m, + $"mode={mode} 引擎重放 {interests[0].InterestAmount} vs 契约 oracle {expectedOracleInterest}," + + $"diff={interests[0].InterestAmount - expectedOracleInterest}——引擎偏离确认书公式(TEST-MATRIX §8a)"); + } + + private static decimal OracleInterest(DateTime unwindDate) => + ContractReferenceCalc.ClosedInterest(ClosedNotional, + ContractReferenceCalc.ReferenceRateAbsolute( + StartDate, unwindDate, ResetDays, Spread, _ => Fr007, + calcFirst: true, calcLast: false, annualDays: AnnualDaysConst)); + + [TestMethod] + public void 引擎_mode9_复利FR007_10_部分平仓30_90天_等于契约oracle() + => AssertEngineMatchesOracle(InterestModeEnum.标的期初全价, Unwind90, Notional, Notional, OracleInterest(Unwind90)); + + [TestMethod] + public void 引擎_mode9_复利FR007_10_部分平仓30_89天_等于契约oracle() + => AssertEngineMatchesOracle(InterestModeEnum.标的期初全价, Unwind89, Notional, Notional, OracleInterest(Unwind89)); + + [TestMethod] + public void 引擎_mode2_复利FR007_10_部分平仓30_显式平掉额_等于契约oracle() + => AssertEngineMatchesOracle(InterestModeEnum.合约名义本金规模, Unwind90, Notional, ClosedNotional, OracleInterest(Unwind90)); + + #endregion + } +} diff --git a/UnitTestProject/Modules/SwapModule/Accrual/SimplePeriodShadowTest.cs b/UnitTestProject/Modules/SwapModule/Accrual/SimplePeriodShadowTest.cs index 283ca087..a1a8a516 100644 --- a/UnitTestProject/Modules/SwapModule/Accrual/SimplePeriodShadowTest.cs +++ b/UnitTestProject/Modules/SwapModule/Accrual/SimplePeriodShadowTest.cs @@ -100,7 +100,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual decimal oldI = 0, oldTd = 0; var svc = new StubSvc(); svc.CalcDailySimpleInterest(preEod, EndDate, position, Notional, flowEvent, - AnnualDays, false, 0m, 1m, Notional, true, false, ref oldI, ref oldTd); + AnnualDays, 0m, 1m, Notional, true, false, ref oldI, ref oldTd); // 新方法:固定利率全段相同 // 旧代码差分: dynomicPrincipal = preEod.TdInterestPrincipal(=0) + posiPrincipal - orginPv = 0 @@ -146,7 +146,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual decimal oldI = 0, oldTd = 0; var svc = new StubSvc(); svc.CalcDailySimpleInterest(preEod, EndDate, position, Notional, flowEvent, - AnnualDays, false, 0m, 0.5m, Notional, true, false, ref oldI, ref oldTd); + AnnualDays, 0m, 0.5m, Notional, true, false, ref oldI, ref oldTd); // 新方法 // 差分本金 = preEod.TdInterestPrincipal + posiPrincipal - orginPv @@ -200,7 +200,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual decimal oldI = 0, oldTd = 0; var svc = new FloatStubSvc(new StubIndexFixer(fixingAtReset)); svc.CalcDailySimpleInterest(preEod, EndDate, position, Notional, flowEvent, - AnnualDays, false, floatRateIn, closePct, Notional, true, false, ref oldI, ref oldTd); + AnnualDays, floatRateIn, closePct, Notional, true, false, ref oldI, ref oldTd); // 新方法:手算 segmentRates(对齐旧代码取价循环的逻辑) // 4/21 <= preEodDate(4/30) → 跳过取价,currentFloat 保持入参 floatRateIn 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 baf4b420..35aac0b8 100644 --- a/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs @@ -134,6 +134,17 @@ namespace YLErp.Modules.SwapModule return new swap_event { id = SwapEvents.Count }; } + /// + /// 捕获 SaveAutoSwapDeal 落库的 flow_event(生产写 DbContext.swap_flow_event)。 + /// 同步到 PersistedFlowEvents 供 AS_009/010/011 断言;基类 FlowEvents 仍由它填充, + /// 供 GetConsumedInterest 真实计算已结利息。 + /// + protected override void PersistFlowEvent(swap_flow_event flowEvent) + { + base.PersistFlowEvent(flowEvent); + PersistedFlowEvents.Add(flowEvent); + } + /// /// 利息腿金额直接给定(付息金额),避免把 GetInterests 的计息细节混入本用例—— /// 本文件关注的是「自动互换是否触发 / 几条 / 资金发生日 / 金额量级」, @@ -144,9 +155,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 9b70262f..ae6a4628 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]; @@ -273,45 +273,28 @@ namespace YLErp.Modules.SwapModule } // ================================================================ - // 场景7:复现"平仓日=重置日 + calcLast=false → 重置日跳过 FR007 取价" + // 场景7:平仓日=重置日 + calcLast=false 的事件利率口径(EQD-6968 自洽化后) // ================================================================ /// - /// [CI_007] 平仓日恰好是重置日时,calcLast=false 不应导致该重置日的 FR007 取价被跳过 + /// [CI_007] 平仓日=重置日 + calcLast=false:排除日不取价,事件利率=末段已消费利率(确定性) /// ---------------------------------------------------------------- - /// 背景(GLMS-JIATT-20260805 根因):InterestCalcMode='10'(算头不算尾,calcLast=false), - /// CalcDailyCompoundInterest 循环里 `if(!calcLast && accrueDate==endDate) continue` 会跳过平仓日当天。 - /// 若平仓日恰好是重置日(i%period==0),这个跳过会让"重置日取新FR007"的代码块永远不执行, - /// 沿用上一个重置周期的旧利率。 + /// 历史(GLMS-JIATT-20260805):原缺陷是重置日取价被 calcLast 跳过 → flowEvent.FloatRate 停留旧值 + /// → 落库后传染 EOD。当时的修复=排除日"有价则取新定盘",事件利率因而取决于平仓时刻 + /// (上午=旧/下午=新),与金额实际使用的利率脱钩。 /// - /// 构造:PosiStartDate=4/27, ResetPeriod=3, InterestCalcMode='10'(calcLast=false) - /// - FR007 按日期分段:5/3之前返回 rateOld=0.001,5/3及之后返回 rateNew=0.002 - /// - 对照A:平仓日=5/5(非重置日,9? 不: (5/5-4/27)=8, 8%3=2 非重置) → 不该取新值 - /// - 对照B:平仓日=5/6(重置日,(5/6-4/27)=9, 9%3=0) → 应取新值 rateNew + /// EQD-6968 自洽化后的新契约: + /// ① 已平部分:排除日一概不取价(有价也不取),事件 FloatRate=末段已消费利率(rateOld), + /// 与金额同源、与平仓时刻无关; + /// ② 剩余持仓的新周期利率:由 EOD 快照"重置日再定盘"显式获取 + /// (InterestEodTailSnapshotTest.CloseOnly_平仓日为重置日_剩余持仓快照再定盘)。 /// - /// 修复前:5/6 重置日被 calcLast 跳过 → 取到旧 rateOld → 与 5/5 相同 - /// 修复后:5/6 重置日正常取价 → 取到 rateNew → 与 5/5 不同 - /// ---------------------------------------------------------------- - /// - /// - /// [CI_007] 平仓日恰好是重置日时,calcLast=false 不应导致该重置日的 FR007 取价被跳过 - /// ---------------------------------------------------------------- - /// 根因(GLMS-JIATT-20260805):InterestCalcMode='10'(calcLast=false), - /// CalcDailyCompoundInterest 循环 `if(!calcLast && accrueDate==endDate) continue` 跳过平仓日。 - /// 若平仓日=重置日,取价代码块被跳过 → flowEvent.FloatRate 停留旧值 → 落库后传染 EOD。 - /// - /// 构造(避开周末,period=7): - /// PosiStartDate=4/27(周一), period=7, interest_rule=0, InterestCalcMode='10' - /// 重置日:i=0→4/27(周一), i=7→5/4(周一,工作日) - /// 平仓日=5/4(=重置日=endDate) - /// FR007 分界:rateDate>=5/4 返回 rateNew,否则 rateOld - /// - /// 修复前:i=7(5/4)被 calcLast 跳过 → FloatRate=rateOld(旧值) - /// 修复后:i=7(5/4)正常取价 → FloatRate=rateNew(新值) + /// 构造(避开周末,period=7):PosiStartDate=6/1(周一), 平仓日=6/8(周一,重置日,7%7=0); + /// FR007 分界:取价日>=6/8 返回 rateNew,否则 rateOld。 /// ---------------------------------------------------------------- /// [TestMethod] - public void CI_007_平仓日等于重置日_calcLast_false_仍应取新FR007() + public void CI_007_平仓日等于重置日_calcLast_false_事件利率为末段已消费利率() { const double rateOld = 0.001; const double rateNew = 0.002; @@ -356,19 +339,21 @@ namespace YLErp.Modules.SwapModule var interests = ServiceByDate().GetInterests(td, td.trade_extend, unwindDate, unwindDate, new List(), 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); var result = interests[0]; Console.WriteLine($"6/8(重置日,周一)平仓:FloatRate={result.FloatRate} Amount={result.InterestAmount:F6}"); - Console.WriteLine($" 期望 FloatRate={rateNew}(6/8 重置日查询日=6/8工作日,应取新利率)"); + Console.WriteLine($" 新口径期望 FloatRate={rateOld}(排除日不取价,事件利率=末段已消费利率)"); - // 核心断言:6/8 是重置日,flowEvent.FloatRate 应反映新利率 rateNew - Assert.IsTrue(Math.Abs((result.FloatRate ?? 0) - (decimal)rateNew) < 0.0001m, - $"平仓日=重置日时 FloatRate 应={rateNew}(取到新利率)。" + - $"实际={result.FloatRate},若={rateOld} 说明 calcLast=false 跳过了重置日取价(GLMS-JIATT-20260805 根因)"); + // 核心断言(EQD-6968 自洽化契约):排除日(不计息)一概不取价——即使 6/8 新定盘已发布, + // 事件 FloatRate 也必须是末段已消费利率 rateOld,与金额同源、与平仓时刻无关。 + // 剩余持仓的新周期利率由 EOD 快照"重置日再定盘"显式获取(见 InterestEodTailSnapshotTest)。 + Assert.IsTrue(Math.Abs((result.FloatRate ?? 0) - (decimal)rateOld) < 0.0001m, + $"排除日不取价:FloatRate 应=末段已消费利率 {rateOld}。实际={result.FloatRate}," + + $"若={rateNew} 说明排除日仍在取价(旧口径:记录利率取决于平仓时刻)"); var interestBeforeResetDate = Principal * (FixedRate + (decimal)rateOld) * 7m / AnnualDays; AssertDecimal(Principal + interestBeforeResetDate, result.InterestPrincipal, @@ -420,16 +405,18 @@ namespace YLErp.Modules.SwapModule var result = service.GetInterests(td, td.trade_extend, resetDate, resetDate, new List { 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; var expectedPrincipal = remainingPrincipal + remainingInterest; - var expectedDailyInterest = expectedPrincipal * (fixedRate + (decimal)newFloatRate) / AnnualDays; AssertDecimal(expectedPrincipal, result.InterestPrincipal); - AssertDecimal(expectedDailyInterest, - result.InterestPrincipal * (result.InterestRate + result.FloatRate.Value) / AnnualDays); + // EQD-6968 自洽化:排除日(平仓日=重置日)不取价,事件利率=末段已消费利率(旧)——与金额同源、 + // 与平仓时刻无关。剩余持仓的新周期利率由 EOD 快照"重置日再定盘"显式获取 + // (InterestEodTailSnapshotTest.CloseOnly_平仓日为重置日_剩余持仓快照再定盘)。 + AssertDecimal((decimal)oldFloatRate, result.FloatRate.Value, + "排除日不取价:事件 FloatRate 应=末段已消费旧利率"); } [TestMethod] @@ -466,8 +453,8 @@ namespace YLErp.Modules.SwapModule var result = service.GetInterests(td, td.trade_extend, unwindDate, unwindDate, new List { 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, @@ -509,7 +496,7 @@ namespace YLErp.Modules.SwapModule decimal tdInterestAmount = 0m; service.CalcDailyCompoundInterestByEod(preEod, resetDate, startDate, position, - principal, principal, flowEvent, AnnualDays, false, 0.013502m, 1m, + principal, principal, flowEvent, AnnualDays, 0.013502m, 1m, ref interestAmount, ref tdInterestAmount); AssertDecimal(principal + pendingInterest, flowEvent.InterestPrincipal, 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 ad142e51..cc13aedf 100644 --- a/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs @@ -51,6 +51,8 @@ namespace YLErp.Modules.SwapModule public SwapDealService DealService { get; set; } public eod_swap_position LastInterestCalculationEodPosition { get; private set; } + public int LastEventType { get; set; } + public StubEodPositionService() : base(nameof(DealInterestsScenarioTest)) { @@ -63,23 +65,25 @@ namespace YLErp.Modules.SwapModule trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate, List 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) { LastInterestCalculationEodPosition = eodPositions.SingleOrDefault(); + LastEventType = eventType; + if (AutoInterests != null) { return AutoInterests; } return (DealService ?? new SwapDealService(this)).GetInterests(td, tradeExtend, valueDate, unwindDate, - eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue, - closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice, - grossPrice, orginPv, add, settment, newCalcLast, closeList); + eodPositions, positions, posiNotionalValue, + closePosiNotionalValue, closePrecent, eventType, tdClose, + orginPv, add, settment, newCalcLast, closeList); } // public 包装:让测试能调用 protected 方法 @@ -99,7 +103,7 @@ namespace YLErp.Modules.SwapModule decimal orginPv = DealInterestsScenarioTest.Principal) { SaveAutoEodInterestPosition(eodPayPosition, null, position, td, valueDate, interval, - lastEodSwap, posiLongNotional, 0m, 1m, orginPv); + lastEodSwap, posiLongNotional + 0m, 1m, orginPv); return PersistedPositions.LastOrDefault(); } @@ -110,7 +114,7 @@ namespace YLErp.Modules.SwapModule decimal closeNotional, bool autoSwap) { SaveAutoEodWithCloseInterestPosition(eodPayPosition, null, position, td, valueDate, interval, - posiLongNotional, posiShortNotional, flowEvents, closeNotional, autoSwap, 1m, + posiLongNotional + posiShortNotional, flowEvents, closeNotional, autoSwap, 1m, DealInterestsScenarioTest.Principal); return PersistedPositions.LastOrDefault(); } @@ -121,7 +125,7 @@ namespace YLErp.Modules.SwapModule decimal grossPrice, decimal orginPv) { SaveEodInterestPositionCopy(eodPayPosition, null, valueDate, td, position, null, - false, posiLongNotional, posiShortNotional, grossPrice, orginPv); + false, posiLongNotional + posiShortNotional, grossPrice, orginPv); return PersistedPositions.LastOrDefault(); } @@ -134,7 +138,7 @@ namespace YLErp.Modules.SwapModule { DealInterests(interestList, eodPositions, new List(), settleDate, td, flowEvents, new List(), null, - posiLongNational, posiShortNational, closeNational, grossPrice, orginPv); + posiLongNational + posiShortNational, closeNational, grossPrice, orginPv); } } @@ -447,7 +451,7 @@ namespace YLErp.Modules.SwapModule /// /// [DI_BRANCH_001] 普通日(无互换无平仓无观察日)→ 走 copy 分支 /// --------------------------------------------------------------- - /// flowEvents 为空,insterval=null,hasSwap=false,hasClose=false + /// flowEvents 为空,observationInterval=null,hasSwap=false,hasClose=false /// → 应走 SaveEodInterestPositionCopy(cs:338) /// --------------------------------------------------------------- /// @@ -522,6 +526,109 @@ namespace YLErp.Modules.SwapModule #endregion // ================================================================ + #region 场景2补充:剩余3分支路由断言(经真实 DealInterests 路由器) + + /// + /// [DI_BRANCH_003] 观察日无平仓(observationDay!=null, hasSwap=false, hasClose=false) + /// -> 走 SaveAutoEodInterestPosition(autoSwap 路径)。 + /// 守卫:CalcSwapInterests 收到 eventType=自动互换、tdClose=false。 + /// 补盖 TEST-MATRIX §6 的 AutoSettle 分支。 + /// + [TestMethod] + public void DI_BRANCH_003_观察日自动结息走SaveAutoEodInterestPosition() + { + var service = new StubEodPositionService(); + var td = CreateTrade(); + var position = CreateInterestPosition(); + var settleDate = new DateTime(2026, 5, 10); + var preEod = CreatePreEod(settleDate.AddDays(-1), DailyInterest); + + position.InterestSwapInterval = JsonConvert.SerializeObject(new List + { + new IntervalModel { Date = settleDate, Rate = FixedRate, Settlement = 1 } + }); + + service.ExecuteDealInterests( + new List { position }, + new List { preEod }, + settleDate, td, new List(), + Principal, 0m, 0m, 1m, Principal); + + Assert.IsTrue(service.PersistedPositions.Count > 0, "观察日应生成eod"); + Assert.AreEqual((int)SwapEventTypeEnum.自动互换, service.LastEventType, "观察日自动结息 eventType 应为自动互换"); + // 观察日自动结息走 SaveAutoEodInterestPosition(无平仓):TdCloseInterest 仅为当日利息,不被平仓放大 + Assert.IsTrue(service.PersistedPositions[0].TdCloseInterest < 0.1m, "观察日自动结息无平仓,TdCloseInterest 应仅为当日利息(<0.1),不应含平仓利息"); + Console.WriteLine("观察日自动结息分支 ✅ eventType=自动互换, tdClose=false"); + } + + /// + /// [DI_BRANCH_004] 观察日+平仓(observationDay!=null, hasClose=true) + /// -> 走 SaveAutoEodWithCloseInterestPosition(autoSwap:true)。 + /// 守卫:CalcSwapInterests 收到 eventType=自动互换、tdClose=true。 + /// 补盖 TEST-MATRIX §6 最弱格子(autoSwap=true 部分平仓)。 + /// + [TestMethod] + public void DI_BRANCH_004_观察日平仓走WithClose_autoSwapTrue() + { + var service = new StubEodPositionService(); + var td = CreateTrade(); + var position = CreateInterestPosition(); + var settleDate = new DateTime(2026, 5, 10); + var preEod = CreatePreEod(settleDate.AddDays(-1), DailyInterest * 13); + + position.InterestSwapInterval = JsonConvert.SerializeObject(new List + { + new IntervalModel { Date = settleDate, Rate = FixedRate, Settlement = 1 } + }); + var closeEvent = CreateSwapFlowEvent(settleDate, DailyInterest * 13); + closeEvent.EventType = (int)SwapFlowEventTypeEnum.平仓; + + service.ExecuteDealInterests( + new List { position }, + new List { preEod }, + settleDate, td, new List { closeEvent }, + Principal, 0m, 300m, 1m, Principal); + + Assert.IsTrue(service.PersistedPositions.Count > 0, "观察日+平仓应生成eod"); + Assert.AreEqual((int)SwapEventTypeEnum.自动互换, service.LastEventType, "autoSwap:true -> eventType 应为自动互换"); + // 含平仓:TdCloseInterest 应明显大于纯当日利息(实测约 0.38) + Assert.IsTrue(service.PersistedPositions[0].TdCloseInterest > 0.1m, "观察日+平仓 TdCloseInterest 应含平仓利息(>0.1)"); + Console.WriteLine("观察日+平仓分支 ✅ eventType=自动互换, TdCloseInterest>0.1 (autoSwap:true)"); + } + + /// + /// [DI_BRANCH_005] 非观察日+平仓(observationDay==null, hasClose=true, hasSwap=false) + /// -> 走 SaveAutoEodWithCloseInterestPosition(autoSwap:false)。 + /// 守卫:CalcSwapInterests 收到 eventType=平仓、tdClose=true。 + /// 补盖 TEST-MATRIX §6 的 CloseOnly 分支。 + /// + [TestMethod] + public void DI_BRANCH_005_纯平仓走WithClose_autoSwapFalse() + { + var service = new StubEodPositionService(); + var td = CreateTrade(); + var position = CreateInterestPosition(); + var settleDate = new DateTime(2026, 5, 10); + var preEod = CreatePreEod(settleDate.AddDays(-1), DailyInterest * 13); + + position.InterestSwapInterval = null; + var closeEvent = CreateSwapFlowEvent(settleDate, DailyInterest * 13); + closeEvent.EventType = (int)SwapFlowEventTypeEnum.平仓; + + service.ExecuteDealInterests( + new List { position }, + new List { preEod }, + settleDate, td, new List { closeEvent }, + Principal, 0m, 300m, 1m, Principal); + + Assert.IsTrue(service.PersistedPositions.Count > 0, "纯平仓应生成eod"); + Assert.AreEqual((int)SwapEventTypeEnum.平仓, service.LastEventType, "autoSwap:false -> eventType 应为平仓"); + Assert.IsTrue(service.PersistedPositions[0].TdCloseInterest > 0.1m, "纯平仓 TdCloseInterest 应含平仓利息(>0.1)"); + Console.WriteLine("纯平仓分支 ✅ eventType=平仓, TdCloseInterest>0.1 (autoSwap:false)"); + } + + #endregion + // 场景3:多日守恒——连续收盘归档,InterestIncomeSum 应线性递增 // ================================================================ @@ -1229,8 +1336,8 @@ namespace YLErp.Modules.SwapModule var result = new SwapDealService(service).GetInterests( td, td.trade_extend, closeDate, closeDate, new List { 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 +1375,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 +1393,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 +1413,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 +1541,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 +1551,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 +1682,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 +1813,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}"); @@ -1724,14 +1831,14 @@ namespace YLErp.Modules.SwapModule decimal expectedAmountAtEnd = 0m; decimal expectedTdAmountAtEnd = 0m; dealService.CalcDailyCompoundInterest( - finalCloseDate, position, remainingNotional, expectedEndFlow, AnnualDays, false, + finalCloseDate, position, remainingNotional, expectedEndFlow, AnnualDays, intermediateEod.FloatRate, 1m, true, false, ref expectedAmountAtEnd, ref expectedTdAmountAtEnd); var expectedPreviousFlow = new swap_flow_event { InterestRate = spread }; decimal expectedAmountAtPreviousEod = 0m; decimal expectedTdAmountAtPreviousEod = 0m; dealService.CalcDailyCompoundInterest( - intermediateDate, position, remainingNotional, expectedPreviousFlow, AnnualDays, false, + intermediateDate, position, remainingNotional, expectedPreviousFlow, AnnualDays, intermediateEod.FloatRate, 1m, true, true, ref expectedAmountAtPreviousEod, ref expectedTdAmountAtPreviousEod); var expectedFinalInterest = intermediateEod.InterestIncomeSum @@ -1739,8 +1846,8 @@ namespace YLErp.Modules.SwapModule var finalInterest = dealService.GetInterests( td, td.trade_extend, finalCloseDate, finalCloseDate, new List { 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 +1936,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 +2034,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 +2083,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 new file mode 100644 index 00000000..bb9af66d --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/DividendEodNoDoubleCountTest.cs @@ -0,0 +1,291 @@ +using YLErp; +using YLErp.DBModels; +using YLErp.DBModels.Enums; +using YLErp.Modules.EodModule; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 端到端:盘中收益互换(DividendIn 由生产方法 GetPreEodDividendSum 真实算出)→ 保存 → EOD, + /// 验证分红【不重复累计】(EOD TdCloseDividend 扣减 DividendIn)且【不丢失】(当日新计进 PosiDividendSum)。 + /// + /// 与 MultiUnwindDividendConservationTest.MU_001 的区别:MU_001 的互换 DividendIn 是测试喂的常量; + /// 本测试的 DividendIn 由生产方法 GetPreEodDividendSum 真实算出(读 EOD 快照),再喂给 EOD—— + /// 覆盖"预览算 DividendIn + EOD 扣减"的完整链路(MU_001 的缺口)。 + /// + [TestClass] + public class DividendEodNoDoubleCountTest + { + private const int SwapTradeId = 9200; + private const long PositionId = 9201; + private const decimal InitialQty = 1000m; + private const decimal RegPer100 = 1.0m; // 每 100 元面值票息 1.0 → qty(1000) 时单期分红 = 1.0×1000/100 = 10 + private static readonly DateTime StartDate = new(2026, 1, 5); + + #region 内存债券付息数据(reg_date 口径,真实生产 GetBondPayments 读取) + + private const string BondUnderlying = "210210.IB"; + private static List 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 数据(不连库)。 + 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); + } + + /// 真实 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 + { + 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) + { + // 桥接真实生产口径:GetBondPayments 按 reg_date 过滤 + CalcPayment 累加(替换原线性假公式 DailyRatePerUnit*days*qty) + var svc = new RealBondPaymentService(_bondPayments, OptUserInfo.UnitTestUser); + return svc.CalcPayment(underlyingCode, fromDate, toDate, qty, shortRatio, directionRatio); + } + protected override underlying_manager GetUnderlyingData(string underlyingCode) + => new underlying_manager { ValueAddedTax = 0m }; + protected override decimal GetUnderlyingPrice(string code, DateTime settleDate, out decimal vobp) + { vobp = 0m; return 1.00m; } + public eod_swap_position ExecuteUpdateEodPosition(swap_position swapPosition, eod_swap_position eod, trade td, DateTime valueDate, DateTime preSettleDate, List unwindEvents) + => UpdateEodPosition(swapPosition, eod, null, td, valueDate, preSettleDate, unwindEvents); + public eod_swap_position ExecuteCopyEodPosition(eod_swap_position eod, trade td, DateTime valueDate, DateTime preSettleDate) + => CopyEodPosition(eod, null, td, valueDate, preSettleDate); + } + + #endregion + + #region 数据构建 + + private static trade CreateTrade() => new trade + { + id = SwapTradeId, TradeNumber = "UT-DIV-EOD-001", ClientId = 999999, + TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate, + ExerciseDate = new DateTime(2027, 1, 5), TradeStatus = "确认成交", ValidState = "Valid", + StructureType = "单标的", QuoteCurrency = "CNY", SettlementCurrency = "CNY", + OriginalStockEqvNotional = (double)(InitialQty * 1.00m) + }; + + private static swap_position CreatePosition() => new swap_position + { + id = PositionId, SwapTradeId = SwapTradeId, + PosiDirection = (int)SwapDirectionEnum.收取, PositionType = (int)PositionTypeFlag.Long, + UnderlyingCode = "210210.IB", ContractSize = 1m, + PosiQuantity = InitialQty, PosiNotionalValue = InitialQty, + PosiNetPrice = 1.000m, PosiGrossPrice = 1.000m, + PosiNetFeePrice = 1.000m, PosiNetNoFeePrice = 1.000m, + IsInitial = true, Invalid = false, + PosiTradingFee = 0, PosiTradingFeePending = 0 + }; + + private static eod_swap_position CreateInitialEod() => new eod_swap_position + { + id = 1, SwapTradeId = SwapTradeId, PositionId = PositionId, + ValueDate = StartDate, PosiQuantity = InitialQty, + PosiDirection = (int)SwapDirectionEnum.收取, PositionType = (int)PositionTypeFlag.Long, + UnderlyingCode = "210210.IB", ContractSize = 1m, + PosiNetPrice = 1.000m, PosiGrossPrice = 1.000m, + PosiNetFeePrice = 1.000m, PosiNetNoFeePrice = 1.000m, + PosiDividendSum = 0m, TdPosiDividend = 0m, TdCloseDividend = 0m, + RealizedDividend = 0m, PosiFeePending = 0m, + InterestProfitSum = 0m, Invalid = false + }; + + private static swap_flow_event SwapEvent(decimal dividendIn, DateTime eventDate) => new swap_flow_event + { + SwapTradeId = SwapTradeId, EventType = (int)SwapFlowEventTypeEnum.互换, + PositionId = PositionId, Quantity = 0m, DividendIn = dividendIn, + MarkClosePnl = 0m, CloseFee = 0m, TradingFeePending = 0m, + EventDate = eventDate, PayDate = eventDate, + DataState = (int)SwapFlowDateStateEnum.完成 + }; + + private static swap_flow_event CloseEvent(decimal qty, decimal dividendIn, DateTime eventDate) => new swap_flow_event + { + SwapTradeId = SwapTradeId, EventType = (int)SwapFlowEventTypeEnum.平仓, + PositionId = PositionId, Quantity = qty, DividendIn = dividendIn, + MarkClosePnl = 0m, CloseFee = 0m, TradingFeePending = 0m, + TradingAmount = qty * 1.000m, + UnwindDate = eventDate, EventDate = eventDate, PayDate = eventDate, + DataState = (int)SwapFlowDateStateEnum.完成 + }; + + private static void AssertDecimalEqual(decimal expected, decimal actual, decimal tol, string msg) + => Assert.IsTrue(Math.Abs(expected - actual) <= tol, $"{msg}: expected={expected} actual={actual}"); + + #endregion + + /// + /// 盘中收益互换:DividendIn 由 GetPreEodDividendSum 真实算(读 T-1 EOD)→ 保存 → EOD。 + /// 验证:不重复(EOD TdCloseDividend 扣 DividendIn)+ 不丢失(当日新计进 PosiDividendSum)+ 守恒。 + /// + /// 序列(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(BondPayments()); + var td = CreateTrade(); + var position = CreatePosition(); + var initialEod = CreateInitialEod(); + + // D1=1/6 无事件 EOD + var d1 = new DateTime(2026, 1, 6); + var r1 = eodSvc.ExecuteCopyEodPosition(initialEod, td, d1, StartDate); + AssertDecimalEqual(10m, r1.PosiDividendSum, 0.01m, "D1 PosiDividendSum(0+1天×10)"); + + // D2=1/7 盘中:DividendIn 由生产方法 GetPreEodDividendSum 真实算(读 D1 EOD,当日 EOD 未生成) + var d2 = new DateTime(2026, 1, 7); + var dealSvc = new DealSvcStub( + new List { new eod_swap { SwapTradeId = SwapTradeId, ValueDate = d1 } }, + new List { r1 }); + decimal dividendIn = dealSvc.ExposeGetPreEodDividendSum(SwapTradeId, PositionId, d2); + AssertDecimalEqual(10m, dividendIn, 0.01m, "盘中 DividendIn=GetPreEodDividendSum 读 T-1(D1)=10"); + Console.WriteLine($"[盘中预览] DividendIn={dividendIn}(读 T-1 EOD PosiDividendSum={r1.PosiDividendSum})"); + + // 保存互换事件(DividendIn=真实算出的值,模拟界面点收益互换后保存) + var swapEvent = SwapEvent(dividendIn, d2); + + // D2=1/7 EOD(UpdateEodPosition,真实生产递推) + var r2 = eodSvc.ExecuteUpdateEodPosition(position, r1, td, d2, d1, new List { swapEvent }); + + // 断言:不重复 + 不丢失 + AssertDecimalEqual(10m, r2.TdPosiDividend, 0.01m, "D2 当日新计(1天×10)"); + AssertDecimalEqual(dividendIn, r2.TdCloseDividend, 0.01m, "D2 TdCloseDividend=互换DividendIn(扣减→不重复累计)"); + AssertDecimalEqual(10m, r2.PosiDividendSum, 0.01m, "D2 PosiDividendSum=前日10+新计10-实现10=10(当日新计挂着→不丢失)"); + + // 守恒:全程新计 - 全程实现 = 末尾 PosiDividendSum + decimal totalNew = r1.TdPosiDividend + r2.TdPosiDividend; + decimal totalRealized = r2.TdCloseDividend; + AssertDecimalEqual(r2.PosiDividendSum, totalNew - totalRealized, 0.01m, + $"守恒:末尾 PosiDividendSum({r2.PosiDividendSum}) = 全程新计({totalNew}) - 全程实现({totalRealized})"); + + Console.WriteLine($"[EOD 后] TdPosiDividend={r2.TdPosiDividend} TdCloseDividend={r2.TdCloseDividend} PosiDividendSum={r2.PosiDividendSum}"); + Console.WriteLine($"结论:互换实现 {dividendIn} 被扣减(不重复);当日新计 {r2.TdPosiDividend} 挂 PosiDividendSum(不丢失)"); + } + + /// + /// 登记日当日全平(盘中平仓→收盘持仓 0):按各交易场所规定,不享有登记日当日的分红 + /// (股权登记日以收盘在册为准;盘中全平→收盘不在册)。验证系统行为符合该规定。 + /// + /// 系统行为:①盘中 DividendIn=GetPreEodDividendSum 读 T-1(=T日前待实现,正确不含登记日当日 reg_date 1/7 的分红); + /// ②EOD 全平 PosiQuantity=0 → TdPosiDividend=0(不计提登记日当日 reg_date 1/7)+ PosiDividendSum=0。 + /// 即登记日当日分红(reg_date 1/7 的 10)既不进 DividendIn、也不进 PosiDividendSum = 正确不享有。 + /// 应得 = T日前待实现累计(r1.PosiDividendSum,仅含 1/6 那期 10);实拿 = DividendIn → 相等,无丢失(不享有当日是正确的)。 + /// + [TestMethod] + public void 登记日全平_按交易场所规定不享有当日分红() + { + var eodSvc = new EodSvcStub(BondPayments()); + var td = CreateTrade(); + var position = CreatePosition(); + var initialEod = CreateInitialEod(); + + // D1=1/6 无事件 EOD + var d1 = new DateTime(2026, 1, 6); + var r1 = eodSvc.ExecuteCopyEodPosition(initialEod, td, d1, StartDate); + AssertDecimalEqual(10m, r1.PosiDividendSum, 0.01m, "D1 PosiDividendSum"); + + // D2=1/7 盘中全平:DividendIn 由生产方法真实算(读 D1 EOD,当日 EOD 未生成) + var d2 = new DateTime(2026, 1, 7); + var dealSvc = new DealSvcStub( + new List { new eod_swap { SwapTradeId = SwapTradeId, ValueDate = d1 } }, + new List { r1 }); + decimal dividendIn = dealSvc.ExposeGetPreEodDividendSum(SwapTradeId, PositionId, d2); + AssertDecimalEqual(10m, dividendIn, 0.01m, "全平 DividendIn=读T-1(D1)=10(漏 D2 当日新计)"); + + // 全平事件(扣全部持仓) + var closeEvent = CloseEvent(InitialQty, dividendIn, d2); + + // D2=1/7 EOD(UpdateEodPosition,全平→PosiQuantity=0) + var r2 = eodSvc.ExecuteUpdateEodPosition(position, r1, td, d2, d1, new List { closeEvent }); + + // 业务规定:登记日当日全平(盘中平仓→收盘持仓为 0),按各交易场所规定不享有登记日当日的分红 + // (股权登记日以收盘在册为准)。故应得 = T日(登记日)之前的待实现累计 = r1.PosiDividendSum(不含登记日当日)。 + // 系统行为正确:①DividendIn 读 T-1(=T日前待实现,正确不含当日);②EOD 全平 PosiQuantity=0 不计提当日。 + // 即登记日当日分红既不进 DividendIn 也不进 PosiDividendSum = 正确不享有。 + decimal expectedTotal = r1.PosiDividendSum; // 应得 = T日前待实现(不含登记日当日,因全平不享有) + decimal actualGot = dividendIn + r2.PosiDividendSum; + + Console.WriteLine($"[登记日全平] 应得(T日前待实现)={expectedTotal}, 实拿(DividendIn+PosiDividendSum)={actualGot}"); + Console.WriteLine($"[登记日全平] DividendIn={dividendIn}, EOD:TdPosiDividend={r2.TdPosiDividend} PosiDividendSum={r2.PosiDividendSum} PosiQuantity={r2.PosiQuantity}"); + + // 断言:实拿 = 应得(登记日全平不享有当日,符合交易场所规定) + AssertDecimalEqual(expectedTotal, actualGot, 0.01m, + $"实拿应=应得(T日前待实现{expectedTotal}),登记日全平不享有当日分红(符合交易场所规定)"); + AssertDecimalEqual(0m, r2.TdPosiDividend, 0.01m, "登记日全平 EOD 不计提当日(PosiQuantity=0,正确)"); + AssertDecimalEqual(0m, r2.PosiDividendSum, 0.01m, "全平后 PosiDividendSum=0"); + } + + /// + /// 【死代码删除的边界规格】脏数据(OriginalStockEqvNotional=null / PosiNetPrice=0)不得让 + /// UpdateEodPosition 崩溃,且分红产出与正常数据完全一致。 + /// 背景:这两个字段在 UpdateEodPosition 内的唯一消费点是历史遗留死代码 + /// (originNotional→totalPayment 全历史重算,结果从未被使用,2026-08 论证后删除)—— + /// 删除前该脏数据会在 EOD 抛 InvalidOperationException/除零;删除后是设计内行为。 + /// 本测试同时钉住:删除后输出等价(与同输入正常数据路径一致)。 + /// + [TestMethod] + public void 脏数据边界_死代码涉及字段_不影响EOD分红产出() + { + // 正常数据基准 + var eodSvcClean = new EodSvcStub(BondPayments()); + var tdClean = CreateTrade(); + var positionClean = CreatePosition(); + var initialEod = CreateInitialEod(); + var d1 = new DateTime(2026, 1, 6); + var d2 = new DateTime(2026, 1, 7); + var r1Clean = eodSvcClean.ExecuteCopyEodPosition(initialEod, tdClean, d1, StartDate); + var r2Clean = eodSvcClean.ExecuteUpdateEodPosition(positionClean, r1Clean, tdClean, d2, d1, + new List { CloseEvent(InitialQty, r1Clean.PosiDividendSum, d2) }); + + // 脏数据:死代码涉及的两字段置脏(活路径零消费,见方法内 grep 论证) + var eodSvcDirty = new EodSvcStub(BondPayments()); + var tdDirty = CreateTrade(); + tdDirty.OriginalStockEqvNotional = null; // 死代码 (decimal) 强转崩溃点 + var positionDirty = CreatePosition(); + positionDirty.PosiNetPrice = 0m; // 死代码除零崩溃点 + var r1Dirty = eodSvcDirty.ExecuteCopyEodPosition(initialEod, tdDirty, d1, StartDate); + var r2Dirty = eodSvcDirty.ExecuteUpdateEodPosition(positionDirty, r1Dirty, tdDirty, d2, d1, + new List { CloseEvent(InitialQty, r1Dirty.PosiDividendSum, d2) }); + + // 脏数据不崩 + 输出与正常数据逐字段一致 + AssertDecimalEqual(r2Clean.TdPosiDividend, r2Dirty.TdPosiDividend, 0.0001m, "TdPosiDividend 不受脏字段影响"); + AssertDecimalEqual(r2Clean.TdCloseDividend, r2Dirty.TdCloseDividend, 0.0001m, "TdCloseDividend 不受脏字段影响"); + AssertDecimalEqual(r2Clean.PosiDividendSum, r2Dirty.PosiDividendSum, 0.0001m, "PosiDividendSum 不受脏字段影响"); + AssertDecimalEqual(r2Clean.RealizedDividend, r2Dirty.RealizedDividend, 0.0001m, "RealizedDividend 不受脏字段影响"); + Console.WriteLine($"[脏数据边界] 正常={r2Clean.PosiDividendSum} 脏数据={r2Dirty.PosiDividendSum}(应相等且不抛异常)"); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/FundingLegs/FundingLegStrategyTest.cs b/UnitTestProject/Modules/SwapModule/FundingLegs/FundingLegStrategyTest.cs index c3afb09f..ebd0803f 100644 --- a/UnitTestProject/Modules/SwapModule/FundingLegs/FundingLegStrategyTest.cs +++ b/UnitTestProject/Modules/SwapModule/FundingLegs/FundingLegStrategyTest.cs @@ -6,16 +6,14 @@ using YLErp.Modules.SwapModule.FundingLegs; namespace UnitTestProject.Modules.SwapModule.FundingLegs { /// - /// 融资腿策略单测。验证每个策略的 CalcNotional 与现有 CalcNotionalByMode switch 完全一致。 - /// 这组测试是后续"迁移调用点"的安全网——迁移前后行为必须不变。 + /// 融资腿策略单测。验证每个 IFundingLegStrategy 实现的 CalcNotional 计息基数公式正确。 + /// 原 CalcNotionalByMode switch 已重构为策略类(见 FundingLegStrategyFactory)。 /// [TestClass] public class FundingLegStrategyTest { private const decimal Fix = 2_000_000m; private const decimal Notional = 100_000_000m; - private const decimal LongNotional = 60_000_000m; - private const decimal ShortNotional = 40_000_000m; #region 固定值(mode 1) @@ -23,7 +21,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs public void 固定值_部分平仓_计息基数恒等于Fix() { var leg = new FixedAmountLeg(); - var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0.5m); + var r = leg.CalcNotional(Fix, Notional, 0.5m); Assert.AreEqual(Fix, r.ClosePrincipal, "平仓本金恒=Fix"); Assert.AreEqual(Fix, r.PosiPrincipal, "持仓本金恒=Fix"); @@ -34,7 +32,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs public void 固定值_全平_计息基数仍等于Fix() { var leg = new FixedAmountLeg(); - var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 1m); + var r = leg.CalcNotional(Fix, Notional, 1m); Assert.AreEqual(Fix, r.ClosePrincipal); } @@ -46,7 +44,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs public void 合约名义本金_部分平仓_本金按比例缩放() { var leg = new ContractNotionalLeg(); - var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0.5m); + var r = leg.CalcNotional(Fix, Notional, 0.5m); Assert.AreEqual(50_000_000m, r.ClosePrincipal); Assert.AreEqual(Notional, r.PosiPrincipal); @@ -57,7 +55,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs public void 合约名义本金_全平_本金等于全额() { var leg = new ContractNotionalLeg(); - var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 1m); + var r = leg.CalcNotional(Fix, Notional, 1m); Assert.AreEqual(Notional, r.ClosePrincipal); } @@ -65,7 +63,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs public void 合约名义本金_零平仓_本金为零() { var leg = new ContractNotionalLeg(); - var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0m); + var r = leg.CalcNotional(Fix, Notional, 0m); Assert.AreEqual(0m, r.ClosePrincipal); Assert.AreEqual(Notional, r.PosiPrincipal); @@ -79,7 +77,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs public void 标的期初全价_部分平仓_主路径公式同mode2() { var leg = new UnderlyingEntryFullPriceLeg(); - var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0.5m); + var r = leg.CalcNotional(Fix, Notional, 0.5m); Assert.AreEqual(50_000_000m, r.ClosePrincipal); Assert.AreEqual(Notional, r.PosiPrincipal); @@ -90,7 +88,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs public void 标的期初全价_全平_本金等于全额() { var leg = new UnderlyingEntryFullPriceLeg(); - var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 1m); + var r = leg.CalcNotional(Fix, Notional, 1m); Assert.AreEqual(Notional, r.ClosePrincipal); } diff --git a/UnitTestProject/Modules/SwapModule/GLMS20260105_0006_RegisterDateDividendTest.cs b/UnitTestProject/Modules/SwapModule/GLMS20260105_0006_RegisterDateDividendTest.cs new file mode 100644 index 00000000..cafe7e59 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/GLMS20260105_0006_RegisterDateDividendTest.cs @@ -0,0 +1,235 @@ +using YLErp.Modules.EodModule; + +namespace YLErp.Modules.SwapModule +{ + /// + /// GLMS-20260105-0006 回归:债券 TRS 登记日当天手动平仓/互换,分红收益应为 36160 而非 0。 + /// 根因双成因: + /// A. BondPaymentService.GetBondPayments 用支付日(pay_date_PL/pay_date_act)而非债权登记日(reg_date)判定谁享有票息 + /// -> 登记日(4/3)当日 EOD 不计提,跨过支付日(4/6)才计提(巧合:4/4-4/5周末,下一交易日恰=支付日,掩盖缺陷) + /// B. SwapDealService.GetPreEodDividendSum 用 ValueDate 严格小于 dealDate 读 T-1 EOD 快照 + /// -> 登记日当天手动平仓读不到当日 EOD,拿到 0 + /// 本文件用手工合成内存数据(不连 96 库),通过 virtual seam 注入,真实跑生产日期逻辑。 + /// + [TestClass] + public class GLMS20260105_0006_RegisterDateDividendTest + { + private const string BondCode = "230004.IB"; + private const int TradeId = 6006; + private const long PositionId = 60061; + private const decimal Qty = 20_000_000m; + private const decimal PaymentPer100 = 0.1808m; + private const decimal ExpectedDividend = 36_160m; // 20,000,000 × 0.1808 / 100 + + // 付息日历(截图):登记日 4/3,支付日 4/6 + private static readonly DateTime RegDate = new(2026, 4, 3); + private static readonly DateTime PayDate = new(2026, 4, 6); + private static readonly DateTime PreRegDate = new(2026, 4, 2); + + // 多次付息日历(截图:债券 230004.IB,每期票息 0.1808,共 5 次登记日) + private static readonly DateTime[] RegDates = { + new(2026, 2, 28), new(2026, 4, 3), new(2026, 4, 29), + new(2026, 5, 29), new(2026, 6, 29) + }; + private static readonly DateTime[] PayDates = { + new(2026, 3, 2), new(2026, 4, 6), new(2026, 4, 30), + new(2026, 6, 1), new(2026, 6, 30) + }; + + #region 成因 A:日期口径 seam + + private sealed class TestableBondPaymentService : BondPaymentService + { + private readonly List _data; + public TestableBondPaymentService(List data) : base(OptUserInfo.UnitTestUser) { _data = data; } + + protected override IQueryable QueryBondPayments(string underlyingCode) + => _data.Where(x => x.underlyingCode == underlyingCode).AsQueryable(); + } + + [TestMethod] + public void CauseA_登记日当日EOD_应按登记日口径选中付息记录() + { + var record = new BondPayment + { + underlyingCode = BondCode, + reg_date = RegDate, // 债权登记日 4/3(关键:分红归属按此判定) + payment_date_pl = PayDate, // 理论付息日 4/6 + payment_date = PayDate, // 实际付息日 4/6 + payment_interest = PaymentPer100 + }; + var svc = new TestableBondPaymentService(new List { record }); + + // 登记日当日的 EOD 计提区间 (4/2, 4/3] + var payments = svc.GetBondPayments(BondCode, PreRegDate, RegDate); + + // 修复前:用支付日(pay_date_PL=4/6)过滤 -> 4/6 不在 (4/2,4/3] -> 0 条(漏计分红) + // 修复后:用债权登记日(reg_date=4/3)过滤 -> 4/3 落在区间 -> 1 条(GLMS-20260105-0006 已修复) + Assert.AreEqual(1, payments.Count, + "登记日(4/3)当日 EOD 应按债权登记日(reg_date)选中该笔付息;" + + "当前按支付日(pay_date_PL=4/6)过滤会漏选->0条,导致分红不计提。"); + } + + [TestMethod] + public void CauseA_MultiRegDate_跨登记日区间命中正确子集() + { + var records = Enumerable.Range(0, 5).Select(i => new BondPayment + { + underlyingCode = BondCode, + reg_date = RegDates[i], + payment_date_pl = PayDates[i], + payment_date = PayDates[i], + payment_interest = PaymentPer100 + }).ToList(); + var svc = new TestableBondPaymentService(records); + + // 单次窗口:每个登记日各自命中 1 条(验证按 reg_date 过滤,非支付日) + for (int i = 0; i < 5; i++) + { + var prev = i == 0 ? RegDates[i].AddDays(-1) : RegDates[i - 1]; + var hit = svc.GetBondPayments(BondCode, prev, RegDates[i]); + Assert.AreEqual(1, hit.Count, $"窗口({prev:yyyy-MM-dd},{RegDates[i]:yyyy-MM-dd}] 应仅命中登记日 {RegDates[i]:yyyy-MM-dd} 那条"); + Assert.AreEqual(RegDates[i], hit[0].reg_date, "命中的应是该登记日记录"); + } + + // 长区间应命中全部 5 条,不漏不混 + var all = svc.GetBondPayments(BondCode, RegDates[0].AddDays(-1), RegDates[4]); + Assert.AreEqual(5, all.Count, "长区间(登记日1前,登记日5] 应命中全部 5 次付息"); + + // 跨登记日中间区间:(4/2, 4/29] 应命中 4/3 与 4/29 两条(不含 2/28、5/29、6/29) + var mid = svc.GetBondPayments(BondCode, new DateTime(2026, 4, 2), new DateTime(2026, 4, 29)); + Assert.AreEqual(2, mid.Count, "(4/2,4/29] 应命中 4/3+4/29 两条"); + CollectionAssert.AreEquivalent( + new[] { new DateTime(2026, 4, 3), new DateTime(2026, 4, 29) }, + mid.Select(x => x.reg_date!.Value).ToArray()); + } + + [TestMethod] + public void CauseA_MultiRegDate_CalcPayment累加五期票息() + { + var records = Enumerable.Range(0, 5).Select(i => new BondPayment + { + underlyingCode = BondCode, + reg_date = RegDates[i], + payment_date_pl = PayDates[i], + payment_date = PayDates[i], + payment_interest = PaymentPer100 + }).ToList(); + var svc = new TestableBondPaymentService(records); + + // 长区间取全部 5 期,CalcPayment 应累加 = 5 × 36160 = 180,800(原测试仅覆盖单期) + var payments = svc.GetBondPayments(BondCode, RegDates[0].AddDays(-1), RegDates[4]); + var total = svc.CalcPayment(payments, Qty, 1, 1); + Assert.AreEqual(5 * ExpectedDividend, total, 0.01m, + "5 期票息累加应为 5 × 36,160 = 180,800;单期口径会漏计其余 4 期"); + } + + #endregion + + #region 成因 B:T-1 快照 seam + + private sealed class TestableSwapDealService : SwapDealService + { + private readonly List _eodSwaps; + private readonly List _eodPositions; + public TestableSwapDealService(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); + } + + [TestMethod] + public void CauseB_登记日当天手动平仓_应读到当日EOD分红36160() + { + // 4/2 EOD:累计分红 0;4/3 EOD(登记日):累计分红 36160(即登记日应有的状态) + var eodSwaps = new List + { + new eod_swap { SwapTradeId = TradeId, ValueDate = PreRegDate }, + new eod_swap { SwapTradeId = TradeId, ValueDate = RegDate } + }; + var eodPositions = new List + { + new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = PreRegDate, PosiDividendSum = 0m, PosiQuantity = Qty }, + new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = RegDate, PosiDividendSum = ExpectedDividend, PosiQuantity = Qty } + }; + var svc = new TestableSwapDealService(eodSwaps, eodPositions); + + // 登记日(4/3)当天手动平仓 + var dividend = svc.ExposeGetPreEodDividendSum(TradeId, PositionId, RegDate); + + // 修复前:ValueDate 严格小于 dealDate 读 T-1(4/2) -> 0(漏读当日分红) + // 修复后:ValueDate 小于等于 dealDate 读当日(4/3) -> 36160(GLMS-20260105-0006 已修复) + Assert.AreEqual(ExpectedDividend, dividend, 0.01m, + "登记日(4/3)当天手动平仓应读到当日 EOD 累计分红 36,160;" + + "当前 GetPreEodDividendSum 用 ValueDate < dealDate 读 T-1 快照->0。"); + } + + [TestMethod] + public void CauseB_MultiRegDate_Auto实现归0后下次登记日重新累加() + { + // 模拟:登记日1(2/28)计提 36160 → auto互换实现归0(3/1) → 登记日2(4/3)再计提 36160 + var eodSwaps = new List + { + new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,2,27) }, + new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,2,28) }, + new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,3,1) }, + new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,4,2) }, + new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,4,3) }, + }; + var eodPositions = new List + { + new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,2,27), PosiDividendSum = 0m, PosiQuantity = Qty }, + new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,2,28), PosiDividendSum = ExpectedDividend, PosiQuantity = Qty }, + new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,3,1), PosiDividendSum = 0m, PosiQuantity = Qty }, + new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,4,2), PosiDividendSum = 0m, PosiQuantity = Qty }, + new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,4,3), PosiDividendSum = ExpectedDividend, PosiQuantity = Qty }, + }; + var svc = new TestableSwapDealService(eodSwaps, eodPositions); + + // 登记日2(4/3)当天手动互换:应读 4/3 EOD = 36160(第二次,非第一次已实现的、非 0) + var dividend = svc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 3)); + Assert.AreEqual(ExpectedDividend, dividend, 0.01m, + "登记日2(4/3)手动互换应读当日EOD=第二次分红36160;" + + "若读T-1(4/2=0)则漏当日,若读2/28则错取第一次已实现的。"); + } + + [TestMethod] + public void CauseB_MultiRegDate_手动互换期间分红挂账累计四期() + { + // 模拟:多次登记日之间未 auto 实现,分红挂账累加 + // 4/3=36160, 4/29=72320, 5/29=108480, 6/29=144640(4期累计) + var eodSwaps = new List + { + new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,4,3) }, + new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,4,29) }, + new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,5,29) }, + new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,6,29) }, + }; + var eodPositions = new List + { + new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,4,3), PosiDividendSum = 1 * ExpectedDividend, PosiQuantity = Qty }, + new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,4,29), PosiDividendSum = 2 * ExpectedDividend, PosiQuantity = Qty }, + new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,5,29), PosiDividendSum = 3 * ExpectedDividend, PosiQuantity = Qty }, + new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,6,29), PosiDividendSum = 4 * ExpectedDividend, PosiQuantity = Qty }, + }; + var svc = new TestableSwapDealService(eodSwaps, eodPositions); + + // 每次登记日当天手动互换应读到该日累计值(验证多次付息累计被正确读取) + Assert.AreEqual(1 * ExpectedDividend, svc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 3)), 0.01m, "4/3 应读 36160"); + Assert.AreEqual(2 * ExpectedDividend, svc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 29)), 0.01m, "4/29 应读 72320(2期累计)"); + Assert.AreEqual(3 * ExpectedDividend, svc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 5, 29)), 0.01m, "5/29 应读 108480(3期累计)"); + // 关键:第 4 期登记日累计 = 4 × 36160 = 144640(原 9df39491 仅覆盖单期 36160,未验证多次付息累计) + Assert.AreEqual(4 * ExpectedDividend, svc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 6, 29)), 0.01m, + "6/29 应读 144640(4期累计);原 9df39491 仅覆盖单期 36160,未验证多次付息累计。"); + } + + #endregion + } +} diff --git a/UnitTestProject/Modules/SwapModule/GLMS20260701DbDiagnoseTest.cs b/UnitTestProject/Modules/SwapModule/GLMS20260701DbDiagnoseTest.cs index 4b41b962..6dfae6ab 100644 --- a/UnitTestProject/Modules/SwapModule/GLMS20260701DbDiagnoseTest.cs +++ b/UnitTestProject/Modules/SwapModule/GLMS20260701DbDiagnoseTest.cs @@ -28,6 +28,7 @@ namespace YLErp.Modules.SwapModule [TestCategory("DbDiagnose")] public void Record_RealSnapshot() { + DbDiagnoseGuard.RequireTestDb(); YLContext db; try { db = DbContextFactory.GetYLDbContext(); } catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; } @@ -113,6 +114,7 @@ namespace YLErp.Modules.SwapModule private void DiagnoseTrade(string tradeNumber) { + DbDiagnoseGuard.RequireTestDb(); YLContext db; try { db = DbContextFactory.GetYLDbContext(); } catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; } @@ -266,6 +268,7 @@ namespace YLErp.Modules.SwapModule public void Diagnose_0006_UnwindPercentRate_Display() { const string tradeNumber = "GLMS-20260701-0006"; + DbDiagnoseGuard.RequireTestDb(); YLContext db; try { db = DbContextFactory.GetYLDbContext(); } catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; } 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/GLMS20260805ClosePercentDiffDiagnoseTest.cs b/UnitTestProject/Modules/SwapModule/GLMS20260805ClosePercentDiffDiagnoseTest.cs index 8d34bc48..3daad3b2 100644 --- a/UnitTestProject/Modules/SwapModule/GLMS20260805ClosePercentDiffDiagnoseTest.cs +++ b/UnitTestProject/Modules/SwapModule/GLMS20260805ClosePercentDiffDiagnoseTest.cs @@ -32,6 +32,7 @@ namespace YLErp.Modules.SwapModule [TestCategory("DbDiagnose")] public void Record_RealSnapshot() { + DbDiagnoseGuard.RequireTestDb(); YLContext db; try { db = DbContextFactory.GetYLDbContext(); } catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; } @@ -193,6 +194,7 @@ namespace YLErp.Modules.SwapModule [TestCategory("DbDiagnose")] public void Diagnose_100vs40_InterestDiff() { + DbDiagnoseGuard.RequireTestDb(); YLContext db; try { db = DbContextFactory.GetYLDbContext(); } catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; } @@ -246,8 +248,19 @@ namespace YLErp.Modules.SwapModule var valueDate = DateTime.Today; var unwindDate = DateTime.Today; - var interests100 = new SwapDealService(user).GetUnwindInterests(valueDate, unwindDate, td.id, cp100_B, (int)SwapEventTypeEnum.平仓); - var interests40 = new SwapDealService(user).GetUnwindInterests(valueDate, unwindDate, td.id, cp40_B, (int)SwapEventTypeEnum.平仓); + // FR007 fixing 是外部数据依赖:非交易日/数据未发布时取价会抛 Exception。 + // 与"连不上库自动 Inconclusive"同语义——外部数据不可用不应判为测试失败。 + List interests100, interests40; + try + { + interests100 = new SwapDealService(user).GetUnwindInterests(valueDate, unwindDate, td.id, cp100_B, (int)SwapEventTypeEnum.平仓); + interests40 = new SwapDealService(user).GetUnwindInterests(valueDate, unwindDate, td.id, cp40_B, (int)SwapEventTypeEnum.平仓); + } + catch (Exception ex) when (ex.Message.Contains("获取不到") && ex.Message.Contains("价格")) + { + Assert.Inconclusive($"FR007 fixing 数据不可用({valueDate:yyyy-MM-dd} 非交易日或数据未发布):{ex.Message}"); + return; + } PrintInterestComparison(interests100, interests40, cp100_B, cp40_B); } diff --git a/UnitTestProject/Modules/SwapModule/GLMS20260817Fr007UnwindMorningTest.cs b/UnitTestProject/Modules/SwapModule/GLMS20260817Fr007UnwindMorningTest.cs new file mode 100644 index 00000000..a83a0c41 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/GLMS20260817Fr007UnwindMorningTest.cs @@ -0,0 +1,603 @@ +using Newtonsoft.Json; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// EQD-6968 FR007 不算尾平仓"上午未发布"误拦截 —— 修复后回归套件(内存,不连库)。 + /// 任务编号 EQD-6968;现象报告日 2026-08-17。参照 GLMS20260703CloseInterestTest 内存 FR007 写法。 + /// + /// 设计:所有场景经单一 Run 运行器驱动真实 GetInterests 平仓利息路径; + /// 内存 StubSwapDealService 重写 TryGetFloatRate 按日期返回 FR007(缺失即返回 false → 触发取价失败)。 + /// 覆盖两条计息路径(复利 CalcDailyCompoundInterest / 单利 CalcDailySimpleInterest)共用的修复点 BuildSegmentRates, + /// 以及全平重放分支、非整倍数边界、数值一致性("跳过取价=沿用上一重置日利率")。 + /// + /// 核心语义:算头不算尾(calcLast=false)时 endDate 当天不计息,其 FR007 利率不参与计息。 + /// 缺价时跳过取价(currentFloat 保持不变),不回退取其他日期利率,不告警。 + /// + /// 守卫矩阵(防"放宽过头",对应 EQD-6968 方案一四场景): + /// Guard_*:算尾("11"或newCalcLast=true)+当日重置日+当日缺价 → 必须仍拦截(正确依赖); + /// PrevBizDay_*:interest_rule=-1(前一营业日基准)→ 取价日回拨,当日未发布也放行(场景2); + /// TailCalced_NonResetDay_*:算尾+当日非重置日 → 当日价未消费,缺价放行(场景3)。 + /// + [TestClass] + public class GLMS20260817Fr007UnwindMorningTest + { + private static readonly Dictionary Fr007Market = new() + { + [new DateTime(2026, 7, 6)] = 0.0142, + [new DateTime(2026, 7, 13)] = 0.01425, + [new DateTime(2026, 7, 20)] = 0.0143, + }; + /// interest_rule=-1(前一营业日基准)取价日市场:重置日 7/6、7/13、7/20(周一) + /// 经 GetFixingDate 回拨至前一营业日 7/3、7/10、7/17(周五)。 + /// 同时供未回拨的 7/5、7/12、7/19(周日):QDP "chn" 日历在测试进程内可能被其他用例替换为 + /// "全营业日"退化态(全量运行实测 GetNonHolidayDefore(7/19)=7/19 不回拨), + /// 两套日期都供价使本套件对进程内日历状态不敏感——被测对象是取价放宽语义,不是日历本身。 + private static readonly Dictionary Fr007MarketPrevBizDay = new() + { + [new DateTime(2026, 7, 3)] = 0.0142, + [new DateTime(2026, 7, 5)] = 0.0142, + [new DateTime(2026, 7, 10)] = 0.01425, + [new DateTime(2026, 7, 12)] = 0.01425, + [new DateTime(2026, 7, 17)] = 0.0143, + [new DateTime(2026, 7, 19)] = 0.0143, + }; + private const double PreviousResetRate = 0.01425; + + private const decimal Notional = 279486108.21m; + private const int AnnualDays = 365; + private const decimal Spread = -0.0155m; + private static readonly DateTime StartDate = new(2026, 7, 6); + private static readonly DateTime TradeDate = new(2026, 7, 3); + private static readonly DateTime CloseDate = new(2026, 7, 20); + private static readonly DateTime NonIntCloseDate = new(2026, 7, 22); + + private sealed class StubSwapDealService : SwapDealService + { + private readonly HashSet _omit; + private readonly double _closeRate; + private readonly Dictionary _market; + public readonly List PricedDates = new(); + + public StubSwapDealService(OptUserInfo optUser, IEnumerable omit, double closeRate = 0.0143, + Dictionary market = null) + : base(optUser) + { + _omit = new HashSet(omit.Select(d => d.Date)); + _closeRate = closeRate; + _market = market; + } + + protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate) + { + rate = 0d; + if (underlyingCode != "FR007") return false; + var map = new Dictionary(_market ?? Fr007Market) { [CloseDate] = _closeRate }; + if (_omit.Contains(valueDate.Date)) return false; + if (map.TryGetValue(valueDate.Date, out rate)) + { + PricedDates.Add(valueDate.Date); + return true; + } + return false; + } + + // 内存世界无历史结息流水,consumedInterest=0(与本类"内存,不连库"声明一致;否则复利路径偷连 96 库) + public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate) => 0m; + } + + private sealed class Outcome + { + public swap_flow_event Fe; + public Exception Ex; + public StubSwapDealService Svc; + public bool Threw => Ex != null; + } + + private static OptUserInfo MakeOptUser() => + new(0, nameof(GLMS20260817Fr007UnwindMorningTest), OptUserFrom.UnitTest); + + private static trade BuildTrade(DateTime closeDate, string calcMode = "10", DateTime? exerciseDate = null) + { + var extend = new trade_extend + { + TradeId = 1, + ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { + AnnualDays = AnnualDays, + InterestCalcMode = calcMode, + SettlementRules = 0 + }) + }; + return new trade + { + id = 1, + TradeNumber = "GLMS-20260817-FR007-MORNING", + ClientId = 999998, + TradeType = "债券TRS", + TradeDate = TradeDate, + StartDate = StartDate, + ExerciseDate = exerciseDate ?? closeDate.AddDays(1), + TradeStatus = "已平仓", + ValidState = "Valid", + trade_extend = extend + }; + } + + private static swap_position BuildPosition(InterestTypeEnum interestType, DateTime closeDate, int restDays = 7, + int interestRule = 0) + { + var intervalModels = new List + { + new IntervalModel { Date = closeDate, Rate = Spread, Settlement = 0 } + }; + return new swap_position + { + id = 1001, + SwapTradeId = 1, + PositionType = (int)PositionTypeFlag.Unknown, + InterestDirection = (int)SwapDirectionEnum.支付, + InterestMode = (int)InterestModeEnum.标的期初全价, + InterestRateDefault = Spread, + InterestPrincipalFix = Notional, + PosiStartDate = StartDate, + PosiMatuirityDate = closeDate, + IsInitial = true, + Invalid = false, + InterestType = (int)interestType, + IsAnnualized = true, + interest_rest_days = restDays, + interest_rule = interestRule, + FloatRateUnderlyingCode = "FR007", + FloatRate = 0m, + PosiNotionalValue = Notional, + UnderlyingCode = "2500002.IB", + InterestSwapInterval = JsonConvert.SerializeObject(intervalModels) + }; + } + + private static eod_swap_position BuildPreEod(DateTime valueDate, decimal floatRate = 0.01425m) + { + return new eod_swap_position + { + id = 5001, + PositionId = 1001, + ValueDate = valueDate, + FloatRate = floatRate, + InterestProfitSum = -100000m, + TdInterestPrincipal = Notional, + InterestIncomeSum = -150000m + }; + } + + private static Outcome Run( + InterestTypeEnum interestType, + bool includeCloseDate, + DateTime? closeDate = null, + int restDays = 7, + eod_swap_position preEod = null, + decimal closePrecent = 1m, + DateTime? omitDate = null, + double closeRate = 0.0143, + string calcMode = "10", + int interestRule = 0, + bool newCalcLast = false, + Dictionary market = null, + DateTime? omitDate2 = null, + bool settment = false, + DateTime? exerciseDate = null) + { + var cd = closeDate ?? CloseDate; + var omit = new HashSet(); + if (omitDate.HasValue || omitDate2.HasValue) + { + if (omitDate.HasValue) omit.Add(omitDate.Value.Date); + if (omitDate2.HasValue) omit.Add(omitDate2.Value.Date); + } + else if (!includeCloseDate) omit.Add(cd.Date); + + var svc = new StubSwapDealService(MakeOptUser(), omit, closeRate, market); + var td = BuildTrade(cd, calcMode, exerciseDate); + var position = BuildPosition(interestType, cd, restDays, interestRule); + var eodList = preEod == null + ? new List() + : new List { preEod }; + try + { + var interests = svc.GetInterests( + td, td.trade_extend, cd, cd, eodList, + new List { position }, + Notional, Notional, closePrecent, + (int)SwapEventTypeEnum.平仓, false, Notional, + false, settment: settment, newCalcLast: newCalcLast, closeList: null); + Assert.AreEqual(1, interests.Count, "应返回恰好 1 条利息事件"); + return new Outcome { Fe = interests[0], Svc = svc }; + } + catch (Exception ex) + { + return new Outcome { Ex = ex }; + } + } + + private static void AssertNoThrow(Outcome o, string scenario) + { + Assert.IsFalse(o.Threw, scenario + " 不应因平仓日 FR007 未发布而抛异常:" + o.Ex?.Message); + Assert.IsNotNull(o.Fe, scenario + " 应返回利息事件"); + Assert.IsFalse(o.Fe.InterestAmount == 0 && o.Fe.FloatRate == 0, scenario + " 利息不应全为零"); + } + + [TestMethod] + public void Red_Compound_WithoutCloseDateFr007_Succeeds() + { + AssertNoThrow(Run(InterestTypeEnum.复利, includeCloseDate: false), "复利-无preEod-缺平仓日"); + } + + [TestMethod] + public void Baseline_Compound_WithCloseDateFr007_Succeeds() + { + AssertNoThrow(Run(InterestTypeEnum.复利, includeCloseDate: true), "复利-无preEod-有平仓日"); + } + + [TestMethod] + public void Red_Compound_FullClose_WithPreEod_WithoutCloseDateFr007_Succeeds() + { + AssertNoThrow(Run(InterestTypeEnum.复利, includeCloseDate: false, preEod: BuildPreEod(new DateTime(2026, 7, 13))), + "复利-全平重放-缺平仓日"); + } + + [TestMethod] + public void Baseline_Compound_FullClose_WithPreEod_WithCloseDateFr007_Succeeds() + { + AssertNoThrow(Run(InterestTypeEnum.复利, includeCloseDate: true, preEod: BuildPreEod(new DateTime(2026, 7, 13))), + "复利-全平重放-有平仓日"); + } + + [TestMethod] + public void Red_Simple_WithoutPreEod_WithoutCloseDateFr007_Succeeds() + { + AssertNoThrow(Run(InterestTypeEnum.单利, includeCloseDate: false), "单利-无preEod-缺平仓日"); + } + + [TestMethod] + public void Red_Simple_WithPreEod_WithoutCloseDateFr007_Succeeds() + { + AssertNoThrow(Run(InterestTypeEnum.单利, includeCloseDate: false, preEod: BuildPreEod(StartDate)), + "单利-带preEod-缺平仓日"); + } + + [TestMethod] + public void Consistency_Compound_SkipEqualsPreviousRate() + { + var worldA = Run(InterestTypeEnum.复利, includeCloseDate: false); + var worldB = Run(InterestTypeEnum.复利, includeCloseDate: true, closeRate: PreviousResetRate); + Assert.IsFalse(worldA.Threw, "世界A 不应抛:" + worldA.Ex?.Message); + Assert.IsFalse(worldB.Threw, "世界B 不应抛:" + worldB.Ex?.Message); + Assert.AreEqual(worldA.Fe.InterestAmount, worldB.Fe.InterestAmount, + "缺价跳过取价世界 应与 显式置上一期利率世界 利息完全一致(该日利率不参与计息,沿用上期)"); + } + + [TestMethod] + public void Consistency_Simple_SkipEqualsPreviousRate() + { + var worldA = Run(InterestTypeEnum.单利, includeCloseDate: false, preEod: BuildPreEod(StartDate)); + var worldB = Run(InterestTypeEnum.单利, includeCloseDate: true, preEod: BuildPreEod(StartDate), closeRate: PreviousResetRate); + Assert.IsFalse(worldA.Threw, "世界A 不应抛:" + worldA.Ex?.Message); + Assert.IsFalse(worldB.Threw, "世界B 不应抛:" + worldB.Ex?.Message); + Assert.AreEqual(worldA.Fe.InterestAmount, worldB.Fe.InterestAmount, + "单利:缺价跳过取价世界 应与 显式置上一期利率世界 利息完全一致(该日利率不参与计息)"); + } + + [TestMethod] + public void Boundary_Compound_NonIntegerMultiple_LastResetStillPrices() + { + var o = Run(InterestTypeEnum.复利, includeCloseDate: false, closeDate: NonIntCloseDate, omitDate: new DateTime(2026, 7, 20)); + Assert.IsTrue(o.Threw, "非整倍数时末段重置日 7/20 缺价应抛异常(该日利率被消费)"); + StringAssert.Contains(o.Ex.Message, "FR007"); + } + + [TestMethod] + public void Boundary_Compound_NonIntegerMultiple_WithCloseDateSucceeds() + { + AssertNoThrow(Run(InterestTypeEnum.复利, includeCloseDate: true, closeDate: NonIntCloseDate), + "非整倍数-有7/20价-应成功"); + } + + [TestMethod] + public void Boundary_Simple_NonIntegerMultiple_LastResetStillPrices() + { + var o = Run(InterestTypeEnum.单利, includeCloseDate: false, closeDate: NonIntCloseDate, + preEod: BuildPreEod(StartDate), omitDate: new DateTime(2026, 7, 20)); + Assert.IsTrue(o.Threw, "单利 非整倍数时末段重置日 7/20 缺价应抛异常"); + StringAssert.Contains(o.Ex.Message, "FR007"); + } + + [TestMethod] + public void Boundary_Simple_NonIntegerMultiple_WithCloseDateSucceeds() + { + AssertNoThrow(Run(InterestTypeEnum.单利, includeCloseDate: true, closeDate: NonIntCloseDate, preEod: BuildPreEod(StartDate)), + "单利-非整倍数-有7/20价-应成功"); + } + + // ── 算尾守卫(EQD-6968 方案一场景4:算尾+当前营业日+当日重置日+当日缺价 → 必须仍拦截)── + // 放宽只针对"该日利率不参与计息"的场景;算尾时当日利率被消费,缺价拦截是正确依赖,不得误放。 + + [TestMethod] + public void Guard_TailCalced_ResetDayFr007Missing_StillThrows() + { + var o = Run(InterestTypeEnum.复利, includeCloseDate: false, calcMode: "11", + preEod: BuildPreEod(new DateTime(2026, 7, 13))); + Assert.IsTrue(o.Threw, "算尾(11)+当日重置日+当日缺价 → 应拦截(该日利率被消费)"); + StringAssert.Contains(o.Ex.Message, "FR007"); + } + + [TestMethod] + public void Guard_TailCalced_Simple_ResetDayFr007Missing_StillThrows() + { + var o = Run(InterestTypeEnum.单利, includeCloseDate: false, calcMode: "11", + preEod: BuildPreEod(StartDate)); + Assert.IsTrue(o.Threw, "单利 算尾(11)+当日重置日+当日缺价 → 应拦截"); + StringAssert.Contains(o.Ex.Message, "FR007"); + } + + [TestMethod] + public void Baseline_TailCalced_ResetDayFr007Present_Succeeds() + { + AssertNoThrow(Run(InterestTypeEnum.复利, includeCloseDate: true, calcMode: "11", + preEod: BuildPreEod(new DateTime(2026, 7, 13))), + "算尾(11)+当日重置日+当日有价 → 应成功"); + } + + // ── newCalcLast 守卫:交易本身"10"不算尾,但本次平仓显式指定算尾 → effectiveCalcLast=true → 缺价仍拦截 ── + + [TestMethod] + public void Guard_NewCalcLast_OverridesToTail_MissingPrice_StillThrows() + { + var o = Run(InterestTypeEnum.复利, includeCloseDate: false, newCalcLast: true, + preEod: BuildPreEod(new DateTime(2026, 7, 13))); + Assert.IsTrue(o.Threw, "不算尾(10)+本次平仓指定算尾+当日缺价 → 应按算尾拦截"); + StringAssert.Contains(o.Ex.Message, "FR007"); + } + + [TestMethod] + public void Baseline_NewCalcLast_OverridesToTail_WithPrice_Succeeds() + { + AssertNoThrow(Run(InterestTypeEnum.复利, includeCloseDate: true, newCalcLast: true, + preEod: BuildPreEod(new DateTime(2026, 7, 13))), + "不算尾(10)+本次平仓指定算尾+当日有价 → 应成功"); + } + + // ── 前一营业日基准(interest_rule=-1,EQD-6968 方案一场景2)── + // 取价日=重置日前一营业日,当日(7/20)定盘未发布也用不到 → 放行; + // 但取价日(前一营业日)本身缺价 → 仍是真实依赖,必须拦截。 + + [TestMethod] + public void PrevBizDay_TailCalced_ResetDayTodayMissing_Succeeds() + { + AssertNoThrow(Run(InterestTypeEnum.复利, includeCloseDate: true, calcMode: "11", interestRule: -1, + omitDate: CloseDate, market: Fr007MarketPrevBizDay, preEod: BuildPreEod(new DateTime(2026, 7, 13))), + "算尾(11)+前一营业日基准+当日(7/20)未发布 → 应放行(取价日7/17已发布)"); + } + + [TestMethod] + public void PrevBizDay_TailCalced_Simple_ResetDayTodayMissing_Succeeds() + { + AssertNoThrow(Run(InterestTypeEnum.单利, includeCloseDate: true, calcMode: "11", interestRule: -1, + omitDate: CloseDate, market: Fr007MarketPrevBizDay, preEod: BuildPreEod(new DateTime(2026, 7, 13))), + "单利 算尾(11)+前一营业日基准+当日未发布 → 应放行"); + } + + [TestMethod] + public void PrevBizDay_NoTail_ResetDayTodayMissing_Succeeds() + { + AssertNoThrow(Run(InterestTypeEnum.复利, includeCloseDate: true, interestRule: -1, + omitDate: CloseDate, market: Fr007MarketPrevBizDay, preEod: BuildPreEod(new DateTime(2026, 7, 13))), + "不算尾(10)+前一营业日基准+当日未发布 → 应放行"); + } + + [TestMethod] + public void PrevBizDay_TailCalced_FixingDayMissing_StillThrows() + { + // 取价日候选 7/17(周五,正常日历回拨) 与 7/19(周日,退化日历不回拨) 都扣掉 → 两种日历态下都缺价 + var o = Run(InterestTypeEnum.复利, includeCloseDate: true, calcMode: "11", interestRule: -1, + omitDate: new DateTime(2026, 7, 17), omitDate2: new DateTime(2026, 7, 19), + market: Fr007MarketPrevBizDay, preEod: BuildPreEod(new DateTime(2026, 7, 13))); + Assert.IsTrue(o.Threw, "算尾(11)+前一营业日基准+取价日本身缺价 → 仍应拦截(真实依赖)"); + StringAssert.Contains(o.Ex.Message, "FR007"); + } + + // ── 算尾+当前营业日+当日非重置日(EQD-6968 方案一场景3)── + // 当日价未被任何计息段消费(末段重置日7/20是历史日),当日(7/22)缺价 → 放行。 + + [TestMethod] + public void TailCalced_NonResetDay_TodayMissing_Succeeds() + { + AssertNoThrow(Run(InterestTypeEnum.复利, includeCloseDate: true, calcMode: "11", closeDate: NonIntCloseDate, + preEod: BuildPreEod(new DateTime(2026, 7, 13))), + "算尾(11)+当日非重置日+当日(7/22)缺价 → 应放行(非重置日不取当日价)"); + } + + [TestMethod] + public void TailCalced_NonResetDay_Simple_TodayMissing_Succeeds() + { + AssertNoThrow(Run(InterestTypeEnum.单利, includeCloseDate: true, calcMode: "11", closeDate: NonIntCloseDate, + preEod: BuildPreEod(new DateTime(2026, 7, 13))), + "单利 算尾(11)+当日非重置日+当日缺价 → 应放行"); + } + + // ── 不算头不算尾(calcMode="00"):CalcFirst=false 组合 ── + // 对尾日 FR007 行为与"10"一致(calcLast 同 false);另以单利精确断言钉 CalcFirst 语义—— + // "00" 比"10"恰好少计开始日一天的利息(单利无基数效应,差值可精确到分毫)。 + + [TestMethod] + public void Red_Compound_NoHeadNoTail_WithoutCloseDateFr007_Succeeds() + { + AssertNoThrow(Run(InterestTypeEnum.复利, includeCloseDate: false, calcMode: "00", + preEod: BuildPreEod(new DateTime(2026, 7, 13))), + "不算头不算尾(00)+缺平仓日价 → 应放行(与10同口径,尾日不参与计息)"); + } + + [TestMethod] + public void CalcFirst_Simple_NoHeadDropsExactlyStartDayInterest() + { + // 无日终快照时 priorValueDate=开始日-1(首重置日 7/6 恒在取价窗内),两世界首段利率同为 7/6 定盘; + // "00" 比"10"恰好少计开始日一天——精确断言钉 CalcFirst 边界与首重置日取价窗。 + var w10 = Run(InterestTypeEnum.单利, includeCloseDate: true, calcMode: "10"); + var w00 = Run(InterestTypeEnum.单利, includeCloseDate: true, calcMode: "00"); + Assert.IsFalse(w10.Threw, "10 不应抛:" + w10.Ex?.Message); + Assert.IsFalse(w00.Threw, "00 不应抛:" + w00.Ex?.Message); + // 开始日 7/6 属首段:all-in = spread(-0.0155) + 定盘(0.0142) = -0.0013; + // 单利下 00 与 10 的利息差 = 恰好首日一天利息(年化 A365)。 + var expectedStartDayInterest = Notional * (Spread + 0.0142m) / AnnualDays; + Assert.AreEqual(expectedStartDayInterest, w10.Fe.InterestAmount - w00.Fe.InterestAmount, 0.0000001m, + "不算头(00)应恰好少计开始日一天利息(CalcFirst 回归锚)"); + } + + // ── 事件利率确定性(EQD-6968 自洽化):排除日不取价,事件利率=末段已消费利率 ── + // 同一交易同一天,尾日价缺(上午平仓) vs 有(下午平仓):金额与落库 FloatRate 必须完全一致, + // 杜绝"记录利率取决于点击时刻"。 + + [TestMethod] + public void Determinism_NoTail_EventFloatRateIndependentOfPublishTime() + { + var wMorning = Run(InterestTypeEnum.复利, includeCloseDate: false, calcMode: "10", + preEod: BuildPreEod(new DateTime(2026, 7, 13))); + var wAfternoon = Run(InterestTypeEnum.复利, includeCloseDate: true, calcMode: "10", closeRate: 0.0199, + preEod: BuildPreEod(new DateTime(2026, 7, 13))); + Assert.IsFalse(wMorning.Threw, "上午世界不应抛:" + wMorning.Ex?.Message); + Assert.IsFalse(wAfternoon.Threw, "下午世界不应抛:" + wAfternoon.Ex?.Message); + Assert.AreEqual(wMorning.Fe.InterestAmount, wAfternoon.Fe.InterestAmount, + "金额不应因尾日价发布与否而变化(尾日利率零消费)"); + Assert.AreEqual(0.01425m, wMorning.Fe.FloatRate, + "事件利率=末段已消费利率(7/13定盘 0.01425),非尾日价"); + Assert.AreEqual(wMorning.Fe.FloatRate, wAfternoon.Fe.FloatRate, + "事件利率必须与平仓时刻(尾日价发布前后)无关"); + } + + // ── 快照利率携带契约(carry-forward):带 preEod 时不重复取 ≤ValueDate 的重置日 ── + // fetchAfterDate=preEod.ValueDate + seed=preEod.FloatRate 是设计分工:≤上一日终的重置日 + // 沿用快照携带的"截至 ValueDate 生效利率"(真实 EOD 快照由当日重置日再定盘写入), + // >上一日终的重新取价。与无日终场景的根本区别:种子有正确来源,不需要强制重取首重置日。 + + [TestMethod] + public void CarryForward_Simple_NoHead_PreEodAtStartCarriesFirstPeriodRate() + { + // preEod.ValueDate=开始日(7/6),FloatRate=7/6定盘0.0142(模拟开始日EOD快照的真实语义) + var o = Run(InterestTypeEnum.单利, includeCloseDate: true, calcMode: "00", + closeDate: new DateTime(2026, 7, 10), preEod: BuildPreEod(StartDate, 0.0142m)); + Assert.IsFalse(o.Threw, "不应抛:" + o.Ex?.Message); + Assert.AreEqual(0, o.Svc.PricedDates.Count, + "≤上一日终(7/6)的重置日不重复取价——首段利率由快照携带(carry-forward 契约)"); + Assert.AreEqual(0.0142m, o.Fe.FloatRate, + "首段(也是末段)利率=快照携带的 7/6 定盘"); + // 不算头不算尾:计息日 [7/7,7/10) 共 3 天 @ (spread+0.0142);单利重放含上日待实现(-100000) + Assert.AreEqual(-100000m + Notional * (Spread + 0.0142m) * 3m / AnnualDays, o.Fe.InterestAmount, 0.0000001m, + "金额=上日待实现+3天×(spread+快照利率),首段未误用种子外的任何值"); + } + + [TestMethod] + public void Eod_NoHead_StartDay_SnapshotRateCarriesFirstFixing() + { + // 链条起点钉死:开始日当天 EOD("00",窗口为空 interestStart=7/7>interestEnd=7/6)—— + // 窗口为空时"不算尾不取价"分支不命中,走正常取价,快照 FloatRate=开始日定盘。 + // 次日重放才能以 fetchAfter=开始日 + 携带利率=开始日定盘 正确续算(见上一用例)。 + var o = Run(InterestTypeEnum.单利, includeCloseDate: true, calcMode: "00", + closeDate: StartDate, settment: true); + AssertNoThrow(o, "开始日EOD(00) 不应抛"); + Assert.AreEqual(0.0142m, o.Fe.FloatRate, + "开始日EOD快照利率=当日(首重置日)定盘——窗口为空不触发不取价分支"); + } + + // ── 窗口判定语义(InitInterestDate 直测;死子句 td.StartDate>interestStart 删除后的边界钉死)── + + [TestMethod] + public void WindowSemantics_NoHeadStartDay_EmptyViaFirstClause() + { + var svc = new StubSwapDealService(MakeOptUser(), new HashSet()); + var td = BuildTrade(StartDate, "00"); // ExerciseDate=7/7 + bool empty = svc.InitInterestDate(StartDate, null, td, tdClose: false, + out var start, out var end); + Assert.IsTrue(empty, "不算头首日:interestStart=7/7 > interestEnd=7/6 → 窗口为空(第一子句兜住)"); + Assert.AreEqual(StartDate, end); + } + + [TestMethod] + public void WindowSemantics_SameDaySettle_EqualDates_NotEmpty() + { + var svc = new StubSwapDealService(MakeOptUser(), new HashSet()); + var td = BuildTrade(new DateTime(2026, 7, 10), "10"); // ExerciseDate=7/11 + bool empty = svc.InitInterestDate(new DateTime(2026, 7, 10), new DateTime(2026, 7, 10), td, tdClose: false, + out var start, out var end); + Assert.IsFalse(empty, "当日已结息(日期相等)窗口非空——利息归零由 GetInterests closeList 净额层处理,不在此判定"); + Assert.AreEqual(new DateTime(2026, 7, 10), start); + Assert.AreEqual(new DateTime(2026, 7, 10), end); + } + + [TestMethod] + public void WindowSemantics_SameDaySettle_OnMaturityRollback_Empty() + { + var svc = new StubSwapDealService(MakeOptUser(), new HashSet()); + var td = BuildTrade(CloseDate, "00", exerciseDate: CloseDate); // 到期日=7/20 且不算尾 + bool empty = svc.InitInterestDate(CloseDate, CloseDate, td, tdClose: false, + out var start, out var end); + Assert.IsTrue(empty, "当日已结息+到期日不算尾回拨:interestStart=7/20 > interestEnd=7/19 → 窗口为空"); + } + + // ── EOD 收盘归档路径(settment=true,此前全套件仅覆盖盘中 settment:false)── + // EOD 不取尾日价的依赖链:InitInterestDate 到期日回拨(endDate=D-1) + CalcEodInterest 的 + // calcToday=false(valueDate==到期日且不算尾) 整体跳过 ByEod 重算——ByEod 的取价 + // (CalcDailyCompoundInterestByEod/CalcDailySimpleInterestByEod 的 isResetDay→ResolveFloatRate) + // 不看 calcLast,任何一环回归都会让到期日收盘重新索要尾日 FR007。以下三例钉死该链。 + + [TestMethod] + public void Eod_NoTail_MaturityDayFr007Missing_Succeeds() + { + // 到期日=7/20(重置日)当天收盘,尾日价未发布 → 不算尾应放行(回拨+跳过重算两道闸) + AssertNoThrow(Run(InterestTypeEnum.复利, includeCloseDate: false, calcMode: "10", + exerciseDate: CloseDate, preEod: BuildPreEod(new DateTime(2026, 7, 13)), settment: true), + "EOD 不算尾(10)+到期日缺价 → 应放行(尾日不参与计息,不得取价)"); + } + + [TestMethod] + public void Eod_Tail_MaturityDayFr007Missing_StillThrows() + { + var o = Run(InterestTypeEnum.复利, includeCloseDate: false, calcMode: "11", + exerciseDate: CloseDate, preEod: BuildPreEod(new DateTime(2026, 7, 13)), settment: true); + Assert.IsTrue(o.Threw, "EOD 算尾(11)+到期日(重置日)缺价 → 应拦截(该日利率被消费)"); + StringAssert.Contains(o.Ex.Message, "FR007"); + } + + [TestMethod] + public void Eod_MidTradeResetDayFr007Missing_StillThrows() + { + // 非到期日的盘中重置日:不算尾也不豁免——新利率自当日起被持续持仓消费,ByEod 必须取到 + var o = Run(InterestTypeEnum.复利, includeCloseDate: false, calcMode: "10", + preEod: BuildPreEod(new DateTime(2026, 7, 13)), settment: true); + Assert.IsTrue(o.Threw, "EOD 不算尾(10)+非到期重置日(7/20)缺价 → 仍应拦截(ByEod 取价链,真实依赖)"); + StringAssert.Contains(o.Ex.Message, "FR007"); + } + + // ── 到期日当天全平:replayEndDate=endDate+1 补计分支(exclusionStart 的存在理由)── + // 不算尾时 InitInterestDate 把 endDate 回拨一天;最终全平的历史差分重放需把窗口补回真实 + // 平仓/到期日(replayEndDate=endDate+1),但该边界日的定盘经 exclusionStart 标记为 + // "有价则取/缺价跳过"——否则到期日上午全平会被尾日价误拦(EQD-6968 在到期日的镜像场景)。 + + [TestMethod] + public void FinalClose_OnMaturityDay_NoTail_CloseDayFr007Missing_Succeeds() + { + AssertNoThrow(Run(InterestTypeEnum.复利, includeCloseDate: false, calcMode: "10", + exerciseDate: CloseDate, preEod: BuildPreEod(new DateTime(2026, 7, 13))), + "到期日当天全平+不算尾+到期日(重置日)缺价 → 应放行(补计重放的边界日不索取定盘)"); + } + + [TestMethod] + public void Guard_FinalClose_OnMaturityDay_Tail_CloseDayFr007Missing_StillThrows() + { + var o = Run(InterestTypeEnum.复利, includeCloseDate: false, calcMode: "11", + exerciseDate: CloseDate, preEod: BuildPreEod(new DateTime(2026, 7, 13))); + Assert.IsTrue(o.Threw, "到期日当天全平+算尾+到期日缺价 → 应拦截(该日利率被消费)"); + StringAssert.Contains(o.Ex.Message, "FR007"); + } + } +} + diff --git a/UnitTestProject/Modules/SwapModule/GLMS20260819Fr007TradeDiscoveryTest.cs b/UnitTestProject/Modules/SwapModule/GLMS20260819Fr007TradeDiscoveryTest.cs new file mode 100644 index 00000000..21fa137f --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/GLMS20260819Fr007TradeDiscoveryTest.cs @@ -0,0 +1,120 @@ +using System; +using System.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using YLErp.DBModels; + +namespace YLErp.Modules.SwapModule +{ + /// + /// EQD-6968 UAT 辅助:从 96 真实库抽取"在途 FR007 互换"具体历史交易, + /// 打印完整交易要素,供 UAT 直接选用(替代手动猜要素)。 + /// 标 [Ignore],手动跑一次即可;依赖 app.config 中 xray 连接(你的环境已指向 96)。 + /// 复用 GLMS20260105GoldenTest 的连库写法:DbContextFactory.GetYLDbContext()。 + /// 用原生 ADO.NET 读结果,规避 EF 实体映射类型踩坑。 + /// + [TestClass] + public class GLMS20260819Fr007TradeDiscoveryTest + { + private const string TradeSql = @" +SELECT + t.id AS TradeId, + p.id AS PositionId, + t.TradeNumber AS TradeNumber, + t.StartDate AS StartDate, + t.ExerciseDate AS ExerciseDate, + t.ValidState AS ValidState, + p.interest_rest_days AS interest_rest_days, + p.interest_rule AS interest_rule, + p.FloatRateUnderlyingCode AS FloatRateUnderlyingCode, + p.IsInitial AS IsInitial, + p.InterestType AS InterestType, + p.InterestMode AS InterestMode, + CASE WHEN te.ExtendJson LIKE '%""InterestCalcMode""%' + THEN SUBSTRING_INDEX(SUBSTRING_INDEX(te.ExtendJson, '""InterestCalcMode"":""', -1), '""', 1) + ELSE '11' END AS InterestCalcMode +FROM trade t +JOIN swap_position p ON p.SwapTradeId = t.id +LEFT JOIN trade_extend te ON te.TradeId = t.id +WHERE t.ValidState = 'Valid' + AND p.Invalid = 0 + AND p.IsInitial = 1 + AND p.FloatRateUnderlyingCode = 'FR007' + AND t.ExerciseDate >= CURDATE() +ORDER BY t.StartDate;"; + + private const string FixingSql = @" +SELECT ValueDate, ReferencePrice +FROM eod_commodity_future_price +WHERE FutureContractId = 'FR007' + AND ValueDate >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) +ORDER BY ValueDate DESC;"; + + private TestContext _testContext; + public TestContext TestContext + { + get => _testContext; + set => _testContext = value; + } + + private static string Fmt(object v) => + v == null || v == DBNull.Value ? "NULL" + : (v is DateTime dt ? dt.ToString("yyyy-MM-dd") : v.ToString()); + + [TestMethod] + [Ignore] + [TestCategory("Discovery")] + public void Discover_InTransitFr007Trades() + { + using (var db = DbContextFactory.GetYLDbContext()) + { + var conn = db.Database.GetDbConnection(); + if (conn.State != ConnectionState.Open) conn.Open(); + using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = TradeSql; + using (var reader = cmd.ExecuteReader()) + { + int n = 0; + while (reader.Read()) + { + n++; + TestContext.WriteLine( + $"TradeId={reader["TradeId"]} PosId={reader["PositionId"]} No={reader["TradeNumber"]} " + + $"Start={Fmt(reader["StartDate"])} Expr={Fmt(reader["ExerciseDate"])} " + + $"CalcMode={Fmt(reader["InterestCalcMode"])} rule={Fmt(reader["interest_rule"])} rest={Fmt(reader["interest_rest_days"])} " + + $"IntType={Fmt(reader["InterestType"])} Mode={Fmt(reader["InterestMode"])}"); + } + TestContext.WriteLine($"=== 在途 FR007 互换共 {n} 笔 ==="); + } + } + } + } + + [TestMethod] + [Ignore] + [TestCategory("Discovery")] + public void Discover_Fr007FixingStatus() + { + using (var db = DbContextFactory.GetYLDbContext()) + { + var conn = db.Database.GetDbConnection(); + if (conn.State != ConnectionState.Open) conn.Open(); + using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = FixingSql; + using (var reader = cmd.ExecuteReader()) + { + int n = 0; + while (reader.Read()) + { + n++; + TestContext.WriteLine($"FR007 ValueDate={Fmt(reader["ValueDate"])} ReferencePrice={Fmt(reader["ReferencePrice"])}"); + } + TestContext.WriteLine($"=== FR007 定盘近 30 天共 {n} 条 ==="); + } + } + } + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/GetInterestsEntrySemanticsTest.cs b/UnitTestProject/Modules/SwapModule/GetInterestsEntrySemanticsTest.cs new file mode 100644 index 00000000..41a3163d --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/GetInterestsEntrySemanticsTest.cs @@ -0,0 +1,470 @@ +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; + } + // 离线自洽:本测试场景无历史已结利息,等价于此前"空库查询返回 0"的行为, + // 使复利路径(GetConsumedInterest)不再依赖数据库连通(YLErp_UNIT_TEST_SKIP_INITIALIZATION=1 可跑)。 + public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate) + => 0m; + } + + private static SwapDealService CreateService() => new StubSwapDealService( + new OptUserInfo(0, nameof(GetInterestsEntrySemanticsTest), OptUserFrom.UnitTest), + new Dictionary + { + [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%:【同请求形状⇒同额】oracle(契约目标语义,修复落地时的现成回归网)。 + /// + /// 修复前(b01b485e 钉住的分歧):盘中 0.036165(平掉额全程重放=确认书公式)vs + /// EOD 0.059042(恒1 掉进全平专属分支,全腿待实现+末段增量,无契约依据,重算结果被丢弃)。 + /// 说明:EOD 平仓后收盘走恒1惯例形状;端到端结算结果由 DI_EXCEL_SCENARIO4 家族对账确认书公式保障 + /// (平仓前剩余+实际平掉额+真实比例),部分平仓不再进 closePrecent==1 分支。 + /// (最终全平=剩余额×∏利率,2026-08-18 手算复核 Excel BL/BN 均符合)。 + /// 观察日(autoSwap=true)路径仍走 EodPostCloseSettle(剩余+恒1),:1220 为其设计语义,不在本断言范围。 + /// + [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)); + + // 契约口径形状(与盘中同形状):平仓前剩余 1000 + 平掉额 300 + 真实比例 0.3 + var eodPostClose = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, + eodPositions, positions, PreClose, Closed, ClosePercent, + (int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose, + add: true, settment: false, newCalcLast: false, closeList: null); + + Assert.AreEqual(1, intraday.Count); + Assert.AreEqual(1, eodPostClose.Count); + Console.WriteLine($"[复利mode2] 盘中 InterestAmount={intraday[0].InterestAmount} / EOD={eodPostClose[0].InterestAmount}"); + + // 契约 oracle:两入口同请求形状必须同额(=确认书公式"平掉额×全程参考利率") + Assert.AreEqual(intraday[0].InterestAmount, eodPostClose[0].InterestAmount, 0.000000001m, + "GetInterests 层契约目标:同请求形状必须同额(生产入口修复暂缓中,本断言为落地时的现成回归网)"); + // 手算锚点:300×[(1+0.011×3/365)×(1+0.011×1/365)−1]=0.036165(确认书公式) + Assert.AreEqual(0.036165m, Math.Round(intraday[0].InterestAmount, 6, MidpointRounding.AwayFromZero), + "盘中重放=契约公式手算锚点 0.036165"); + } + + /// + /// 【回归钉子】复利×mode2×部分平仓:观察日路径(EodPostCloseSettle 剩余+恒1)保持设计语义不回退。 + /// 修复只改 autoSwap=false 分支;观察日恒1 全量结息是 :1220 分支的设计意图(结现),锁死其当前值。 + /// + [TestMethod] + public void 复利_mode2_部分平仓_观察日恒1语义保持() + { + var td = CreateTrade(); + var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.复利, floating: true); + var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose); + var eodPositions = new List { preEod }; + var positions = new List { position }; + + var observationDay = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, + eodPositions, positions, Remaining, Closed, 1m, + (int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose, + add: true, settment: false, newCalcLast: false, closeList: null); + + Assert.AreEqual(1, observationDay.Count); + Assert.AreEqual(0.059041913305m, observationDay[0].InterestAmount, 0.000000001m, + "观察日(autoSwap=true)路径:剩余+恒1 的全平分支为其设计语义(结现),修复不得改变此值"); + } + + /// + /// 单利×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)); + + // 契约口径形状(与盘中同形状):平仓前剩余 1000 + 平掉额 300 + 真实比例 0.3 + var eodPostClose = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, + eodPositions, positions, PreClose, Closed, ClosePercent, + (int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose, + add: true, settment: false, newCalcLast: false, closeList: null); + + Assert.AreEqual(1, intraday.Count); + Assert.AreEqual(1, eodPostClose.Count); + Console.WriteLine($"[单利mode2] 盘中 InterestAmount={intraday[0].InterestAmount} / EOD={eodPostClose[0].InterestAmount}"); + Console.WriteLine($"[单利mode2] TdInterestAmount: 盘中={intraday[0].TdInterestAmount} / EOD={eodPostClose[0].TdInterestAmount}"); + // 契约目标:两入口同请求形状必须同额(单利:平掉额基数 + 快照×比例链路一致) + Assert.AreEqual(intraday[0].InterestAmount, eodPostClose[0].InterestAmount, 0.000000001m, + "GetInterests 层契约目标:单利×mode2 同请求形状必须同额(生产入口修复暂缓中)"); + Assert.IsTrue(intraday[0].InterestAmount != 0m, "盘中单利结息额不应为0"); + } + + /// + /// mode9 全平(契约目标形状:平仓前剩余=平掉额=1000、比例恒1): + /// 结息额非零且=全平语义(:1220 全平分支:待实现+末段增量,尾差一次带走——设计意图维持)。 + /// + [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 }; + + // 全平:平仓前剩余=平掉=1000,比例恒1(全平专属分支) + var result = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, + eodPositions, positions, PreClose, PreClose, 1m, + (int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose, + add: true, settment: false, newCalcLast: false, closeList: null); + + Assert.AreEqual(1, result.Count); + Console.WriteLine($"[复利mode9全平] InterestAmount={result[0].InterestAmount}"); + Assert.IsTrue(result[0].InterestAmount != 0m, + "mode9 全平:结息本金=平掉额(1000),结息额非零(全平语义钉子)"); + } + + #region CalcEodPostCloseSettleInterests 接缝映射钉子 + + /// + /// 参数捕获 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 + + #region 守恒不变量(§7-1, 免 oracle/免库, 守 EOD平仓后收盘×部分平仓 裸格) + + // 守恒不变量统一断言在"剩余持仓前递"(preEod.PosiNotionalValue)上:该字段由 CalcUnwindInterest/ + // InitSwapDealInterest 在 preEod.id==0 时写入(posiPrincipal),与利息算法(单/复、FR007)无关, + // 是最稳健、码算、免库的守恒观测点。期初(orginPv) = 前递剩余 + 平掉额(closePosiNotionalValue) 必须成立。 + // 全部内存构造(StubSwapDealService 避库);funding-leg(mode2)不触发早路由 continue,故亦是早路由改动护栏。 + + /// + /// 建一个"无历史 eod"快照(id==0),使引擎把本次剩余持仓写入 preEod.PosiNotionalValue。 + /// + private static eod_swap_position NewPreEod(decimal carryPrincipal) + => new() + { + id = 0, SwapTradeId = 1, PositionId = 1001, + ValueDate = new DateTime(2026, 4, 29), ClientId = 999998, + FloatRate = 0m, TdInterestPrincipal = carryPrincipal, + PosiNotionalValue = carryPrincipal, InterestIncomeSum = 0.05m, InterestProfitSum = 0.05m + }; + + /// + /// §7-1 守恒①:EOD平仓后收盘×部分平仓,引擎把剩余持仓(700)前递进 preEod.PosiNotionalValue, + /// 且 期初 = 前递剩余(码算) + 平掉额(输入) = 1000。 + /// 守 2035e1df 裸格(§6 空洞1):若 EOD 入口把前递值误写成平掉额/期初,守恒等式即破。 + /// + [TestMethod] + public void EOD平仓后收盘_部分平仓_守恒_剩余前递且期初等于剩余加平掉额() + { + var td = CreateTrade(); + var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.单利); + var preEod = NewPreEod(Remaining); // 无历史 eod → 引擎写回剩余 + var eodPositions = new List { preEod }; + var positions = new List { position }; + + var result = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, + eodPositions, positions, Remaining, Closed, 1m, + (int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose, + add: false, settment: false, newCalcLast: false, closeList: null); + + Assert.AreEqual(1, result.Count, "EOD平仓后收盘部分平仓应产生 1 条利息事件"); + // 码算:引擎把剩余持仓前递(return 700) + Assert.AreEqual(Remaining, preEod.PosiNotionalValue, + "EOD平仓后收盘必须把剩余持仓(700)前递进 preEod.PosiNotionalValue;若误写平掉额/期初则守恒破坏"); + // 守恒:期初 = 前递剩余(码算) + 平掉额(输入) + Assert.AreEqual(PreClose, preEod.PosiNotionalValue + Closed, + "期初(orginPv=1000) 必须 = 剩余(700) + 平掉额(300);本金口径不守恒则利息算错"); + } + + /// + /// §7-1 守恒②:EOD平仓后收盘×全平,剩余持仓前递=0(清仓)。守全平非零边界的互补面。 + /// + [TestMethod] + public void EOD平仓后收盘_全平_守恒_剩余前递归零() + { + var td = CreateTrade(); + var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.单利); + var preEod = NewPreEod(0m); + var eodPositions = new List { preEod }; + var positions = new List { position }; + + var result = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, + eodPositions, positions, 0m, PreClose, 1m, + (int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose, + add: false, settment: false, newCalcLast: false, closeList: null); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual(0m, preEod.PosiNotionalValue, + "全平后剩余持仓前递必须为 0;非 0 表示平仓未清仓,守恒破坏"); + Assert.AreEqual(PreClose, preEod.PosiNotionalValue + PreClose, + "全平守恒:期初(1000) = 剩余(0) + 平掉额(1000)"); + } + + /// + /// §7-1 守恒③(逐日):两次部分平仓,Day2 剩余前递 = 当日剩余(码算),且 期初 - 前递剩余 = 平掉额, + /// 构成跨日携带链守恒。Day1 期初1000→平300剩700;Day2 期初700→平210剩490;累计平掉510+剩余490=1000。 + /// + [TestMethod] + public void EOD平仓后收盘_两次部分平仓_逐日守恒_期初减剩余前递等于平掉额() + { + var td = CreateTrade(); + var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.单利); + + // Day1:期初1000,平300,剩700 + var preEod1 = NewPreEod(PreClose); + var result1 = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, + new List { preEod1 }, new List { position }, + Remaining, Closed, 1m, + (int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose, + add: false, settment: false, newCalcLast: false, closeList: null); + Assert.AreEqual(1, result1.Count); + Assert.AreEqual(Remaining, preEod1.PosiNotionalValue, "Day1 剩余前递应为 700"); + + // Day2:期初=Day1剩余700,平210,剩490 + const decimal day2OrginPv = 700m; + const decimal day2Closed = 210m; + const decimal day2Remaining = 490m; + var preEod2 = NewPreEod(day2OrginPv); // 承载=Day1剩余700 + var result2 = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, + new List { preEod2 }, new List { position }, + day2Remaining, day2Closed, 1m, + (int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: day2OrginPv, + add: false, settment: false, newCalcLast: false, closeList: null); + + Assert.AreEqual(1, result2.Count); + // 码算:Day2 剩余前递=当日剩余(490) + Assert.AreEqual(day2Remaining, preEod2.PosiNotionalValue, "Day2 剩余前递=剩余(490,码算值)"); + // 逐日守恒:期初 - 剩余前递 = 平掉额(210) + Assert.AreEqual(day2Closed, day2OrginPv - preEod2.PosiNotionalValue, + "Day2 守恒:期初(700) - 剩余前递(490) 必须 = 平掉额(210);跨日携带链本金不守恒则利息算错"); + } + + /// + /// §7-1 守恒④(纯数学,ClosePercentMath):多次平仓累计占期初比例 = 1 - ∏(1 - 各次剩余口径)。 + /// 初次占期初30%(平300/名义1000)→剩余口径0.3;二次占期初50%(平350/剩余700)→剩余口径0.5; + /// 累计平掉 = 1 - 0.7×0.5 = 0.65。验证 ClosePercentMath 双口径换算在多次平仓下不漂移。 + /// + [TestMethod] + public void 多次平仓_占期初累计比例等于各次剩余口径连乘补数() + { + var b1 = ClosePercentMath.ToRemainingClosePercent(0.3m, 1000m, 1000m); + Assert.AreEqual(0.3m, b1, "初次平仓占期初30% → 剩余口径应为 0.3"); + var b2 = ClosePercentMath.ToRemainingClosePercent(0.5m, 700m, 700m); + Assert.AreEqual(0.5m, b2, "二次平仓占期初50%(占剩余700) → 剩余口径应为 0.5"); + + var cumulativeClosed = 1m - (1m - b1) * (1m - b2); + Assert.AreEqual(0.65m, cumulativeClosed, 0.0000001m, + "多次平仓累计平掉比例必须=各次剩余口径连乘的补数;否则本金口径在多次平仓下分裂"); + + var back = ClosePercentMath.ToOriginalClosePercent(cumulativeClosed, 1000m, 1000m); + Assert.AreEqual(0.65m, back, 0.0000001m, "累计占期初比例反向还原必须一致"); + } + + #endregion + } +} 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/InitUnwindTradingFeeTest.cs b/UnitTestProject/Modules/SwapModule/InitUnwindTradingFeeTest.cs index 8ebfb199..34000da7 100644 --- a/UnitTestProject/Modules/SwapModule/InitUnwindTradingFeeTest.cs +++ b/UnitTestProject/Modules/SwapModule/InitUnwindTradingFeeTest.cs @@ -1,4 +1,3 @@ -using System.Reflection; using YLErp.DBModels; namespace YLErp.Modules.SwapModule @@ -7,26 +6,10 @@ namespace YLErp.Modules.SwapModule public class InitUnwindTradingFeeTest { private static decimal InvokeCalcInitTradingFee(swap_position position, UnwindData unwindData) - { - var method = typeof(SwapDealService).GetMethod( - "CalcInitTradingFee", - BindingFlags.NonPublic | BindingFlags.Static); - - Assert.IsNotNull(method, "未找到 CalcInitTradingFee 私有静态方法"); - - return (decimal)method.Invoke(null, new object[] { position, unwindData }); - } + => TradingFeeCalc.CalcInitTradingFee(position, unwindData); private static decimal InvokeCalcInitTradingFeePending(swap_position oriPosition, swap_position position, UnwindData unwindData) - { - var method = typeof(SwapDealService).GetMethod( - "CalcInitTradingFeePending", - BindingFlags.NonPublic | BindingFlags.Static); - - Assert.IsNotNull(method, "CalcInitTradingFeePending was not found"); - - return (decimal)method.Invoke(null, new object[] { oriPosition, position, unwindData }); - } + => TradingFeeCalc.CalcInitTradingFeePending(oriPosition, position, unwindData); [TestMethod] public void 百分比模式_按平仓名义本金计算并四舍五入到两位() diff --git a/UnitTestProject/Modules/SwapModule/InterestEodScenarioDispatchTest.cs b/UnitTestProject/Modules/SwapModule/InterestEodScenarioDispatchTest.cs new file mode 100644 index 00000000..8b4844ab --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/InterestEodScenarioDispatchTest.cs @@ -0,0 +1,30 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace YLErp.Modules.SwapModule +{ + /// + /// DealInterests 分派优先级(TEST-MATRIX §6 空洞补盖)。 + /// 纯函数 InterestEodScenarioDispatch.ResolveInterestScenario 的 8 组合表驱动单测,不连库、无 DB。 + /// 守卫:手工互换压制观察日自动结息;观察日±平仓区分 autoSwap 真/假;纯平仓与普通日分流。 + /// + [TestClass] + public class InterestEodScenarioDispatchTest + { + [DataTestMethod] + [DataRow(false, false, false, InterestEodScenario.RollForward)] // 普通日 + [DataRow(false, false, true, InterestEodScenario.CloseOnly)] // 纯平仓(非观察日) + [DataRow(false, true, false, InterestEodScenario.ManualSwap)] // 手工互换(非观察日) + [DataRow(false, true, true, InterestEodScenario.ManualSwap)] // 手工互换+平仓 → 手工优先 + [DataRow(true, false, false, InterestEodScenario.AutoSettle)] // 观察日, 无平仓 + [DataRow(true, false, true, InterestEodScenario.AutoSettleWithClose)] // 观察日+平仓 (autoSwap=true) + [DataRow(true, true, false, InterestEodScenario.ManualSwap)] // 观察日+手工互换 → 手工优先 + [DataRow(true, true, true, InterestEodScenario.ManualSwap)] // 观察日+手工互换+平仓 → 手工优先 + public void ResolveInterestScenario_CoversAllEightCombinations( + bool hasInterval, bool hasSwap, bool hasClose, InterestEodScenario expected) + { + var actual = InterestEodScenarioDispatch.ResolveInterestScenario(hasInterval, hasSwap, hasClose); + Assert.AreEqual(expected, actual, + $"hasInterval={hasInterval}, hasSwap={hasSwap}, hasClose={hasClose}"); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/InterestEodTailSnapshotTest.cs b/UnitTestProject/Modules/SwapModule/InterestEodTailSnapshotTest.cs new file mode 100644 index 00000000..30c380ac --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/InterestEodTailSnapshotTest.cs @@ -0,0 +1,319 @@ +using Newtonsoft.Json; + +namespace YLErp.Modules.SwapModule +{ + /// + /// L1(类内去重)前置安全网:DealInterests 四分支中无 golden 语料的三格 + /// (AutoSettle / AutoSettleWithClose / CloseOnly)尾部滚存字段特征化快照。 + /// + /// - ManualSwap / RollForward 两格已由 DealInterestsGoldenReplayTest 语料钉住 + /// (字段集见 GoldenReplayFramework.EodPositionToJson)。 + /// - 本测试钉"现状行为":L1 抽共享助手(腿字段拷贝段 + 滚存收尾段)前后, + /// 以下字段必须逐字段不变。变化=去重改了口径。 + /// - 同时断言接 seam 指纹(哪个计息接缝 + eventType)与 autoInterests 收集行为, + /// 兼作 L2(按腿拆类)的路由验收。 + /// - 计息金额由受控 CalcResult 注入(不连库、不依赖真实计息引擎)。 + /// + [TestClass] + public class InterestEodTailSnapshotTest + { + private const decimal Principal = 10000m; + private const decimal Rate = 0.03m; + private static readonly DateTime StartDate = new(2026, 4, 27); + private static readonly DateTime SettleDate = StartDate.AddDays(10); // 第10天收盘 + private const decimal Accrued10d = 8.22m; // 受控:10天理论应结 + private const decimal DailyNew = 0.82m; // 受控:当日新增 + private const decimal ManualSettled = 3.5m; // 受控:盘中平仓已结 + private const decimal Remaining = 7000m; // 平仓后剩余本金 + private const decimal ClosedNotional = 3000m; // 本次平掉本金 + + private sealed class TailStubService : TestableSwapEodPositionService + { + public TailStubService() : base(nameof(InterestEodTailSnapshotTest)) { } + + /// 受控计息结果:两个计息 seam 均返回它 + public List CalcResult { get; set; } = new(); + + public string LastCalcSeam { get; private set; } = ""; + public List CalcEventTypes { get; } = new(); + + 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) + { + LastCalcSeam = nameof(CalcSwapInterests); + CalcEventTypes.Add(eventType); + return CalcResult; + } + + protected override List CalcEodPostCloseSettleInterests(InterestCalcRequest req) + { + LastCalcSeam = nameof(CalcEodPostCloseSettleInterests); + CalcEventTypes.Add(req.EventType); + return CalcResult; + } + + /// 持仓延续腿重置日再定盘接缝:计数并返回受控新定盘(不连库) + public decimal RefixResult { get; set; } + public int RefixCalls { get; private set; } + + protected override decimal ResolveOngoingResetFixing(swap_position position, DateTime valueDate) + { + RefixCalls++; + return RefixResult; + } + + public List ExecuteDealInterests( + List interestList, List eodPositions, + DateTime settleDate, trade td, List flowEvents, + decimal posiTotalNotional, decimal closeNational, decimal grossPrice, decimal orginPv) + { + var autoInterests = new List(); + DealInterests(interestList, eodPositions, new List(), + settleDate, td, flowEvents, autoInterests, null, + posiTotalNotional, closeNational, grossPrice, orginPv); + return autoInterests; + } + } + + private static trade CreateTrade() => new() + { + id = 1, TradeNumber = "TAIL-SNAP-001", ClientId = 999998, + TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate, + ExerciseDate = new DateTime(2027, 4, 27), TradeStatus = "确认成交", + ValidState = "Valid", StructureType = "单标的", + QuoteCurrency = "CNY", SettlementCurrency = "CNY", + trade_extend = new trade_extend + { + TradeId = 1, + ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson { AnnualDays = 365, InterestCalcMode = "10", SettlementRules = 0 }) + } + }; + + /// true=当日观察日(Settlement=1);false=观察日在别日 + private static swap_position CreateInterestPosition(bool observationDay) + { + var interval = observationDay + ? new IntervalModel { Date = SettleDate, Rate = Rate, Settlement = 1 } + : new IntervalModel { Date = StartDate, Rate = Rate, Settlement = 1 }; + return new swap_position + { + id = 1001, SwapTradeId = 1, InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.标的期初全价, InterestRateDefault = Rate, + InterestPrincipalFix = Principal, PosiStartDate = StartDate, + PosiMatuirityDate = new DateTime(2027, 4, 27), IsInitial = true, + InterestType = (int)InterestTypeEnum.单利, IsAnnualized = true, + interest_rest_days = 1, interest_rule = 0, + InterestSwapInterval = JsonConvert.SerializeObject(new List { interval }) + }; + } + + private static eod_swap_position CreatePreEod(decimal accumulated) => new() + { + id = 100, PositionId = 1001, ValueDate = SettleDate.AddDays(-1), + InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)InterestModeEnum.标的期初全价, + InterestIncomeSum = accumulated, InterestProfitSum = accumulated, + InterestRateDefault = Rate, TdInterestPrincipal = Principal, + InterestType = (int)InterestTypeEnum.单利, IsAnnualized = true, interest_rest_days = 1 + }; + + private static swap_flow_event CreateCalcResult() => new() + { + EventType = (int)SwapEventTypeEnum.自动互换, PositionId = 1001, + InterestAmount = Accrued10d, TdInterestAmount = DailyNew, + InterestClosePnL = Accrued10d, + InterestPrincipal = Principal, InterestRate = Rate, + InterestDirection = (int)SwapDirectionEnum.收取 + }; + + private static swap_flow_event CreateCloseEvent() => new() + { + EventType = (int)SwapEventTypeEnum.平仓, PositionId = 1001, + InterestAmount = ManualSettled, InterestClosePnL = ManualSettled, + InterestRate = Rate, InterestFee = 0m, + InterestPrincipal = ClosedNotional, + InterestDirection = (int)SwapDirectionEnum.收取, + DataState = (int)SwapFlowDateStateEnum.完成 + }; + + /// AutoSettle 格:观察日无平仓 → SaveAutoEodInterestPosition,返回值收集进 autoInterests + [TestMethod] + public void AutoSettle_观察日无平仓_尾部快照() + { + var service = new TailStubService { CalcResult = new List { CreateCalcResult() } }; + var autoInterests = service.ExecuteDealInterests( + new List { CreateInterestPosition(observationDay: true) }, + new List { CreatePreEod(Accrued10d) }, + SettleDate, CreateTrade(), new List(), + Principal, 0m, 100m, Principal); + + Assert.AreEqual("CalcSwapInterests", service.LastCalcSeam, "观察日无平仓应走 CalcSwapInterests seam"); + Assert.AreEqual((int)SwapEventTypeEnum.自动互换, service.CalcEventTypes.Single(), "eventType 应为自动互换"); + Assert.AreEqual(1, autoInterests.Count, "观察日分支应收集返回值进 autoInterests(→资金记录)"); + + var p = service.PersistedPositions.Single(); + // 钉值于 2026-08-17 现状行为(受控输入:应结8.22/新增0.82/本金10000) + Assert.AreEqual(8.22m, p.TdCloseInterest); + Assert.AreEqual(0.82m, p.TdInterestIncome); + Assert.AreEqual(10000m, p.TdInterestPrincipal); + Assert.AreEqual(0.03m, p.TdInterestRate); + Assert.AreEqual(0.00m, p.InterestIncomeSum, "应结=结算,待实现清零"); + Assert.AreEqual(0m, p.InterestFeeSum); + Assert.AreEqual(0m, p.InterestProfitSum); + Assert.AreEqual(8.22m, p.RealizedInterest); + Assert.AreEqual(0m, p.RealizedInterestFee); + Assert.AreEqual(0m, p.SwapPositionValue); + Assert.AreEqual(1.0m, p.TdCurrency); + } + + /// AutoSettleWithClose 格(TEST-MATRIX §6 最弱格):观察日+平仓 → SaveAutoEodWithCloseInterestPosition(autoSwap:true),补结差额=恒1全额−盘中已结 + [TestMethod] + public void AutoSettleWithClose_观察日加平仓_尾部快照() + { + var service = new TailStubService { CalcResult = new List { CreateCalcResult() } }; + var autoInterests = service.ExecuteDealInterests( + new List { CreateInterestPosition(observationDay: true) }, + new List { CreatePreEod(Accrued10d) }, + SettleDate, CreateTrade(), new List { CreateCloseEvent() }, + Remaining, ClosedNotional, 100m, Remaining); + + Assert.AreEqual("CalcEodPostCloseSettleInterests", service.LastCalcSeam, "观察日+平仓应走 EodPostCloseSettle seam"); + Assert.AreEqual((int)SwapEventTypeEnum.自动互换, service.CalcEventTypes.Single(), "autoSwap=true → eventType=自动互换"); + Assert.AreEqual(1, autoInterests.Count, "观察日分支应收集返回值进 autoInterests"); + Assert.AreEqual(Accrued10d - ManualSettled, autoInterests[0].InterestAmount, "补结差额=恒1全额8.22−盘中已结3.50"); + + var p = service.PersistedPositions.Single(); + // 钉值于 2026-08-17 现状行为(受控输入:恒1全额8.22/盘中已结3.5/剩余7000/平掉3000) + Assert.AreEqual(8.22m, p.TdCloseInterest, "TdCloseInterest=盘中已结3.50+补结4.72"); + Assert.AreEqual(0.5753424657534246575342465753m, p.TdInterestIncome, "autoSwap 重算展示应计=剩余7000×3%/365"); + Assert.AreEqual(7000m, p.TdInterestPrincipal, "单利部分平仓:跨日本金=剩余"); + Assert.AreEqual(0.03m, p.TdInterestRate); + Assert.AreEqual(0.00m, p.InterestIncomeSum, "恒1口径:理论应结8.22−结算8.22=0"); + Assert.AreEqual(0m, p.InterestFeeSum); + Assert.AreEqual(0m, p.InterestProfitSum); + Assert.AreEqual(8.22m, p.RealizedInterest); + Assert.AreEqual(0m, p.RealizedInterestFee); + Assert.AreEqual(0m, p.SwapPositionValue); + Assert.AreEqual(1.0m, p.TdCurrency); + } + + /// CloseOnly 格:非观察日平仓 → SaveAutoEodWithCloseInterestPosition(autoSwap:false),返回值不收集,TdCloseInterest=盘中已结 + [TestMethod] + public void CloseOnly_非观察日平仓_尾部快照() + { + var service = new TailStubService { CalcResult = new List { CreateCalcResult() } }; + var autoInterests = service.ExecuteDealInterests( + new List { CreateInterestPosition(observationDay: false) }, + new List { CreatePreEod(Accrued10d) }, + SettleDate, CreateTrade(), new List { CreateCloseEvent() }, + Remaining, ClosedNotional, 100m, Remaining); + + Assert.AreEqual("CalcEodPostCloseSettleInterests", service.LastCalcSeam, "纯平仓应走 EodPostCloseSettle seam"); + Assert.AreEqual((int)SwapEventTypeEnum.平仓, service.CalcEventTypes.Single(), "autoSwap=false → eventType=平仓"); + Assert.AreEqual(0, autoInterests.Count, "纯平仓分支不收集返回值(结算已在盘中流水定格)"); + + var p = service.PersistedPositions.Single(); + // 钉值于 2026-08-17 现状行为(受控输入:恒1重算8.22/盘中已结3.5/剩余7000/平掉3000) + Assert.AreEqual(ManualSettled, p.TdCloseInterest, "TdCloseInterest 应仅为盘中已结3.50,不叠加恒1重算值"); + Assert.AreEqual(0.5753424657534246575342465753m, p.TdInterestIncome, "不算尾路径:剩余7000×3%/365"); + Assert.AreEqual(7000m, p.TdInterestPrincipal, "单利部分平仓:跨日本金=剩余"); + Assert.AreEqual(0.03m, p.TdInterestRate, "非观察日:利率取平仓流水 InterestRate"); + Assert.AreEqual(5.295342465753m, p.InterestIncomeSum, "尾差递推:上日8.22+新增0.575342−已结3.50"); + Assert.AreEqual(0m, p.InterestFeeSum); + Assert.AreEqual(5.295342465753m, p.InterestProfitSum); + Assert.AreEqual(3.5m, p.RealizedInterest); + Assert.AreEqual(0m, p.RealizedInterestFee); + Assert.AreEqual(5.295342465753m, p.SwapPositionValue); + Assert.AreEqual(1.0m, p.TdCurrency); + } + + #region 持仓延续腿重置日再定盘(EQD-6968 自洽化:快照利率载体) + + private const decimal OldFloat = 0.01425m; + private const decimal NewFloat = 0.0143m; + /// 4/27+14:7 天周期的重置日平仓 + private static readonly DateTime ResetSettle = StartDate.AddDays(14); + /// 4/27+10:非重置日平仓(10%7≠0) + private static readonly DateTime NonResetSettle = StartDate.AddDays(10); + + private static swap_position CreateFloatLegPosition() => new() + { + id = 1001, SwapTradeId = 1, InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.标的期初全价, InterestRateDefault = Rate, + InterestPrincipalFix = Principal, PosiStartDate = StartDate, + PosiMatuirityDate = new DateTime(2027, 4, 27), IsInitial = true, + InterestType = (int)InterestTypeEnum.单利, IsAnnualized = true, + interest_rest_days = 7, interest_rule = 0, + FloatRateUnderlyingCode = "FR007", + InterestSwapInterval = JsonConvert.SerializeObject(new List + { new IntervalModel { Date = StartDate, Rate = Rate, Settlement = 1 } }) + }; + + private static eod_swap_position CreatePreEodBefore(DateTime settle, decimal accumulated) => new() + { + id = 100, PositionId = 1001, ValueDate = settle.AddDays(-1), + InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)InterestModeEnum.标的期初全价, + InterestIncomeSum = accumulated, InterestProfitSum = accumulated, + InterestRateDefault = Rate, TdInterestPrincipal = Principal, + InterestType = (int)InterestTypeEnum.单利, IsAnnualized = true, interest_rest_days = 7 + }; + + private static swap_flow_event CalcResultWithFloat(decimal floatRate) + { + var e = CreateCalcResult(); + e.FloatRate = floatRate; + return e; + } + + /// + /// 平仓日恰为重置日且剩余持仓>0:快照 FloatRate 必须显式再定盘为当日新定盘—— + /// 它是后续非重置日(ByEod 沿用 preEod.FloatRate)与当日应计(intersetAcmount)的利率载体。 + /// 排除日"纯跳过"后事件利率=末段已消费利率(OldFloat),载体职责与本步骤显式分离。 + /// + [TestMethod] + public void CloseOnly_平仓日为重置日_剩余持仓快照再定盘() + { + var service = new TailStubService + { + CalcResult = new List { CalcResultWithFloat(OldFloat) }, + RefixResult = NewFloat, + }; + service.ExecuteDealInterests( + new List { CreateFloatLegPosition() }, + new List { CreatePreEodBefore(ResetSettle, Accrued10d) }, + ResetSettle, CreateTrade(), new List { CreateCloseEvent() }, + Remaining, ClosedNotional, 100m, Remaining); + + Assert.AreEqual(1, service.RefixCalls, "不算尾+平仓日=重置日+剩余>0:应恰好显式再定盘一次"); + Assert.AreEqual(NewFloat, service.PersistedPositions.Single().FloatRate, + "剩余持仓快照利率=当日新定盘(非事件末段旧利率)"); + } + + [TestMethod] + public void CloseOnly_平仓日非重置日_不再定盘_快照沿用事件利率() + { + var service = new TailStubService + { + CalcResult = new List { CalcResultWithFloat(OldFloat) }, + RefixResult = NewFloat, + }; + service.ExecuteDealInterests( + new List { CreateFloatLegPosition() }, + new List { CreatePreEodBefore(NonResetSettle, Accrued10d) }, + NonResetSettle, CreateTrade(), new List { CreateCloseEvent() }, + Remaining, ClosedNotional, 100m, Remaining); + + Assert.AreEqual(0, service.RefixCalls, "非重置日平仓:无需再定盘"); + Assert.AreEqual(OldFloat, service.PersistedPositions.Single().FloatRate, + "快照沿用事件末段已消费利率(周期未切换)"); + } + #endregion + } +} diff --git a/UnitTestProject/Modules/SwapModule/Margin/MarginInterestGoldenReplayTest.cs b/UnitTestProject/Modules/SwapModule/Margin/MarginInterestGoldenReplayTest.cs new file mode 100644 index 00000000..d50d9c4c --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/Margin/MarginInterestGoldenReplayTest.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using YLErp; +using YLErp.DBModels; +using YLErp.DBModels.Enums; +using YLErp.Modules.SwapModule; +using YLErp.Modules.SwapModule.Margin; + +namespace UnitTestProject.Modules.SwapModule.Margin +{ + /// + /// 黄金回放验证(连真实测试库 192.168.2.96):对真实保证金交易逐日 EOD 比对 + /// GetInterests(settment=true,保证金分支现走 CalcMarginInterest) vs 直接调 CalcMarginInterest, + /// 验证 GetInterests→CalcMarginInterest 接线的参数对齐(rate/posiPrincipal/preEod 等)正确。 + /// + /// 数据来自 96 库的真实保证金交易,覆盖追加预付金多行、多次部分平仓(InterestPrincipalFix 下台阶)、 + /// 跨 EOD 续接等单元测试够不到的边界。作为保证金计息迁移后的真实库回归守护。 + /// + [TestClass] + public class MarginInterestGoldenReplayTest + { + // 96 库里已确认含真实保证金腿的交易 + private static readonly string[] TradeNumbers = + { + "GLMS-20260701-0008", + "GLMS-20260701-0013", + "GLMS-20260701-0006", + }; + + private sealed class StubSvc : SwapDealService + { + public StubSvc() : base(new OptUserInfo(0, nameof(MarginInterestGoldenReplayTest), OptUserFrom.UnitTest)) { } + } + + /// + /// 逐交易、逐 EOD 日,比对保证金腿新旧计息 InterestAmount/TdInterestAmount。 + /// 入参对齐口径(与 GetInterests 内部一致): + /// posiPrincipal = InterestPrincipalFix;closePrincipal = Fix×closePercent(EOD=1); + /// rate = oldEvt.InterestRate(严格取旧管线算出的 rate,消除 GetFixedRate 差异); + /// 方向 = FlipDirection(position.InterestDirection)(GetInterests:742 对保证金翻转); + /// preEod = 该 PositionId 上一日终 eod_swap_position;annualDays/calcFirst/calcLast 来自 trade_extend。 + /// + [TestMethod] + [TestCategory("DbDiagnose")] + public void 保证金腿_真实库_EOD逐日新旧比对() + { + DbDiagnoseGuard.RequireTestDb(); + YLContext db; + try { db = DbContextFactory.GetYLDbContext(); } + catch (Exception ex) { Assert.Inconclusive($"无法连接测试库(192.168.2.96):{ex.Message}"); return; } + + var svc = new StubSvc(); + int totalCompared = 0, mismatches = 0, skipped = 0; + var diffLog = new StringBuilder(); + + foreach (var tradeNumber in TradeNumbers) + { + var td = db.trade.FirstOrDefault(t => t.TradeNumber == tradeNumber); + if (td == null) { Console.WriteLine($"跳过:库无 {tradeNumber}"); skipped++; continue; } + + var extend = db.trade_extend.FirstOrDefault(x => x.TradeId == td.id); + int annualDays = extend?.ExtendObj.AnnualDays ?? 365; + bool calcFirst = extend?.ExtendObj.InterestCalcMode?.StartsWith("1") ?? true; + bool calcLast = extend?.ExtendObj.InterestCalcMode?.EndsWith("1") ?? true; + + var marginPositions = db.swap_position + .Where(p => p.SwapTradeId == td.id && !p.Invalid + && (p.InterestMode == (int)InterestModeEnum.初始预付金 + || p.InterestMode == (int)InterestModeEnum.追加预付金)) + .ToList(); + if (marginPositions.Count == 0) { Console.WriteLine($"跳过:{tradeNumber} 无保证金腿"); skipped++; continue; } + + // 该交易保证金腿的 EOD 日期序列 + var eodDates = db.eod_swap_position + .Where(e => e.SwapTradeId == td.id && (e.InterestMode == 5 || e.InterestMode == 6)) + .Select(e => e.ValueDate).Distinct().OrderBy(d => d).ToList(); + + Console.WriteLine($"===== {tradeNumber} (id={td.id}):{marginPositions.Count} 条保证金腿,{eodDates.Count} 个 EOD 日 ====="); + + foreach (var valueDate in eodDates) + { + // 上一日终 preEod(取 eod_swap 最近 < valueDate 的日期) + var preDate = db.eod_swap + .Where(e => e.SwapTradeId == td.id && e.ValueDate < valueDate) + .OrderByDescending(e => e.ValueDate) + .Select(e => (DateTime?)e.ValueDate).FirstOrDefault(); + var preEods = preDate == null + ? new List() + : db.eod_swap_position + .Where(e => e.SwapTradeId == td.id && e.ValueDate == preDate.Value + && (e.InterestMode == 5 || e.InterestMode == 6)) + .ToList(); + + // 旧管线:GetInterests(settment=true)。保证金分支不用 posiNotionalValue/closePosiNotionalValue/grossPrice/orginPv,传 0。 + List oldList; + try + { + oldList = svc.GetInterests(td, extend, valueDate, valueDate, + preEods, marginPositions, + 0m, 0m, 1.0m, + (int)SwapEventTypeEnum.自动互换, tdClose: false, + orginPv: 0m, + add: false, settment: true, newCalcLast: false, closeList: null); + } + catch (Exception ex) + { + Console.WriteLine($" {tradeNumber} @ {valueDate:yyyy-MM-dd} 旧管线异常:{ex.GetType().Name} {ex.Message}"); + continue; + } + + // 新方法:逐保证金腿 + foreach (var pos in marginPositions) + { + var oldEvt = oldList.FirstOrDefault(i => i.PositionId == pos.id); + if (oldEvt == null) continue; + + var preEod = preEods.FirstOrDefault(e => e.PositionId == pos.id) ?? new eod_swap_position { id = 0 }; + var posClone = pos.Clone(); + posClone.InterestDirection = MarginCalc.FlipDirection(pos.InterestDirection); + decimal rate = oldEvt.InterestRate; // 严格对齐旧管线 rate(含 GetFixedRate + Round(12)) + + swap_flow_event newEvt; + try + { + newEvt = svc.CalcMarginInterest(td, valueDate, valueDate, posClone, rate, + pos.InterestPrincipalFix, pos.InterestPrincipalFix, 1.0m, + annualDays, calcFirst, calcLast, preEod, + (int)SwapEventTypeEnum.自动互换, add: false, settment: true, interestWindowEmpty: false); + } + catch (Exception ex) + { + mismatches++; + diffLog.AppendLine($"✗ {tradeNumber} PosId={pos.id} @ {valueDate:yyyy-MM-dd} 新方法异常:{ex.GetType().Name} {ex.Message}"); + continue; + } + + totalCompared++; + decimal diffI = Math.Abs(newEvt.InterestAmount - oldEvt.InterestAmount); + decimal diffTd = Math.Abs(newEvt.TdInterestAmount - oldEvt.TdInterestAmount); + const decimal tol = 0.000001m; + if (diffI > tol || diffTd > tol) + { + mismatches++; + diffLog.AppendLine($"✗ {tradeNumber} PosId={pos.id} Mode={pos.InterestMode} @ {valueDate:yyyy-MM-dd}: " + + $"旧 I={oldEvt.InterestAmount} Td={oldEvt.TdInterestAmount} | " + + $"新 I={newEvt.InterestAmount} Td={newEvt.TdInterestAmount} | " + + $"diffI={diffI} diffTd={diffTd} | " + + $"preEod.id={preEod.id} TdIntPrin={preEod.TdInterestPrincipal} ProfitSum={preEod.InterestProfitSum} | " + + $"Fix={pos.InterestPrincipalFix} rate={rate} IntType={pos.InterestType} IsAnnualized={pos.IsAnnualized}"); + } + } + } + } + + Console.WriteLine($"\n===== 比对汇总:共 {totalCompared} 条,不一致 {mismatches} 条,跳过 {skipped} 个交易 ====="); + if (diffLog.Length > 0) Console.WriteLine(diffLog.ToString()); + + Assert.IsTrue(mismatches == 0, + $"保证金新旧管线 EOD 真实库比对有 {mismatches}/{totalCompared} 条不一致——提交2 前必须解决(详见输出)"); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/Margin/MarginInterestShadowTest.cs b/UnitTestProject/Modules/SwapModule/Margin/MarginInterestShadowTest.cs new file mode 100644 index 00000000..f7ed0b2f --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/Margin/MarginInterestShadowTest.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Generic; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; +using YLErp; +using YLErp.DBModels; +using YLErp.DBModels.Enums; +using YLErp.Modules.SwapModule; +using YLErp.Modules.SwapModule.Accrual; +using YLErp.Modules.SwapModule.Margin; + +namespace UnitTestProject.Modules.SwapModule.Margin +{ + /// + /// 影子对账:保证金腿方法 CalcMarginInterest(EOD 用昨日终本金、盘中用 accrualBasis 差分)vs + /// 旧通用管线 CalcDailySimpleInterestByEod/CalcDailySimpleInterest。 + /// + /// 保证金是纯固定利率单利(FloatRateUnderlyingCode 恒空、InterestType 恒单利、SwapIntervalList 单段)。 + /// 本测试在生产切到 CalcMarginInterest 后作为回归守护,确认其 InterestAmount/TdInterestAmount + /// 与旧纯函数(SimpleInterestAccrual)数值一致。覆盖 EOD 续接/首日、盘中全平/部分平仓/互换。 + /// + [TestClass] + public class MarginInterestShadowTest + { + private const decimal Principal = 2_000_000m; // 保证金本金(InterestPrincipalFix) + private const decimal Rate = 0.03m; // 3% 年化固定利率 + private const int AnnualDays = 365; + private static readonly DateTime StartDate = new(2026, 7, 1); + private static readonly DateTime ExerciseDate = new(2027, 6, 30); + + private sealed class StubSvc : SwapDealService + { + public StubSvc() : base(new OptUserInfo(0, nameof(MarginInterestShadowTest), OptUserFrom.UnitTest)) { } + } + + private static trade CreateTrade() => new trade + { + id = 1, TradeNumber = "UT-MARGIN-SHADOW", ClientId = 999998, + TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate, + ExerciseDate = ExerciseDate, TradeStatus = "确认成交", ValidState = "Valid", + trade_extend = new trade_extend + { + TradeId = 1, + ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { AnnualDays = AnnualDays, InterestCalcMode = "10", SettlementRules = 0 }) + } + }; + + /// 保证金腿(初始预付金 mode 5):固定利率、单利、年化、无浮动标的。 + private static swap_position CreateMarginPosition() => new swap_position + { + id = 2001, SwapTradeId = 1, PosiDirection = 0, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.初始预付金, + InterestRateDefault = Rate, InterestPrincipalFix = Principal, + PosiStartDate = StartDate, PosiMatuirityDate = ExerciseDate, + IsInitial = true, Invalid = false, + InterestType = (int)InterestTypeEnum.单利, + IsAnnualized = true, interest_rest_days = 1, interest_rule = 0, + FloatRateUnderlyingCode = null, InterestSwapInterval = "[]" + }; + + /// 构造昨日终 eod_swap_position(已含累计利息 InterestProfitSum 与昨日终本金)。 + private static eod_swap_position CreatePreEod(DateTime valueDate, decimal profitSum) => new eod_swap_position + { + id = 1, SwapTradeId = 1, PositionId = 2001, + ValueDate = valueDate, + TdInterestPrincipal = Principal, InterestPrincipalFix = Principal, + InterestProfitSum = profitSum, PosiNotionalValue = Principal, FloatRate = 0m + }; + + // ──────────────────────────── EOD 路径 ──────────────────────────── + + /// EOD 续接单日:有历史归档,notional=昨日终本金。 + [TestMethod] + public void 影子_EOD续接单日_新旧一致() + { + var td = CreateTrade(); + var position = CreateMarginPosition(); + var valueDate = StartDate.AddDays(5); + const decimal profitSum = 820m; + + // 旧方法 + decimal oldI = 0, oldTd = 0; + var svc = new StubSvc(); + svc.CalcDailySimpleInterestByEod(CreatePreEod(StartDate.AddDays(4), profitSum), + valueDate, td.StartDate.Value, position, Principal, Principal, + new swap_flow_event { InterestRate = Rate }, AnnualDays, 0m, 1m, ref oldI, ref oldTd); + + // 新方法(独立 preEod,相同初始值) + var newEvt = svc.CalcMarginInterest(td, valueDate, valueDate, position, Rate, Principal, Principal, 1m, + AnnualDays, calcFirst: true, calcLast: true, + CreatePreEod(StartDate.AddDays(4), profitSum), 0, add: false, settment: true, interestWindowEmpty: false); + + Console.WriteLine($"旧: I={oldI} Td={oldTd}"); + Console.WriteLine($"新: I={newEvt.InterestAmount} Td={newEvt.TdInterestAmount} ClosePnL={newEvt.InterestClosePnL}"); + Assert.AreEqual(oldI, newEvt.InterestAmount, "InterestAmount 一致"); + Assert.AreEqual(oldTd, newEvt.TdInterestAmount, "TdInterestAmount 一致"); + } + + /// EOD 首日(preEod.id==0):首日初始化 notional=posiPrincipal。 + [TestMethod] + public void 影子_EOD首日_新旧一致() + { + var td = CreateTrade(); + var position = CreateMarginPosition(); + var valueDate = StartDate; + + decimal oldI = 0, oldTd = 0; + var svc = new StubSvc(); + svc.CalcDailySimpleInterestByEod(new eod_swap_position { id = 0 }, + valueDate, td.StartDate.Value, position, Principal, Principal, + new swap_flow_event { InterestRate = Rate }, AnnualDays, 0m, 1m, ref oldI, ref oldTd); + + var newEvt = svc.CalcMarginInterest(td, valueDate, valueDate, position, Rate, Principal, Principal, 1m, + AnnualDays, calcFirst: true, calcLast: true, + new eod_swap_position { id = 0 }, 0, add: false, settment: true, interestWindowEmpty: false); + + Assert.AreEqual(oldI, newEvt.InterestAmount, "InterestAmount 一致"); + Assert.AreEqual(oldTd, newEvt.TdInterestAmount, "TdInterestAmount 一致"); + } + + // ──────────────────────────── 盘中路径 ──────────────────────────── + + /// 盘中全平(closePercent=1):新方法 notional=posiPrincipal,旧方法差分 accrualBasis 恒=posiPrincipal。 + [TestMethod] + public void 影子_盘中全平_新旧一致() + { + var td = CreateTrade(); + var position = CreateMarginPosition(); + var valueDate = StartDate.AddDays(5); + const decimal profitSum = 820m; + + // 旧方法:orginPv 经 PreviousBalance 对齐到昨日终保证金余额 → accrualBasis 恒= Principal + decimal oldI = 0, oldTd = 0; + var svc = new StubSvc(); + var preEodOld = CreatePreEod(StartDate.AddDays(4), profitSum); + decimal orginPv = MarginCalc.PreviousBalance(preEodOld, Principal); + svc.CalcDailySimpleInterest(preEodOld, valueDate, position, Principal, + new swap_flow_event { InterestRate = Rate }, AnnualDays, 0m, 1m, orginPv, + calcFirst: true, calcLast: false, ref oldI, ref oldTd); + + // 新方法:notional = posiPrincipal(无差分、无 orginPv) + var newEvt = svc.CalcMarginInterest(td, valueDate, valueDate, position, Rate, Principal, Principal, 1m, + AnnualDays, calcFirst: true, calcLast: false, + CreatePreEod(StartDate.AddDays(4), profitSum), 0, add: false, settment: false, interestWindowEmpty: false); + + Console.WriteLine($"旧: I={oldI} Td={oldTd}"); + Console.WriteLine($"新: I={newEvt.InterestAmount} Td={newEvt.TdInterestAmount}"); + Assert.AreEqual(oldI, newEvt.InterestAmount, "InterestAmount 一致"); + Assert.AreEqual(oldTd, newEvt.TdInterestAmount, "TdInterestAmount 一致"); + } + + /// 盘中部分平仓(closePercent=0.5):缩放累计,新旧线性等价。 + [TestMethod] + public void 影子_盘中部分平仓_新旧一致() + { + var td = CreateTrade(); + var position = CreateMarginPosition(); + var valueDate = StartDate.AddDays(5); + const decimal profitSum = 820m; + const decimal closePct = 0.5m; + + decimal oldI = 0, oldTd = 0; + var svc = new StubSvc(); + var preEodOld = CreatePreEod(StartDate.AddDays(4), profitSum); + decimal orginPv = MarginCalc.PreviousBalance(preEodOld, Principal); + svc.CalcDailySimpleInterest(preEodOld, valueDate, position, Principal, + new swap_flow_event { InterestRate = Rate }, AnnualDays, 0m, closePct, orginPv, + calcFirst: true, calcLast: false, ref oldI, ref oldTd); + + var newEvt = svc.CalcMarginInterest(td, valueDate, valueDate, position, Rate, + Principal * closePct, Principal, closePct, + AnnualDays, calcFirst: true, calcLast: false, + CreatePreEod(StartDate.AddDays(4), profitSum), 0, add: false, settment: false, interestWindowEmpty: false); + + Console.WriteLine($"旧: I={oldI} Td={oldTd}"); + Console.WriteLine($"新: I={newEvt.InterestAmount} Td={newEvt.TdInterestAmount}"); + Assert.AreEqual(oldI, newEvt.InterestAmount, "InterestAmount 一致"); + Assert.AreEqual(oldTd, newEvt.TdInterestAmount, "TdInterestAmount 一致"); + } + + /// 互换事件(swap=true,盘中):利息应归零。 + [TestMethod] + public void 影子_盘中互换_利息归零() + { + var td = CreateTrade(); + var position = CreateMarginPosition(); + var valueDate = StartDate.AddDays(5); + + var svc = new StubSvc(); + var newEvt = svc.CalcMarginInterest(td, valueDate, valueDate, position, Rate, Principal, Principal, 1m, + AnnualDays, calcFirst: true, calcLast: false, + CreatePreEod(StartDate.AddDays(4), 820m), 0, add: false, settment: false, interestWindowEmpty: true); + + Assert.AreEqual(0m, newEvt.InterestAmount, "互换利息归零"); + Assert.AreEqual(0m, newEvt.TdInterestAmount, "互换 TdInterestAmount 归零"); + Assert.AreEqual(0m, newEvt.InterestClosePnL, "互换 InterestClosePnL 归零"); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/Margin/MarginLegTest.cs b/UnitTestProject/Modules/SwapModule/Margin/MarginLegTest.cs deleted file mode 100644 index 156eb932..00000000 --- a/UnitTestProject/Modules/SwapModule/Margin/MarginLegTest.cs +++ /dev/null @@ -1,121 +0,0 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; -using YLErp.Derivatives.Interest; -using YLErp.Modules.SwapModule.Margin; - -namespace UnitTestProject.Modules.SwapModule.Margin -{ - /// - /// 保证金账户(MarginAccount)单测。验证余额变动(追加/释放/返还)。 - /// 保证金就是保证金——有余额、有利率、有利息,不存在"计息基数/Notional"概念。 - /// - [TestClass] - public class MarginLegTest - { - private const decimal Opening = 2_000_000m; - - #region MarginAccount 余额变动 - - [TestMethod] - public void 账户_初始余额等于期初保证金() - { - var account = new MarginAccount(new MarginBalance(Opening)); - Assert.AreEqual(Opening, account.Balance.Balance); - } - - [TestMethod] - public void 账户_追加保证金_余额增加() - { - var account = new MarginAccount(new MarginBalance(Opening)); - account.Deposit(500_000m); - Assert.AreEqual(2_500_000m, account.Balance.Balance); - } - - [TestMethod] - public void 账户_释放保证金_余额减少() - { - var account = new MarginAccount(new MarginBalance(Opening)); - account.Withdraw(800_000m); - Assert.AreEqual(1_200_000m, account.Balance.Balance); - } - - [TestMethod] - public void 账户_释放超过余额_不低于零() - { - var account = new MarginAccount(new MarginBalance(Opening)); - account.Withdraw(3_000_000m); - Assert.AreEqual(0m, account.Balance.Balance, "保证金余额不低于零"); - } - - #endregion - - #region 三种保证金形态解析器 - - [TestMethod] - public void 三种形态解析器_各自返回正确Form和余额() - { - IMarginResolver cash = new CashMargin(); - IMarginResolver credit = new CreditMargin(); - IMarginResolver guarantee = new GuaranteeMargin(); - - Assert.AreEqual(MarginForm.Cash, cash.Form); - Assert.AreEqual(MarginForm.Credit, credit.Form); - Assert.AreEqual(MarginForm.Guarantee, guarantee.Form); - - Assert.AreEqual(Opening, cash.Resolve(Opening).Balance); - Assert.AreEqual(Opening, credit.Resolve(Opening).Balance); - Assert.AreEqual(Opening, guarantee.Resolve(Opening).Balance); - } - - #endregion - - #region MarginAccount 计息 - - [TestMethod] - public void 计息_单利7天_余额200万年化3pct() - { - var account = new MarginAccount(new MarginBalance(2_000_000m)); - // 200万 × 3% / 365 × 7天 = 1150.68... - var r = account.AccrueInterest( - rate: 0.03m, - startDate: new System.DateTime(2026, 5, 4), - endDate: new System.DateTime(2026, 5, 11), - boundary: AccrualBoundary.StartOnly, - annualDays: 365); - - Assert.IsTrue(r.Accrued > 0, "7天利息应大于0"); - System.Console.WriteLine($"保证金7天利息={r.Accrued}"); - } - - [TestMethod] - public void 计息_零余额_利息为零() - { - var account = new MarginAccount(new MarginBalance(0m)); - var r = account.AccrueInterest(0.03m, - new System.DateTime(2026, 5, 4), new System.DateTime(2026, 5, 11), - AccrualBoundary.StartOnly, 365); - - Assert.AreEqual(0m, r.Accrued); - } - - [TestMethod] - public void 计息_释放后余额减少_利息相应减少() - { - var full = new MarginAccount(new MarginBalance(2_000_000m)); - var half = new MarginAccount(new MarginBalance(2_000_000m)); - half.Withdraw(1_000_000m); - - var rFull = full.AccrueInterest(0.03m, - new System.DateTime(2026, 5, 4), new System.DateTime(2026, 5, 11), - AccrualBoundary.StartOnly, 365); - var rHalf = half.AccrueInterest(0.03m, - new System.DateTime(2026, 5, 4), new System.DateTime(2026, 5, 11), - AccrualBoundary.StartOnly, 365); - - Assert.IsTrue(rHalf.Accrued < rFull.Accrued, "释放后利息应更少"); - Assert.IsTrue(System.Math.Abs(rFull.Accrued - rHalf.Accrued * 2m) < 0.01m, - "余额减半, 利息也应减半"); - } - - #endregion - } -} diff --git a/UnitTestProject/Modules/SwapModule/Margin/MarginModesTest.cs b/UnitTestProject/Modules/SwapModule/Margin/MarginModesTest.cs index edf94c89..7732e13b 100644 --- a/UnitTestProject/Modules/SwapModule/Margin/MarginModesTest.cs +++ b/UnitTestProject/Modules/SwapModule/Margin/MarginModesTest.cs @@ -8,7 +8,7 @@ namespace UnitTestProject.Modules.SwapModule.Margin { /// /// MarginModes 统一判断口径测试。 - /// 验证它和现有散落的 marginTypes/InterestMarginModels/premiumModes 内容一致。 + /// 验证 MarginModes 由框架常量 ConsTrade.InterestMarginModels 派生,内容一致。 /// [TestClass] public class MarginModesTest @@ -37,7 +37,7 @@ namespace UnitTestProject.Modules.SwapModule.Margin Assert.IsFalse(MarginModes.Contains((int)InterestModeEnum.标的期初全价)); } - /// 守护:和 ConsTrade.InterestMarginModels 内容必须一致(迁移期对齐)。 + /// 回归护栏:MarginModes 由 ConsTrade.InterestMarginModels 派生,内容须一致(防止有人又独立重写集合导致口径分裂)。 [TestMethod] public void 与ConsTradeInterestMarginModels内容一致() { 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/Penalty/PenaltyBoundaryMatrixTest.cs b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyBoundaryMatrixTest.cs new file mode 100644 index 00000000..6ebb8bf0 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyBoundaryMatrixTest.cs @@ -0,0 +1,211 @@ +using YLErp.Modules.SwapModule.Accrual; +using YLErp.Modules.SwapModule.Penalty; + +namespace UnitTestProject.Modules.SwapModule.Penalty +{ + /// + /// EQD-6977 罚息边界矩阵测试(全部断言金标准恒等式:全期 = 实结 + 罚息)。 + /// + /// 覆盖易错边界: + /// ① 平仓日恰为重置日(算尾/不算尾)——重置日快照基数还是上一段的,①须取 InterestIncomeSum; + /// ② 重置日前一日平仓(② 几乎整段、窗口首段 0 天); + /// ③ 到期日恰为重置日(末段 [到期,到期] 1 天); + /// ④ 锚点偏离(td.StartDate=7/31 但腿 PosiStartDate=8/3 的延期/存续腿——重置网格整体不同); + /// ⑤ 起息日当天平仓(无 preEod)。 + /// + /// 一致性前提(与现实世界对齐):冻结利率 = 当前重置区间(含 unwind-1 的区间)的在役利率, + /// 即"历史末段利率 = 冻结利率";历史各段定盘不同(体现真实 FR007 利率历史)。 + /// + [TestClass] + public class PenaltyBoundaryMatrixTest + { + private const decimal Notional = 100_000_000m; + private const int AnnualDays = 365; + private static readonly decimal[] Hist = { 0.0310m, 0.0420m, 0.0530m, 0.0225m }; // 7/31 / 8/7 / 8/14 / 8/21 段 + private static readonly decimal Frozen = Hist[^1]; // 冻结 = 当前区间在役利率 = 历史末段 + + /// 指定重置网格上的复利重放 [gridStart, end];超出所给历史段后沿用冻结利率。 + private static decimal AccrueOnGrid(DateTime gridStart, DateTime end, AccrualBoundary boundary, + decimal[] histRates, decimal notional = Notional, int period = 7) + { + var frozen = histRates[^1]; + var segs = new List<(DateTime, decimal)>(); + var i = 0; + for (var d = gridStart; d <= end; d = d.AddDays(period)) + segs.Add((d, i < histRates.Length ? histRates[i++] : frozen)); + return CompoundInterestAccrual.AccruePeriod( + notional: notional, segmentRates: segs, + startDate: gridStart, endDate: end, + boundary: boundary, annualDays: AnnualDays, isAnnualized: true, + resetCarryInterest: 0m, realizedInterest: 0m, unwindFraction: 1m, + finalBasis: out _).Accrued; + } + + private static trade CreateTrade(DateTime startDate, DateTime maturity) + => new() + { + id = 1, TradeNumber = "UT-BOUNDARY", ClientId = 999998, + TradeType = "收益互换", TradeDate = startDate, StartDate = startDate, + ExerciseDate = maturity, TradeStatus = "确认成交", ValidState = "Valid" + }; + + private static swap_position CompoundLeg(DateTime posiStart, DateTime maturity, decimal spread, int periodDays = 7) + => new() + { + id = 1001, SwapTradeId = 1, PosiDirection = 0, InterestDirection = 1, + InterestMode = (int)InterestModeEnum.标的期初全价, InterestRateDefault = spread, + InterestPrincipalFix = Notional, PosiStartDate = posiStart, PosiMatuirityDate = maturity, + IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.复利, + IsAnnualized = true, interest_rest_days = periodDays, interest_rule = 0, + FloatRateUnderlyingCode = null, InterestSwapInterval = "[]" + }; + + /// 日终快照:TdInterestPrincipal=当日实际滚动基数、InterestIncomeSum=截至当日待实现利息。 + private static eod_swap_position Snap(DateTime valueDate, decimal rollingBasis, decimal incomeSum) + => new() { id = 9, PositionId = 1001, ValueDate = valueDate, + TdInterestPrincipal = rollingBasis, InterestIncomeSum = incomeSum }; + + private static decimal RunFee(trade td, swap_position p, decimal settledAmount, + eod_swap_position? preEod, DateTime unwind, bool settled, decimal spread, + decimal interestPrincipal = 0m, bool maturityCalcLast = true) + { + var e = new swap_flow_event + { + PositionId = p.id, InterestAmount = settledAmount, InterestFee = 0m, + InterestDirection = 1, InterestClosePnL = settledAmount, + InterestPrincipal = interestPrincipal // 复利主路径下=重放末次并本金后基数(=被平份额本金+①) + }; + PenaltyInterestFeeMerger.Merge( + td, new List { p }, new List { e }, + unwind, AnnualDays, settled, maturityCalcLast: maturityCalcLast, + posiNotionalValue: Notional, closePosiNotionalValue: Notional, closePercent: 1m, + getSpread: _ => spread, getPreEod: _ => preEod, tryGetFixing: (d, c) => spread); + return e.InterestFee; + } + + [TestMethod] + public void 平仓日恰为重置日_算尾_恒等式成立() + { + var start = new DateTime(2026, 7, 31); var unwind = new DateTime(2026, 8, 21); var maturity = new DateTime(2026, 8, 31); + var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.Both, Hist); // [7/31..8/21](末日=重置日,1 天) + var basisThru813 = AccrueOnGrid(start, new DateTime(2026, 8, 13), AccrualBoundary.Both, Hist); // 8/14 起段基数 + var incomeSum = AccrueOnGrid(start, unwind.AddDays(-1), AccrualBoundary.Both, Hist); // 8/20 待实现 + + // 前提自检:重置日快照基数(8/14段)≠今日应并入额(8/20待实现),旧公式(basis−P)必错——用例有鉴别力 + Assert.AreNotEqual((double)basisThru813, (double)incomeSum, 1000d, "快照基数与重置日应并入额应显著不同"); + + var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, Frozen), elapsed, + Snap(unwind.AddDays(-1), Notional + basisThru813, incomeSum), unwind, settled: true, spread: Frozen); + + var full = AccrueOnGrid(start, maturity, AccrualBoundary.Both, Hist); + Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01, + "重置日当天平仓(算尾):① 须取 InterestIncomeSum,全期=实结+罚息"); + } + + [TestMethod] + public void 平仓日恰为重置日_不算尾_恒等式成立() + { + var start = new DateTime(2026, 7, 31); var unwind = new DateTime(2026, 8, 21); var maturity = new DateTime(2026, 8, 31); + var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.StartOnly, Hist); // [7/31..8/20] + var incomeSum = elapsed; // 不算尾时实结=8/20待实现 + var basisThru813 = AccrueOnGrid(start, new DateTime(2026, 8, 13), AccrualBoundary.Both, Hist); + + var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, Frozen), elapsed, + Snap(unwind.AddDays(-1), Notional + basisThru813, incomeSum), unwind, settled: false, spread: Frozen); + + var full = AccrueOnGrid(start, maturity, AccrualBoundary.Both, Hist); + Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01, + "重置日当天平仓(不算尾):②=0,罚息含平仓日,全期=实结+罚息"); + } + + [TestMethod] + public void 重置日前一日平仓_段内几乎整段承接_恒等式成立() + { + var start = new DateTime(2026, 7, 31); var unwind = new DateTime(2026, 8, 27); var maturity = new DateTime(2026, 8, 31); + var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.Both, Hist); // [7/31..8/27],段内已计 8/21..8/27 + var basisThru820 = AccrueOnGrid(start, new DateTime(2026, 8, 20), AccrualBoundary.Both, Hist); // 8/21 起段基数 + var incomeSum = AccrueOnGrid(start, unwind.AddDays(-1), AccrualBoundary.Both, Hist); + + var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, Frozen), elapsed, + Snap(unwind.AddDays(-1), Notional + basisThru820, incomeSum), unwind, settled: true, spread: Frozen); + + var full = AccrueOnGrid(start, maturity, AccrualBoundary.Both, Hist); + Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01, + "重置日前一日平仓:窗口首段 0 天、② 于 8/28 整段并入,全期=实结+罚息"); + } + + [TestMethod] + public void 到期日恰为重置日_末段一天_恒等式成立() + { + // 8/18 平仓:当前区间为 8/14 段(r3) → 冻结利率=r3=历史末段;到期 9/4 恰为重置日(末段 [9/4,9/4] 1 天) + var start = new DateTime(2026, 7, 31); var unwind = new DateTime(2026, 8, 18); var maturity = new DateTime(2026, 9, 4); + var hist = new decimal[] { 0.0310m, 0.0420m, 0.0530m }; // 7/31 / 8/7 / 8/14(=冻结 5.3%) + var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.Both, hist); + var basisThru813 = AccrueOnGrid(start, new DateTime(2026, 8, 13), AccrualBoundary.Both, hist); + var incomeSum = AccrueOnGrid(start, unwind.AddDays(-1), AccrualBoundary.Both, hist); + + var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, hist[^1]), elapsed, + Snap(unwind.AddDays(-1), Notional + basisThru813, incomeSum), unwind, settled: true, spread: hist[^1]); + + var full = AccrueOnGrid(start, maturity, AccrualBoundary.Both, hist); + Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01, + "到期日=重置日:末段 [9/4,9/4] 1 天收尾,全期=实结+罚息"); + } + + [TestMethod] + public void 锚点偏离_延期腿按腿起息日网格_恒等式成立() + { + // 交易起始 7/31,但腿 PosiStartDate=8/3(延期/存续腿)→ 真实重置网格 8/10/8/17/8/24/8/31 + var tradeStart = new DateTime(2026, 7, 31); var posiStart = new DateTime(2026, 8, 3); + var unwind = new DateTime(2026, 8, 19); var maturity = new DateTime(2026, 9, 3); + var hist = new decimal[] { 0.0300m, 0.0400m, 0.0225m }; // 8/3 / 8/10 / 8/17(=冻结) 三段历史 + + var elapsed = AccrueOnGrid(posiStart, unwind, AccrualBoundary.Both, hist); + var basisThru816 = AccrueOnGrid(posiStart, new DateTime(2026, 8, 16), AccrualBoundary.Both, hist); // 8/17 起段基数 + var incomeSum = AccrueOnGrid(posiStart, unwind.AddDays(-1), AccrualBoundary.Both, hist); + + var fee = RunFee(CreateTrade(tradeStart, maturity), CompoundLeg(posiStart, maturity, hist[^1]), elapsed, + Snap(unwind.AddDays(-1), Notional + basisThru816, incomeSum), unwind, settled: true, spread: hist[^1]); + + var full = AccrueOnGrid(posiStart, maturity, AccrualBoundary.Both, hist); + Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01, + "锚点偏离:罚息分段/重置日判定必须用 position.PosiStartDate 网格(误用 td.StartDate 网格必挂)"); + } + + [TestMethod] + public void 无preEod且此前已有重置_经事件基数兜底_恒等式精确成立() + { + // UAT 实测场景(tradeId=2447):环境无日终快照、起息后已发生 8/19 重置并本。 + // 兜底① = normalEvent.InterestPrincipal − 本金(复利重放末次并本金后基数); + // 修复前 ①=0 少算 ≈3.17 元(并入额×冻结利率×段尾天数),本用例钉死兜底路径的精确性。 + var start = new DateTime(2026, 8, 5); var unwind = new DateTime(2026, 8, 20); var maturity = new DateTime(2026, 9, 30); + var hist = new decimal[] { 0.0216m, 0.0144m }; // 8/5 段 2.16% / 8/19 段 1.44%(=冻结),14 天重置 + var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.StartOnly, hist, period: 14); // 已结 [8/5..8/19] + var replayFinalBasis = Notional + AccrueOnGrid(start, new DateTime(2026, 8, 18), AccrualBoundary.Both, hist, period: 14); // 8/19 重置并本后基数 + + var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, hist[^1], periodDays: 14), elapsed, + preEod: null, unwind: unwind, settled: false, spread: hist[^1], + interestPrincipal: replayFinalBasis, maturityCalcLast: false); // 不算尾合约、14天重置(对应 UAT tradeId=2447 口径) + + var full = AccrueOnGrid(start, maturity, AccrualBoundary.StartOnly, hist, period: 14); + Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01, + "无preEod+已有重置:兜底取事件基数后 ① 精确,全期=实结+罚息(修复前差≈3.17元)"); + } + + [TestMethod] + public void 起息日当天平仓_无preEod_恒等式成立() + { + // 首日平仓:当前区间=首段(r1),无 preEod 时取价委托返回首段定盘 → 冻结利率=r1,全程恒率 + var start = new DateTime(2026, 7, 31); var maturity = new DateTime(2026, 8, 31); + var hist = new decimal[] { 0.0310m }; + var elapsed = AccrueOnGrid(start, start, AccrualBoundary.Both, hist); // 首日 1 天 + + var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, hist[^1]), elapsed, + preEod: null, unwind: start, settled: true, spread: hist[^1]); + + var full = AccrueOnGrid(start, maturity, AccrualBoundary.Both, hist); + Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01, + "起息日当天平仓:①=0、②=首日利息于 8/7 并入,全期=实结+罚息"); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs new file mode 100644 index 00000000..2186bfdc --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs @@ -0,0 +1,167 @@ +using YLErp.Modules.SwapModule.Accrual; +using YLErp.Modules.SwapModule.Penalty; + +namespace UnitTestProject.Modules.SwapModule.Penalty +{ + /// + /// EQD-6977 罚息接缝 headless 测试(无 DB:spread/preEod/取价 全部以委托注入)。 + /// 锁定:Merge 把罚息金额并入既有利息事件的 InterestFee(不新增事件、不改 InterestAmount); + /// 承接量取实际计息状态(preEod 基数 + 事件实结金额)——含【多区间不同定盘】恒等式钉死, + /// 该用例在"冻结利率重放推导承接量"的旧实现下必挂(FR007 真实利率历史场景)。 + /// + [TestClass] + public class PenaltyInterestFeeMergerTest + { + private const decimal Notional = 100_000_000m; + private const decimal Rate = 0.0225m; // 冻结 all-in 年化 + private const int AnnualDays = 365; + private static readonly DateTime StartDate = new(2026, 7, 31); + private static readonly DateTime MaturityDate = new(2026, 8, 31); + private static readonly DateTime UnwindDate = new(2026, 8, 25); + private static readonly DateTime LastResetBeforeUnwind = new(2026, 8, 21); + + private static trade CreateTrade() + => new() + { + id = 1, TradeNumber = "UT-MERGE", ClientId = 999998, + TradeType = "收益互换", StartDate = StartDate, TradeDate = StartDate, + ExerciseDate = MaturityDate, TradeStatus = "确认成交", ValidState = "Valid" + }; + + private static swap_position Leg(InterestTypeEnum interestType) + => new() + { + id = 1001, SwapTradeId = 1, PosiDirection = 0, InterestDirection = 1, + InterestMode = (int)InterestModeEnum.标的期初全价, InterestRateDefault = Rate, + InterestPrincipalFix = Notional, PosiStartDate = StartDate, PosiMatuirityDate = MaturityDate, + IsInitial = true, Invalid = false, InterestType = (int)interestType, + IsAnnualized = true, interest_rest_days = 7, interest_rule = 0, + FloatRateUnderlyingCode = null, InterestSwapInterval = "[]" + }; + + /// 正常平仓利息流(模拟 GetInterests 产出):InterestAmount=实结利息、InterestFee=0。 + private static swap_flow_event NormalEvent(decimal settledAmount) + => new() { PositionId = 1001, InterestAmount = settledAmount, InterestFee = 0m, + InterestDirection = 1, InterestClosePnL = settledAmount }; // 模拟 GetInterests 已算好的 PnL(收取=+1) + + private static eod_swap_position PreEod(decimal rollingBasis, decimal floatRate = 0m) + => new() { id = 9, PositionId = 1001, ValueDate = UnwindDate.AddDays(-1), + TdInterestPrincipal = rollingBasis, FloatRate = floatRate }; + + private static void RunMerge( + swap_position p, swap_flow_event normalEvent, eod_swap_position? preEod, + Func? getSpread = null, Func? tryGetFixing = null) + { + getSpread ??= _ => Rate; + tryGetFixing ??= (d, code) => Rate; + PenaltyInterestFeeMerger.Merge( + CreateTrade(), new List { p }, new List { normalEvent }, + UnwindDate, AnnualDays, + unwindDaySettled: true, maturityCalcLast: true, + posiNotionalValue: Notional, closePosiNotionalValue: Notional, closePercent: 1m, + getSpread: getSpread, + getPreEod: _ => preEod, + tryGetFixing: tryGetFixing); + } + + /// 复利重放 [StartDate, endDate],重置段=每 7 天;分段利率由 rates 决定(rates.Count=1 时为常率)。 + private static decimal CompoundAccruedTo(DateTime endDate, AccrualBoundary boundary, params decimal[] rates) + { + var segs = new List<(DateTime, decimal)>(); + var i = 0; + for (var d = StartDate; d <= endDate; d = d.AddDays(7)) + // 超出所给历史段后沿用最后区间利率——即“未来段冻结为最后区间利率”的语义(勿循环回绕) + segs.Add((d, rates.Length == 1 ? rates[0] : i < rates.Length ? rates[i++] : rates[^1])); + return CompoundInterestAccrual.AccruePeriod( + notional: Notional, segmentRates: segs, + startDate: StartDate, endDate: endDate, + boundary: boundary, annualDays: AnnualDays, isAnnualized: true, + resetCarryInterest: 0m, realizedInterest: 0m, unwindFraction: 1m, + finalBasis: out _).Accrued; + } + + [TestMethod] + public void 单利固定腿_罚息并入InterestFee_不新增事件() + { + var e = NormalEvent(settledAmount: 50_000m); + RunMerge(Leg(InterestTypeEnum.单利), e, preEod: PreEod(Notional)); + + Assert.AreEqual(0d, (double)(e.InterestFee - Rate * Notional * 6m / AnnualDays), 0.0001, + "罚息=利率×本金×6天/基准(窗口 (8/25, 8/31])"); + Assert.AreEqual(50_000d, (double)e.InterestAmount, 0.0001, "正常实结利息不受影响"); + Assert.AreEqual((double)(50_000m + e.InterestFee), (double)e.InterestClosePnL, 0.0001, "PnL=(实结+罚息)×方向(收取=+1)"); + } + + [TestMethod] + public void 浮动腿_取价委托解析冻结率_并入费用() + { + var p = Leg(InterestTypeEnum.单利); + p.FloatRateUnderlyingCode = "FR007"; + var e = NormalEvent(settledAmount: 50_000m); + // 无 preEod → 走取价委托:all-in = spread(0) + 定盘(Rate) + RunMerge(p, e, preEod: null, getSpread: _ => 0m, tryGetFixing: (d, code) => Rate); + + Assert.AreEqual(0d, (double)(e.InterestFee - Rate * Notional * 6m / AnnualDays), 0.0001, + "浮动腿冻结率=取价委托值(零利差)"); + } + + [TestMethod] + public void 复利常率_承接取实际状态_恒等式全期等于已结加罚息() + { + // 实际计息状态:preEod 滚动基数 = P + 已并入利息(截至 8/20);事件实结 = elapsed([7/31,8/25] Both) + var capitalized = CompoundAccruedTo(LastResetBeforeUnwind.AddDays(-1), AccrualBoundary.Both, Rate); + var elapsed = CompoundAccruedTo(UnwindDate, AccrualBoundary.Both, Rate); + var e = NormalEvent(elapsed); + RunMerge(Leg(InterestTypeEnum.复利), e, preEod: PreEod(Notional + capitalized)); + + var full = CompoundAccruedTo(MaturityDate, AccrualBoundary.Both, Rate); + Assert.AreEqual((double)full, (double)(elapsed + e.InterestFee), 0.0001, + "常率下 全期 = 已结(事件实结) + 罚息(InterestFee)"); + } + + [TestMethod] + public void 复利多区间不同定盘_承接取实际状态_恒等式仍成立() + { + // 真实 FR007 世界:四个历史重置区间定盘各不相同,冻结利率=最后区间(2.25%) + var r1 = 0.0310m; var r2 = 0.0420m; var r3 = 0.0530m; var r4 = Rate; // r4=0.0225 冻结值 + var rates = new[] { r1, r2, r3, r4 }; + + // 实际计息状态(与 GetInterests 重放同源): + var capitalized = CompoundAccruedTo(LastResetBeforeUnwind.AddDays(-1), AccrualBoundary.Both, rates); // 已并入 8/21 重置日 + var elapsed = CompoundAccruedTo(UnwindDate, AccrualBoundary.Both, rates); // 实结(含 8/21..8/25 段内利息) + var e = NormalEvent(elapsed); + RunMerge(Leg(InterestTypeEnum.复利), e, preEod: PreEod(Notional + capitalized)); + + // 全期参照:历史段按各自真实定盘、8/28 起的未来段按冻结利率(=r4,恰好同段延续) + var full = CompoundAccruedTo(MaturityDate, AccrualBoundary.Both, rates); + Assert.AreEqual((double)full, (double)(elapsed + e.InterestFee), 0.01, + "多区间不同定盘下 全期(历史实率+未来冻结) = 实结 + 罚息——承接量必须来自实际状态"); + // 反证旧缺陷:冻结重放推导的承接①(全程 r4)≠ 实际①(分段实率),差额显著 + var frozenReplayCapitalized = CompoundAccruedTo(LastResetBeforeUnwind.AddDays(-1), AccrualBoundary.Both, Rate); + Assert.AreNotEqual((double)capitalized, (double)frozenReplayCapitalized, 1000d, + "前提自检:分段实率与冻结重放的已并入利息应显著不同(否则用例失去鉴别力)"); + } + + [TestMethod] + public void 复利无preEod_承接退化为实结全额_可计算不崩溃() + { + var elapsed = CompoundAccruedTo(UnwindDate, AccrualBoundary.Both, Rate); + var e = NormalEvent(elapsed); + RunMerge(Leg(InterestTypeEnum.复利), e, preEod: null); + + Assert.IsTrue(e.InterestFee > 0m, "无 preEod(首日平仓等)仍可计算罚息"); + } + + [TestMethod] + public void 冻结利率解析失败_跳过该腿不阻断() + { + var p = Leg(InterestTypeEnum.单利); + p.FloatRateUnderlyingCode = "FR007"; + var e = NormalEvent(settledAmount: 50_000m); + RunMerge(p, e, preEod: null, getSpread: _ => 0m, tryGetFixing: (d, code) => null); + + Assert.AreEqual(0m, e.InterestFee, "缺价跳过:不加罚息、不抛异常"); + Assert.AreEqual(50_000d, (double)e.InterestAmount, 0.0001, "正常平仓不受影响"); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/Penalty/PenaltyLegRateResolverTest.cs b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyLegRateResolverTest.cs new file mode 100644 index 00000000..c81d60e6 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyLegRateResolverTest.cs @@ -0,0 +1,81 @@ +using YLErp.DBModels.Enums; +using YLErp.Modules.SwapModule; +using YLErp.Modules.SwapModule.Accrual; +using YLErp.Modules.SwapModule.Penalty; + +namespace UnitTestProject.Modules.SwapModule.Penalty +{ + /// + /// EQD-6977 罚息冻结利率解析契约测试。 + /// 规则(需求 2.2.2):冻结为「最后一个重置区间」定盘;终止日为重置日也取上一区间。 + /// + [TestClass] + public class PenaltyLegRateResolverTest + { + private const decimal Spread = 0.05m; // +500bp + private static readonly DateTime UnwindDate = new(2026, 8, 25); + + private static swap_position CreateFloatPosition(int interestRule = 0) + => new() + { + id = 1001, SwapTradeId = 1, PosiDirection = 0, + InterestDirection = (int)SwapDirectionEnum.支付, + InterestMode = (int)InterestModeEnum.标的期初全价, + InterestRateDefault = Spread, + PosiStartDate = new DateTime(2026, 7, 31), + interest_rest_days = 7, interest_rule = interestRule, + FloatRateUnderlyingCode = "FR007", + FloatRate = 0.0185m + }; + + [TestMethod] + public void 浮动腿_preEod快照优先_重置日下午仍取上一区间() + { + // 8/25 为重置日且下午已出新价的边缘场景:preEod.FloatRate(昨日区间定盘)仍优先, + // 解析器不做任何取价——「终止日取上一区间」由快照语义天然覆盖。 + var p = CreateFloatPosition(); + var rate = PenaltyLegRateResolver.ResolveFrozenRate( + p, spread: Spread, preEodFloatRate: 0.0210m, + unwindDate: UnwindDate, tryGetFixing: _ => throw new AssertFailedException("preEod 在场时不应取价")); + + Assert.AreEqual(Spread + 0.0210m, rate.AllInRate, "冻结 all-in = 利差 + 上一区间定盘"); + } + + [TestMethod] + public void 浮动腿_无preEod_按前一营业日取价日取定盘() + { + var p = CreateFloatPosition(interestRule: 0); // 当前营业日规则 + DateTime? askedDate = null; + var rate = PenaltyLegRateResolver.ResolveFrozenRate( + p, spread: Spread, preEodFloatRate: null, + unwindDate: UnwindDate, + tryGetFixing: d => { askedDate = d; return 0.0195m; }); + + Assert.AreEqual(new DateTime(2026, 8, 24), askedDate, "取价日 = GetFixingDate(8/24, rule=0)"); + Assert.AreEqual(Spread + 0.0195m, rate.AllInRate); + } + + [TestMethod] + public void 浮动腿_无preEod_缺价抛异常() + { + var p = CreateFloatPosition(); + Assert.ThrowsException(() => + PenaltyLegRateResolver.ResolveFrozenRate( + p, spread: Spread, preEodFloatRate: null, + unwindDate: UnwindDate, tryGetFixing: _ => null)); + } + + [TestMethod] + public void 固定腿_不取价_直接固定利率() + { + var p = CreateFloatPosition(); + p.FloatRateUnderlyingCode = null; + + var rate = PenaltyLegRateResolver.ResolveFrozenRate( + p, spread: Spread, preEodFloatRate: null, + unwindDate: UnwindDate, tryGetFixing: _ => throw new AssertFailedException("固定腿不应取价")); + + Assert.AreEqual(Spread, rate.AllInRate); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/Penalty/SwapPenaltyInterestCalculatorTest.cs b/UnitTestProject/Modules/SwapModule/Penalty/SwapPenaltyInterestCalculatorTest.cs new file mode 100644 index 00000000..1696f94d --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/Penalty/SwapPenaltyInterestCalculatorTest.cs @@ -0,0 +1,176 @@ +using YLErp; +using YLErp.DBModels.Enums; +using YLErp.Modules.SwapModule; +using YLErp.Modules.SwapModule.Accrual; +using YLErp.Modules.SwapModule.Penalty; + +namespace UnitTestProject.Modules.SwapModule.Penalty +{ + /// + /// EQD-6977 平仓罚息计算器契约测试(返回罚息金额)。 + /// + /// 金标准恒等式(需求核心语义,2026-08-20 裁定的精确续接口径): + /// 全期利息 = 平仓日已结利息 + 罚息金额 + /// 历史口径:7/31 起息、8/31 到期、7 天重置(8/7/8/14/8/21/8/28)、8/25 提前终止 + /// (平仓日落在 8/21–8/28 重置段中间——复利承接两分量的关键场景)。 + /// + [TestClass] + public class SwapPenaltyInterestCalculatorTest + { + private const decimal Notional = 100_000_000m; + private const decimal Rate = 0.0225m; // 冻结 all-in 年化 + private const int AnnualDays = 365; + private static readonly DateTime StartDate = new(2026, 7, 31); + private static readonly DateTime MaturityDate = new(2026, 8, 31); + private static readonly DateTime UnwindDate = new(2026, 8, 25); + private static readonly DateTime LastResetBeforeUnwind = new(2026, 8, 21); + + private static swap_position CreatePosition(InterestTypeEnum interestType, SwapDirectionEnum direction) + => new() + { + id = 1001, SwapTradeId = 1, PosiDirection = 0, + InterestDirection = (int)direction, + InterestMode = (int)InterestModeEnum.标的期初全价, + InterestRateDefault = Rate, + InterestPrincipalFix = Notional, + PosiStartDate = StartDate, PosiMatuirityDate = MaturityDate, + IsInitial = true, Invalid = false, + InterestType = (int)interestType, + IsAnnualized = true, interest_rest_days = 7, interest_rule = 0, + FloatRateUnderlyingCode = null, + InterestSwapInterval = "[]" + }; + + private static AccrualPolicy Policy(swap_position p) + => AccrualPolicy.BuildEod(p, AnnualDays, p.InterestType == (int)InterestTypeEnum.复利); + + /// 常率复利重放 [7/31, endDate],重置段 = 每 7 天。 + private static decimal CompoundAccruedTo(DateTime endDate, AccrualBoundary boundary) + { + var segs = new List<(DateTime, decimal)>(); + for (var d = StartDate; d <= endDate; d = d.AddDays(7)) segs.Add((d, Rate)); + return CompoundInterestAccrual.AccruePeriod( + notional: Notional, segmentRates: segs, + startDate: StartDate, endDate: endDate, + boundary: boundary, annualDays: AnnualDays, isAnnualized: true, + resetCarryInterest: 0m, realizedInterest: 0m, unwindFraction: 1m, + finalBasis: out _).Accrued; + } + + private static decimal CalcCompoundPenalty( + swap_position p, decimal closePrincipal, bool settled, decimal capitalized, decimal carryIn) + => SwapPenaltyInterestCalculator.CalcPenaltyAmount( + p, closePrincipal, + unwindDate: UnwindDate, maturityDate: MaturityDate, + unwindDaySettled: settled, maturityCalcLast: true, + capitalizedInterest: capitalized, carryInInterest: carryIn, + frozenRate: FundingLegRate.Fixed(Rate), + policy: Policy(p), resetAnchor: StartDate); + + [TestMethod] + public void 金标准恒等式_复利_全期等于已结加罚息() + { + var p = CreatePosition(InterestTypeEnum.复利, SwapDirectionEnum.支付); + var elapsed = CompoundAccruedTo(UnwindDate, AccrualBoundary.Both); + var capitalized = CompoundAccruedTo(LastResetBeforeUnwind.AddDays(-1), AccrualBoundary.Both); + var carryIn = elapsed - capitalized; + + var penalty = CalcCompoundPenalty(p, Notional, settled: true, capitalized, carryIn); + + var full = CompoundAccruedTo(MaturityDate, AccrualBoundary.Both); + Assert.AreEqual((double)full, (double)(elapsed + penalty), 0.0001, + $"全期({full}) 应等于 已结({elapsed}) + 罚息({penalty});承接①={capitalized} ②={carryIn}"); + } + + [TestMethod] + public void 金标准恒等式_复利_不算尾平仓日() + { + // 不算尾:正常结算未计 8/25 → 罚息含 8/25(IncludeStart=true),承接②少一天 + var p = CreatePosition(InterestTypeEnum.复利, SwapDirectionEnum.支付); + var elapsed = CompoundAccruedTo(UnwindDate, AccrualBoundary.StartOnly); + var capitalized = CompoundAccruedTo(LastResetBeforeUnwind.AddDays(-1), AccrualBoundary.Both); + var carryIn = elapsed - capitalized; + + var penalty = CalcCompoundPenalty(p, Notional, settled: false, capitalized, carryIn); + + var full = CompoundAccruedTo(MaturityDate, AccrualBoundary.Both); + Assert.AreEqual((double)full, (double)(elapsed + penalty), 0.0001, + "不算尾时罚息窗口须补回平仓日,恒等式仍成立"); + } + + [TestMethod] + public void 单利固定腿_剩余期限利息等于公式() + { + // 需求 2.2.1:剩余利息 = 固定 × 名义本金 × 剩余天数 / 计息基准 + // 算尾平仓日 + 到期算尾:窗口 (8/25, 8/31] = 6 天 + var amount = SwapPenaltyInterestCalculator.CalcPenaltyAmount( + CreatePosition(InterestTypeEnum.单利, SwapDirectionEnum.支付), Notional, + unwindDate: UnwindDate, maturityDate: MaturityDate, + unwindDaySettled: true, maturityCalcLast: true, + capitalizedInterest: 0m, carryInInterest: 0m, + frozenRate: FundingLegRate.Fixed(Rate), + policy: Policy(CreatePosition(InterestTypeEnum.单利, SwapDirectionEnum.支付)), + resetAnchor: StartDate); + + var expected = Rate * Notional * 6m / AnnualDays; + Assert.AreEqual((double)expected, (double)amount, 0.0001, "6 天 = 8/26..8/31"); + } + + [TestMethod] + public void 边界四象限_剩余天数口径正确() + { + var p = CreatePosition(InterestTypeEnum.单利, SwapDirectionEnum.支付); + // 8/26..8/31 共 6 个计息日候选;IncludeStart 加 8/25、IncludeEnd 加 8/31 由约定裁剪 + var cases = new (bool settled, bool calcLast, int days)[] + { + (true, true, 6), // (8/25, 8/31] 8/26..8/31 + (true, false, 5), // (8/25, 8/31) 8/26..8/30 + (false, true, 7), // [8/25, 8/31] 8/25..8/31 + (false, false, 6), // [8/25, 8/31) 8/25..8/30 + }; + foreach (var (settled, calcLast, days) in cases) + { + var amount = SwapPenaltyInterestCalculator.CalcPenaltyAmount( + p, Notional, + unwindDate: UnwindDate, maturityDate: MaturityDate, + unwindDaySettled: settled, maturityCalcLast: calcLast, + capitalizedInterest: 0m, carryInInterest: 0m, + frozenRate: FundingLegRate.Fixed(Rate), + policy: Policy(p), resetAnchor: StartDate); + var expected = Rate * Notional * days / AnnualDays; + Assert.AreEqual((double)expected, (double)amount, 0.0001, + $"settled={settled}, calcLast={calcLast} → {days} 天"); + } + } + + [TestMethod] + public void 部分平仓_仅被平份额计罚息() + { + var p = CreatePosition(InterestTypeEnum.复利, SwapDirectionEnum.支付); + var elapsed = CompoundAccruedTo(UnwindDate, AccrualBoundary.Both); + var capitalized = CompoundAccruedTo(LastResetBeforeUnwind.AddDays(-1), AccrualBoundary.Both); + var carryIn = elapsed - capitalized; + + // 被平 30%:本金与两承接量同比缩放,罚息应恰为全额的 30% + var full = CalcCompoundPenalty(p, Notional, true, capitalized, carryIn); + var partial = CalcCompoundPenalty(p, Notional * 0.3m, true, capitalized * 0.3m, carryIn * 0.3m); + + Assert.AreEqual((double)(full * 0.3m), (double)partial, 0.0001, + "被平 30%(本金与承接量同比)罚息应恰为全额的 30%"); + } + + [TestMethod] + public void 零剩余期限_金额为零() + { + var amount = SwapPenaltyInterestCalculator.CalcPenaltyAmount( + CreatePosition(InterestTypeEnum.复利, SwapDirectionEnum.支付), Notional, + unwindDate: MaturityDate, maturityDate: MaturityDate, + unwindDaySettled: true, maturityCalcLast: true, + capitalizedInterest: 90_000m, carryInInterest: 10_000m, + frozenRate: FundingLegRate.Fixed(Rate), + policy: Policy(CreatePosition(InterestTypeEnum.复利, SwapDirectionEnum.支付)), + resetAnchor: StartDate); + Assert.AreEqual(0m, amount, "平仓日=到期日无剩余期限,罚息为 0(承接量不产生利息)"); + } + } +} 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/SwapFlowFr007EntryPrecisionTest.cs b/UnitTestProject/Modules/SwapModule/SwapFlowFr007EntryPrecisionTest.cs new file mode 100644 index 00000000..db9779f4 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapFlowFr007EntryPrecisionTest.cs @@ -0,0 +1,30 @@ +namespace YLErp.Modules.SwapModule +{ + /// + /// FR007 界面手工录入落库精度契约(EQD-6968 测试期间发现): + /// FR007 官方发布为百分数下 4 位(1.4150%),前端 ÷100 转 6 位小数(0.014150)传后端; + /// 原 Math.Round(,4) 截成 0.0142(丢 0.5bp,1 亿本金 7 天约 96 元)。修复后保留 6 位。 + /// bond-sync 自动同步链(BigDecimal 全精度)不经此路径,无影响。 + /// + [TestClass] + public class SwapFlowFr007EntryPrecisionTest + { + [TestMethod] + public void 官方四位百分数定盘_六位小数全精度保留() + { + // 2026-08-19 官方发布 1.4150% —— 前端 1.4150/100 后的入参 + Assert.AreEqual(0.01415, SwapFlowService.RoundFr007Price(1.4150 / 100.0), 1e-9, + "1.4150% 落库应保留 0.014150,不得截成 0.0142(丢 0.5bp)"); + Assert.AreEqual(0.021137, SwapFlowService.RoundFr007Price(2.1137 / 100.0), 1e-9, + "百分数下第3、4位(小数第5、6位)必须保留"); + } + + [TestMethod] + public void 常规两位百分数定盘_行为不变() + { + // 历史常见形态(2.11% 等):修复前后结果一致 + Assert.AreEqual(0.0211, SwapFlowService.RoundFr007Price(0.0211), 0d); + Assert.AreEqual(0.0142, SwapFlowService.RoundFr007Price(0.0142), 0d); + } + } +} 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..01df1f4e 100644 --- a/UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs @@ -1,12 +1,9 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Microsoft.VisualStudio.TestTools.UnitTesting; using Newtonsoft.Json; +using System.Globalization; using YLErp; -using YLErp.DBModels; using YLErp.DBModels.Enums; using YLErp.Modules.SwapModule; +using YLErp.Modules.SwapModule.ReturnLegs; namespace UnitTestProject.Modules.SwapModule { @@ -178,17 +175,17 @@ namespace UnitTestProject.Modules.SwapModule protected override List 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 +223,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(); } @@ -252,7 +249,7 @@ namespace UnitTestProject.Modules.SwapModule private static void DebugCompare(string tag, decimal oracle, decimal actual, eod_swap_position eod = null) { var diff = actual - oracle; - var sb = new System.Text.StringBuilder(); + var sb = new StringBuilder(); sb.AppendLine($"[DBG][{tag}] oracle={oracle:F4} actual={actual:F4} diff={diff:F4}"); if (eod != null) { @@ -394,9 +391,9 @@ namespace UnitTestProject.Modules.SwapModule var interests = svc.GetInterests( td, td.trade_extend, valueDate, valueDate, prevEod, new List { position }, - closeNotional, closeNotional, 0m, closeNotional, 1m, + closeNotional, closeNotional, 1m, (int)SwapEventTypeEnum.平仓, - false, false, 0m, closeNotional, false, settment: false, newCalcLast: isMaturity); + false, closeNotional, false, settment: false, newCalcLast: isMaturity); Assert.AreEqual(1, interests.Count); return interests[0]; } @@ -421,8 +418,8 @@ namespace UnitTestProject.Modules.SwapModule public void 场景3_第3重置期内全平(string note, bool compound, bool calcFirst, bool calcLast, int rule, int interestMode, string spreadStr, string oracleStr) { - var spread = decimal.Parse(spreadStr, System.Globalization.CultureInfo.InvariantCulture); - var oracle = decimal.Parse(oracleStr, System.Globalization.CultureInfo.InvariantCulture); + var spread = decimal.Parse(spreadStr, CultureInfo.InvariantCulture); + var oracle = decimal.Parse(oracleStr, CultureInfo.InvariantCulture); var mode = (calcFirst && calcLast) ? "11" : "10"; var type = compound ? InterestTypeEnum.复利 : InterestTypeEnum.单利; @@ -468,9 +465,9 @@ namespace UnitTestProject.Modules.SwapModule public void 场景4_部分平仓后再全平(string note, bool compound, bool calcFirst, bool calcLast, int rule, int interestMode, string spreadStr, string oraclePartialStr, string oracleFinalStr) { - var spread = decimal.Parse(spreadStr, System.Globalization.CultureInfo.InvariantCulture); - var oraclePartial = decimal.Parse(oraclePartialStr, System.Globalization.CultureInfo.InvariantCulture); - var oracleFinal = decimal.Parse(oracleFinalStr, System.Globalization.CultureInfo.InvariantCulture); + var spread = decimal.Parse(spreadStr, CultureInfo.InvariantCulture); + var oraclePartial = decimal.Parse(oraclePartialStr, CultureInfo.InvariantCulture); + var oracleFinal = decimal.Parse(oracleFinalStr, CultureInfo.InvariantCulture); var mode = (calcFirst && calcLast) ? "11" : "10"; var type = compound ? InterestTypeEnum.复利 : InterestTypeEnum.单利; @@ -492,25 +489,61 @@ namespace UnitTestProject.Modules.SwapModule _eod.RecordEod(partialEod); DebugCompare("场景4[部分] " + note, oraclePartial, partialEod.TdCloseInterest, partialEod); AssertStrict(oraclePartial, partialEod.TdCloseInterest, "场景4[部分] " + note); - // 覆盖 mode 2/9 分叉(SwapEodPositionService:1436-1469):部分平仓后 TdInterestPrincipal 的经济口径 - // 必须 = 剩余动态本金(剩余名义本金 + 已并入本金的重置日待实现利息),mode2/9 应当一致。 - // 单利:line 1491 直接取 posiNotionalValue = remainingNotional,无累计利息。 - // 复利:base = interests.First().InterestPrincipal(本服务 CalcSwapInterests 已捕获到 LastBaseInterestPrincipal); - // mode2 仅在 calcLast 时于 1464 行反推剩余(× (1-cp)/cp),mode9 直取 base(GLMS-20260421-0004 禁止反推)。 - // calcLast=false(如“算头不算尾”)或 mode9 被错误反推会膨胀 ~2.3 倍(494982903.27),下方断言精确拦截回归。 + // TdInterestPrincipal 独立经济不变量断言(取代原 reverseMode2 镜像布尔)。 + // 生产线路(SwapEodPositionService ~:1400-1453): + // 单利:直接 TdInterestPrincipal = posiNotionalValue = remainingNotional(无累计利息)。 + // 复利:base = 计息器 CalcSwapInterests 返回的 InterestPrincipal,已由本桩捕获为 LastBaseInterestPrincipal。 + // - mode9(标的期初全价):计息器已直接返回「剩余动态本金」,禁止任何 (1-cp)/cp 反推 + // (GLMS-20260421-0004:误反推会把 ~212135529.97 膨胀到 ~494982903.27)。 + // - mode2(合约名义本金规模)仅 calcLast 时 InterestPrincipal 为已平部分,需反推剩余 + // = base*(1-cp)/cp(经济恒等式 remaining = closed/cp − closed,非代码镜像)。 + // 下方用独立 if 锁死 mode9=base,不依赖 reverseMode2 布尔的拼写—— + // 重基线时即便把 reverseMode2 错写成含 mode9,mode9 仍走 base 分支,断言照红。 decimal expectedTdPrincipal; if (!compound) { expectedTdPrincipal = remainingNotional; } + else if (interestMode == (int)InterestModeEnum.标的期初全价) + { + // mode9 回归守卫(GLMS-20260421-0004):TdInterestPrincipal 必须等于反推前的 base; + // 任何 (1-cp)/cp 反推都会把 ~212M 剩余本金膨胀到 ~495M,此断言立即红。 + expectedTdPrincipal = _eod.LastBaseInterestPrincipal; + } else { + // mode2(合约名义本金规模):仅 calcLast 时 InterestPrincipal 为已平部分,需反推剩余 + // = base*(1-cp)/cp(经济恒等式 remaining = closed/cp − closed,非代码镜像)。 + // !calcLast 时生产走 usesFullPreviousEodPrincipal 分支(*= (1-cp)),本测因部分平仓前一日 eod + // 差 3 天使 Days==1 不成立而跳过,故此处取 base。 var cp = partialCloseNotional / Notional; // = 0.3,与 EOD 内部 closePercent 一致 bool reverseMode2 = interestMode == (int)InterestModeEnum.合约名义本金规模 && calcLast; expectedTdPrincipal = reverseMode2 ? _eod.LastBaseInterestPrincipal * (1m - cp) / cp : _eod.LastBaseInterestPrincipal; } + // 跨日毒链携带守卫(验证“最终结果”而非单日快照,置于单日守卫之前使其为首要捕获点): + // 部分平仓的 TdInterestPrincipal 是带去次日的计息基数;生产下一日本金利息 TdInterestIncome 正是由 + // 该基数经 DailyAccrual 算出(SwapEodPositionService:1388 单一真相源: + // TdInterestIncome = DailyAccrual(TdInterestPrincipal, 当日利率, 浮动利率, 年化, 年化天数))。 + // 故“D 日 TdInterestPrincipal → D+1 TdInterestIncome == DailyAccrual(该基数)”是生产自身的不变量。 + // 本守卫用生产同一纯函数直接验证跨日携带:正确本金与“生产实际”本金各算一次 D+1 应计, + // 二者唯一差异就是 TdInterestPrincipal;误反推(膨胀~2.3x)会让 D+1 应计同步膨胀,差远超容差→红。 + // (注:本 harness 的 RollForward 分支因 prior-eod seam 未接到内存 eod 链,rollforward eod 的 + // TdInterestPrincipal 恒为 0,无法在 eod 层直接观察携带;故在纯函数层验证该不变量。) + var annualDays = td.trade_extend == null ? 365 : td.trade_extend.ExtendObj.AnnualDays; + var correctNextDayIncome = InterestIncomeCalc.DailyAccrual( + expectedTdPrincipal, partialEod.TdInterestRate, partialEod.FloatRate, partialEod.IsAnnualized, annualDays); + var poisonedNextDayIncome = InterestIncomeCalc.DailyAccrual( + partialEod.TdInterestPrincipal, partialEod.TdInterestRate, partialEod.FloatRate, partialEod.IsAnnualized, annualDays); + var carryTol = Math.Max(0.01m, Math.Abs(correctNextDayIncome) * 0.05m); + Assert.IsTrue(Math.Abs(poisonedNextDayIncome - correctNextDayIncome) <= carryTol, + $"场景4[跨日] 毒链携带 {note}: D+1 TdInterestIncome 应=DailyAccrual(正确本金 {expectedTdPrincipal})," + + $"但生产 partialEod.TdInterestPrincipal={partialEod.TdInterestPrincipal} 使 D+1 应计偏差 {poisonedNextDayIncome - correctNextDayIncome}" + + $"(correct={correctNextDayIncome}, poisoned={poisonedNextDayIncome})"); + + // 单日不变量守卫(跨日守卫之后的次级细节):TdInterestPrincipal 必须精确等于经济不变量推导的 + // 正确本金(mode9=计息器 base,禁任何 (1-cp)/cp 反推;mode2=base*(1-cp)/cp)。 AssertStrict(expectedTdPrincipal, partialEod.TdInterestPrincipal, "场景4[部分] TdInterestPrincipal " + note); // 部分平仓后,剩余名义本金缩减为 70%(真实代码路径更新持仓口径) @@ -521,6 +554,7 @@ namespace UnitTestProject.Modules.SwapModule RunDailyEodFromStart(_eod, new DateTime(2026, 5, 12), new DateTime(2026, 5, 19)); var prevEodFull = _eod.LatestEodForPosition(position.id, new DateTime(2026, 5, 19)); + // 第二步:2026-05-19 全部平仓剩余 70%(consumedInterest 此时从真实累积的 flow event 读取, // 真实扣除 5/11 部分平仓已结利息——绝无硬编码 0) var fullFlow = CalcCloseFlow(td, position, new DateTime(2026, 5, 19), new List(), remainingNotional, remainingNotional); diff --git a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs index a750ccec..c71b5569 100644 --- a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs @@ -80,16 +80,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/SwapUnwindFloatingLegDiagnosticTdd.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindFloatingLegDiagnosticTdd.cs index 84a7230d..69f7ca72 100644 --- a/UnitTestProject/Modules/SwapModule/SwapUnwindFloatingLegDiagnosticTdd.cs +++ b/UnitTestProject/Modules/SwapModule/SwapUnwindFloatingLegDiagnosticTdd.cs @@ -5,12 +5,12 @@ using YLErp.DBModels.Enums; namespace YLErp.Modules.SwapModule { /// - /// 诊断测试:验证「浮动腿 fpositions 仍用 origPositions(orig 100M)」对本 deal 的 - /// 预付金/返回预付金结果是否产生影响。结论预期:本 deal 利息腿只有 mode 9(标的期初全价) - /// 与 mode 5(初始预付金),CalcNotionalByMode 中 posiLong/posiShort 仅在「多头/空头存续名义本金」 - /// 分支被消费(L709-716),故本 deal 即便 fpositions 用 orig 100M,预付金腿结果也不受其影响。 - /// 本测试仅做诊断/验证,不改动任何生产代码;用反射调用 private CalcNotionalByMode 以直接证明 - /// “mode 9 / mode 5 的 closePrincipal 不依赖 posiLong/posiShort”。 + /// 诊断测试骨架:针对 GLMS 双轨持仓(orig/real)构造预付金腿(mode 5)与标的期初全价腿(mode 9), + /// 用于验证“浮动腿 fpositions 用 origPositions 对预付金/标的端计息基数的影响”。 + /// 计息基数现由 FundingLegStrategyFactory + 各 IFundingLegStrategy 策略类计算 + /// (原 private CalcNotionalByMode 已重构移除);多空存续腿的 posiLong/posiShort 因界面禁用 + /// 已从策略接口删除,故预付金/标的端计息基数不依赖多空头寸。 + /// 注:当前仅含数据构造,反射诊断方法尚未实现(无 [TestMethod])。 /// [TestClass] public class SwapUnwindFloatingLegDiagnosticTdd 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/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs b/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs index 6c83858e..38128373 100644 --- a/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs +++ b/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs @@ -68,7 +68,7 @@ namespace YLErp.Modules.SwapModule /// 捕获真实收盘产生的 swap_flow_event(生产写 DbContext.swap_flow_event)。 /// 与 PersistEodSwapPosition 同理,这里只收集不写库,供 GetConsumedInterest 真实计算。 /// - protected void PersistFlowEvent(swap_flow_event flowEvent) + protected override void PersistFlowEvent(swap_flow_event flowEvent) { if (flowEvent.id == 0) flowEvent.id = _nextId++; FlowEvents.Add(flowEvent); @@ -111,5 +111,16 @@ namespace YLErp.Modules.SwapModule ClientCashCalls.Add((amount, action)); return _nextId++; } + + /// + /// AddClientCashInCashOut 生产实现会查 DataCacheProvider.GetClientDataSource().GetData(ClientId), + /// 纯内存测试无客户缓存会抛"客户信息未找到"。与 AddClientCash 同构 no-op, + /// 仅捕获调用记录,供断言使用。 + /// + public override int AddClientCashInCashOut(OtcTradeBase td, double amount, string action, DateTime valueDate) + { + ClientCashCalls.Add((amount, action)); + return _nextId++; + } } } diff --git a/UnitTestProject/Program.cs b/UnitTestProject/Program.cs index 0f768067..708646cf 100644 --- a/UnitTestProject/Program.cs +++ b/UnitTestProject/Program.cs @@ -30,9 +30,24 @@ namespace YLErp YLServiceLocator.SetServiceCollection(services); - AppManager.Initialize(YLErp.Enums.SubSystemName.UnitTest, configuration); - - DataCacheManager.UpdateOnce(); + // P0 容错(2026-08-16):初始化段(AppManager.Initialize 内部 InitializePsConfig 读 AppConfig 表、 + // DataCacheManager.UpdateOnce 预热)在测试库不可达时不再让 ModuleInitializer 抛异常连坐全部 903 个 + // 测试——降级为醒目警告,纯内存测试照常可跑;依赖配置/缓存/库的测试将以各自的连接错误失败 + // (与降级前表现一致,只是不再全红归因到"类创建失败")。完全跳过初始化仍用 + // YLErp_UNIT_TEST_SKIP_INITIALIZATION=1。实测触发链:M1→AppManager.Initialize→ConfigDic→ + // ServerVersion.AutoDetect→MySQL 不可达(Program.cs:33,2026-08-16 栈实证)。 + try + { + AppManager.Initialize(YLErp.Enums.SubSystemName.UnitTest, configuration); + DataCacheManager.UpdateOnce(); + } + catch (Exception ex) + { + var warn = $"[UnitTest初始化降级] 初始化段失败(测试库不可达?):{ex.GetType().Name}: {ex.Message}。" + + "纯内存测试继续;依赖配置/缓存/数据库的测试将失败——这是网络问题不是代码问题。"; + Console.WriteLine(warn); + logger.Warn(warn); + } services.AddHttpClient("") .ConfigurePrimaryHttpMessageHandler(messageHandler => diff --git a/YLErpDAL/DataBase/ClientDBContext.cs b/YLErpDAL/DataBase/ClientDBContext.cs index c7098ab9..d210be40 100644 --- a/YLErpDAL/DataBase/ClientDBContext.cs +++ b/YLErpDAL/DataBase/ClientDBContext.cs @@ -19,6 +19,8 @@ namespace BaseOUDAL public DbSet client_black { get; set; } + public DbSet client_blacklog { get; set; } + public DbSet client_file { get; set; } public DbSet client_file_audit { get; set; } @@ -57,4 +59,4 @@ namespace BaseOUDAL public DbSet client_customer_manage { get; set; } } -} \ No newline at end of file +} diff --git a/YLErpDAL/Model/ClientBlackApprovalQueryRes.cs b/YLErpDAL/Model/ClientBlackApprovalQueryRes.cs new file mode 100644 index 00000000..92658cab --- /dev/null +++ b/YLErpDAL/Model/ClientBlackApprovalQueryRes.cs @@ -0,0 +1,20 @@ +namespace YLErp.Model +{ + public class ClientBlackApprovalQueryRes + { + public int id { get; set; } + public string EncryptId { get; set; } + public string ProcessStatus { get; set; } + public int ProcessOrderId { get; set; } + public string ProcessRoleName { get; set; } + public string ClientName { get; set; } + public int ProcessRoleId { get; set; } + public string Comments { get; set; } + public string ApprovalOptName { get; set; } + public DateTime? ApprovalOptDate { get; set; } + public string State { get; set; } + public int? creator_id { get; set; } + public string creator_name { get; set; } + public DateTime? creator_time { get; set; } + } +} diff --git a/YLErpDAL/Model/ClientBlackAuditReq.cs b/YLErpDAL/Model/ClientBlackAuditReq.cs new file mode 100644 index 00000000..7cf85003 --- /dev/null +++ b/YLErpDAL/Model/ClientBlackAuditReq.cs @@ -0,0 +1,18 @@ +using YLErp.Helpers; + +namespace YLErp.Model +{ + /// + /// 黑名单审批请求。 + /// + public class ClientBlackAuditReq + { + public string enid { get; set; } + + public int id => DataProtectHelper.DecryptInt(enid); + + public string status { get; set; } + + public string auditComment { get; set; } + } +} 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/Model/clientblackReq.cs b/YLErpDAL/Model/clientblackReq.cs index 47cf73de..f7b8f958 100644 --- a/YLErpDAL/Model/clientblackReq.cs +++ b/YLErpDAL/Model/clientblackReq.cs @@ -14,6 +14,12 @@ namespace YLErp.Model /// public string Name { get; set; } + public DateTime? DateFromOptDate { get; set; } + + public DateTime? DateToOptDate { get; set; } + + public string ClientBlackStates { get; set; } + } } diff --git a/YLErpDAL/Modules/ClientModule/ClientBlackApprovalPolicy.cs b/YLErpDAL/Modules/ClientModule/ClientBlackApprovalPolicy.cs new file mode 100644 index 00000000..ed9271cb --- /dev/null +++ b/YLErpDAL/Modules/ClientModule/ClientBlackApprovalPolicy.cs @@ -0,0 +1,94 @@ +using YLErp.Model; + +namespace YLErp.Modules.ClientModule +{ + public static class ClientBlackApprovalPolicy + { + public static readonly string[] EffectiveStates = + { + client_black.已加入, + client_black.删除审批中, + client_black.删除已拒绝 + }; + + public static bool IsEffective(string state) + { + return EffectiveStates.Contains(state); + } + + public static bool CanSubmitAddition(string state) + { + return state == client_black.未提交 || state == client_black.新增已拒绝; + } + + public static ClientBlackAdditionResult GetAdditionResult(bool hasApprovalProcess) + { + return hasApprovalProcess + ? new ClientBlackAdditionResult(client_black.未提交, 0, false) + : new ClientBlackAdditionResult(client_black.已加入, -2, true); + } + + public static bool CanRequestRemoval(string state) + { + return state == client_black.已加入 || state == client_black.删除已拒绝; + } + + public static bool CanDeleteDraft(string state) + { + return state == client_black.未提交 || state == client_black.新增已拒绝; + } + + public static ClientBlackRemovalResult GetRemovalResult(bool hasApprovalProcess) + { + return hasApprovalProcess + ? new ClientBlackRemovalResult(client_black.删除审批中, 1, false) + : new ClientBlackRemovalResult(client_black.已加入, -2, true); + } + + public static bool CanReplaceRemarks(string state) + { + return state != client_black.新增审批中 && state != client_black.删除审批中; + } + + public static bool CanWithdraw(string state, int approvalProcess) + { + return approvalProcess == 1 && + (state == client_black.新增审批中 || state == client_black.删除审批中); + } + + public static string GetRejectedState(string state) + { + return state switch + { + client_black.新增审批中 => client_black.新增已拒绝, + client_black.删除审批中 => client_black.删除已拒绝, + _ => throw new ArgumentException("当前状态不允许拒绝审批", nameof(state)) + }; + } + + public static ClientBlackWithdrawResult GetWithdrawResult(string state) + { + return state switch + { + client_black.新增审批中 => new ClientBlackWithdrawResult(client_black.未提交, 0), + client_black.删除审批中 => new ClientBlackWithdrawResult(client_black.已加入, -2), + _ => throw new ArgumentException("当前状态不允许撤回审批", nameof(state)) + }; + } + + public static ClientBlackFinalResult GetFinalResult(string state) + { + return state switch + { + client_black.新增审批中 => new ClientBlackFinalResult(client_black.已加入, false), + client_black.删除审批中 => new ClientBlackFinalResult(null, true), + _ => throw new ArgumentException("当前状态不允许完成审批", nameof(state)) + }; + } + } + + public readonly record struct ClientBlackWithdrawResult(string State, int ApprovalProcess); + public readonly record struct ClientBlackFinalResult(string State, bool ShouldDelete); + public readonly record struct ClientBlackAdditionResult(string State, int ApprovalProcess, bool IsEffective); + public readonly record struct ClientBlackRemovalResult(string State, int ApprovalProcess, bool ShouldDelete); +} diff --git a/YLErpDAL/Modules/ClientModule/ClientBlackService.cs b/YLErpDAL/Modules/ClientModule/ClientBlackService.cs index 894dec2a..c5ef8b49 100644 --- a/YLErpDAL/Modules/ClientModule/ClientBlackService.cs +++ b/YLErpDAL/Modules/ClientModule/ClientBlackService.cs @@ -41,6 +41,19 @@ namespace YLErp.Modules.ClientModule { predicate = predicate.And(d => d.Name.Contains(req.Name)); } + if (!string.IsNullOrEmpty(req.ClientBlackStates)) + { + var states = req.ClientBlackStates.Split(',', StringSplitOptions.RemoveEmptyEntries); + predicate = predicate.And(d => states.Contains(d.State)); + } + if (req.DateFromOptDate.HasValue) + { + predicate = predicate.And(d => d.OptDate >= req.DateFromOptDate.Value); + } + if (req.DateToOptDate.HasValue) + { + predicate = predicate.And(d => d.OptDate < req.DateToOptDate.Value.AddDays(1)); + } } var query = DbContext.client_black.AsNoTracking().Where(predicate); @@ -93,6 +106,324 @@ namespace YLErp.Modules.ClientModule return retListResult; } + public List ProcessList() + { + return DbContextFactory.GetYLDbContext().approvalprocess + .Where(s => s.processType == "ClientBlackProcess") + .OrderBy(s => s.order) + .ToList(); + } + + public int DeleteClientBlack(IEnumerable ids) + { + var idList = ids?.Distinct().ToList() ?? new List(); + if (idList.Count == 0) + { + throw new ServiceException("请选择要移出的黑名单客户"); + } + + var rows = DbContext.client_black.Where(x => idList.Contains(x.id)).ToList(); + if (rows.Count != idList.Count) + { + throw new ServiceException("未找到要删除的数据"); + } + + var hasProcess = ProcessList().Any(); + var submitRemovalApprovalCount = 0; + foreach (var row in rows) + { + if (ClientBlackApprovalPolicy.CanDeleteDraft(row.State)) + { + DbContext.client_black.Remove(row); + ClientBlackCategoryLog(row.id, "已删除"); + continue; + } + + if (!ClientBlackApprovalPolicy.CanRequestRemoval(row.State)) + { + throw new ServiceException($"黑名单客户{row.Name}当前状态不允许移出"); + } + + if (hasProcess) + { + var result = ClientBlackApprovalPolicy.GetRemovalResult(true); + row.State = result.State; + row.ApprovalProcess = result.ApprovalProcess; + row.ApprovalOptName = UserName; + row.ApprovalOptDate = DateTime.Now; + ClientBlackCategoryLog(row.id, client_black.删除审批中); + submitRemovalApprovalCount++; + } + else + { + RemoveEffectiveBlack(row); + } + } + + DbContext.SaveChanges(); + return submitRemovalApprovalCount; + } + + public void WithdrawApprovalClientBlack(List ids, out int withdrawCount, out string msg) + { + withdrawCount = 0; + msg = ""; + var rows = DbContext.client_black.Where(x => ids.Contains(x.id)).ToList(); + foreach (var row in rows) + { + if (!ClientBlackApprovalPolicy.CanWithdraw(row.State, row.ApprovalProcess)) + { + if (row.ApprovalProcess > 1) + { + msg += row.Name + ","; + } + continue; + } + + var result = ClientBlackApprovalPolicy.GetWithdrawResult(row.State); + row.State = result.State; + row.ApprovalProcess = result.ApprovalProcess; + row.ApprovalOptName = null; + row.ApprovalOptDate = null; + ClientBlackCategoryLog(row.id, row.State); + withdrawCount++; + } + DbContext.SaveChanges(); + } + + public void SubmitApprovalClientBlack(List ids) + { + var rows = DbContext.client_black.Where(x => ids.Contains(x.id)).ToList(); + var process = ProcessList(); + foreach (var row in rows) + { + if (!ClientBlackApprovalPolicy.CanSubmitAddition(row.State)) + { + continue; + } + + if (process.Count == 0) + { + row.State = client_black.已加入; + row.ApprovalProcess = -2; + row.ApprovalOptName = UserName; + row.ApprovalOptDate = DateTime.Now; + var notifications = new List<(Client oldClient, Client newClient)>(); + ApplyEffectiveAddition(row.Name, notifications); + ClientBlackCategoryLog(row.id, client_black.已加入, "未设置审批流程,直接通过"); + DbContext.SaveChanges(); + SendClientNotifications(notifications); + continue; + } + + row.State = client_black.新增审批中; + row.ApprovalProcess = 1; + row.ApprovalOptName = UserName; + row.ApprovalOptDate = DateTime.Now; + ClientBlackCategoryLog(row.id, client_black.新增审批中); + } + DbContext.SaveChanges(); + } + + public string AuditClientBlack(ClientBlackAuditReq req, bool isBatch = false, string optType = "") + { + var row = DbContext.client_black.Find(req.id); + if (row == null) + { + throw new ServiceException("审批失败,系统中没有该黑名单记录"); + } + if (row.State != client_black.新增审批中 && row.State != client_black.删除审批中) + { + throw new ServiceException("当前黑名单不在审批中"); + } + + var process = ProcessList(); + var currentNode = process.FirstOrDefault(x => x.order == row.ApprovalProcess); + if (currentNode == null || !UserBLL.GetRolesByUserId(UserId).Any(x => x.Id == currentNode.roleId)) + { + throw new ServiceException("当前用户无权审批该节点"); + } + if (req.status == "reject") + { + row.State = ClientBlackApprovalPolicy.GetRejectedState(row.State); + row.ApprovalProcess = -1; + row.ApprovalOptDate = DateTime.Now; + row.OptId = UserId; + row.OptName = UserName; + row.OptDate = DateTime.Now; + ClientBlackCategoryLog(row.id, row.State, req.auditComment); + DbContext.SaveChanges(); + return "提交成功"; + } + + if (req.status != "pass") + { + throw new ServiceException("status参数不支持:" + req.status); + } + + var nextNode = process.FirstOrDefault(x => x.order > row.ApprovalProcess); + if (nextNode != null) + { + row.ApprovalProcess = nextNode.order; + row.ApprovalOptDate = DateTime.Now; + row.OptId = UserId; + row.OptName = UserName; + row.OptDate = DateTime.Now; + ClientBlackCategoryLog(row.id, row.State, req.auditComment); + DbContext.SaveChanges(); + return "提交成功"; + } + + var final = ClientBlackApprovalPolicy.GetFinalResult(row.State); + if (final.ShouldDelete) + { + RemoveEffectiveBlack(row, req.auditComment, isBatch ? optType : null); + DbContext.SaveChanges(); + } + else + { + row.State = final.State; + row.ApprovalProcess = -2; + row.ApprovalOptDate = DateTime.Now; + var notifications = new List<(Client oldClient, Client newClient)>(); + ApplyEffectiveAddition(row.Name, notifications); + ClientBlackCategoryLog(row.id, isBatch ? optType : row.State, req.auditComment); + DbContext.SaveChanges(); + SendClientNotifications(notifications); + } + return "提交成功"; + } + + public SearchListResult ClientBlackApprovalQuery(ClientBlackReq req) + { + var process = ProcessList(); + var predicate = PredicateBuilder.Create(x => x.ApprovalProcess > 0); + if (!string.IsNullOrWhiteSpace(req.Name)) + { + predicate = predicate.And(x => x.Name.Contains(req.Name)); + } + var query = from row in DbContext.client_black.AsNoTracking().Where(predicate) + select new ClientBlackApprovalQueryRes + { + id = row.id, + EncryptId = row.EncryptId, + ProcessOrderId = row.ApprovalProcess, + ProcessRoleId = 0, + ProcessStatus = "审批中 流程" + (row.ApprovalProcess - 1) + "/" + process.Count, + State = row.State, + ClientName = row.Name, + Comments = row.Remarks, + ApprovalOptName = row.ApprovalOptName, + ApprovalOptDate = row.ApprovalOptDate, + creator_id = row.creator_id, + creator_name = row.creator_name, + creator_time = row.creator_time + }; + if (string.IsNullOrEmpty(req.sidx)) + { + req.sidx = "ApprovalOptDate"; + req.sord = "desc"; + } + var result = query.OrderByDescending(x => x.ApprovalOptDate).ToSearchList(req); + var roles = new ErpBaseContext().Roles + .Select(x => new { x.Id, x.Name }) + .ToDictionary(x => x.Id, x => x.Name); + foreach (var item in result.rows) + { + var node = process.FirstOrDefault(x => x.order == item.ProcessOrderId); + if (node == null) + { + continue; + } + + item.ProcessRoleId = node.roleId; + item.ProcessRoleName = roles.TryGetValue(node.roleId, out var roleName) ? roleName : string.Empty; + } + return result; + } + + private void ApplyEffectiveAddition(string name, List<(Client oldClient, Client newClient)> notifications) + { + var client = DbContext.client.FirstOrDefault(c => c.Name == name); + if (client == null) + { + return; + } + var dt = DateTime.Now; + var oldClient = client.Clone(); + if (client.ProcessStatus == "已开户") + { + client.ProcessOrderId = -4; + client.ProcessStatus = "已休眠"; + client.OptId = UserId; + client.OptName = UserName; + client.OptDate = dt; + DbContext.ClientAuditLog.Add(new ClientAuditLog + { + ClientId = client.id, + OptType = "休眠", + Changes = string.Empty, + DataType = "00", + OptId = UserId, + OptName = UserName, + OptDate = dt + }); + notifications.Add((oldClient, client)); + } + DbContext.ClientAuditLog.Add(new ClientAuditLog + { + ClientId = client.id, + OptType = "加入黑名单", + Changes = string.Empty, + DataType = "00", + OptId = UserId, + OptName = UserName, + OptDate = dt + }); + } + + private void RemoveEffectiveBlack(client_black row, string changes = null, string optType = null) + { + var client = DbContext.client.FirstOrDefault(c => c.Name == row.Name); + if (client != null) + { + DbContext.ClientAuditLog.Add(new ClientAuditLog + { + ClientId = client.id, + OptType = "移除黑名单", + Changes = string.Empty, + DataType = "00", + OptId = UserId, + OptName = UserName, + OptDate = DateTime.Now + }); + } + DbContext.client_black.Remove(row); + ClientBlackCategoryLog(row.id, optType ?? "已删除", changes); + } + + private void SendClientNotifications(List<(Client oldClient, Client newClient)> notifications) + { + foreach (var (oldClient, newClient) in notifications) + { + new ClientKafkaService(_kafkaProduce).Send(newClient, oldClient); + } + } + + public void ClientBlackCategoryLog(int clientblackId, string optType, string changes = null) + { + DbContext.client_blacklog.Add(new ClientBlackLog + { + ClientBlackId = clientblackId, + OptType = optType, + Changes = changes, + DataType = "00", + OptId = UserId, + OptName = UserName, + OptDate = DateTime.Now + }); + } + /// /// 客户黑名单导入 /// @@ -165,37 +496,47 @@ namespace YLErp.Modules.ClientModule public void AddClientBlack(IEnumerable list, bool checkStatus) { + var inputList = list?.ToList() ?? new List(); var errMsgList = new List(); - var nameList = list.Select(O => O.Name); - var dbList = DbContext.client_black.Where(O => nameList.Contains(O.Name)); + var nameList = inputList.Select(O => O.Name).ToList(); + var dbList = DbContext.client_black.Where(O => nameList.Contains(O.Name)).ToList(); + foreach (var item in dbList) + { + var obj = inputList.FirstOrDefault(O => O.Name.Equals(item.Name, StringComparison.OrdinalIgnoreCase)); + if (obj == null) + { + continue; + } + if (!ClientBlackApprovalPolicy.CanReplaceRemarks(item.State)) + { + throw new ServiceException("黑名单客户在审批中无法修改!"); + } + if (checkStatus && !string.IsNullOrWhiteSpace(item.Remarks) && item.Remarks != obj.Remarks) + { + errMsgList.Add($"{item.Name}"); + } + } if (checkStatus) { - foreach (var item in dbList) - { - var obj = list.First(O => O.Name.Equals(item.Name, StringComparison.OrdinalIgnoreCase)); - if (!string.IsNullOrWhiteSpace(item.Remarks) && item.Remarks != obj.Remarks) - { - errMsgList.Add($"{item.Name}"); - continue; - } - } if (errMsgList.Count > 0) { var msg = ""; if (errMsgList.Count <= 5) { - msg = $"客户:{string.Join(",", errMsgList)},备注已存在,是否替换?"; + msg = $"客户:{string.Join(",", errMsgList)}当前已在黑名单中,本次将修改备注,备注已存在,是否确认?"; } else { - msg = $"{string.Join(",", errMsgList.Take(5))} 等{errMsgList.Count}个客户,备注已存在,是否替换?"; + msg = $"{string.Join(",", errMsgList.Take(5))} 等{errMsgList.Count}个客户当前已在黑名单中,本次将修改备注,备注已存在,是否确认?"; } throw new ServiceException(msg); } } // 在外部定义列表来保存需要通知的客户对 var clientsToNotify = new List<(Client oldClient, Client newClient)>(); - foreach (var item in list) + var newItems = new List(); + var processList = ProcessList(); + foreach (var item in inputList) { if (string.IsNullOrWhiteSpace(item.Name)) { @@ -206,61 +547,45 @@ namespace YLErp.Modules.ClientModule item.OptId = UserId; item.OptName = UserName; item.OptDate = DateTime.Now; - var clientexistence = DbContext.client.FirstOrDefault(c => c.Name == item.Name); - if (clientexistence != null) + var existing = dbList.FirstOrDefault(x => x.Name.Equals(item.Name, StringComparison.OrdinalIgnoreCase)); + if (existing != null) { - var dt = DateTime.Now; - if (clientexistence.ProcessStatus == "已开户") + var oldRemarks = existing.Remarks; + existing.Remarks = item.Remarks; + existing.OptId = UserId; + existing.OptName = UserName; + existing.OptDate = DateTime.Now; + if (oldRemarks != existing.Remarks) { - var oldClient= clientexistence.Clone(); - clientexistence.ProcessOrderId = -4; - clientexistence.ProcessStatus = "已休眠"; - clientexistence.OptId = UserId; - clientexistence.OptName = UserName; - clientexistence.OptDate = dt; - - DbContext.ClientAuditLog.Add(new ClientAuditLog - { - ClientId = clientexistence.id, - OptType = "休眠", - Changes = string.Empty, - DataType = "00", - OptId = UserId, - OptName = UserName, - OptDate = dt - }); - // 如果原有状态是已开户,添加到通知列表 - if (oldClient != null) - { - clientsToNotify.Add((oldClient, clientexistence)); - } + ClientBlackCategoryLog(existing.id, "修改备注", $"备注:{oldRemarks ?? string.Empty} -> {existing.Remarks ?? string.Empty}"); } - ///日志记录 - DbContext.ClientAuditLog.Add(new ClientAuditLog - { - ClientId = clientexistence.id, - OptType = "加入黑名单", - Changes = string.Empty, - DataType = "00", - OptId = UserId, - OptName = UserName, - OptDate = dt - }); + continue; } + var additionResult = ClientBlackApprovalPolicy.GetAdditionResult(processList.Any()); + item.State = additionResult.State; + item.ApprovalProcess = additionResult.ApprovalProcess; + item.creator_id = UserId; + item.creator_name = UserName; + item.creator_time = DateTime.Now; + if (additionResult.IsEffective) + { + ApplyEffectiveAddition(item.Name, clientsToNotify); + } + newItems.Add(item); } - if (dbList.Any()) + DbContext.client_black.AddRange(newItems); + DbContext.SaveChanges(); + foreach (var item in newItems) { - DbContext.client_black.RemoveRange(dbList); - DbContext.SaveChanges(); + ClientBlackCategoryLog(item.id, item.State); } - DbContext.client_black.AddRange(list); DbContext.SaveChanges(); // 发送Kafka消息 foreach (var (oldClient, newClient) in clientsToNotify) { new ClientKafkaService(_kafkaProduce).Send(newClient, oldClient); } - var importHasTagClientNames = list.Where(p => p.Tags != null && p.Tags.Count > 0).Select(p => p.Name).Distinct().ToList(); + var importHasTagClientNames = inputList.Where(p => p.Tags != null && p.Tags.Count > 0).Select(p => p.Name).Distinct().ToList(); if (importHasTagClientNames != null && importHasTagClientNames.Count > 0) { var dbClients = DbContext.client.AsNoTracking().Where(p => importHasTagClientNames.Contains(p.Name)).Select(p => new ClientSimpleDto @@ -273,7 +598,7 @@ namespace YLErp.Modules.ClientModule var tagService = new TagService(OptUser); dbClients.ForEach(p => { - var importInfo = list.FirstOrDefault(d => d.Name.Equals(p.Name)); + var importInfo = inputList.FirstOrDefault(d => d.Name.Equals(p.Name)); if (importInfo != null) { tagService.SetClientTagForClientImport(new TagModule.Dto.SetClientTagForClientEditRequest { ClientId = p.id, Tags = importInfo.Tags }); diff --git a/YLErpDAL/Modules/ClientModule/ClientImportService.cs b/YLErpDAL/Modules/ClientModule/ClientImportService.cs index 4914a9c2..a59cf971 100644 --- a/YLErpDAL/Modules/ClientModule/ClientImportService.cs +++ b/YLErpDAL/Modules/ClientModule/ClientImportService.cs @@ -317,7 +317,7 @@ namespace YLErp.Modules.ClientModule { return "第" + rowNum + "行客户类别,机构属性,客户性质关联性质有误,导入失败"; } - if (DbContext.client_black.Any(c => c.Name == Name)) + if (DbContext.client_black.Any(c => c.Name == Name && ClientBlackApprovalPolicy.EffectiveStates.Contains(c.State))) { return $"客户'{Name}'已经存在于黑名单中”"; } @@ -1070,7 +1070,7 @@ namespace YLErp.Modules.ClientModule } } } - if (DbContext.client_black.Any(c => c.Name == Name)) + if (DbContext.client_black.Any(c => c.Name == Name && ClientBlackApprovalPolicy.EffectiveStates.Contains(c.State))) { return "" + Name + "客户已经存在于黑名单中”"; } @@ -1733,7 +1733,7 @@ namespace YLErp.Modules.ClientModule //默认为1 IsReceiveEmail = 1; - if (DbContext.client_black.Any(c => c.Name == Name)) + if (DbContext.client_black.Any(c => c.Name == Name && ClientBlackApprovalPolicy.EffectiveStates.Contains(c.State))) { return "" + Name + "客户已经存在于黑名单中"; } diff --git a/YLErpDAL/Modules/ClientModule/ClientProcessLogService.cs b/YLErpDAL/Modules/ClientModule/ClientProcessLogService.cs index d39d0a1e..68dc2e69 100644 --- a/YLErpDAL/Modules/ClientModule/ClientProcessLogService.cs +++ b/YLErpDAL/Modules/ClientModule/ClientProcessLogService.cs @@ -130,7 +130,7 @@ namespace YLErp.Modules.ClientModule try { - if (DbContext.client_black.Any(c => c.Name == client.Name)) + if (DbContext.client_black.Any(c => c.Name == client.Name && ClientBlackApprovalPolicy.EffectiveStates.Contains(c.State))) { client.RejectOrderId = client.ApprovalOrderId; client.ApprovalOrderId = -1; diff --git a/YLErpDAL/Modules/ClientModule/ClientProcessService.cs b/YLErpDAL/Modules/ClientModule/ClientProcessService.cs index 414d2b08..ba2554e3 100644 --- a/YLErpDAL/Modules/ClientModule/ClientProcessService.cs +++ b/YLErpDAL/Modules/ClientModule/ClientProcessService.cs @@ -158,7 +158,7 @@ namespace YLErp.Modules.ClientModule throw new ServiceException("客户名称 必须填写"); } - if (DbContext.client_black.Any(c => c.Name == req.Name)) + if (DbContext.client_black.Any(c => c.Name == req.Name && ClientBlackApprovalPolicy.EffectiveStates.Contains(c.State))) { throw new ServiceException("该客户为黑名单客户,无法进行下一步操作"); } diff --git a/YLErpDAL/Modules/ClientModule/ClientSaveService.cs b/YLErpDAL/Modules/ClientModule/ClientSaveService.cs index b56b4083..358160d1 100644 --- a/YLErpDAL/Modules/ClientModule/ClientSaveService.cs +++ b/YLErpDAL/Modules/ClientModule/ClientSaveService.cs @@ -703,7 +703,7 @@ namespace YLErp.Modules.ClientModule //新增时,新的客户名如果在黑名单里,不允许新增 //修改时,旧的客户名如果在黑名单里,不允许修改 - if (!isAddNew && !req.Name.Equals(blackNameForCheck) && DbContext.client_black.Any(x => x.Name == blackNameForCheck)) + if (!isAddNew && !req.Name.Equals(blackNameForCheck) && DbContext.client_black.Any(x => x.Name == blackNameForCheck && ClientBlackApprovalPolicy.EffectiveStates.Contains(x.State))) { throw new ServiceException("该客户为黑名单客户," + (req.id > 0 ? "不允许修改客户名称" : "不允许新增")); } diff --git a/YLErpDAL/Modules/DataProviderModule/EodPriceQueryService.cs b/YLErpDAL/Modules/DataProviderModule/EodPriceQueryService.cs index 7792e5e1..23e1dd26 100644 --- a/YLErpDAL/Modules/DataProviderModule/EodPriceQueryService.cs +++ b/YLErpDAL/Modules/DataProviderModule/EodPriceQueryService.cs @@ -149,8 +149,14 @@ namespace YLErp.Modules.DataProviderModule price = 0; valueDate = valueDate.Date; + + // FR007 走整表快照缓存(预载全历史,O(1) 命中零查询)。miss=行尚不存在(当日未发布/补录), + // 继续直查库兜底——miss 永不进快照(负缓存防线,详见 Fr007FixingCache 注释)。 + if (underlyingCode == Fr007FixingCache.UnderlyingCode && Fr007FixingCache.TryGet(valueDate, out price)) + return true; + using var db = DbContextFactory.GetYLDbContext(); - var data = db.eod_commodity_future_price.Where(x => x.ValueDate == valueDate && x.UnderlyingCode == underlyingCode).OrderByDescending(o => o.ValueDate).FirstOrDefault(); + var data = db.eod_commodity_future_price.Where(x => x.ValueDate == valueDate && x.UnderlyingCode == underlyingCode).FirstOrDefault(); if (data != null) { @@ -158,6 +164,11 @@ namespace YLErp.Modules.DataProviderModule // 利息腿计算直接作为 floatRate 参与 principal*(fixedRate+floatRate)/annualDays,无需再 ÷100。 price = data.ReferencePrice ?? 0; + // 快照漏掉的行(当日新发布/补录历史)直查命中——失效快照,下次访问整表重载后回到 O(1), + // 否则该键在 TTL 窗口内每次读取都退化为点查。 + if (underlyingCode == Fr007FixingCache.UnderlyingCode) + Fr007FixingCache.Invalidate(); + return true; } diff --git a/YLErpDAL/Modules/DataProviderModule/Fr007FixingCache.cs b/YLErpDAL/Modules/DataProviderModule/Fr007FixingCache.cs new file mode 100644 index 00000000..8fa0d85f --- /dev/null +++ b/YLErpDAL/Modules/DataProviderModule/Fr007FixingCache.cs @@ -0,0 +1,109 @@ +using YLErp.Modules; + +namespace YLErp.Modules.DataProviderModule +{ + /// + /// FR007 定盘快照缓存。定盘是"每工作日一个数、发布后不变"的小表(一年约 250 行,全历史数千行), + /// 故采用整表快照而非逐键缓存: + /// + /// 预载:首次访问一次性 SELECT 全历史进字典,此后读取 O(1)、零查询; + /// miss 不进快照(负缓存防线):当日定盘 ~11:15 后才出现、历史可补录—— + /// 字典查不到必须回退直查库,绝不能把"查不到"缓存住,否则发布后仍沿用旧利率; + /// 写侧版本失效:经应用的 FR007 写入(SwapFlowService 新增/修改/删除)立即失效, + /// 下次访问整表重载一次; + /// TTL 1 分钟兜底:覆盖直改库与其他通用价格写入口(导入/复制/合成等), + /// 可见性窗口 ≤1 分钟,重启无需。 + /// + /// 快照以引用替换发布(Volatile.Write),读侧要么看到旧快照要么看到新快照,无撕裂; + /// 重载加锁去抖,避免并发下重复整表查询。 + /// + public static class Fr007FixingCache + { + public const string UnderlyingCode = "FR007"; + + /// TTL:兜底"直接改库/通用写入口"的可见性窗口(FR007 专属写入口走版本失效,不受此限)。 + internal static TimeSpan Ttl = TimeSpan.FromMinutes(1); + + /// 测试接缝:时钟(默认 UtcNow Ticks)。 + internal static Func NowTicks = () => DateTime.UtcNow.Ticks; + + /// 测试接缝:快照装载器(默认直查库)。返回 日期→ReferencePrice 全量映射。 + internal static Func> LoadSnapshot = LoadFromDb; + + private static Dictionary _snapshot = new(); + private static bool _loaded; // 是否已完成首次装载 + private static long _loadedAt; // 快照装载时刻(NowTicks 口径) + private static long _writeVersion; // 写侧版本(Invalidate 递增) + private static long _snapVersion; // 装载时的写侧版本 + private static readonly object _reloadLock = new(); + + private static Dictionary LoadFromDb() + { + using var db = DbContextFactory.GetYLDbContext(); + return db.eod_commodity_future_price + .Where(x => x.UnderlyingCode == UnderlyingCode && x.ReferencePrice != null) + .Select(x => new { x.ValueDate, Price = x.ReferencePrice!.Value }) + .AsEnumerable() + .GroupBy(x => x.ValueDate.Date) + .ToDictionary(g => g.Key, g => g.First().Price); + } + + /// 命中返回 true。miss 仅代表"快照里没有",调用方须直查库兜底(当日新发布/补录)。 + public static bool TryGet(DateTime valueDate, out double price) + { + EnsureFresh(); + return Volatile.Read(ref _snapshot).TryGetValue(valueDate.Date, out price); + } + + /// 写侧失效:FR007 行经应用新增/修改/删除后调用,下次访问整表重载。 + public static void Invalidate() => Interlocked.Increment(ref _writeVersion); + + /// 重置为未装载状态并恢复默认接缝(仅测试用)。 + internal static void ResetForTest() + { + lock (_reloadLock) + { + _snapshot = new Dictionary(); + _loaded = false; + _loadedAt = 0; + _writeVersion = 0; + _snapVersion = 0; + Ttl = TimeSpan.FromMinutes(1); + NowTicks = () => DateTime.UtcNow.Ticks; + LoadSnapshot = LoadFromDb; + } + } + + private static void EnsureFresh() + { + if (IsFresh()) return; + lock (_reloadLock) + { + if (IsFresh()) return; + try + { + var snap = LoadSnapshot(); + Volatile.Write(ref _snapshot, snap); + Volatile.Write(ref _loaded, true); + Volatile.Write(ref _loadedAt, NowTicks()); + Volatile.Write(ref _snapVersion, Volatile.Read(ref _writeVersion)); + } + catch (Exception ex) + { + // 重载失败(如DB抖动):保留旧快照并重置TTL时钟——历史定盘不可变,旧值依旧正确; + // 同时防止每次读取都重试全表SELECT(重试风暴)。不抛:命中继续走旧快照, + // miss照旧回退直查库,新数据取不到仍会在直查处响亮报错。 + // 注:版本失效(Invalidate)后的重载失败不会被TTL掩盖——版本不相等会持续重试直到成功。 + Volatile.Write(ref _loaded, true); + Volatile.Write(ref _loadedAt, NowTicks()); + LogFactory.GetLogger("Fr007FixingCache").Error("FR007定盘缓存重载失败,沿用旧快照", ex); + } + } + } + + private static bool IsFresh() + => Volatile.Read(ref _loaded) + && Volatile.Read(ref _snapVersion) == Volatile.Read(ref _writeVersion) + && NowTicks() - Volatile.Read(ref _loadedAt) <= Ttl.Ticks; + } +} diff --git a/YLErpDAL/Modules/EodModule/BondPaymentService.cs b/YLErpDAL/Modules/EodModule/BondPaymentService.cs index d5f5f45f..9605de64 100644 --- a/YLErpDAL/Modules/EodModule/BondPaymentService.cs +++ b/YLErpDAL/Modules/EodModule/BondPaymentService.cs @@ -98,8 +98,13 @@ namespace YLErp.Modules.EodModule /// public List GetBondPayments(string underlyingCode, DateTime startDate, DateTime endDate) { - var result = DbContext.bondPayment.Where(x => x.underlyingCode == underlyingCode - && x.payment_date > startDate && x.payment_date <= endDate).AsNoTracking().ToList(); + // GLMS-20260105-0006:票息归属按债权登记日(reg_date)判定,而非支付日(pay_date_PL/pay_date_act)。 + // 登记日当天 EOD 即应计提;原按支付日口径会让"登记日≠支付日"的债券漏计(二者恰差一工作日时缺陷被掩盖)。 + var result = QueryBondPayments(underlyingCode) + .Where(x => x.reg_date > startDate && x.reg_date <= endDate) + .AsNoTracking().ToList(); + Log.Info($"[分红-登记日口径] GetBondPayments underlyingCode={underlyingCode} 区间=({startDate:yyyy-MM-dd},{endDate:yyyy-MM-dd}] 按reg_date过滤, 命中 {result.Count} 条: " + + string.Join(",", result.Select(r => r.reg_date?.ToString("yyyy-MM-dd")))); // 让 Copy/Update EOD 始终只依赖 BondPaymentService,而不必在收盘链路直接累加 ex_dividend_info。 // 口径约定:bond_payment_info.payment_interest 对 Stock/Fund 统一按“每 10 份派现金额”存储, @@ -149,6 +154,13 @@ namespace YLErp.Modules.EodModule return result; } + /// + /// 可测性 seam:返回某债券的全部付息记录(未做日期过滤)。测试可 override 注入内存数据, + /// 以验证日期口径(GLMS-20260105-0006:应按债权登记日 reg_date 而非支付日 pay_date_PL/pay_date_act 判定)。 + /// + protected virtual IQueryable QueryBondPayments(string underlyingCode) + => DbContext.bondPayment.Where(x => x.underlyingCode == underlyingCode); + public List GetTargetDatePayments(string underlyingCode, DateTime targetDate) { diff --git a/YLErpDAL/Modules/SwapModule/ARCHITECTURE.md b/YLErpDAL/Modules/SwapModule/ARCHITECTURE.md index 43f2b474..669e64ff 100644 --- a/YLErpDAL/Modules/SwapModule/ARCHITECTURE.md +++ b/YLErpDAL/Modules/SwapModule/ARCHITECTURE.md @@ -52,11 +52,7 @@ SwapModule/ │ ├── Margin/ 保证金(mode 5/6) │ ├── MarginModes mode 判断(含 ForLinq for EF Core) -│ ├── MarginBalance 保证金余额(值对象) -│ ├── MarginAccount 余额管理 + AccrueInterest 计息入口 -│ ├── MarginCalc 纯函数(PreviousBalance/FlipDirection/AccumulateSettlement) -│ ├── IMarginResolver 保证金形态接口 -│ └── Cash/Credit/Guarantee 三种形态实现 +│ └── MarginCalc 纯函数(PreviousBalance/FlipDirection/AccumulateSettlement) │ ├── ReturnLegs/ 标的端 │ ├── ReturnLegSummary 标的端汇总值 @@ -66,6 +62,14 @@ SwapModule/ │ ├── DirectionRatio 方向因子(LongShort + ReceivePay) │ └── PositionValueCalc 持仓价值汇总(利息端 + 浮动端) │ +├── Accrual/ 计息(生产实现,自洽域) +│ ├── InterestMath 共用数学:Round/AccrualDays/FundingLegPrecision + AccrualBoundary/InterestResult +│ ├── SimpleInterestAccrual 单利纯函数(AccrueEod 单日 + AccruePeriod 多日) +│ ├── CompoundInterestAccrual 复利纯函数(EodBasis/AccrueEod/AccruePeriod) +│ ├── AccrualPolicy 计息政策(算头算尾/单复利/重置周期/年化) +│ ├── AccrualTrace 计息 trace 收集器(SwapCalcTrace.Write 常驻落盘) +│ └── FundingLegRate all-in 利率值对象 +│ ├── SwapDealService.cs 盘中平仓/互换主逻辑 ├── SwapEodPositionService.cs EOD 日终归档主逻辑 ├── SwapDealIndexFixer.cs SwapDealService 专用取价器(委托 TryGetFloatRate) @@ -76,12 +80,16 @@ SwapModule/ ``` Interest/ -├── SwapInterest.cs 纯函数库(AccrueSimple/AccrueCompound/ApplyUnwind) ├── IIndexFixer.cs 取价接口 -├── IndexFixerBase.cs 取价日计算工具 -└── Fr007IndexFixer.cs FR007 取价生产实现(调 EodPriceQueryService) +└── IndexFixerBase.cs 取价日计算工具 ``` +> 注:① `Fr007IndexFixer.cs`(FR007 取价生产实现)在 SwapModule 下,不在本目录。 +> ② 2026-08 计息类型(InterestMath/AccrualBoundary/InterestResult/AccrualTrace)已整体迁至 SwapModule/Accrual/, +> Core 不再持有计息实现。原 Core 层 SwapInterest 的算法方法(AccrueSimple/AccrueCompoundInArrears/ApplyUnwind/ +> AccrueUnrealized/ToInterestRate)与 AccrualContext/InterestRate 从未接线(生产走 Accrual/ 目录),作为孤儿死代码删除—— +> 其舍入/rollover 口径与生产实现已分叉,若将来重建须先补对账测试,勿凭记忆复原。 + ## InterestModeEnum(显式赋值,DB 契约) ``` @@ -132,7 +140,7 @@ Unknown = 0 |---|---|---| | 公司行为(送股/拆股) | QtyRollforward.corpActionDeltaQty | ✅ | | 公司行为(登记日快照) | DividendCalc + BondPayment | 见 corp-action-refactor-proposal.md | -| 保证金配置/规则/占用 | MarginAccount + MarginCalc | ✅ | +| 保证金配置/规则/占用 | MarginCalc | ✅ | | RecordMarginCashFlow 迁入 Margin | AddClientCash 加 virtual | 待做 | | EOD 编排拆分 | SwapPositionCompose | 待业务需求驱动 | ``` diff --git a/YLErpDAL/Modules/SwapModule/Accrual/AccrualPolicy.cs b/YLErpDAL/Modules/SwapModule/Accrual/AccrualPolicy.cs index 34b2a1f7..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。 @@ -29,4 +27,8 @@ public sealed class AccrualPolicy public AccrualPolicy(AccrualBoundary convention, bool isCompound, int resetPeriodDays, int annualDays, bool isAnnualized = false) => (Convention, IsCompound, ResetPeriodDays, AnnualDays, IsAnnualized) = (convention, isCompound, resetPeriodDays, annualDays, isAnnualized); + + /// 从 swap_position 构造 EOD 计息政策(算头算尾,重置周期取 interest_rest_days)。 + public static AccrualPolicy BuildEod(DBModels.swap_position position, int annualDays, bool isCompound) + => new(AccrualBoundary.Both, isCompound, position.interest_rest_days ?? 1, annualDays, position.IsAnnualized); } 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 91% rename from Framework/YLErp.Core/Interest/AccrualTrace.cs rename to YLErpDAL/Modules/SwapModule/Accrual/AccrualTrace.cs index 108b2692..4e73dfb8 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 常驻落盘。落盘职责归一处,计息代码零日志依赖、保持干净。 @@ -71,6 +67,10 @@ public sealed class AccrualTrace => Add(AccrualTraceEvent.End, default, $"END accrued={totalAccrued:F6} today={totalToday:F6}"); + /// 自由文本注解(如罚息接缝的诊断行),不绑定特定计息语义。 + public void Note(string message) + => Add(AccrualTraceEvent.Note, default, message); + private void Add(AccrualTraceEvent step, DateTime date, string line) => _entries.Add(new AccrualTraceEntry(step, date, line)); @@ -82,7 +82,7 @@ public sealed class AccrualTrace /// 追踪条目的语义类别(对应 QuantLib/Strata 的"事件"概念),便于程序化筛选(如"只看重置日")。 public enum AccrualTraceEvent { - Start, DayAccrual, ResetBefore, ResetAfter, Rollover, Unwind, End + Start, DayAccrual, ResetBefore, ResetAfter, Rollover, Unwind, End, Note } /// 单条追踪记录:类别 + 日期 + 已渲染文本。 diff --git a/YLErpDAL/Modules/SwapModule/Accrual/CompoundInterestAccrual.cs b/YLErpDAL/Modules/SwapModule/Accrual/CompoundInterestAccrual.cs index 9418aa46..9a6b09fd 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); @@ -64,6 +61,11 @@ public static class CompoundInterestAccrual /// 复利多日计息(替换 CalcDailyCompoundInterest 的纯数学部分)。 /// 从 startDate 到 endDate 全程重放,每个重置日把累计利息并入本金。 /// + /// + /// 窗口前已计未结转利息(EQD-6977 罚息承接):在【首个重置日】并入计息基数—— + /// 与"持有至到期"全期轨迹严格对齐(恒等式:全期复利 = 平仓日已结利息 + 罚息窗口利息)。 + /// 默认 0 时与旧行为逐位一致。与 resetCarryInterest 互斥使用(后者是全平重放的末段存量替代)。 + /// public static InterestResult AccruePeriod( decimal notional, IReadOnlyList<(DateTime StartDate, decimal Rate)> segmentRates, @@ -76,10 +78,14 @@ public static class CompoundInterestAccrual decimal realizedInterest, decimal unwindFraction, out decimal finalBasis, - AccrualTrace? trace = null) + AccrualTrace? trace = null, + decimal carryInInterest = 0m) { decimal accrualBasis = notional; - decimal accrued = 0m; + // carryInInterest 是"窗口前已计未结转利息":先并入 accrued,随首个重置日的 + // basis = notional + accrued 一并资本化,并在其后每个重置日持续留在基数里 + //(与全窗口重放时 accrued 含全部历史利息的轨迹严格一致);最终报告时扣除。 + decimal accrued = carryInInterest; trace?.MarkStart(startDate, endDate, boundary, annualDays, isAnnualized); @@ -107,7 +113,7 @@ public static class CompoundInterestAccrual var segIncludeStart = (si == 0) ? boundary.IncludeStart : true; var segIncludeEnd = isLastSegment ? boundary.IncludeEnd : false; - var days = SwapInterest.AccrualDays(segmentRates[si].StartDate, segEnd, + var days = InterestMath.AccrualDays(segmentRates[si].StartDate, segEnd, AccrualBoundary.Of(segIncludeStart, segIncludeEnd)); if (days <= 0) continue; @@ -119,13 +125,16 @@ public static class CompoundInterestAccrual finalBasis = accrualBasis; + // 报告口径只含窗口内增量(carryIn 是窗口前已结利息,由正常平仓流单独结算) + accrued -= carryInInterest; + if (realizedInterest != 0m) trace?.Unwind(endDate, unwindFraction, realizedInterest * unwindFraction, accrued - realizedInterest * unwindFraction); accrued -= realizedInterest * unwindFraction; var result = new InterestResult( - SwapInterest.Round(accrued, Precision), - SwapInterest.Round(accrued, Precision)); + InterestMath.Round(accrued, Precision), + InterestMath.Round(accrued, Precision)); trace?.MarkEnd(result.Accrued, result.AccruedToday); return result; } diff --git a/YLErpDAL/Modules/SwapModule/Accrual/FundingLegRate.cs b/YLErpDAL/Modules/SwapModule/Accrual/FundingLegRate.cs index 6240eae6..731860e4 100644 --- a/YLErpDAL/Modules/SwapModule/Accrual/FundingLegRate.cs +++ b/YLErpDAL/Modules/SwapModule/Accrual/FundingLegRate.cs @@ -1,3 +1,5 @@ +using YLErp.DBModels; + namespace YLErp.Modules.SwapModule.Accrual; /// @@ -26,4 +28,10 @@ public readonly struct FundingLegRate /// 构造浮动腿利率(all-in = 加点利差 + 指数定盘)。 public static FundingLegRate Floating(decimal spread, decimal indexFixing) => new(spread + indexFixing); + + /// 从 swap_position 构造:固定腿→Fixed(spread),浮动腿→Floating(spread+fixing)。 + public static FundingLegRate Build(swap_position position, decimal spread, decimal effectiveFloat) + => string.IsNullOrEmpty(position.FloatRateUnderlyingCode) + ? Fixed(spread) + : Floating(spread, effectiveFloat); } 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/ClosePercentMath.cs b/YLErpDAL/Modules/SwapModule/ClosePercentMath.cs new file mode 100644 index 00000000..9269a449 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/ClosePercentMath.cs @@ -0,0 +1,65 @@ +using YLErp.DBModels; + +namespace YLErp.Modules.SwapModule; + +/// +/// 平仓比例(ClosePercent) 数学——占期初(A) / 占剩余(B) 两种口径的转换。 +/// 从 SwapDealService 提取为共享模块,两个 service 均可引用。 +/// +public static class ClosePercentMath +{ + /// + /// 取上一日终的浮动端名义本金(orginPv 的来源)。 + /// 优先取浮动腿 PosiNotionalValue 之和,取不到用 eod_swap 多空绝对值之和,都没有用 currentNotional 兜底。 + /// + public static decimal ResolveUnwindPreviousNotional( + eod_swap lastEod, + IEnumerable lastEodPositions, + decimal currentNotional) + { + var floatingPositions = lastEodPositions?.Where(x => x.PosiDirection > 0).ToList(); + decimal previousNotional; + if (floatingPositions?.Count > 0) + { + previousNotional = floatingPositions.Sum(x => x.PosiNotionalValue); + } + else + { + previousNotional = lastEod == null + ? currentNotional + : Math.Abs(lastEod.NotionalValueLong) + Math.Abs(lastEod.NotionalValueShort); + } + + return previousNotional == 0m && currentNotional != 0m + ? currentNotional + : previousNotional; + } + + /// + /// A(占期初) → B(占剩余),用于把前端传入的占期初比例换算成后端计算用的占剩余比例。 + /// + public static decimal ToRemainingClosePercent(decimal originalClosePercent, decimal notionalValue, decimal posiNotionalValue) + { + if (posiNotionalValue <= 0) return originalClosePercent; + var remaining = originalClosePercent * notionalValue / posiNotionalValue; + return remaining > 1 ? 1 : remaining; + } + + /// + /// B(占剩余) → A(占期初),用于落库 / 事件列表展示还原。见 ToRemainingClosePercent。 + /// + public static decimal ToOriginalClosePercent(decimal remainingClosePercent, decimal notionalValue, decimal posiNotionalValue) + { + if (notionalValue <= 0) return remainingClosePercent; + return remainingClosePercent * posiNotionalValue / notionalValue; + } + + /// + /// 计算 InitUnwind 默认占期初(A)平仓比例 = PosiNotionalValue / NotionalValue。 + /// 未平仓时 =1(平100%);部分平仓后自动变为剩余比例。 + /// + public static decimal CalcDefaultInitClosePercent(decimal notionalValue, decimal posiNotionalValue) + { + return notionalValue > 0 ? posiNotionalValue / notionalValue : 1; + } +} diff --git a/YLErpDAL/Modules/SwapModule/EodPnlCalculator.cs b/YLErpDAL/Modules/SwapModule/EodPnlCalculator.cs new file mode 100644 index 00000000..3532be45 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/EodPnlCalculator.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using YLErp; +using YLErp.DBModels; +using YLErp.DBModels.Enums; +using YLErp.Modules.SwapModule.ReturnLegs; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 互换日终盈亏/精度计算纯函数集合。 + /// 自 SwapEodPositionService 抽出,支持无库单测;同类内部调用无需前缀。 + /// + public static class EodPnlCalculator + { + // 日终利息待实现需跨日累计,按表设计保留 12 位;已实现结算仍按金额两位处理。 + private const int EodInterestStoragePrecision = 12; + + internal static decimal RoundMoney(decimal value) + { + return Math.Round(value, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + } + + internal static decimal RoundEodInterest(decimal value) + { + return Math.Round(value, EodInterestStoragePrecision, MidpointRounding.AwayFromZero); + } + + /// + /// 仅在写入 eod_swap_position 前统一快照精度。 + /// 浮动腿收益最终以金额两位展示和存储;利息腿的待实现、计息基数及利率保留 12 位, + /// 使部分结算后的尾差可继续参与后续计息。 + /// + internal static void NormalizeEodPositionForStorage(eod_swap_position position) + { + if (string.IsNullOrEmpty(position.UnderlyingCode)) + { + // 利息腿没有标的代码:待实现字段保留高精度,已实现结算字段收敛到金额两位。 + position.InterestPrincipalFix = RoundEodInterest(position.InterestPrincipalFix); + position.InterestRateDefault = RoundEodInterest(position.InterestRateDefault); + position.InterestFeePending = RoundEodInterest(position.InterestFeePending); + position.TdInterestPrincipal = RoundEodInterest(position.TdInterestPrincipal); + position.TdInterestRate = RoundEodInterest(position.TdInterestRate); + position.TdInterestIncome = RoundEodInterest(position.TdInterestIncome); + position.TdInterestFee = RoundEodInterest(position.TdInterestFee); + position.InterestIncomeSum = RoundEodInterest(position.InterestIncomeSum); + position.InterestFeeSum = RoundEodInterest(position.InterestFeeSum); + position.InterestProfitSum = RoundEodInterest(position.InterestProfitSum); + position.FloatRate = RoundEodInterest(position.FloatRate); + position.SwapPositionValue = RoundEodInterest(position.SwapPositionValue); + position.TdCloseInterest = RoundMoney(position.TdCloseInterest); + position.TdCloseInterestFee = RoundMoney(position.TdCloseInterestFee); + position.RealizedInterest = RoundMoney(position.RealizedInterest); + position.RealizedInterestFee = RoundMoney(position.RealizedInterestFee); + } + else + { + // 浮动腿有标的代码:其损益作为金额结果落库,统一按两位四舍五入。 + position.TdPosiDividend = RoundMoney(position.TdPosiDividend); + position.PosiMtmPnL = RoundMoney(position.PosiMtmPnL); + position.PosiDividendSum = RoundMoney(position.PosiDividendSum); + position.PosiFeePending = RoundMoney(position.PosiFeePending); + position.PosiProfitSum = RoundMoney(position.PosiProfitSum); + position.TdCloseMtmPnl = RoundMoney(position.TdCloseMtmPnl); + position.TdCloseDividend = RoundMoney(position.TdCloseDividend); + position.TdCloseFee = RoundMoney(position.TdCloseFee); + position.RealizedMtmPnL = RoundMoney(position.RealizedMtmPnL); + position.RealizedDividend = RoundMoney(position.RealizedDividend); + position.RealizedFee = RoundMoney(position.RealizedFee); + position.SwapPositionValue = RoundMoney(position.SwapPositionValue); + } + position.RealizedPnl = RoundMoney(position.RealizedPnl); + } + + /// + /// 浮动腿累计已实现盈亏由盯市、分红和费用三个已实现组成项汇总。 + /// 各组成项已经按本方视角落库,此处不再额外转换方向。 + /// + internal static void SetFloatingRealizedPnl(eod_swap_position position) + { + position.RealizedPnl = position.RealizedMtmPnL + + position.RealizedDividend + + position.RealizedFee; + } + + /// + /// 汇总单条日终腿的我方已实现收益。 + /// 浮动腿及普通利息腿维持数据库记录的方向;初始/追加预付金腿的利息 + /// 则与保证金本金方向相反。这样“收取对手方保证金”产生的利息会作为 + /// 我方支付给对手方的成本计入,而不会错误增加框架合约已实现收益。 + /// 抽为静态纯函数以支持无库单测(marginTypes 等价于 ConsTrade.InterestMarginModels)。 + /// + public static decimal CalculateSwapRealizedPnl(eod_swap_position position) + { + var interestRatio = DirectionRatio.InterestLegPnl(position.InterestDirection, position.InterestMode); + + return position.RealizedMtmPnL + + position.RealizedDividend + + position.RealizedFee + + position.RealizedInterest * interestRatio + + position.RealizedInterestFee; + } + + /// 填充框架合约的持仓腿汇总字段(多空名义本金/市值/浮动盈亏/dv01/平仓量)。 + /// SaveEodSwap 与 UpdateEodSwap 共用,消除 ~10 行重复。 + internal static void FillPositionLegSummary(eod_swap eod_Swap, List positions) + { + eod_Swap.NotionalValueLong = Math.Round(positions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.PosiNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + eod_Swap.NotionalValueShort = Math.Round(-Math.Abs(positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue)), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + eod_Swap.MarketValueLong = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.UnderlyingMarketValue); + eod_Swap.MarketValueShort = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.UnderlyingMarketValue); + eod_Swap.FloatingPnL = positions.Sum(s => s.PosiProfitSum); + eod_Swap.dv01 = positions.Sum(s => s.dv01 ?? 0); + eod_Swap.TdCloseQty = positions.Sum(s => s.TdCloseQty); + } + + /// 利息腿 PnL 汇总(按方向比例 + 保证金翻转)。原 SaveEodSwap/UpdateEodSwap 各一段 ForEach。 + internal static decimal SumInterestPnL(List interestPositions) + { + decimal interestPnL = 0; + foreach (var x in interestPositions) + interestPnL += x.InterestProfitSum * DirectionRatio.InterestLegPnl(x.InterestDirection, x.InterestMode); + return interestPnL; + } + + /// + /// 风险报表符号归一化:把历史两种符号口径的 TdCloseInterest/RealizedInterest + /// 统一按"绝对金额 × 业务方向"重写。普通利息腿收取为正、支付为负; + /// 预付金腿利息方向与保证金本金方向相反。随后重算 RealizedPnl。 + /// 抽为 public static 纯函数以支持无库单测(见 SwapReportInterestSignNormalizeTest)。 + /// 仅当 InterestDirection > 0 时执行(与原内联逻辑等价)。 + /// + public static void NormalizeInterestSignForReport(eod_swap_position position) + { + if (position.InterestDirection <= 0) return; + + if (position.InterestMode == (int)InterestModeEnum.标的期初全价) + { + return; + } + var interestRatio = DirectionRatio.InterestLegPnl(position.InterestDirection, position.InterestMode); + position.TdCloseInterest = Math.Abs(position.TdCloseInterest) * interestRatio; + position.RealizedInterest = Math.Abs(position.RealizedInterest) * interestRatio; + // 兼容修复前已落库的利息腿:当时只累计了明细字段,未同步写入 RealizedPnl。 + position.RealizedPnl = position.RealizedInterest + position.RealizedInterestFee; + } + + /// + /// 计算预付金利率。多条初始/追加预付金腿按本金绝对值加权, + /// 不按收付方向轧差,避免相反方向本金抵消后放大利率。 + /// + internal static decimal CalculateWeightedMarginRate(IEnumerable margins) + { + var marginList = margins.ToList(); + var totalWeight = marginList.Sum(x => Math.Abs(x.InterestPrincipalFix)); + return totalWeight == 0 + ? 0 + : marginList.Sum(x => x.InterestRateDefault * Math.Abs(x.InterestPrincipalFix)) / totalWeight; + } + + /// + /// 计算预付金利息金额。InterestIncomeSum 已是各腿利息金额, + /// 按收取为正、支付为负直接轧差求和,不做本金加权。 + /// 抽为 public static 纯函数以支持无库单测(见 SwapWeightedMarginInterestTest)。 + /// + public static decimal CalculateWeightedMarginInterest(IEnumerable margins) + { + return margins.Sum(x => + x.InterestIncomeSum * DirectionRatio.ReceivePay(x.InterestDirection)); + } + + /// + /// 固定利息腿的累计已实现盈亏 = 累计已实现利息 + 累计已实现利息费用。 + /// 4 处 SaveAutoEodInterestPosition/SaveEodInterestPosition 路径口径一致, + /// 抽为 public static 纯函数以支持无库单测(见 SwapFixedLegRealizedPnlTest), + /// 并消除复制粘贴带来的笔误风险(如 L1296 历史双分号)。 + /// + public static void SetFixedLegRealizedPnl(eod_swap_position position) + { + position.RealizedPnl = position.RealizedInterest + position.RealizedInterestFee; + } + } +} 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/ContractNotionalLeg.cs b/YLErpDAL/Modules/SwapModule/FundingLegs/ContractNotionalLeg.cs index f1d62dd0..97e6faea 100644 --- a/YLErpDAL/Modules/SwapModule/FundingLegs/ContractNotionalLeg.cs +++ b/YLErpDAL/Modules/SwapModule/FundingLegs/ContractNotionalLeg.cs @@ -11,6 +11,6 @@ public sealed class ContractNotionalLeg : IFundingLegStrategy { public InterestModeEnum Mode => InterestModeEnum.合约名义本金规模; - public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal posiLong, decimal posiShort, decimal closePercent) + public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal closePercent) => new(posiNotional * closePercent, posiNotional, closePercent); } diff --git a/YLErpDAL/Modules/SwapModule/FundingLegs/FixedAmountLeg.cs b/YLErpDAL/Modules/SwapModule/FundingLegs/FixedAmountLeg.cs index 6e477b07..451384ec 100644 --- a/YLErpDAL/Modules/SwapModule/FundingLegs/FixedAmountLeg.cs +++ b/YLErpDAL/Modules/SwapModule/FundingLegs/FixedAmountLeg.cs @@ -12,6 +12,6 @@ public sealed class FixedAmountLeg : IFundingLegStrategy { public InterestModeEnum Mode => InterestModeEnum.固定值; - public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal posiLong, decimal posiShort, decimal closePercent) + public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal closePercent) => new(fix, fix, 1m); } diff --git a/YLErpDAL/Modules/SwapModule/FundingLegs/IFundingLegStrategy.cs b/YLErpDAL/Modules/SwapModule/FundingLegs/IFundingLegStrategy.cs index 2d29f8c0..9ef23074 100644 --- a/YLErpDAL/Modules/SwapModule/FundingLegs/IFundingLegStrategy.cs +++ b/YLErpDAL/Modules/SwapModule/FundingLegs/IFundingLegStrategy.cs @@ -22,10 +22,8 @@ public interface IFundingLegStrategy /// /// 合约固定本金(固定值/预付金腿用;其余腿忽略)。 /// 当前剩余名义本金(数量 × 全价)。 - /// 多头剩余名义本金(多空存续腿用,当前界面已禁用)。 - /// 空头剩余名义本金。 /// 平仓比例(占剩余,0~1)。 - NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal posiLong, decimal posiShort, decimal closePercent); + NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal closePercent); } /// diff --git a/YLErpDAL/Modules/SwapModule/FundingLegs/UnderlyingEntryFullPriceLeg.cs b/YLErpDAL/Modules/SwapModule/FundingLegs/UnderlyingEntryFullPriceLeg.cs index f78d9b74..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,13 +7,13 @@ 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 { public InterestModeEnum Mode => InterestModeEnum.标的期初全价; - public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal posiLong, decimal posiShort, decimal closePercent) + public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal closePercent) => new(posiNotional * closePercent, posiNotional, closePercent); } diff --git a/YLErpDAL/Modules/SwapModule/InterestCalcRequest.cs b/YLErpDAL/Modules/SwapModule/InterestCalcRequest.cs new file mode 100644 index 00000000..56e2a5cb --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/InterestCalcRequest.cs @@ -0,0 +1,91 @@ +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; } + + /// 是否计罚息(EQD-6977):利息端按持有至到期计息。由平仓页下拉经 UnwindData 透传;默认 false。 + public bool IsPenaltyInterest { 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, + bool isPenaltyInterest = false) + { + Td = td; TradeExtend = tradeExtend; ValueDate = valueDate; UnwindDate = unwindDate; + EodPositions = eodPositions; Positions = positions; + PosiNotionalValue = posiNotionalValue; ClosePosiNotionalValue = closePosiNotionalValue; + ClosePercent = closePercent; EventType = eventType; TdClose = tdClose; OrginPv = orginPv; + Add = add; NewCalcLast = newCalcLast; CloseList = closeList; + IsPenaltyInterest = isPenaltyInterest; + } + + /// + /// 【盘中平仓/互换结息】场景(→ 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, + bool isPenaltyInterest = false) + => new(td, tradeExtend, valueDate, unwindDate, eodPositions, positions, + preCloseNotional, closedNotional, closePercentRemaining, + eventType, tdClose, orginPv, add, newCalcLast, closeList, isPenaltyInterest); + + /// + /// 【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/InterestEodScenarioDispatch.cs b/YLErpDAL/Modules/SwapModule/InterestEodScenarioDispatch.cs new file mode 100644 index 00000000..3df6e857 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/InterestEodScenarioDispatch.cs @@ -0,0 +1,47 @@ +using YLErp.Models; + +namespace YLErp.Modules.SwapModule; + +/// +/// 利息腿 EOD 归档场景分派规格(纯函数集;表驱动单测见 InterestEodScenarioDispatchTest)。 +/// 优先级链(承重业务语义,不能乱序): +/// ① hasSwap(手工互换) → 按事件流水重新生成,压制观察日自动结息 +/// ② observationInterval(观察日) 存在 → 自动结息;同日有平仓 → autoSwap=true +/// ③ hasClose(纯平仓, 非观察日) → autoSwap=false +/// ④ 普通日 → 复制上一日终并计提当日新增 +/// 注意:hasSwap/hasClose 是交易级标志(整笔合约当天有无事件), +/// observationInterval 是腿级(本条利息腿当天是否观察日)——粒度不同,分派依赖此区分。 +/// 生产分派点:SwapEodPositionService.DealInterests(分派结构接线前为影子规格,见其分派处注释)。 +/// +public enum InterestEodScenario +{ + ManualSwap, // ① 手工互换:压制观察日自动结息 + AutoSettleWithClose, // ② 观察日 + 当日平仓 (autoSwap=true) + AutoSettle, // ② 观察日 + 当日无平仓 + CloseOnly, // ③ 非观察日 + 当日平仓 (autoSwap=false) + RollForward, // ④ 普通日滚动 +} + +public static class InterestEodScenarioDispatch +{ + /// 三分量布尔 → 场景(8 组合表驱动见 InterestEodScenarioDispatchTest)。 + public static InterestEodScenario ResolveInterestScenario(bool hasInterval, bool hasSwap, bool hasClose) + { + if (hasSwap) + return InterestEodScenario.ManualSwap; + if (hasInterval) + return hasClose ? InterestEodScenario.AutoSettleWithClose : InterestEodScenario.AutoSettle; + if (hasClose) + return InterestEodScenario.CloseOnly; + return InterestEodScenario.RollForward; + } + + /// + /// 查找利息腿在指定结算日的观察日信息(SwapIntervalList 中 Date==settleDate 且 Settlement==1 的记录)。 + /// 观察日即自动结息触发日;返回 null 表示当日非观察日。分派见 ResolveInterestScenario。 + /// + public static IntervalModel FindObservationInterval(swap_position interest, DateTime settleDate) + { + return interest.SwapIntervalList.FirstOrDefault(x => x.Date == settleDate && x.Settlement == 1); + } +} diff --git a/YLErpDAL/Modules/SwapModule/Margin/CashMargin.cs b/YLErpDAL/Modules/SwapModule/Margin/CashMargin.cs deleted file mode 100644 index 128f34ba..00000000 --- a/YLErpDAL/Modules/SwapModule/Margin/CashMargin.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace YLErp.Modules.SwapModule.Margin; - -/// 现金保证金:余额 = 现金余额。 -public sealed class CashMargin : IMarginResolver -{ - public MarginForm Form => MarginForm.Cash; - - public MarginBalance Resolve(decimal postedAmount) - => new(postedAmount); -} diff --git a/YLErpDAL/Modules/SwapModule/Margin/CreditMargin.cs b/YLErpDAL/Modules/SwapModule/Margin/CreditMargin.cs deleted file mode 100644 index dd3c0032..00000000 --- a/YLErpDAL/Modules/SwapModule/Margin/CreditMargin.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace YLErp.Modules.SwapModule.Margin; - -/// 授信保证:余额 = 已用授信额度。 -public sealed class CreditMargin : IMarginResolver -{ - public MarginForm Form => MarginForm.Credit; - - public MarginBalance Resolve(decimal postedAmount) - => new(postedAmount); -} diff --git a/YLErpDAL/Modules/SwapModule/Margin/GuaranteeMargin.cs b/YLErpDAL/Modules/SwapModule/Margin/GuaranteeMargin.cs deleted file mode 100644 index 8506a687..00000000 --- a/YLErpDAL/Modules/SwapModule/Margin/GuaranteeMargin.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace YLErp.Modules.SwapModule.Margin; - -/// 担保品:余额 = 担保品市值。 -public sealed class GuaranteeMargin : IMarginResolver -{ - public MarginForm Form => MarginForm.Guarantee; - - public MarginBalance Resolve(decimal postedAmount) - => new(postedAmount); -} diff --git a/YLErpDAL/Modules/SwapModule/Margin/IMarginResolver.cs b/YLErpDAL/Modules/SwapModule/Margin/IMarginResolver.cs deleted file mode 100644 index 1e24f876..00000000 --- a/YLErpDAL/Modules/SwapModule/Margin/IMarginResolver.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace YLErp.Modules.SwapModule.Margin; - -/// 保证金形态:现金 / 授信 / 担保。预留扩展。 -public enum MarginForm -{ - /// 现金保证金:余额 = 现金余额。 - Cash, - /// 授信保证:余额 = 已用授信额度。 - Credit, - /// 担保品:余额 = 担保品市值。 - Guarantee, -} - -/// -/// 按保证金形态解析余额。三种形态可互换地产出一个 MarginBalance(满足 LSP), -/// 这是保证金领域唯一合理的多态点(差异仅在"余额如何取得")。 -/// 具体余额来源(资金流水 / 授信占用 / 担保估值)后续按形态填充。 -/// -public interface IMarginResolver -{ - MarginForm Form { get; } - - MarginBalance Resolve(decimal postedAmount); -} diff --git a/YLErpDAL/Modules/SwapModule/Margin/MarginAccount.cs b/YLErpDAL/Modules/SwapModule/Margin/MarginAccount.cs deleted file mode 100644 index fcdf3f14..00000000 --- a/YLErpDAL/Modules/SwapModule/Margin/MarginAccount.cs +++ /dev/null @@ -1,43 +0,0 @@ -using YLErp.Core.Interest; -using YLErp.Derivatives.Interest; - -namespace YLErp.Modules.SwapModule.Margin; - -/// -/// 保证金账户。管理保证金余额的变动(追加/释放/返还),并提供计息入口。 -/// -/// 保证金是独立的资金管理概念(初始保证金/维持保证金/保证金余额/追保), -/// 与融资腿(funding leg)完全无关。现有代码把保证金塞进 InterestMode==5/6 -/// 当计息腿处理是错误的,本类是正确建模的起点。 -/// -/// 利息计算委托 SwapInterest 纯函数(余额×利率×天数/年化), -/// 保证金账户只提供余额和计息入口,不自己实现计息算法。 -/// -public sealed class MarginAccount -{ - /// 当前保证金余额。 - public MarginBalance Balance { get; private set; } - - public MarginAccount(MarginBalance openingBalance) - => Balance = openingBalance; - - /// 追加保证金(余额增加)。 - public void Deposit(decimal amount) - => Balance = new MarginBalance(Balance.Balance + amount); - - /// 释放/返还保证金(余额减少,不低于 0)。 - public void Withdraw(decimal amount) - => Balance = new MarginBalance(Math.Max(0m, Balance.Balance - amount)); - - /// - /// 按当前余额计算保证金利息。委托 SwapInterest.AccrueSimple。 - /// 保证金利息是券商对客户保证金存款付息(方向与融资腿相反)。 - /// - /// 保证金利率(年化,如 0.03 = 3%)。 - /// 计息开始日。 - /// 计息结束日。 - /// 算头算尾规则。 - /// 年化天数(365 或 360)。 - public InterestResult AccrueInterest(decimal rate, System.DateTime startDate, System.DateTime endDate, AccrualBoundary boundary, int annualDays) - => SwapInterest.AccrueSimple(new AccrualContext(annualDays), Balance.Balance, rate, startDate, endDate, boundary); -} diff --git a/YLErpDAL/Modules/SwapModule/Margin/MarginBalance.cs b/YLErpDAL/Modules/SwapModule/Margin/MarginBalance.cs deleted file mode 100644 index 491de03c..00000000 --- a/YLErpDAL/Modules/SwapModule/Margin/MarginBalance.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace YLErp.Modules.SwapModule.Margin; - -/// -/// 保证金余额。现金、授信、担保等多种保证金形态的统一表达。 -/// -/// 保证金就是保证金——有余额、有利率、有利息,不存在"计息基数/Notional"概念。 -/// 余额随追加/释放/盈亏变动,利息由 SwapInterest 纯函数按 余额×利率×天数/年化 计算。 -/// -public readonly struct MarginBalance -{ - /// 保证金余额:现金余额 / 授信占用 / 担保品市值。 - public decimal Balance { get; } - - public MarginBalance(decimal balance) - => Balance = balance; -} diff --git a/YLErpDAL/Modules/SwapModule/Margin/MarginModes.cs b/YLErpDAL/Modules/SwapModule/Margin/MarginModes.cs index 359e3fc9..99346d4b 100644 --- a/YLErpDAL/Modules/SwapModule/Margin/MarginModes.cs +++ b/YLErpDAL/Modules/SwapModule/Margin/MarginModes.cs @@ -7,14 +7,18 @@ namespace YLErp.Modules.SwapModule.Margin; /// /// 保证金计息模式(mode 5 初始预付金 / mode 6 追加预付金)的统一判断口径。 /// -/// 现状(待收敛):同一集合 {初始预付金, 追加预付金} 在代码里复制了至少 6 次—— -/// ConsTrade.InterestMarginModels(框架级) -/// SwapEodPositionService.marginTypes(实例字段) -/// SwapEodPositionService.premiumModes(局部变量) -/// SwapEventEmailService.marginTypes -/// EodClientBalanceCalc.marginTypes -/// ClientBalanceUtility.marginTypes -/// 任何一处漏改(如新增保证金形态)都会导致口径分裂。本类收敛到单一来源。 +/// 依赖方向:本类位于 YLErpDAL 层,单一真源是框架层常量 +/// (YLErp.DBModels)。Core 不能反向依赖 DAL, +/// 故本类的集合直接由该框架常量派生(new HashSet/List),而非独立重写—— +/// 任何一处要新增保证金形态,只需改 ConsTrade.InterestMarginModels 即全局生效。 +/// +/// 收敛历史:早期同一集合 {初始预付金, 追加预付金} 在代码里被复制多次 +/// (ConsTrade.InterestMarginModels / SwapEodPositionService.marginTypes / +/// SwapEodPositionService.premiumModes / SwapEventEmailService.marginTypes / +/// EodClientBalanceCalc.marginTypes / ClientBalanceUtility.marginTypes)。 +/// 现余额/邮件/利息等入口已改用本类;SwapEodPositionService.premiumModes 局部变量 +/// 也已替换为 MarginModes.ForLinq。ConsTrade.InterestMarginModels 作为框架级常量保留 +/// (它是唯一真源,并非冗余)。 /// /// 注意:这里的"保证金 mode"是现有系统把保证金错误建模为计息腿的历史遗留。 /// 按 Margin 限界上下文的设计方向,未来保证金不应用 InterestMode 标识, @@ -22,34 +26,24 @@ namespace YLErp.Modules.SwapModule.Margin; /// public static class MarginModes { - /// 所有属于保证金的 InterestMode(初始预付金 / 追加预付金)。 - public static readonly IReadOnlyCollection All = new HashSet - { - (int)InterestModeEnum.初始预付金, - (int)InterestModeEnum.追加预付金, - }; + /// 所有属于保证金的 InterestMode(派生自 ConsTrade.InterestMarginModels)。 + public static readonly IReadOnlyCollection All = new HashSet(ConsTrade.InterestMarginModels); /// /// List 形态,供 EF Core LINQ 表达式用(HashSet.Contains 无法翻译成 SQL)。 - /// 替代 ConsTrade.InterestMarginModels。 + /// 内容派生自框架常量 ConsTrade.InterestMarginModels(单一真源),本类仅做形态适配。 /// - public static readonly List ForLinq = new() - { - (int)InterestModeEnum.初始预付金, - (int)InterestModeEnum.追加预付金, - }; + public static readonly List ForLinq = new List(ConsTrade.InterestMarginModels); /// 判断 mode 是否属于保证金(非 LINQ 场景用)。 public static bool Contains(int interestMode) => All.Contains(interestMode); /// 固定值 + 保证金 mode 集合(固定值/初始预付金/追加预付金)。 /// 用于 EOD 场景判断"计息基数取 InterestPrincipalFix 而非持仓名义本金"的腿。 - /// 替代 SwapEodPositionService 中 3 处内联 new List{固定值, 初始预付金, 追加预付金}。 - public static readonly IReadOnlyCollection FixedAmountAndMargin = new HashSet + /// 保证金部分派生自 ConsTrade.InterestMarginModels,固定值额外并入。 + public static readonly IReadOnlyCollection FixedAmountAndMargin = new HashSet(ConsTrade.InterestMarginModels) { (int)InterestModeEnum.固定值, - (int)InterestModeEnum.初始预付金, - (int)InterestModeEnum.追加预付金, }; /// 判断 mode 是否为固定值或保证金。 diff --git a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs new file mode 100644 index 00000000..7ca04a77 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs @@ -0,0 +1,148 @@ +using YLErp.Modules.SwapModule.Accrual; +using YLErp.Modules.SwapModule.FundingLegs; +using YLErp.Modules.SwapModule.ReturnLegs; + +namespace YLErp.Modules.SwapModule.Penalty; + +/// +/// EQD-6977 罚息接缝(纯函数,无 DB 依赖):把罚息金额**并入既有平仓利息流的 InterestFee(其他费用含罚息)**。 +/// 不产生独立罚息事件——前端其他费用列/盈亏公式(含 InterestFee)与日终 TdCloseInterestFee 链路天然承接。 +/// +/// 设计:上帝类(SwapDealService.GetIntradayUnwindInterests)仅注入三个外部依赖委托—— +/// getSpread(加点利差)/ getPreEod(上一日终快照行)/ tryGetFixing(定盘取价),本类零 DB 耦合、可 headless 单测。 +/// +/// 复利承接量(精确续接口径的关键)**必须取实际计息状态**,严禁冻结利率重放推导: +/// 承接① capitalized = max(0, preEod.TdInterestPrincipal×份额 − closePrincipal) —— 实际滚动复利基数中已并入部分; +/// 承接② carryIn = 正常平仓流实结 InterestAmount − ① —— 最近重置日后实际已计利息; +/// 无 preEod(首日平仓):①=0、②=实结金额。 +/// 逐腿全程 trace 落盘(SwapCalcTrace),供计算过程分析与错误定位。 +/// +public static class PenaltyInterestFeeMerger +{ + /// + /// 对每条融资腿:解析冻结利率 → 以实际计息状态推导承接量 → 计算罚息 → 并入该腿正常平仓利息事件的 InterestFee。 + /// 取不到冻结利率(浮动腿缺价且无 preEod)时跳过该腿(不阻断正常平仓),留 trace。 + /// + public static void Merge( + trade td, + List fundingPositions, + List interests, + DateTime unwindDate, + int annualDays, + bool unwindDaySettled, + bool maturityCalcLast, + decimal posiNotionalValue, + decimal closePosiNotionalValue, + decimal closePercent, + Func getSpread, + Func getPreEod, + Func tryGetFixing, + AccrualTrace? trace = null) + { + if (td.ExerciseDate == null) + { + trace?.Note("PENALTY|跳过 交易无到期日(ExerciseDate=null)"); + return; + } + var maturityDate = td.ExerciseDate.Value; + + foreach (var position in fundingPositions) + { + // 正常平仓利息流(GetInterests 刚产出)——承接②的事实源与罚息并入目标 + var normalEvent = interests.FirstOrDefault(x => x.PositionId == position.id); + if (normalEvent == null) + { + trace?.Note($"PENALTY|p{position.id} 跳过 无正常平仓利息流(意外:融资腿应有对应事件)"); + continue; + } + + // 复用 GetInterests 的本金口径(mode2 无条件覆盖 / mode9 全平兜底,见其根因位置注释) + var mode = (InterestModeEnum)position.InterestMode; + var r = FundingLegStrategyFactory.Get(mode) + .CalcNotional(position.InterestPrincipalFix, posiNotionalValue, closePercent); + decimal closePrincipal = r.ClosePrincipal; + if (mode == InterestModeEnum.合约名义本金规模 + || (mode == InterestModeEnum.标的期初全价 && posiNotionalValue == 0m)) + { + closePrincipal = closePosiNotionalValue; + } + var share = r.PosiPrincipal > 0m ? Math.Min(1m, closePrincipal / r.PosiPrincipal) : 1m; + var isCompound = position.InterestType == (int)InterestTypeEnum.复利; + + // 冻结利率:前一晚收盘在役利率优先(preEod.FloatRate),无快照再按取价日=unwindDate-1 所在区间取定盘 + FundingLegRate frozenRate; + string rateSource; + var preEod = getPreEod(position); + try + { + frozenRate = PenaltyLegRateResolver.ResolveFrozenRate( + position, getSpread(position), preEod?.FloatRate, unwindDate, + d => tryGetFixing(d, position.FloatRateUnderlyingCode)); + rateSource = preEod != null + ? $"preEod.FloatRate@{preEod.ValueDate:yyyy-MM-dd}" + : "定盘取价(unwindDate-1区间)"; + } + catch (Exception ex) + { + trace?.Note($"PENALTY|p{position.id} 跳过 冻结利率解析失败:{ex.Message}"); + continue; + } + + // 复利承接:实际滚动基数中已并入部分(①)+ 段内实际已计利息(②)。单利无并本金语义恒 0。 + // ① 的取值依赖平仓日是否为重置日、有无日终快照(数据契约): + // 段中平仓 + 有快照:TdInterestPrincipal 即当前段滚动基数(=本金+①),直接作差; + // 段中平仓 + 无快照:兜底取 normalEvent.InterestPrincipal——复利重放(CalcDailyCompoundInterest) + // 会把它写为末次并本金后的基数(=被平份额本金+①),同样是实际值而非推导值; + // 重置日当天平仓:快照基数仍是【上一段】的(今日并入尚未发生),须改取 + // preEod.InterestIncomeSum(昨日全部待实现利息 = 今日并入新段基数的那部分)。 + decimal capitalized = 0m, carryIn = 0m; + if (isCompound) + { + var periodDays = position.interest_rest_days ?? 1; + var unwindOnResetDay = SwapDealService.IsResetDay(unwindDate, position.PosiStartDate, periodDays); + if (unwindOnResetDay) + { + capitalized = (preEod?.InterestIncomeSum ?? 0m) * share; + if (preEod == null && (unwindDate - position.PosiStartDate).Days >= periodDays) + trace?.Note($"PENALTY|p{position.id} 注意 无preEod且平仓日=重置日:①退化0(此前重置并入额缺失,请核对日终归档完整性)"); + } + else if (preEod != null) + { + capitalized = Math.Max(0m, preEod.TdInterestPrincipal * share - closePrincipal); + } + else + { + capitalized = Math.Max(0m, normalEvent.InterestPrincipal - closePrincipal); + } + // ① 不得超过实结金额(数据异常时钳制并留痕,避免负②进入计息) + if (capitalized > Math.Max(0m, normalEvent.InterestAmount)) + { + trace?.Note($"PENALTY|p{position.id} 注意 承接①钳制:推导 {capitalized:F4} > 实结 {normalEvent.InterestAmount:F4}(快照/事件数据异常,请核对 preEod.TdInterestPrincipal/InterestIncomeSum)"); + capitalized = Math.Max(0m, normalEvent.InterestAmount); + } + carryIn = normalEvent.InterestAmount - capitalized; + } + + var policy = AccrualPolicy.BuildEod(position, annualDays, isCompound); + // 锚点 = PosiStartDate:与正常计息重放(CalcDailyCompoundInterest 的分段网格)一致,延期腿勿用 td.StartDate + var penalty = SwapPenaltyInterestCalculator.CalcPenaltyAmount( + position, closePrincipal, unwindDate, maturityDate, + unwindDaySettled, maturityCalcLast, + capitalized, carryIn, + frozenRate, policy, position.PosiStartDate, trace); + penalty = InterestMath.Round(penalty, InterestMath.FundingLegPrecision); + + var feeBefore = normalEvent.InterestFee; + normalEvent.InterestFee += penalty; + normalEvent.InterestClosePnL += penalty * DirectionRatio.ReceivePay(position.InterestDirection); + + trace?.Note( + $"PENALTY|p{position.id} 完成 mode={mode} {(isCompound ? "复利" : "单利")} " + + $"窗口=[{unwindDate:yyyy-MM-dd}→{maturityDate:yyyy-MM-dd}] 平仓日已结={unwindDaySettled} 到期算尾={maturityCalcLast} | " + + $"本金 close={closePrincipal:F2} posi={r.PosiPrincipal:F2} share={share:P4} | " + + $"冻结利率={frozenRate.AllInRate:P6} 来源={rateSource} | " + + $"承接①={capitalized:F4} ②={carryIn:F4} 实结={normalEvent.InterestAmount:F4} | " + + $"罚息={penalty:F2} → InterestFee {feeBefore:F2}→{normalEvent.InterestFee:F2} PnL含罚息={normalEvent.InterestClosePnL:F2}"); + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyLegRateResolver.cs b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyLegRateResolver.cs new file mode 100644 index 00000000..cb7bed57 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyLegRateResolver.cs @@ -0,0 +1,48 @@ +using YLErp.Derivatives.Interest; +using YLErp.Modules.SwapModule.Accrual; + +namespace YLErp.Modules.SwapModule.Penalty; + +/// +/// EQD-6977 罚息冻结利率解析(纯函数)。 +/// +/// 规则(需求 2.2.2):剩余期限利率冻结为「最后一个重置区间」的 FR007 定盘值—— +/// 终止日恰为重置日且下午已出新价时,仍取上一重置区间(边缘场景显式落地)。 +/// +/// 冻结来源优先级: +/// 1. preEod.FloatRate——上一日终快照即昨日「实际在役」利率(GetFloatRate 非重置日正是沿用它), +/// 天然覆盖重置日下午边缘;且避开 td.StartDate / PosiStartDate 双锚点推导(见 GetFloatDate 锚点注记); +/// 2. 无 preEod(首日平仓等):取价日 = GetFixingDate(unwindDate-1)(-1 所在重置区间的定盘, +/// interest_rule 0=当前营业日/-1=前一营业日由 IndexFixerBase 统一处理)。 +/// 固定腿利率本即冻结,直接 Fixed;剩余期限的加点利差由调用方按 SwapIntervalList 取 as-of 平仓日值传入。 +/// +public static class PenaltyLegRateResolver +{ + /// + /// 解析罚息窗口的冻结 all-in 利率。 + /// + /// 利息腿(融资腿,非保证金) + /// 加点利差(调用方按 SwapIntervalList 取 as-of unwindDate 值,同 GetFixedRate 口径) + /// 上一日终快照 FloatRate;无 preEod 传 null + /// 提前终止日 + /// 定盘取价委托(测试可注入);入参=取价日,无价返回 null + public static FundingLegRate ResolveFrozenRate( + swap_position position, + decimal spread, + decimal? preEodFloatRate, + DateTime unwindDate, + Func tryGetFixing) + { + if (string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) + return FundingLegRate.Fixed(spread); + + if (preEodFloatRate.HasValue) + return FundingLegRate.Floating(spread, preEodFloatRate.Value); + + var fixingDate = IndexFixerBase.GetFixingDate(unwindDate.AddDays(-1), position.interest_rule); + var fixing = tryGetFixing(fixingDate); + if (!fixing.HasValue) + throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fixingDate:yyyy年MM月dd日}的价格"); + return FundingLegRate.Floating(spread, fixing.Value); + } +} diff --git a/YLErpDAL/Modules/SwapModule/Penalty/SwapPenaltyInterestCalculator.cs b/YLErpDAL/Modules/SwapModule/Penalty/SwapPenaltyInterestCalculator.cs new file mode 100644 index 00000000..a2e03d05 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/Penalty/SwapPenaltyInterestCalculator.cs @@ -0,0 +1,137 @@ +using YLErp.Modules.SwapModule.Accrual; + +namespace YLErp.Modules.SwapModule.Penalty; + +/// +/// EQD-6977 平仓罚息计算器(纯函数)——返回罚息金额。 +/// +/// 口径(2026-08-20 裁定,评审 11.5):**精确续接**。唯一近似 = 未来 FR007 不可得—— +/// 剩余窗口一律用「前一晚收盘在役利率」(preEod.FloatRate,由 PenaltyLegRateResolver 解析); +/// 其余与正常到期计息**丝毫不能差**:每 7 天重置节奏照旧、并本金照旧、单/复利走同一套 +/// Accrual 纯函数。金标准恒等式(验收基准): +/// +/// 全期利息 = 平仓日已结利息(正常平仓流) + 罚息金额(本方法) +/// +/// 边界规格(经金标准恒等式测试钉死): +/// - IncludeStart = !unwindDaySettled:正常结算已计平仓日(算尾)→ 罚息自次日起;不算尾 → 含平仓日; +/// - IncludeEnd = maturityCalcLast:到期日沿用交易自身算尾约定(非本次平仓的 newCalcLast)。 +/// +/// 复利承接(平仓日落在重置段中间时与全期轨迹逐日对齐的两个量,**必须来自实际计息状态**, +/// 由调用方 PenaltyInterestFeeMerger 从 preEod 快照与正常平仓流实结金额推导——严禁冻结利率重放推导, +/// FR007 有真实利率历史时重放值必偏): +/// - capitalizedInterest = 已并入最近重置日的累计利息 → 窗口首日起即加入计息基数; +/// - carryInInterest = 最近重置日之后已计至平仓日的利息 → 首个窗口重置日并入并持续留在基数。 +/// 二者之和 = 被平部分的平仓日实结利息(正常平仓流 InterestAmount)。 +/// +/// 产物为金额,由接缝并入既有利息事件的 InterestFee(其他费用含罚息);不产生独立罚息事件。 +/// 仅融资腿;保证金腿(MarginModes)与浮动端 P&L 不进入本模块。 +/// +public static class SwapPenaltyInterestCalculator +{ + /// + /// 计算罚息窗口 [unwindDate, maturityDate] 的罚息金额。 + /// + /// 被平的融资腿 + /// 被平部分计息本金(部分平仓仅算被平份额) + /// 提前终止日(窗口起点) + /// 合约原始到期日(窗口终点,= td.ExerciseDate) + /// 正常平仓利息是否已计平仓日(effectiveCalcLast = calcLast || newCalcLast) + /// 交易到期日算尾约定(tradeExtend.CalcLast) + /// 复利承接①:已并入最近重置日的累计利息(被平份额),窗口首日起即入基数;单利传 0 + /// 复利承接②:最近重置日后已计至平仓日的利息(被平份额),首个窗口重置日并入;单利传 0 + /// 冻结利率(PenaltyLegRateResolver.ResolveFrozenRate 产物 = 前一晚收盘在役利率) + /// 计息政策(单复利/重置周期/年化天数;Convention 由本方法覆盖) + /// 重置日锚点 = position.PosiStartDate(与正常计息重放网格一致,勿用 td.StartDate) + /// 计息轨迹(可选,SwapCalcTrace 落盘) + public static decimal CalcPenaltyAmount( + swap_position position, + decimal closePrincipal, + DateTime unwindDate, + DateTime maturityDate, + bool unwindDaySettled, + bool maturityCalcLast, + decimal capitalizedInterest, + decimal carryInInterest, + FundingLegRate frozenRate, + AccrualPolicy policy, + DateTime resetAnchor, + AccrualTrace? trace = null) + { + var boundary = AccrualBoundary.Of(includeStart: !unwindDaySettled, includeEnd: maturityCalcLast); + var allInRate = frozenRate.AllInRate; + + return policy.IsCompound + ? AccrueCompound(closePrincipal, capitalizedInterest, carryInInterest, unwindDate, maturityDate, boundary, policy, resetAnchor, allInRate, trace) + : AccrueSimple(closePrincipal, unwindDate, maturityDate, boundary, policy, allInRate, trace); + } + + /// + /// 复利罚息计息:即使冻结利率为单值,也必须按重置日分段(并本金发生在分段边界),每段同一冻结利率。 + /// notional = 本金 + 已并入最近重置日的利息(capitalizedInterest):全期轨迹中当前重置段的滚动基数, + /// 段内每一天都在其上计息——平仓日落在段中间时与全期逐日对齐的关键。carryInInterest 在首个窗口重置日并入。 + /// + private static decimal AccrueCompound( + decimal closePrincipal, decimal capitalizedInterest, decimal carryInInterest, + DateTime unwindDate, DateTime maturityDate, AccrualBoundary boundary, AccrualPolicy policy, + DateTime resetAnchor, decimal allInRate, AccrualTrace? trace) + { + var segments = BuildFrozenSegments(unwindDate, maturityDate, policy.ResetPeriodDays, resetAnchor, allInRate); + var r = CompoundInterestAccrual.AccruePeriod( + notional: closePrincipal + capitalizedInterest, + segmentRates: segments, + startDate: unwindDate, + endDate: maturityDate, + boundary: boundary, + annualDays: policy.AnnualDays, + isAnnualized: policy.IsAnnualized, + resetCarryInterest: 0m, + realizedInterest: 0m, + unwindFraction: 1m, + finalBasis: out _, + trace: trace, + carryInInterest: carryInInterest); + return r.Accrued; + } + + /// + /// 单利罚息计息:无并本金语义,冻结利率即单段全程(需求 2.2.1 公式:利率 × 名义本金 × 剩余天数 / 计息基准)。 + /// + private static decimal AccrueSimple( + decimal closePrincipal, DateTime unwindDate, DateTime maturityDate, + AccrualBoundary boundary, AccrualPolicy policy, decimal allInRate, AccrualTrace? trace) + { + var r = SimpleInterestAccrual.AccruePeriod( + priorAccrued: 0m, + notional: closePrincipal, + unwindFraction: 1m, + segmentRates: new List<(DateTime StartDate, decimal Rate)> { (unwindDate, allInRate) }, + startDate: unwindDate, + endDate: maturityDate, + priorValueDate: unwindDate.AddDays(-1), + boundary: boundary, + annualDays: policy.AnnualDays, + isAnnualized: policy.IsAnnualized, + trace: trace); + return r.Accrued; + } + + /// + /// 复利冻结分段:段边界 = 窗口内重置日((d - anchor) % period == 0,对齐 IsResetDay 公式), + /// 每段填同一冻结利率。首段必为 (unwindDate, rate);[start,end] 含端点的重置日也生成段 + /// (末段起点==到期日时由 AccruePeriod 的边界决定是否计息)。 + /// + private static List<(DateTime StartDate, decimal Rate)> BuildFrozenSegments( + DateTime start, DateTime end, int periodDays, DateTime anchor, decimal rate) + { + if (periodDays <= 1) + return new List<(DateTime, decimal)> { (start, rate) }; + + var segments = new List<(DateTime StartDate, decimal Rate)> { (start, rate) }; + for (var d = start.AddDays(1); d <= end; d = d.AddDays(1)) + { + if (SwapDealService.IsResetDay(d, anchor, periodDays)) + segments.Add((d, rate)); + } + return segments; + } +} 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 cef8c391..5772c6ba 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; @@ -11,6 +10,7 @@ using YLErp.Modules.EodModule; using YLErp.Modules.SwapModule.Accrual; using YLErp.Modules.SwapModule.FundingLegs; using YLErp.Modules.SwapModule.Margin; +using YLErp.Modules.SwapModule.Penalty; using YLErp.Modules.SwapModule.ReturnLegs; using YLErp.Modules.TradeModule; using YLErp.Modules.TradeModule.DealModule; @@ -46,106 +46,17 @@ namespace YLErp.Modules.SwapModule /// 原 private 改 protected virtual,使测试 stub 可整体 override,规避内部 new SwapEventService 连库。 protected virtual long SaveSwapDeal(UnwindData unwindData, int eventType, int clientCashId, string eventResason = "", bool approve = false) { - NormalizeNotionalValues(unwindData); + UnwindNormalizer.NormalizeNotionalValues(unwindData); return SaveSwapDealInternal(unwindData, eventType, clientCashId, eventResason, approve); } - private static void NormalizeNotionalValues(UnwindData unwindData) - { - unwindData.NotionalValue = Math.Round(unwindData.NotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - unwindData.PosiNotionalValue = Math.Round(unwindData.PosiNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - unwindData.CloseNotionalValue = Math.Round(unwindData.CloseNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - } - - private static bool NormalizeFullCloseRequest(UnwindData unwindData) - { - if (unwindData.CloseMethod != (int)CloseMethodEnum.全部平仓 - && unwindData.ClosePercent < 1 - && !(unwindData.PositionQty > 0 && unwindData.CloseQty >= unwindData.PositionQty) - && !(unwindData.PosiNotionalValue > 0 && unwindData.CloseNotionalValue >= unwindData.PosiNotionalValue)) - { - return false; - } - - var closeQty = unwindData.CloseQty; - var closeNotionalValue = unwindData.CloseNotionalValue; - unwindData.ClosePercent = 1; - if (unwindData.PositionQty > 0) unwindData.CloseQty = unwindData.PositionQty; - if (unwindData.PosiNotionalValue > 0) unwindData.CloseNotionalValue = unwindData.PosiNotionalValue; - return closeQty != unwindData.CloseQty || closeNotionalValue != unwindData.CloseNotionalValue; - } - - private static void RecalculateNormalizedUnwindAmounts(UnwindData unwindData) - { - var floatLeg = unwindData.FlowEvents.FirstOrDefault(x => !string.IsNullOrEmpty(x.UnderlyingCode)); - if (floatLeg == null || floatLeg.PosiGrossPrice == 0) return; - - var input = new UnwindInput - { - Multiplier = ConsGlobal.InstrumentType.IsBond(floatLeg.UnderlyingInstrumentType) ? 100 : 1, - PosiGrossPrice = floatLeg.PosiGrossPrice, - TradingAmountAvg = floatLeg.TradingAmountAvg, - CloseQty = unwindData.CloseQty, - PositionQty = unwindData.PositionQty, - ContractSize = floatLeg.ContractSize, - CloseNotionalValue = unwindData.CloseNotionalValue, - PayDirection = floatLeg.PayDirection, - PositionType = floatLeg.PositionType, - TradingFee = floatLeg.TradingFee.ToString(), - TradingFeePending = floatLeg.TradingFeePending.ToString(), - DividendIn = floatLeg.DividendIn.ToString() - }; - foreach (var leg in unwindData.FlowEvents.Where(x => string.IsNullOrEmpty(x.UnderlyingCode))) - { - var target = MarginModes.Contains(leg.InterestMode) - ? input.MarginLegs - : input.InterestLegs; - target.Add(new LegInput { InterestClosePnL = leg.InterestClosePnL }); - } - - var result = FrontendCalcReference.CalcUnwind(input); - floatLeg.MarkClosePnl = result.MarkClosePnl; - unwindData.SwapCloseAmount = result.SwapCloseAmount; - unwindData.SwapRealizedPnL = result.SwapRealizedPnL; - unwindData.SwapMarginRebatePnl = result.SwapMarginRebatePnl; - } - - private static bool IsFullCloseAfterDeduction(UnwindData unwindData, double remainingNotional, double remainingQuantity) - { - return unwindData.ClosePercent == 1 || (remainingNotional == 0 && remainingQuantity == 0); - } - - // 待实现利息会进入 decimal(30,12) 日终快照;精度常量统一引用 SwapInterest.FundingLegPrecision,消除重复定义。 - private const int InterestCalculationPrecision = SwapInterest.FundingLegPrecision; - - /// - /// 手工平仓、手工互换及收益结算的利息事件按金额两位落库。 - /// 自动平仓保留原有计算与落库口径,不适用本阶段的手工结算规则。 - /// - private static bool NormalizeSettledInterestAmounts(IEnumerable flowEvents, int eventType, string eventReason) - { - if ((eventType != (int)SwapEventTypeEnum.平仓 && eventType != (int)SwapEventTypeEnum.互换) - || eventReason == "系统操作_自动平仓") - { - return false; - } - - foreach (var flowEvent in flowEvents.Where(x => string.IsNullOrEmpty(x.UnderlyingCode))) - { - // 只处理利息腿;浮动腿损益在日终快照入口统一按两位落库。 - flowEvent.InterestPrincipal = Math.Round(flowEvent.InterestPrincipal, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - flowEvent.InterestAmount = Math.Round(flowEvent.InterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - flowEvent.TdInterestAmount = Math.Round(flowEvent.TdInterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - flowEvent.InterestClosePnL = Math.Round(flowEvent.InterestClosePnL, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - flowEvent.InterestFee = Math.Round(flowEvent.InterestFee, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - } - return true; - } + // 待实现利息会进入 decimal(30,12) 日终快照;精度常量统一引用 InterestMath.FundingLegPrecision,消除重复定义。 + private const int InterestCalculationPrecision = InterestMath.FundingLegPrecision; // 客户现金在 SaveSwapDeal 之前创建,手工结算必须先收敛流水并重算汇总金额。 private void NormalizeManualSettlementAmounts(UnwindData unwindData, int eventType, string eventReason) { - if (!NormalizeSettledInterestAmounts(unwindData.FlowEvents, eventType, eventReason)) + if (!UnwindNormalizer.NormalizeSettledInterestAmounts(unwindData.FlowEvents, eventType, eventReason)) { return; } @@ -559,7 +470,7 @@ namespace YLErp.Modules.SwapModule public UnwindData InitUnwind(int tradeId) { var td = DbContext.trade.Find(tradeId); - var positions = FindActiveSwapPositions(tradeId); + 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.互换 }; @@ -615,6 +526,8 @@ namespace YLErp.Modules.SwapModule : Convert.ToDecimal(td.StockEqvNotional); unwindData.PositionQty = position != null ? position.PosiQuantity : Convert.ToDecimal(td.TradeAmount); unwindData.AnnualDays = tradeExtend == null ? 365 : tradeExtend.ExtendObj.AnnualDays; + // EQD-6977 罚息:平仓页「是否罚息」默认带出簿记值;以平仓时选择为准(可改),此处仅默认值 + unwindData.IsPenaltyInterest = tradeExtend != null && tradeExtend.ExtendObj.IsPenaltyInterest; unwindData.CloseMethod = (int)CloseMethodEnum.全部平仓; // 占期初(original)语义(A):默认"平掉剩余全部持仓" = 剩余名义本金/期初名义本金。 // 未平仓时 PosiNotionalValue==NotionalValue → 1(平100%);部分平仓后自动变为剩余比例(如已平10%则默认90%)。 @@ -634,13 +547,14 @@ namespace YLErp.Modules.SwapModule // DividendPending = "待结算分红收益"(仍挂在账上、未来才结的存量 = PosiDividendSum 全量口径, // 见 GetPreEodDividendSum 注释的口径论证;切勿改回硬0或分摊,会落库回归) decimal preEodDividendSum = GetPreEodDividendSum(tradeId, position.PositionId, dealDate); + 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; floatEvent.UnderlyingInstrumentType = position.UnderlyingInstrumentType; floatEvent.CloseFee = 0; floatEvent.BeforeCloseFee = oriPosition.PosiTradingFeePending; - floatEvent.TradingFee = CalcInitTradingFee(oriPosition, unwindData); + floatEvent.TradingFee = TradingFeeCalc.CalcInitTradingFee(oriPosition, unwindData); floatEvent.PosiTradingFeeUnit = oriPosition?.PosiTradingFeeUnit ?? 0; floatEvent.PosiFeeType = oriPosition?.PosiFeeType ?? 0; floatEvent.MarkClosePnl = 0; @@ -654,8 +568,8 @@ namespace YLErp.Modules.SwapModule floatEvent.PositionQty = 0; floatEvent.ContractSize = position.ContractSize; floatEvent.TradingAmount = floatEvent.Quantity * floatEvent.ContractSize; - var ratio = position.PosiDirection == (int)SwapDirectionEnum.收取 ? -1m : 1m; - floatEvent.TradingFeePending = CalcInitTradingFeePending(oriPosition, position, unwindData); + var ratio = -DirectionRatio.ReceivePay(position.PosiDirection); + floatEvent.TradingFeePending = TradingFeeCalc.CalcInitTradingFeePending(oriPosition, position, unwindData); floatEvent.DataState = (int)SwapFlowDateStateEnum.完成; floatEvent.InterestMode = position.InterestMode; floatEvent.ClientId = td.ClientId; @@ -665,37 +579,6 @@ namespace YLErp.Modules.SwapModule } return unwindData; } - private static decimal CalcInitTradingFee(swap_position oriPosition, UnwindData unwindData) - { - if (oriPosition == null || unwindData == null) - { - return 0; - } - - if (oriPosition.PosiFeeType == 1) - { - return Math.Round(oriPosition.PosiTradingFeeUnit * unwindData.CloseQty, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - } - - return Math.Round(oriPosition.PosiTradingFeeUnit / 100m * unwindData.CloseNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - } - - private static decimal CalcInitTradingFeePending(swap_position oriPosition, swap_position position, UnwindData unwindData) - { - if (oriPosition == null || unwindData == null || oriPosition.PosiTradingFeeUnit == 0) - { - return position?.PosiTradingFeePending ?? 0; - } - - var closeBase = oriPosition.PosiFeeType == 1 ? unwindData.CloseQty : unwindData.CloseNotionalValue; - var originalBase = oriPosition.PosiFeeType == 1 ? unwindData.NotionalQty : unwindData.NotionalValue; - if (originalBase <= 0) - { - return position?.PosiTradingFeePending ?? 0; - } - - return Math.Round(oriPosition.PosiTradingFeePending * closeBase / originalBase, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - } /// /// 校验上日是否收盘 /// @@ -733,56 +616,6 @@ namespace YLErp.Modules.SwapModule /// /// /// - public UnwindData InitLongShortUnwind(int tradeId, SwapEventTypeEnum eventTypeEnum) - { - var td = DbContext.trade.Find(tradeId); - if (td == null) - { - throw new ServiceException("未找到交易信息"); - } - var positions = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && x.IsInitial && !x.Invalid); - List eventTyps = new List() { (int)SwapEventTypeEnum.平仓, (int)SwapEventTypeEnum.互换, (int)SwapEventTypeEnum.自动互换 }; - var dealDate = valuedateBLL.ValueDate <= td.ExerciseDate.Value ? valuedateBLL.ValueDate : td.ExerciseDate.Value; - //CheckLastEod(dealDate, td.TradeDate.Value, tradeId); //去掉平仓收盘限制 - var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == tradeId); - td.trade_extend = tradeExtend; - var preDealDate = GetPreDealDate(tradeId, dealDate, eventTyps); - double stockEqvNotional = td.StockEqvNotional;//剩余名义本金 - var hasProcess = HasTradeProcess(); - swap_flow_event floatEvent = new swap_flow_event(); - UnwindData unwindData = new UnwindData(); - if (((valuedateBLL.SystemDate.CloseReCheck == 1) || (valuedateBLL.SystemDate.CloseReApprove == 1 && hasProcess)) && (td.TradeStatus == ConsTrade.平仓待复核 || td.TradeStatus == ConsTrade.互换待复核)) - { - var swapEvent = GetSwapEvent(tradeId, (int)eventTypeEnum); - if (swapEvent == null) - { - throw new Exception("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效"); - } - unwindData = swapEvent.unwindData; - } - else - { - unwindData.StartDate = td.TradeDate.Value; - if (preDealDate.HasValue) - { - unwindData.StartDate = preDealDate.Value; - } - unwindData.ValueDate = dealDate; - unwindData.UnwindDate = dealDate; - unwindData.PayDate = QdpCalendarHelper.GetNonHoliday(dealDate.AddDays(td.trade_extend.ExtendObj.SettlementRules)); - unwindData.SwapTradeId = tradeId; - unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0); - unwindData.NotionalQty = positions.Sum(s => s.PosiQuantity); - unwindData.PosiNotionalValue = Convert.ToDecimal(stockEqvNotional); - unwindData.PositionQty = 0;//平仓只做了结为0,互换用不上 - unwindData.AnnualDays = tradeExtend == null ? 365 : tradeExtend.ExtendObj.AnnualDays; - if (eventTypeEnum == SwapEventTypeEnum.平仓) - { - unwindData.FlowEvents = GetUnwindInterests(dealDate, unwindData.UnwindDate.Value, tradeId, 1, (int)SwapEventTypeEnum.平仓); - } - } - return unwindData; - } /// /// 平仓初始化 /// @@ -793,7 +626,7 @@ namespace YLErp.Modules.SwapModule { var checkEventTypes = new List() { (int)SwapEventTypeEnum.互换, (int)SwapEventTypeEnum.自动互换 }; var td = DbContext.trade.Find(tradeId); - var positions = FindActiveSwapPositions(tradeId); + 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); @@ -854,7 +687,9 @@ namespace YLErp.Modules.SwapModule floatEvent.PositionId = position.PositionId; // 方案C:分红收益改由上一收盘日 EOD PosiDividendSum 提供(单一可信源), // 前端 getDivindIn 不再覆盖;消除"期初持仓×totalInterest"对已平仓部分的重复计入。 - floatEvent.DividendIn = GetPreEodDividendSum(tradeId, position.PositionId, dealDate); + decimal preEodDividendSum = GetPreEodDividendSum(tradeId, position.PositionId, dealDate); + 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; floatEvent.CloseFee = 0; @@ -886,7 +721,7 @@ namespace YLErp.Modules.SwapModule /// 平仓比例 /// /// - public List GetUnwindInterests(DateTime valueDate, DateTime unwindDate, int tradeId, decimal closePercent, int eventType) + public List GetUnwindInterests(DateTime valueDate, DateTime unwindDate, int tradeId, decimal closePercent, int eventType, bool isPenaltyInterest = false) { List interests = new List(); if (closePercent > 1) @@ -902,33 +737,32 @@ 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); // 上一日终的浮动端本金 - var grossPrice = realPostitions.Where(x => x.PosiDirection > 0).FirstOrDefault()?.PosiGrossPrice; - var closeList = DbContext.swap_flow_event.Where(x => x.SwapTradeId == tradeId - && x.UnwindDate == unwindDate - && eventTypes.Contains(x.EventType) + var closeList = DbContext.swap_flow_event.Where(x => x.SwapTradeId == tradeId + && x.UnwindDate == unwindDate + && 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, + isPenaltyInterest)); return interests; } @@ -937,8 +771,8 @@ namespace YLErp.Modules.SwapModule /// 根因(多次部分平仓预付金返还错误):预付金腿(初始/追加)的"当前剩余本金"存于实时持仓 /// realPositions.InterestPrincipalFix,每次平仓由 UpdateInitalPosition 递减;而原始腿 /// origPositions(IsInitial=1)的 InterestPrincipalFix 恒为初始值。GetInterests 算 - /// closePrincipal = Fix × closePercent 与预付金计息基数 orginPv(InitSwapDealInterest) 时都读 - /// position.InterestPrincipalFix,若沿用原始腿,会在多次部分平仓后仍返还/计算初始本金(如始终 99000)。 + /// closePrincipal = Fix × closePercent 时读 position.InterestPrincipalFix,若沿用原始腿, + /// 会在多次部分平仓后仍返还/计算初始本金(如始终 99000)。 /// 修复:迭代源仍用 origPositions(保留 orig.id → eod_swap_position.PositionId 的日终匹配, /// 全库实测 eod 均按 orig.id 归档;若换 realPositions 会破坏 preEod 匹配导致利息重算错误),仅对预付金腿 /// Clone 覆盖其本金值为实时腿的剩余本金。real 与 orig 通过 real.PositionId == orig.id 精确 1:1 关联。 @@ -1019,24 +853,7 @@ namespace YLErp.Modules.SwapModule eod_swap lastEod, IEnumerable lastEodPositions, decimal currentNotional) - { - var floatingPositions = lastEodPositions?.Where(x => x.PosiDirection > 0).ToList(); - decimal previousNotional; - if (floatingPositions?.Count > 0) - { - previousNotional = floatingPositions.Sum(x => x.PosiNotionalValue); - } - else - { - previousNotional = lastEod == null - ? currentNotional - : Math.Abs(lastEod.NotionalValueLong) + Math.Abs(lastEod.NotionalValueShort); - } - - return previousNotional == 0m && currentNotional != 0m - ? currentNotional - : previousNotional; - } + => ClosePercentMath.ResolveUnwindPreviousNotional(lastEod, lastEodPositions, currentNotional); /// /// 获取利息腿"已通过历史互换结出的累计利息"(用于复利重算时扣除,类比分红的 CalcConsumedDividend)。 @@ -1067,14 +884,53 @@ namespace YLErp.Modules.SwapModule /// 上一日终持仓 /// 期初利率端 /// 持仓名义本金 - /// 多头持仓名义本金 - /// 空头持仓名义本金 /// 平仓名义本金 /// /// /// /// /// + /// + /// 【盘中平仓/互换结息】显式入口——GetInterests(settment:false) 盘中语义的具名封装(2026-08 显式化重构)。 + /// 语义契约见 InterestCalcRequest.IntradayUnwind 工厂注释;计息走 CalcUnwindInterest 全区间重放。 + /// + public List GetIntradayUnwindInterests(InterestCalcRequest req) + { + var interests = 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); + // EQD-6977 罚息:GetInterests 返回后将罚息金额并入既有利息流的 InterestFee(其他费用含罚息)。 + // 仅手动平仓(isPenaltyInterest)且事件类型为平仓时触发;互换结现路径不带罚息。 + if (req.IsPenaltyInterest && req.EventType == (int)SwapEventTypeEnum.平仓) + MergePenaltyIntoFee(req, interests); + return interests; + } + + /// + /// EQD-6977 罚息接缝(委托注入 + 轨迹落盘):在 GetInterests 返回后把罚息金额并入 + /// 各融资腿正常平仓利息事件的 InterestFee(不产生独立罚息事件)。 + /// 仅在此处耦合上帝类的利率解析(GetFixedRate / IndexFixer)与轨迹常驻落盘(SwapCalcTrace.Write), + /// 其余罚息计息数学全部下沉至 Penalty 模块,保持上帝类最小侵入。 + /// + private void MergePenaltyIntoFee(InterestCalcRequest req, List interests) + { + var fundingPositions = req.Positions.Where(p => !MarginModes.Contains(p.InterestMode)).ToList(); + var annualDays = req.TradeExtend == null ? 365 : req.TradeExtend.ExtendObj.AnnualDays; + var calcLast = req.TradeExtend?.ExtendObj.CalcLast ?? true; + var trace = new AccrualTrace(); + PenaltyInterestFeeMerger.Merge( + req.Td, fundingPositions, interests, req.UnwindDate, annualDays, + unwindDaySettled: calcLast || req.NewCalcLast, + maturityCalcLast: calcLast, + req.PosiNotionalValue, req.ClosePosiNotionalValue, req.ClosePercent, + getSpread: p => GetFixedRate(p, req.UnwindDate), + getPreEod: p => req.EodPositions.FirstOrDefault(x => x.PositionId == p.id), + tryGetFixing: (d, code) => IndexFixer.TryGetFixing(d, code, out decimal r) ? (decimal?)r : null, + trace: trace); + SwapCalcTrace.Write(trace); // 与既有 4 处 SwapCalcTrace.Write 同款常驻落盘 + } + public List GetInterests( trade td, trade_extend tradeExtend, @@ -1083,14 +939,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, @@ -1099,8 +951,10 @@ namespace YLErp.Modules.SwapModule { List interests = new List(); var annualDays = tradeExtend == null ? 365 : tradeExtend.ExtendObj.AnnualDays; - bool calcFirst = tradeExtend?.ExtendObj.InterestCalcMode?.StartsWith("1") ?? true; - bool calcLast = tradeExtend?.ExtendObj.InterestCalcMode.EndsWith("1") ?? true; + bool calcFirst = tradeExtend?.ExtendObj.CalcFirst ?? true; + bool calcLast = tradeExtend?.ExtendObj.CalcLast ?? true; + // 计息到尾日(含平仓场景覆盖):交易本身算尾 或 本次平仓指定算尾(newCalcLast) + bool effectiveCalcLast = calcLast || newCalcLast; foreach (var position in positions) { // 初始化持仓信息 @@ -1110,30 +964,34 @@ namespace YLErp.Modules.SwapModule // 计算计息区间 int interestPeriod = position.interest_rest_days ?? 1; - // true 跳过 不计利息; false 正常利息 - bool swap = InitInterestDate(unwindDate, preDealDate, td, tdClose, out DateTime startDate, out DateTime endDate); + // true=计息窗口为空(不计利息,利率与金额归零;典型触发=不算头首日/不算尾到期日回拨翻转, + // 判定只看日期窗口与事件类型无关;当日已结息日期相等时窗口非空,归零由下方 closeList 净额层处理) + bool interestWindowEmpty = InitInterestDate(unwindDate, preDealDate, td, tdClose, out DateTime startDate, out DateTime endDate); - // 计算名义本金 - decimal closePrincipal; - decimal posiPrincipal; - decimal newClosePercent = closePrecent; - var mode = (InterestModeEnum)position.InterestMode; + // 获取利率(保证金/融资腿共用:SwapIntervalList 取当日适用固定利率 + 精度收口) + decimal rate = Math.Round(GetFixedRate(position, unwindDate), InterestCalculationPrecision, MidpointRounding.AwayFromZero); // 做精度调整 原数据有精度误差 + // ── 边界隔离:保证金腿(5/6)在循环最外层路由,后续融资腿分支树不感知保证金概念 ── + // 有意跳过 GetFloatRate:CalcMarginInterest 纯固定利率(FundingLegRate.Fixed)且 FloatRate 恒 0, + // 浮动取价/回写对保证金无意义;即使脏数据填了 FloatRateUnderlyingCode 且缺价,也不应阻断保证金结算。 if (MarginModes.Contains(position.InterestMode)) { - // 保证金腿: 计息基数 = InterestPrincipalFix(保证金余额) - closePrincipal = position.InterestPrincipalFix * closePrecent; - posiPrincipal = position.InterestPrincipalFix; - } - else - { - // 融资腿(1/2/9): 走策略工厂 - var r = FundingLegStrategyFactory.Get(mode) - .CalcNotional(position.InterestPrincipalFix, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue, closePrecent); - closePrincipal = r.ClosePrincipal; - posiPrincipal = r.PosiPrincipal; - newClosePercent = r.ClosePercent; + positionClone.InterestDirection = MarginCalc.FlipDirection(position.InterestDirection); + // 保证金腿: 计息基数 = InterestPrincipalFix(保证金余额),无融资腿差分公式与 orginPv 维度 hack + interests.Add(CalcMarginInterest(td, valueDate, endDate, positionClone, rate, + position.InterestPrincipalFix * closePrecent, position.InterestPrincipalFix, + closePrecent, annualDays, calcFirst, effectiveCalcLast, preEodPosition, eventType, add, settment, interestWindowEmpty)); + continue; } + + // 计算名义本金(以下仅融资腿 1/2/9:走策略工厂) + var mode = (InterestModeEnum)position.InterestMode; + var r = FundingLegStrategyFactory.Get(mode) + .CalcNotional(position.InterestPrincipalFix, posiNotionalValue, closePrecent); + decimal closePrincipal = r.ClosePrincipal; + decimal posiPrincipal = r.PosiPrincipal; + decimal newClosePercent = r.ClosePercent; + // 根因位置:SwapEodPositionService.SaveAutoEodWithCloseInterestPosition 在平仓后收盘时传入 // “收盘后剩余本金 + closePercent=1”,与盘中“平仓前本金 + 实际关闭比例”不是同一语义。 // GetInterests 同时被盘中试算和 EOD 平仓后收盘调用:后者传入的 @@ -1142,20 +1000,14 @@ namespace YLErp.Modules.SwapModule // 模式2(合约名义本金规模)的本次结息本金必须始终是实际平仓额,因此无条件覆盖, // 否则会错误地用剩余 70 结算本次平掉的 30。模式9(标的期初全价)的部分平仓 // 仍保留既有的剩余/复利动态本金承接逻辑;仅最终全平时 posi=0,才覆盖以避免结息本金为 0。 - if ((InterestModeEnum)position.InterestMode == InterestModeEnum.合约名义本金规模 - || ((InterestModeEnum)position.InterestMode == InterestModeEnum.标的期初全价 + if (mode == InterestModeEnum.合约名义本金规模 + || (mode == InterestModeEnum.标的期初全价 && posiNotionalValue == 0m)) { closePrincipal = closePosiNotionalValue; } - if (MarginModes.Contains(position.InterestMode)) - { - positionClone.InterestDirection = MarginCalc.FlipDirection(position.InterestDirection); - } - // 获取利率 - decimal rate = Math.Round(GetFixedRate(position, unwindDate), InterestCalculationPrecision, MidpointRounding.AwayFromZero); // 做精度调整 原数据有精度误差 - decimal floatRate = GetFloatRate(position, preEodPosition, td.StartDate.Value, endDate, interestPeriod, swap, positionClone); + decimal floatRate = GetFloatRate(position, preEodPosition, td.StartDate.Value, endDate, interestPeriod, interestWindowEmpty, positionClone, effectiveCalcLast); // 根据场景计算利息 if (settment) @@ -1171,8 +1023,8 @@ namespace YLErp.Modules.SwapModule ? GetConsumedInterest(td.id, position.id, endDate) : 0m; interests.Add(CalcUnwindInterest(td, valueDate, endDate, positionClone, rate, floatRate, posiPrincipal, - closePrincipal, newClosePercent, annualDays, preEodPosition, eventType, add, swap, orginPv, calcFirst, - calcLast||newCalcLast, consumedInterest)); + closePrincipal, newClosePercent, annualDays, preEodPosition, eventType, add, interestWindowEmpty, orginPv, calcFirst, + effectiveCalcLast, consumedInterest)); } } //当日有平仓或互换记录时,避免重复结算 @@ -1192,7 +1044,7 @@ namespace YLErp.Modules.SwapModule item.InterestClosePnL = 0; } } - else if (!calcLast && !newCalcLast) + else if (!effectiveCalcLast) { // 平仓不算尾:扣除已结算的利息(算尾时利息已包含关闭日,无重叠) var closePnl = closeEvent.Sum(s => s.InterestClosePnL); @@ -1216,32 +1068,19 @@ namespace YLErp.Modules.SwapModule /// 分母为 0(无持仓等异常场景)时原样返回,避免除零。 /// public static decimal ToRemainingClosePercent(decimal originalClosePercent, decimal notionalValue, decimal posiNotionalValue) - { - if (posiNotionalValue <= 0) return originalClosePercent; - var remaining = originalClosePercent * notionalValue / posiNotionalValue; - return remaining > 1 ? 1 : remaining; - } + => ClosePercentMath.ToRemainingClosePercent(originalClosePercent, notionalValue, posiNotionalValue); /// /// B(占剩余) → A(占期初),用于落库 / 事件列表展示还原。见 ToRemainingClosePercent。 /// public static decimal ToOriginalClosePercent(decimal remainingClosePercent, decimal notionalValue, decimal posiNotionalValue) - { - if (notionalValue <= 0) return remainingClosePercent; - return remainingClosePercent * posiNotionalValue / notionalValue; - } + => ClosePercentMath.ToOriginalClosePercent(remainingClosePercent, notionalValue, posiNotionalValue); /// - /// 计算 InitUnwind 默认占期初(A)平仓比例 = "平掉剩余全部持仓"对应的占期初比例。 - /// 即:ClosePercent(A) = PosiNotionalValue / NotionalValue。 - /// 未平仓时 PosiNotionalValue==NotionalValue → 1(平100%); - /// 部分平仓后自动变为剩余比例(如已平 30% 则默认 0.7)。 - /// 与互换/提前终止 InitIncome 保持一致。抽出为纯函数以支持无库单测。 + /// 计算 InitUnwind 默认占期初(A)平仓比例 = PosiNotionalValue / NotionalValue。 /// public static decimal CalcDefaultInitClosePercent(decimal notionalValue, decimal posiNotionalValue) - { - return notionalValue > 0 ? posiNotionalValue / notionalValue : 1; - } + => ClosePercentMath.CalcDefaultInitClosePercent(notionalValue, posiNotionalValue); /// /// 读取"上一收盘日"浮动腿的待实现分红(eod_swap_position.PosiDividendSum), @@ -1274,16 +1113,37 @@ namespace YLErp.Modules.SwapModule /// protected virtual decimal GetPreEodDividendSum(int tradeId, long positionId, DateTime dealDate) { - var lastEod = DbContext.eod_swap - .Where(x => x.ValueDate < dealDate && x.SwapTradeId == tradeId) + var preEod = GetPreEodPositionByDate(tradeId, positionId, dealDate); + var sum = preEod == null ? 0m : preEod.PosiDividendSum; + Logger.Info($"[分红-读取] GetPreEodDividendSum tradeId={tradeId} positionId={positionId} dealDate={dealDate:yyyy-MM-dd} 取EOD日期={(preEod?.ValueDate):yyyy-MM-dd} PosiDividendSum={sum}"); + return sum; + } + + /// + /// 取 dealDate 对应"上一收盘日"持仓的累计分红快照。 + /// GLMS-20260105-0006:登记日当天手动平仓/互换时,当日 EOD 快照已含分红,应取到当日而非 T-1。 + /// 故由 ValueDate 严格小于 dealDate 改为 小于等于:当日 EOD 存在则读当日,否则回退上一收盘日(原口径不变)。 + /// + protected virtual eod_swap_position GetPreEodPositionByDate(int tradeId, long positionId, DateTime dealDate) + { + var lastEod = QueryPreEodSwaps(tradeId) + .Where(x => x.ValueDate <= dealDate) .OrderByDescending(o => o.ValueDate).FirstOrDefault(); var preEodDate = lastEod == null ? dealDate.AddDays(-1) : lastEod.ValueDate; - var preEod = new SwapEodPositionService(this) - .GetPreEodPositions(tradeId, preEodDate) - .FirstOrDefault(x => x.PositionId == positionId); - return preEod == null ? 0m : preEod.PosiDividendSum; + 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); } + /// 可测性 seam:返回某交易的全部 eod_swap 行(不做日期过滤)。测试可 override 注入内存数据。 + protected virtual IQueryable QueryPreEodSwaps(int tradeId) + => DbContext.eod_swap.Where(x => x.SwapTradeId == tradeId); + + /// 可测性 seam:取指定收盘日的持仓累计分红快照。测试可 override 注入内存数据。 + protected virtual eod_swap_position QueryPreEodPosition(int tradeId, long positionId, DateTime valueDate) + => new SwapEodPositionService(this) + .GetPreEodPositions(tradeId, valueDate) + .FirstOrDefault(x => x.PositionId == positionId); + /// /// 获取固定利率 /// @@ -1295,29 +1155,73 @@ namespace YLErp.Modules.SwapModule return nextInterval?.Rate ?? position.InterestRateDefault; } + /// + /// 重置日判定:自锚点日起每 period 天一遇,锚点当日即首个重置日((日-锚点)%period==0)。 + /// EQD-6968 取价依赖三判定之一。锚点口径注记:GetFloatRate 传交易起始日 td.StartDate, + /// 分段重放/当日归周期判定传 position.PosiStartDate——非初始持仓(部分平仓剩余仓)两锚点可能不同, + /// 本方法只收口公式、不统一锚点(统一属行为变更,需业务定调)。 + /// SwapEodPositionService 的"持仓延续腿重置日再定盘"亦用本判定(td.StartDate 锚点)。 + /// + internal static bool IsResetDay(DateTime date, DateTime anchorDate, int period) + => (date - anchorDate).Days % period == 0; + /// /// 获取浮动利率 /// - private decimal GetFloatRate(swap_position position, eod_swap_position preEod, DateTime startDate, DateTime endDate, int period, bool swap, swap_position positionClone) + private decimal GetFloatRate(swap_position position, eod_swap_position preEod, DateTime startDate, DateTime endDate, int period, bool interestWindowEmpty, swap_position positionClone, bool calcLast = true) { if (string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) return position.FloatRate; int days = (endDate - startDate).Days; + bool isResetDay = IsResetDay(endDate, startDate, period); + // 重置日恰为到期日(endDate)时,取价日=endDate;否则=startDate(原逻辑)。 DateTime rateDate = IndexFixerBase.GetFixingDate( - days % period == 0 ? endDate : startDate, position.interest_rule); + isResetDay ? endDate : startDate, position.interest_rule); + // 历史上有"取错重置日利率"的线上 bug,取价决策必须常驻落盘(SwapCalcTrace.Critical 无条件 Info)。 + SwapCalcTrace.Critical( + $"FIX GetFloatRate p{position.id} [{startDate:yyyy-MM-dd}→{endDate:yyyy-MM-dd}] days={days} period={period} " + + $"重置日={isResetDay} rule={position.interest_rule} 取价日={rateDate:yyyy-MM-dd} calcLast={calcLast} " + + $"preEod={(preEod.id != 0 ? $"{preEod.ValueDate:yyyy-MM-dd}:{preEod.FloatRate:P6}" : "无")}"); - if (preEod.id != 0 && days % period != 0) + if (preEod.id != 0 && !isResetDay) { + SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} 非重置日→沿用昨日终FloatRate={preEod.FloatRate:P6}"); position.FloatRate = positionClone.FloatRate = preEod.FloatRate; return preEod.FloatRate; } + // EQD-6968 口径自洽化:不算尾(calcLast=false)时 endDate 当天不计息,其定盘一概不取 + // (有价也不取)——事件/回写利率与金额同源(末段已消费利率),杜绝"上午/下午落库利率不同"。 + // 剩余持仓的新周期利率由 SwapEodPositionService 的"重置日再定盘"显式获取,不靠此处顺带。 + if (!interestWindowEmpty && !calcLast && isResetDay) + { + var keptNoFetch = preEod.id != 0 ? preEod.FloatRate : position.FloatRate; + SwapCalcTrace.Critical( + $"FIX GetFloatRate p{position.id} 不算尾重置日→不取尾日定盘,沿用已有利率={keptNoFetch:P6}(来源={(preEod.id != 0 ? "preEod.FloatRate" : "position.FloatRate")})"); + return keptNoFetch; + } + if (IndexFixer.TryGetFixing(rateDate, position.FloatRateUnderlyingCode, out decimal rate)) { + SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} 重置日→取{rateDate:yyyy-MM-dd}定盘={rate:P6}"); position.FloatRate = positionClone.FloatRate = rate; return position.FloatRate; } - if (!swap) throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{rateDate:yyyy年MM月dd日}的价格"); + if (!interestWindowEmpty) + { + if (calcLast) + { + SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} {rateDate:yyyy-MM-dd}缺价且算尾→抛异常拦截"); + throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{rateDate:yyyy年MM月dd日}的价格"); + } + // 算头不算尾(calcLast=false):endDate 当天不计息,其 FR007 利率不参与计息, + // 缺价时直接沿用已有利率,不回退取其他日期利率,不告警。 + var kept = preEod.id != 0 ? preEod.FloatRate : position.FloatRate; + SwapCalcTrace.Critical( + $"FIX GetFloatRate p{position.id} {rateDate:yyyy-MM-dd}缺价且不算尾→沿用已有利率={kept:P6}(来源={(preEod.id != 0 ? "preEod.FloatRate" : "position.FloatRate")})"); + return kept; + } + SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} 计息窗口为空→利率不参与,返回0"); return 0m; } @@ -1370,12 +1274,12 @@ namespace YLErp.Modules.SwapModule if (position.InterestType == (int)InterestTypeEnum.复利) { // 复利计算 - CalcDailyCompoundInterestByEod(preEod, valueDate, td.StartDate.Value, position, closePrincipal, posiPrincipal, interest, annualDays, false, eodFloatRate, 1m, ref interestAmount, ref tdInterestAmount); + CalcDailyCompoundInterestByEod(preEod, valueDate, td.StartDate.Value, position, closePrincipal, posiPrincipal, interest, annualDays, eodFloatRate, 1m, ref interestAmount, ref tdInterestAmount); } else { // 单利计算 - CalcDailySimpleInterestByEod(preEod, valueDate, td.StartDate.Value, position, closePrincipal, posiPrincipal, interest, annualDays, false, eodFloatRate, 1m, ref interestAmount, ref tdInterestAmount); + CalcDailySimpleInterestByEod(preEod, valueDate, td.StartDate.Value, position, closePrincipal, posiPrincipal, interest, annualDays, eodFloatRate, 1m, ref interestAmount, ref tdInterestAmount); } } @@ -1383,7 +1287,140 @@ namespace YLErp.Modules.SwapModule interest.InterestAmount = Math.Round(interestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero); interest.TdInterestAmount = Math.Round(tdInterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero); // 计算InterestClosePnL(方向:收取=1为正,支付=-1为负) - var interestRatio = position.InterestDirection == 1 ? 1m : -1m; + var interestRatio = DirectionRatio.ReceivePay(position.InterestDirection); + interest.InterestClosePnL = interest.InterestAmount * interestRatio; + + if (add) UpdateDbOption(interest); + return interest; + } + + /// + /// 保证金腿(InterestMode 5/6)专属计息——替代 CalcEodInterest/CalcUnwindInterest 对保证金的处理。 + /// + /// 保证金是纯固定利率单利:浮动利率(FR007)/分段利率/复利对其均为死分支(前端无入口、 + /// 确认书不含、FundingLegRate.Build 对空 FloatRateUnderlyingCode 恒返回 Fixed)。故本方法直接用 + /// SimpleInterestAccrual 纯函数计息,本金取保证金余额: + /// EOD = 昨日终本金 preEod.TdInterestPrincipal(与旧 CalcDailySimpleInterestByEod 同源,无差分) + /// 盘中 = accrualBasis(preEod.TdInterestPrincipal + posiPrincipal - orginPv) + /// 盘中保留差分是必要的:posiPrincipal 是否经 ResolveInterestLegPositions 对齐到实时剩余是路径相关的 + /// (生产对齐 / 诊断测试用原始腿),单一本金变量无法覆盖两种状态,差分经 orginPv 自适应。orginPv 在 + /// 本方法内部按保证金维度计算(PreviousBalance),消除原 InitSwapDealInterest 的外部维度 hack + /// (融资腿 orginPv=浮动端名义本金)。保留累计语义(priorAccrued + 增量),满足下游字段契约。 + /// + /// + /// 前提(由前端保证金表单 + SwapTradeService 构造保证): + /// 1. InterestType=单利。本方法恒走 SimpleInterestAccrual 单利,不查 InterestType; + /// 若库内 InterestMode=5/6 且 InterestType=复利(脏数据),会与旧 CalcEodInterest 复利分支不一致。 + /// 2. rate 由 GetFixedRate 提供(SwapDealService.cs:866)——从 SwapIntervalList 取 Date ≤ unwindDate 最近段的 Rate, + /// 空表/单段时返回 InterestRateDefault。SwapIntervalList 是"互换观察日排期"(阶梯利率表 + 结息日历,非 FR007 浮动—— + /// 浮动由 FloatRateUnderlyingCode + interest_rest_days 独立驱动);保证金前端亦开放"设置观察日"分段录入。 + /// 盘中用该 rate 覆盖全程,与旧 CalcDailySimpleInterest 完全一致(BuildSegmentRates 的 spread 同样是 GetFixedRate 单一值全程, + /// 不按 SwapIntervalList 切段)——SwapIntervalList 阶梯利率在盘中半路变更的精细处理是既有未覆盖口径,非本次引入; + /// EOD 路径因每日重取 GetFixedRate(valueDate) 故能正确反映阶梯。 + /// 契约与副作用: + /// 3. position.InterestDirection 须已由调用方翻转(GetInterests:742 FlipDirection);本方法不翻转。 + /// 4. preEod 在 id==0 时被就地修改(设 TdInterestPrincipal/PosiNotionalValue/FloatRate),与旧 CalcEodInterest 一致。 + /// 定位:SwapCalcTrace 落盘 AccrueEod/AccrualPeriod 的 notional/days/rate/accrued;盘中 accrualBasis 可从 trace 的 notional 反推。 + /// + /// true=收盘归档(EOD),false=盘中平仓/互换。 + /// 计息窗口为空(仅盘中生效,true 时利息归零,同 InitSwapDealInterest;典型场景=互换当日已结息)。 + public swap_flow_event CalcMarginInterest( + trade td, DateTime valueDate, DateTime endDate, swap_position position, decimal rate, + decimal closePrincipal, decimal posiPrincipal, decimal closePercent, + int annualDays, bool calcFirst, bool calcLast, + eod_swap_position preEod, int eventType, bool add, bool settment, bool interestWindowEmpty) + { + // 当日是否计息(算头算尾)——同 CalcEodInterest + bool calcToday = true; + if (!calcFirst && valueDate == td.StartDate.Value) calcToday = false; + if (!calcLast && valueDate == td.ExerciseDate.Value) calcToday = false; + if (valueDate < position.PosiStartDate) calcToday = false; + + // 首日初始化 preEod——同 CalcEodInterest + if (preEod.id == 0) + { + preEod.FloatRate = 0m; + preEod.TdInterestPrincipal = posiPrincipal; + preEod.PosiNotionalValue = posiPrincipal; + } + + // 字段映射(保证金 FloatRate 恒 0;方向 position.InterestDirection 已由 GetInterests 翻转) + var interest = new swap_flow_event + { + SwapTradeId = td.id, + SwapTradeNo = td.TradeNumber, + EventType = eventType, + EventReason = "交易", + EventDate = valueDate, + PositionId = position.id, + InterestDirection = position.InterestDirection, + InterestRate = rate, + InterestPrincipal = closePrincipal, + InterestSwapInterval = position.InterestSwapInterval, + InterestMode = position.InterestMode, + FloatRate = 0m, + DataState = (int)SwapFlowDateStateEnum.完成, + ClientId = td.ClientId, + UnwindDate = settment ? valueDate : endDate + }; + + // 计息窗口为空:利息归零(同 InitSwapDealInterest;典型场景=互换事件) + if (interestWindowEmpty && !settment) + { + interest.InterestAmount = 0m; + interest.TdInterestAmount = 0m; + interest.InterestClosePnL = 0m; + if (add) UpdateDbOption(interest); + return interest; + } + + decimal interestAmount = 0m; + decimal tdInterestAmount = 0m; + var legRate = FundingLegRate.Fixed(rate); // 保证金纯固定(无浮动) + + if (calcToday) + { + if (settment) + { + // EOD:单日增量,累计 = 昨日累计 + 今日增量;notional = 昨日终本金(无差分) + var policy = AccrualPolicy.BuildEod(position, annualDays, isCompound: false); + var r = SimpleInterestAccrual.AccrueEod( + priorAccrued: preEod.InterestProfitSum, + priorNotional: preEod.TdInterestPrincipal, + unwindFraction: 1m, + rate: legRate, policy: policy, eodDate: valueDate); + interestAmount = r.Accrued; + tdInterestAmount = r.AccruedToday; + } + else + { + // 盘中:accrualBasis 自适应"实时剩余本金"——posiPrincipal 已对齐(ResolveInterestLegPositions) + // 时 = posiPrincipal;未对齐的原始腿经 orginPv(=PreviousBalance 昨日终) 修正回昨日终剩余。 + // 单一本金变量无法覆盖两种 position 状态,故保留差分(与 EOD 直接用 preEod.TdInterestPrincipal 不同)。 + // orginPv 在此内部按保证金维度计算,消除原 InitSwapDealInterest 的外部维度 hack。 + var orginPv = MarginCalc.PreviousBalance(preEod, posiPrincipal); + var accrualBasis = preEod.TdInterestPrincipal + posiPrincipal - orginPv; + var segmentRates = new List<(DateTime, decimal)> { (position.PosiStartDate, rate) }; + var r = SimpleInterestAccrual.AccruePeriod( + priorAccrued: preEod.InterestProfitSum * closePercent, + notional: accrualBasis, + unwindFraction: closePercent, + segmentRates: segmentRates, + startDate: position.PosiStartDate, + endDate: endDate, + priorValueDate: preEod.ValueDate, + boundary: AccrualBoundary.Of(calcFirst, calcLast), + annualDays: annualDays, + isAnnualized: position.IsAnnualized); + interestAmount = r.Accrued; + tdInterestAmount = r.AccruedToday; + interest.InterestPrincipal = accrualBasis * closePercent; // 同 CalcDailySimpleInterest:1304 + } + } + + interest.InterestAmount = Math.Round(interestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero); + interest.TdInterestAmount = Math.Round(tdInterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero); + var interestRatio = DirectionRatio.ReceivePay(position.InterestDirection); interest.InterestClosePnL = interest.InterestAmount * interestRatio; if (add) UpdateDbOption(interest); @@ -1393,34 +1430,26 @@ namespace YLErp.Modules.SwapModule /// /// 计算盘中利息(平仓/互换) /// - private swap_flow_event CalcUnwindInterest(trade td, DateTime valueDate, DateTime endDate, swap_position position, decimal rate, decimal floatRate, decimal posiPrincipal, decimal closePrincipal, decimal closePercent, int annualDays, eod_swap_position preEod, int eventType, bool add, bool swap, decimal orginPv, bool calcFirst, bool calcLast, decimal consumedInterest = 0m) + private swap_flow_event CalcUnwindInterest(trade td, DateTime valueDate, DateTime endDate, swap_position position, decimal rate, decimal floatRate, decimal posiPrincipal, decimal closePrincipal, decimal closePercent, int annualDays, eod_swap_position preEod, int eventType, bool add, bool interestWindowEmpty, decimal orginPv, bool calcFirst, bool calcLast, decimal consumedInterest = 0m) { if (preEod.id == 0) { preEod.FloatRate = floatRate; preEod.TdInterestPrincipal = posiPrincipal; preEod.PosiNotionalValue = posiPrincipal; - preEod.ValueDate = td.StartDate.Value; - if (calcFirst) - { - preEod.ValueDate = preEod.ValueDate.AddDays(-1); - } + // priorValueDate 恒取 开始日-1:首个重置日(=开始日)的定盘覆盖 [开始日,下一重置日) 全部计息日 + //(不算头时 7/7 起的计息日仍属首段),必须落在取价窗内。原实现仅算头回拨一天, + // 不算头时 fetchAfter=开始日 会跳过首重置日取价、首段误用种子利率 + //(历史上靠 GetFloatRate 尾日取价回填种子掩盖;EQD-6968 自洽化后暴露并根治)。 + // "不算头少计一天"由计息边界 IncludeStart=false 承担,与此处无关。 + preEod.ValueDate = td.StartDate.Value.AddDays(-1); } - return InitSwapDealInterest(td, valueDate, endDate, rate, position, add, swap, posiPrincipal, - closePrincipal, closePercent, annualDays, eventType, preEod, false, + return InitSwapDealInterest(td, valueDate, endDate, rate, position, add, interestWindowEmpty, posiPrincipal, + closePrincipal, closePercent, annualDays, eventType, preEod, orginPv, calcFirst, calcLast, consumedInterest); } /// - /// 保证金腿的 orginPv 维度重映射。 - /// - /// 保证金腿被迫走融资腿的差分公式(dynomicPrincipal = TdInterestPrincipal + posiPrincipal - orginPv), - /// 但 orginPv 对融资腿是"交易名义本金(千万~亿级)",对保证金腿必须是"保证金本金"—— - /// 否则维度不匹配会算出巨负值。本方法把保证金场景的 orginPv 对齐到"上一日保证金本金"。 - /// - /// 待迁入 Margin 模块:保证金独立计息入口建好后,此方法移入 MarginAccount/MarginService。 - /// - /// /// 写入保证金的资金记录:应付预付金(SwapMarginAmount)和预付金返息(SwapMarginRebatePnl)。 /// 依赖实例方法 AddClientCash/AddClientCashInCashOut,暂留此处。 /// @@ -1453,7 +1482,7 @@ namespace YLErp.Modules.SwapModule /// 计息年化利率 /// 利息腿 /// 是否新增 - /// 是否已互换 + /// 计息窗口为空(InitInterestDate 判定:true=本次不计利息,利率与金额归零;典型场景=互换当日已结息) /// 上一日终归档 /// 当日适用名义本金 /// 当日平仓名义本金 @@ -1465,14 +1494,13 @@ namespace YLErp.Modules.SwapModule decimal rate, swap_position position, bool add, - bool swap, + bool interestWindowEmpty, decimal posiNotionalValue, decimal closePosiNotionalValue, decimal closePrecent, int annualDays, int eventType, eod_swap_position preEodPosition, - bool needPrice, decimal orginPv, bool calcFirst, bool calcLast, @@ -1497,13 +1525,10 @@ namespace YLErp.Modules.SwapModule interest.ClientId = td.ClientId; interest.UnwindDate = endDate; - // 保证金腿的 orginPv 对齐到保证金本金维度,避免差分公式维度不匹配算出巨负值 - if (MarginModes.Contains(position.InterestMode)) - { - orginPv = MarginCalc.PreviousBalance(preEodPosition, position.InterestPrincipalFix); - } + // 保证金腿已走 CalcMarginInterest(不经过本方法),orginPv 维度重映射不再需要; + // orginPv 此处仅对融资腿生效(差分公式 accrualBasis = TdInterestPrincipal + posiPrincipal - orginPv)。 - if (swap) + if (interestWindowEmpty) { interest.InterestAmount = 0; // 利息金额 interest.TdInterestAmount = 0; // 当日新增利息 @@ -1514,16 +1539,16 @@ namespace YLErp.Modules.SwapModule { decimal InterestAmount = 0; decimal TdInterestAmount = 0; - var interestRatio = position.InterestDirection == 1 ? 1m : -1m; + var interestRatio = DirectionRatio.ReceivePay(position.InterestDirection); var floateRate = preEodPosition.FloatRate; if (position.InterestType == (int)InterestTypeEnum.复利) { - var daysFromStart = (endDate - position.PosiStartDate).Days; var daysFromPreEod = preEodPosition.id != 0 ? (endDate - preEodPosition.ValueDate).Days : 0; // 不算尾 + 当日即新周期首日 + 未到重置日 ==> 说明这一天应归入下一个计息周期 当天无需单独计息 - if (!calcLast && daysFromPreEod == 1 && daysFromStart % (position.interest_rest_days ?? 1) != 0) + if (!calcLast && daysFromPreEod == 1 + && !IsResetDay(endDate, position.PosiStartDate, position.interest_rest_days ?? 1)) { interest.InterestPrincipal = preEodPosition.TdInterestPrincipal * closePrecent; // 计息基数 interest.FloatRate = preEodPosition.FloatRate; @@ -1545,11 +1570,15 @@ namespace YLErp.Modules.SwapModule // 把上日尚未实现的的利息 按本次平掉的这部分计息基数分给本次平仓 并在重置日并入计息基数 // 它只在当前 endDate 恰好为重置日时使用,避免把同一笔历史利息重复资本化。 var resetCarryInterest = preEodPosition.InterestIncomeSum * remainingPercent; - CalcDailyCompoundInterest(endDate, position, closePosiNotionalValue, interest, annualDays, needPrice, + CalcDailyCompoundInterest(endDate, position, closePosiNotionalValue, interest, annualDays, floateRate, closePrecent, calcFirst, calcLast, ref InterestAmount, ref TdInterestAmount, consumedInterest, resetCarryInterest); if (preEodPosition.id != 0 && closePrecent == 1m) { + // 【全平专属分支触发标记】(快速定位):设计意图=真全平(尾差一次带走)与观察日恒1全额结息。 + // 普通部分平仓经 EOD 恒1惯例也会进入本分支(重算中间值不进结算现金流);本行日志用于监控进入者分布。 + Logger.Info($"[利息-全平专属分支] tradeId={td.id} posiId={position.id} valueDate={valueDate:yyyy-MM-dd} " + + $"closePrecent={closePrecent} preEod.InterestIncomeSum={preEodPosition.InterestIncomeSum}"); // 最终全平只重放上一日终之后的新增利息;历史部分平仓的两位结算尾差已在日终待实现中。 // InterestAmount 是本次最终应结金额;TdInterestAmount 是不按关闭比例缩放的参考累计值。 // 二者在全平时都以上一日 InterestIncomeSum 为起点,保证之前攒下的尾差最后一次带走。 @@ -1573,8 +1602,8 @@ namespace YLErp.Modules.SwapModule } // 计算截至本次平仓日的累计利息 amountAtEnd CalcDailyCompoundInterest(replayEndDate, position, closePosiNotionalValue, - interestAtEnd, annualDays, needPrice, floateRate, closePrecent, - calcFirst, calcLast, ref amountAtEnd, ref tdAmountAtEnd, consumedInterest); + interestAtEnd, annualDays, floateRate, closePrecent, + calcFirst, calcLast, ref amountAtEnd, ref tdAmountAtEnd, consumedInterest, exclusionStart: endDate); var interestAtPreviousEod = new swap_flow_event { InterestRate = rate }; decimal amountAtPreviousEod = 0m; decimal tdAmountAtPreviousEod = 0m; @@ -1582,7 +1611,7 @@ namespace YLErp.Modules.SwapModule // 因此此处按闭区间包含上一日终当天,避免算头不算尾时重复加入该日利息。 // 计算截至上一日终累积的利息 amountAtPreviousEod CalcDailyCompoundInterest(preEodPosition.ValueDate, position, closePosiNotionalValue, - interestAtPreviousEod, annualDays, needPrice, floateRate, closePrecent, + interestAtPreviousEod, annualDays, floateRate, closePrecent, calcFirst, true, ref amountAtPreviousEod, ref tdAmountAtPreviousEod, consumedInterest); // 例如 0004:5/18 待实现 -118631.261797,加 5/19 新增约 -4648.912760, // 得到最终应结 -123280.174557,按金额两位落为 Excel BN 的 -123280.17。 @@ -1594,7 +1623,7 @@ namespace YLErp.Modules.SwapModule } else { - CalcDailySimpleInterest(preEodPosition, endDate, position, posiNotionalValue, interest, annualDays, needPrice, floateRate, closePrecent, orginPv, calcFirst, calcLast, ref InterestAmount, ref TdInterestAmount); + CalcDailySimpleInterest(preEodPosition, endDate, position, posiNotionalValue, interest, annualDays, floateRate, closePrecent, orginPv, calcFirst, calcLast, ref InterestAmount, ref TdInterestAmount); } interest.InterestAmount = Math.Round(InterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero); @@ -1617,37 +1646,58 @@ namespace YLErp.Modules.SwapModule if (string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) return fallback; var fixingDate = IndexFixerBase.GetFixingDate(date, position.interest_rule); if (IndexFixer.TryGetFixing(fixingDate, position.FloatRateUnderlyingCode, out decimal fixing)) - return fixing != 0m ? fixing : fallback; + { + if (fixing != 0m) + { + SwapCalcTrace.Critical( + $"FIX Resolve p{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd} rule={position.interest_rule})→定盘={fixing:P6}"); + return fixing; + } + SwapCalcTrace.Critical( + $"FIX Resolve p{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd})→定盘=0视为缺价,沿用fallback={fallback:P6}"); + return fallback; + } + SwapCalcTrace.Critical( + $"FIX Resolve p{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd} rule={position.interest_rule})→缺价,抛异常"); throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fixingDate:yyyy年MM月dd日}的价格"); } - /// 构造利率值对象:固定腿→Fixed(spread),浮动腿→Floating(spread+fixing)。 - private static FundingLegRate BuildLegRate(swap_position position, decimal spread, decimal effectiveFloat) - => string.IsNullOrEmpty(position.FloatRateUnderlyingCode) - ? FundingLegRate.Fixed(spread) - : FundingLegRate.Floating(spread, effectiveFloat); - - /// 构造 EOD 计息政策(算头算尾,重置周期取 position.interest_rest_days)。 - private static AccrualPolicy BuildEodPolicy(swap_position position, int annualDays, bool isCompound) - => new AccrualPolicy(AccrualBoundary.Both, isCompound, position.interest_rest_days ?? 1, annualDays, position.IsAnnualized); - /// /// 按重置周期切分利率段,每段记录 all-in 利率(spread+fixing)。返回 (分段列表, 末段浮动利率)。 /// fetchAfterDate: 仅该日期之后的重置日才取 FR007(单利传 ValueDate,复利传 null 全程取)。 + /// exclusionStart: 排除区间起点——该日期起(含)的重置日视为"排除日"(不算尾的不计息边界日), + /// 一概不取价;缺省=endDate。仅不算尾(calcLast=false)生效;算尾所有重置日照常强制取价。 /// private (List<(DateTime StartDate, decimal Rate)> Segments, decimal LastFloat) BuildSegmentRates( DateTime startDate, DateTime endDate, int interestPeriod, swap_position position, decimal spread, decimal initialFloat, - DateTime? fetchAfterDate) + DateTime? fetchAfterDate, bool calcLast = true, DateTime? exclusionStart = null) { var rates = new List<(DateTime, decimal)>(); var calcDays = (endDate - startDate).Days; decimal currentFloat = initialFloat; + SwapCalcTrace.Critical( + $"FIX Segments p{position.id} [{startDate:yyyy-MM-dd}→{endDate:yyyy-MM-dd}] period={interestPeriod} " + + $"fetchAfter={(fetchAfterDate?.ToString("yyyy-MM-dd") ?? "全程")} calcLast={calcLast} " + + $"排除起点={(exclusionStart?.ToString("yyyy-MM-dd") ?? (calcLast ? "无" : endDate.ToString("yyyy-MM-dd")))} seed={initialFloat:P6} spread={spread:P6}"); for (int i = 0; i <= calcDays; i += interestPeriod) { var resetDate = startDate.AddDays(i); - if (fetchAfterDate == null || resetDate > fetchAfterDate.Value) + bool needFetch = (fetchAfterDate == null || resetDate > fetchAfterDate.Value); + // 算头不算尾(calcLast=false)时,endDate 当天不计息,其重置日利率不参与计息—— + // 排除日(EQD-6968 自洽化)一概不取价(有价也不取),currentFloat 保持末段已消费利率: + // 事件/快照回写的浮动利率与金额同源、与平仓时刻无关。剩余持仓的新周期利率由 + // SwapEodPositionService 的"重置日再定盘"显式获取,不靠排除日顺带。算尾照常强制取价。 + bool isExcludedEnd = !calcLast && resetDate >= (exclusionStart ?? endDate); + if (needFetch && !isExcludedEnd) + { currentFloat = ResolveFloatRate(position, resetDate, currentFloat); + } + else if (needFetch && isExcludedEnd) + { + SwapCalcTrace.Critical( + $"FIX Segment p{position.id} {resetDate:yyyy-MM-dd} 排除日(不计息)→不取价,沿用末段={currentFloat:P6}"); + } rates.Add((resetDate, spread + currentFloat)); } return (rates, currentFloat); @@ -1665,16 +1715,18 @@ namespace YLErp.Modules.SwapModule /// 年化天数 /// public void CalcDailyCompoundInterest(DateTime endDate, swap_position position, decimal principal, swap_flow_event flowEvent, - int annualDays, bool needPrice, decimal floateRate, decimal closePercent, bool calcFirst, bool calcLast, - ref decimal InterestAmount, ref decimal TdInterestAmount, decimal consumedInterest = 0m, decimal resetCarryInterest = 0m) + int annualDays, decimal floateRate, decimal closePercent, bool calcFirst, bool calcLast, + ref decimal InterestAmount, ref decimal TdInterestAmount, decimal consumedInterest = 0m, decimal resetCarryInterest = 0m, DateTime? exclusionStart = null) { var startDate = position.PosiStartDate; int interestPeriod = position.interest_rest_days ?? 1; // 分段取率:复利全程重放,每个重置日(含 startDate)取 FR007(fetchAfterDate=null)。 + // calcLast 透传,与下方 AccruePeriod 的边界同源,避免两处漂移; + // exclusionStart 仅重放补计不算尾漏计利息时非 null(=真实平仓日),锚定尾日跳过取价起点。 var (segmentRates, currentFloat) = BuildSegmentRates( startDate, endDate, interestPeriod, position, flowEvent.InterestRate, floateRate, - fetchAfterDate: null); + fetchAfterDate: null, calcLast: calcLast, exclusionStart: exclusionStart); // 纯函数复利计息:分段重置日并本金 + resetCarryInterest + 扣 consumedInterest var interestTrace = new AccrualTrace(); @@ -1704,19 +1756,20 @@ namespace YLErp.Modules.SwapModule /// /// 计算单利 盘中(按重置天数分段,每段使用对应浮动利率) /// - public void CalcDailySimpleInterest(eod_swap_position preEodPosition, DateTime endDate, swap_position position, decimal posiPrincipal, swap_flow_event flowEvent, int annualDays, bool needPrice, decimal floateRate, decimal closePercent, decimal orginPv, bool calcFirst, bool calcLast, ref decimal InterestAmount, ref decimal TdInterestAmount, decimal consumedInterest = 0m) + public void CalcDailySimpleInterest(eod_swap_position preEodPosition, DateTime endDate, swap_position position, decimal posiPrincipal, swap_flow_event flowEvent, int annualDays, decimal floateRate, decimal closePercent, decimal orginPv, bool calcFirst, bool calcLast, ref decimal InterestAmount, ref decimal TdInterestAmount, decimal consumedInterest = 0m, DateTime? exclusionStart = null) { var startDate = position.PosiStartDate; int interestPeriod = position.interest_rest_days ?? 1; - // orginPv 是路径相关参考本金(资金腿=上一日终浮动端名义本金;保证金腿=上一日终保证金余额)。 + // orginPv 是路径相关参考本金(资金腿=上一日终浮动端名义本金)。保证金腿已走 CalcMarginInterest,不经此方法。 // 单利差分:accrualBasis 全程恒定 = 昨日终滚动基数 + 当日名义本金 - 参考本金。 var accrualBasis = preEodPosition.TdInterestPrincipal + posiPrincipal - orginPv; // 分段取率:仅 ValueDate 之后的重置日才取 FR007(fetchAfterDate=ValueDate)。 + // calcLast 透传,与下方 AccruePeriod 的边界同源,避免两处漂移。 var (segmentRates, currentFloat) = BuildSegmentRates( startDate, endDate, interestPeriod, position, flowEvent.InterestRate, floateRate, - fetchAfterDate: preEodPosition.ValueDate); + fetchAfterDate: preEodPosition.ValueDate, calcLast: calcLast, exclusionStart: exclusionStart); // 纯函数计息:Accrued=缩放累计(InterestAmount),AccruedToday=未缩放累计(TdInterestAmount) var interestTrace = new AccrualTrace(); @@ -1754,7 +1807,7 @@ namespace YLErp.Modules.SwapModule /// 是否年化 /// 年化天数 /// - public void CalcDailyCompoundInterestByEod(eod_swap_position preEodPosition, DateTime endDate, DateTime tradeDate, swap_position position, decimal principal, decimal posiPrincipal, swap_flow_event flowEvent, int annualDays, bool needPrice, decimal floateRate, decimal closePercent, ref decimal InterestAmount, ref decimal TdInterestAmount) + public void CalcDailyCompoundInterestByEod(eod_swap_position preEodPosition, DateTime endDate, DateTime tradeDate, swap_position position, decimal principal, decimal posiPrincipal, swap_flow_event flowEvent, int annualDays, decimal floateRate, decimal closePercent, ref decimal InterestAmount, ref decimal TdInterestAmount) { int interestPeriod = position.interest_rest_days ?? 1; var isResetDay = (endDate - tradeDate).Days % interestPeriod == 0; @@ -1770,8 +1823,8 @@ namespace YLErp.Modules.SwapModule : 1m; // 纯数学下沉至 CompoundInterestAccrual.AccrueEod(DDD 命名 + 末位生产精度 12 舍入)。 - var legRate = BuildLegRate(position, flowEvent.InterestRate, effectiveFloat); - var accrualPolicy = BuildEodPolicy(position, annualDays, isCompound: true); + var legRate = FundingLegRate.Build(position, flowEvent.InterestRate, effectiveFloat); + var accrualPolicy = AccrualPolicy.BuildEod(position, annualDays, isCompound: true); // 完整计息 trace:前后日期/基数/利率/重置标志全过程,经 SwapCalcTrace 常驻落盘(关键路径日志)。 var interestTrace = new AccrualTrace(); @@ -1789,7 +1842,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; @@ -1801,7 +1854,7 @@ namespace YLErp.Modules.SwapModule /// /// 计算单利 收盘(按重置天数分段,每段使用对应浮动利率) /// - public void CalcDailySimpleInterestByEod(eod_swap_position preEodPosition, DateTime endDate, DateTime tradeDate, swap_position position, decimal principal, decimal posiPrincipal, swap_flow_event flowEvent, int annualDays, bool needPrice, decimal floateRate, decimal closePercent, ref decimal InterestAmount, ref decimal TdInterestAmount) + public void CalcDailySimpleInterestByEod(eod_swap_position preEodPosition, DateTime endDate, DateTime tradeDate, swap_position position, decimal principal, decimal posiPrincipal, swap_flow_event flowEvent, int annualDays, decimal floateRate, decimal closePercent, ref decimal InterestAmount, ref decimal TdInterestAmount) { // 首次操作(preEod.id == 0):计息基数按存量本金初始化——保留旧行为(含对 preEod 的就地修正)。 if (preEodPosition.id == 0) @@ -1818,8 +1871,8 @@ namespace YLErp.Modules.SwapModule flowEvent.FloatRate = effectiveFloat; // 纯数学下沉至 SimpleInterestAccrual(末位生产精度 12 舍入)。 - var legRate = BuildLegRate(position, flowEvent.InterestRate, effectiveFloat); - var accrualPolicy = BuildEodPolicy(position, annualDays, isCompound: false); + var legRate = FundingLegRate.Build(position, flowEvent.InterestRate, effectiveFloat); + var accrualPolicy = AccrualPolicy.BuildEod(position, annualDays, isCompound: false); // 完整计息 trace:收集器由适配器创建,随后经 SwapCalcTrace 常驻落盘(关键路径日志,无条件)。 var interestTrace = new AccrualTrace(); var result = SimpleInterestAccrual.AccrueEod( @@ -1847,7 +1900,7 @@ namespace YLErp.Modules.SwapModule { throw new ServiceException("未找到交易信息"); } - NormalizeEventUnwindDate(unwindData); + UnwindNormalizer.NormalizeEventUnwindDate(unwindData); // 提交时再次从有效 EOD/实时腿复核 Stock/Fund 基线,不能只相信前端缓存的数量和价格。 var restoredCorporateActionBaseline = TryRestoreEffectiveFundPosition(unwindData, unwindData.ValueDate); // 这是直接提交路径的最后一道复核。若返回 false(非 Stock/Fund、无快照、或 EOD 后已有完成流水), @@ -1860,15 +1913,15 @@ namespace YLErp.Modules.SwapModule td.StockEqvNotional = Convert.ToDouble(unwindData.PosiNotionalValue); td.TradeAmount = Convert.ToDouble(unwindData.PositionQty); } - NormalizeNotionalValues(unwindData); + UnwindNormalizer.NormalizeNotionalValues(unwindData); NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum.平仓, "系统操作_平仓"); //CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制 // 前端按"占期初(original)"语义传 ClosePercent(A);后端全链路按"占剩余(remaining)"语义(B)消费。 // 入口统一转换为 B,落库展示用的 A 由 SaveSwapDealInternal 还原。 unwindData.ClosePercent = ToRemainingClosePercent(unwindData.ClosePercent, unwindData.NotionalValue, unwindData.PosiNotionalValue); - if (NormalizeFullCloseRequest(unwindData)) + if (UnwindNormalizer.NormalizeFullCloseRequest(unwindData)) { - RecalculateNormalizedUnwindAmounts(unwindData); + UnwindNormalizer.RecalculateNormalizedUnwindAmounts(unwindData); } ValidateFrontendPnL(unwindData, isIncome: false); // 只读校验告警,不阻断交易 bool cofirm = false; @@ -1881,7 +1934,7 @@ namespace YLErp.Modules.SwapModule var eventId = SaveSwapDeal(unwindData, (int)SwapEventTypeEnum.平仓, clientCashId, "系统操作_平仓"); var remainingStockEqvNotional = Math.Round(td.StockEqvNotional - Convert.ToDouble(unwindData.CloseNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); var remainingTradeAmount = td.TradeAmount - Convert.ToDouble(unwindData.CloseQty); - var isFullClose = IsFullCloseAfterDeduction(unwindData, remainingStockEqvNotional, remainingTradeAmount); + var isFullClose = UnwindNormalizer.IsFullCloseAfterDeduction(unwindData, remainingStockEqvNotional, remainingTradeAmount); if (isFullClose) { td.TradeStatus = "已平仓"; @@ -1915,7 +1968,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); @@ -2053,153 +2106,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); @@ -2255,84 +2161,6 @@ namespace YLErp.Modules.SwapModule unwindData.SwapRealizedPnL = Math.Round(unwindData.SwapRealizedPnL, 2, MidpointRounding.AwayFromZero); } /// - /// 多空组合平仓 - /// - /// - /// - public void SwapLongShortUnwind(UnwindData unwindData) - { - var td = DbContext.trade.Find(unwindData.SwapTradeId); - if (td == null) - { - throw new ServiceException("未找到交易信息"); - } - NormalizeEventUnwindDate(unwindData); - unwindData.SwapRealizedPnL = unwindData.SwapCloseAmount; - NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum.平仓, "系统操作_平仓"); - var trans = DbContext.Database.BeginTransaction(); - try - { - int clientCashId = AddClientCashInCashOut(td, Convert.ToDouble(unwindData.SwapCloseAmount), ClientCashInCashOut.系统操作_平仓费, unwindData.ValueDate); - RecordMarginCashFlow(td, unwindData); - SaveSwapDeal(unwindData, (int)SwapEventTypeEnum.平仓, clientCashId, "系统操作_平仓"); - td.UnWindDate = unwindData.UnwindDate; - td.StockEqvNotional = 0; - td.TradeStatus = "已平仓"; - DbContext.SaveChanges(); - trans.Commit(); - } - catch (Exception ex) - { - trans.Rollback(); - throw; - } - finally - { - trans.Dispose(); - } - - } - /// - /// 多空组合互换 - /// - /// - /// - public void SwapLongShort(UnwindData unwindData) - { - var td = DbContext.trade.Find(unwindData.SwapTradeId); - if (td == null) - { - throw new ServiceException("未找到交易信息"); - } - NormalizeEventUnwindDate(unwindData); - unwindData.SwapRealizedPnL = unwindData.SwapCloseAmount; - NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum.互换, "系统操作_互换"); - var trans = DbContext.Database.BeginTransaction(); - try - { - int clientCashId = AddClientCashInCashOut(td, Convert.ToDouble(unwindData.SwapCloseAmount), ClientCashInCashOut.系统操作_互换, unwindData.ValueDate); - SaveSwapDeal(unwindData, (int)SwapEventTypeEnum.互换, clientCashId, "系统操作_互换"); - td.UnWindDate = unwindData.UnwindDate; - if (td.ExerciseDate <= unwindData.ValueDate) - { - td.Notional = 0; - td.StockEqvNotional = 0; - td.TradeStatus = "已到期"; - } - DbContext.SaveChanges(); - trans.Commit(); - } - catch (Exception ex) - { - trans.Rollback(); - throw; - } - finally - { - trans.Dispose(); - } - } - /// /// 互换 /// /// @@ -2344,7 +2172,7 @@ namespace YLErp.Modules.SwapModule { throw new ServiceException("未找到交易信息"); } - NormalizeEventUnwindDate(unwindData); + UnwindNormalizer.NormalizeEventUnwindDate(unwindData); // 正常页面先由 InitIncome 读取最近有效 Stock/Fund EOD;本提交方法本身不再重读快照, // 直接使用调用方传入的数据。若数据来自待复核事件,则它是申请时冻结的快照,日期之后的除权 // 不会在这里回写,属于审批链路的残余风险。 @@ -2388,8 +2216,8 @@ namespace YLErp.Modules.SwapModule // 这是为了保持待复核事件可重放的一致性,但也意味着申请后发生除权时仍可能带入冻结的旧基线; // 直接提交路径的 EOD 复核不覆盖此审批路径。 swapEvent.unwindData = JsonConvert.DeserializeObject(swapEvent.EventData); - NormalizeEventUnwindDate(swapEvent.unwindData); - NormalizeNotionalValues(swapEvent.unwindData); + UnwindNormalizer.NormalizeEventUnwindDate(swapEvent.unwindData); + UnwindNormalizer.NormalizeNotionalValues(swapEvent.unwindData); // Stored events keep display ratio A; approval calculations consume remaining ratio B. swapEvent.unwindData.ClosePercent = ToRemainingClosePercent( swapEvent.unwindData.ClosePercent, @@ -2404,9 +2232,9 @@ namespace YLErp.Modules.SwapModule swapEvent.unwindData.FlowEvents = flowList; if (eventType == (int)SwapEventTypeEnum.平仓) { - if (NormalizeFullCloseRequest(swapEvent.unwindData)) + if (UnwindNormalizer.NormalizeFullCloseRequest(swapEvent.unwindData)) { - RecalculateNormalizedUnwindAmounts(swapEvent.unwindData); + UnwindNormalizer.RecalculateNormalizedUnwindAmounts(swapEvent.unwindData); } } if (eventType == (int)SwapEventTypeEnum.互换) @@ -2435,7 +2263,7 @@ namespace YLErp.Modules.SwapModule { var remainingStockEqvNotional = Math.Round(td.StockEqvNotional - Convert.ToDouble(swapEvent.unwindData.CloseNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); var remainingTradeAmount = td.TradeAmount - Convert.ToDouble(swapEvent.unwindData.CloseQty); - var isFullClose = IsFullCloseAfterDeduction(swapEvent.unwindData, remainingStockEqvNotional, remainingTradeAmount); + var isFullClose = UnwindNormalizer.IsFullCloseAfterDeduction(swapEvent.unwindData, remainingStockEqvNotional, remainingTradeAmount); if (isFullClose) { td.TradeStatus = "已平仓"; @@ -2480,7 +2308,7 @@ namespace YLErp.Modules.SwapModule { throw new ServiceException("未找到交易信息"); } - NormalizeEventUnwindDate(unwindData); + UnwindNormalizer.NormalizeEventUnwindDate(unwindData); // 进入审批申请时保存的是前端冻结的事件数据;当前路径不执行直接 SwapUnwind 的 Stock/Fund EOD 复核。 // 因而申请发生在除权前、审批发生在除权后的场景,冻结数据仍是旧基线,需重新发起申请才能刷新。 if (eventType == (int)SwapEventTypeEnum.互换) @@ -2496,9 +2324,9 @@ namespace YLErp.Modules.SwapModule unwindData.ClosePercent = ToRemainingClosePercent(unwindData.ClosePercent, unwindData.NotionalValue, unwindData.PosiNotionalValue); if (eventType == (int)SwapEventTypeEnum.平仓) { - if (NormalizeFullCloseRequest(unwindData)) + if (UnwindNormalizer.NormalizeFullCloseRequest(unwindData)) { - RecalculateNormalizedUnwindAmounts(unwindData); + UnwindNormalizer.RecalculateNormalizedUnwindAmounts(unwindData); } } string action = eventType == (int)SwapEventTypeEnum.互换 ? ClientCashInCashOut.系统操作_互换 : ClientCashInCashOut.系统操作_平仓费; @@ -2531,11 +2359,6 @@ namespace YLErp.Modules.SwapModule } } - private static void NormalizeEventUnwindDate(UnwindData unwindData) - { - unwindData.UnwindDate = unwindData.ValueDate; - } - /// /// 保存平仓/互换事件 /// @@ -2549,7 +2372,7 @@ namespace YLErp.Modules.SwapModule throw new ServiceException("未找到交易信息"); } var flowList = new List(unwindData.FlowEvents); - NormalizeSettledInterestAmounts(flowList, eventType, eventResason); + UnwindNormalizer.NormalizeSettledInterestAmounts(flowList, eventType, eventResason); unwindData.FlowEvents.Clear(); // 落库展示用"占期初(original)"语义(A);计算链(费用递减/全平判定)用"占剩余(remaining)"语义(B)。 // 序列化前把 ClosePercent 还原为 A,序列化后立即还原回 B 供后续使用。 diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index 85b2d474..07bbac84 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -161,72 +161,13 @@ namespace YLErp.Modules.SwapModule : ConsGlobal.SwapDeliveryPriceRound; } - // 日终利息待实现需跨日累计,按表设计保留 12 位;已实现结算仍按金额两位处理。 - private const int EodInterestStoragePrecision = 12; - - private static decimal RoundMoney(decimal value) - { - return Math.Round(value, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - } - - private static decimal RoundEodInterest(decimal value) - { - return Math.Round(value, EodInterestStoragePrecision, MidpointRounding.AwayFromZero); - } - - /// - /// 仅在写入 eod_swap_position 前统一快照精度。 - /// 浮动腿收益最终以金额两位展示和存储;利息腿的待实现、计息基数及利率保留 12 位, - /// 使部分结算后的尾差可继续参与后续计息。 - /// - private static void NormalizeEodPositionForStorage(eod_swap_position position) - { - if (string.IsNullOrEmpty(position.UnderlyingCode)) - { - // 利息腿没有标的代码:待实现字段保留高精度,已实现结算字段收敛到金额两位。 - position.InterestPrincipalFix = RoundEodInterest(position.InterestPrincipalFix); - position.InterestRateDefault = RoundEodInterest(position.InterestRateDefault); - position.InterestFeePending = RoundEodInterest(position.InterestFeePending); - position.TdInterestPrincipal = RoundEodInterest(position.TdInterestPrincipal); - position.TdInterestRate = RoundEodInterest(position.TdInterestRate); - position.TdInterestIncome = RoundEodInterest(position.TdInterestIncome); - position.TdInterestFee = RoundEodInterest(position.TdInterestFee); - position.InterestIncomeSum = RoundEodInterest(position.InterestIncomeSum); - position.InterestFeeSum = RoundEodInterest(position.InterestFeeSum); - position.InterestProfitSum = RoundEodInterest(position.InterestProfitSum); - position.FloatRate = RoundEodInterest(position.FloatRate); - position.SwapPositionValue = RoundEodInterest(position.SwapPositionValue); - position.TdCloseInterest = RoundMoney(position.TdCloseInterest); - position.TdCloseInterestFee = RoundMoney(position.TdCloseInterestFee); - position.RealizedInterest = RoundMoney(position.RealizedInterest); - position.RealizedInterestFee = RoundMoney(position.RealizedInterestFee); - } - else - { - // 浮动腿有标的代码:其损益作为金额结果落库,统一按两位四舍五入。 - position.TdPosiDividend = RoundMoney(position.TdPosiDividend); - position.PosiMtmPnL = RoundMoney(position.PosiMtmPnL); - position.PosiDividendSum = RoundMoney(position.PosiDividendSum); - position.PosiFeePending = RoundMoney(position.PosiFeePending); - position.PosiProfitSum = RoundMoney(position.PosiProfitSum); - position.TdCloseMtmPnl = RoundMoney(position.TdCloseMtmPnl); - position.TdCloseDividend = RoundMoney(position.TdCloseDividend); - position.TdCloseFee = RoundMoney(position.TdCloseFee); - position.RealizedMtmPnL = RoundMoney(position.RealizedMtmPnL); - position.RealizedDividend = RoundMoney(position.RealizedDividend); - position.RealizedFee = RoundMoney(position.RealizedFee); - position.SwapPositionValue = RoundMoney(position.SwapPositionValue); - } - position.RealizedPnl = RoundMoney(position.RealizedPnl); - } - #region 可测试化接缝(Seams)——override 这些虚方法可在测试中替换 DB/外部调用,生产代码行为不变 /// 持久化 eod 持仓记录(生产: DbContext.Add;测试: 收集到列表) protected virtual void PersistEodSwapPosition(eod_swap_position position) { // 所有新增或更新的日终持仓都经过此入口,避免不同日终分支出现精度差异。 - NormalizeEodPositionForStorage(position); + EodPnlCalculator.NormalizeEodPositionForStorage(position); var storagePriceRound = GetStorageDeliveryPriceRound(position.UnderlyingInstrumentType, position.UnderlyingCode); position.PosiGrossPrice = Math.Round(position.PosiGrossPrice, storagePriceRound, MidpointRounding.AwayFromZero); position.UnderlyingPrice = Math.Round(position.UnderlyingPrice, storagePriceRound, MidpointRounding.AwayFromZero); @@ -254,26 +195,49 @@ 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); + + /// + /// 持仓延续腿重置日再定盘(EQD-6968 口径自洽化接缝)。 + /// 生产:Fr007IndexFixer.GetFixingOrThrow——缺价抛异常,与 ByEod 重置日再定盘/EodCheckSettlePrice + /// 同口径(EOD 时点当日 FR007 已由收盘前检查把关);测试:override 注入受控定盘。 + /// + protected virtual decimal ResolveOngoingResetFixing(swap_position position, DateTime valueDate) + => Fr007IndexFixer.Instance.GetFixingOrThrow(valueDate, position.interest_rule, position.FloatRateUnderlyingCode); + // FindTrade 已上提到基类 SwapTradeBaseService(三子类实现一致,消除重复) /// 查找交易扩展(生产: DbContext.trade_extend;测试: 内存字典) @@ -291,7 +255,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;测试: 内存字典) @@ -306,6 +270,12 @@ namespace YLErp.Modules.SwapModule return new SwapEventService(this).AddSwapEventDate(tradeDate, swapTradeId, eventType, data, clientCashId, save, reason); } + /// 持久化互换流水事件(生产: DbContext.swap_flow_event.Add;测试: 收集到列表) + protected virtual void PersistFlowEvent(swap_flow_event flowEvent) + { + DbContext.swap_flow_event.Add(flowEvent); + } + /// 在事务中执行(生产: BeginTransaction/Commit/Rollback;测试: 直接执行不包事务) protected virtual void ExecuteInTransaction(Action action) { @@ -677,17 +647,17 @@ 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); - //获取自动互换的 interval 信息,用于确定结算日期 - IntervalModel autoInterval = null; + DealInterests(interestList, eodPositions, todyEodPositions, settleDate, td, flowEvents, autoInterests, lastEodSwap, posiLongNotional + posiShortNotional, closePosiNotional, grossPrice, orginPv); + //获取自动互换的观察日信息,用于确定结算日期 + IntervalModel observationInterval = null; foreach (var interest in interestList) { - autoInterval = interest.SwapIntervalList.FirstOrDefault(x => x.Date == settleDate && x.Settlement == 1); - if (autoInterval != null) + observationInterval = InterestEodScenarioDispatch.FindObservationInterval(interest, settleDate); + if (observationInterval != null) break; } // 自动互换(仅利息/预付金,不含分红) - DealAutoInterests(autoInterests, td, settleDate, preDealDate, posiLongNotional + posiShortNotional, autoInterval); + DealAutoInterests(autoInterests, td, settleDate, preDealDate, posiLongNotional + posiShortNotional, observationInterval); // 分红独立处理:只要当天有债券需要分红,则生成分红自动互换,与利息互换无关 DealDividends(curEodPosis, td, settleDate, tradeExtend); //多空组合判断是否已到到期日且无持仓信息 @@ -1342,8 +1312,7 @@ namespace YLErp.Modules.SwapModule List flowEvents, List autoInterests, eod_swap lastEodSwap, - decimal posiLongNational, - decimal posiShortNational, + decimal posiTotalNotional, decimal closeNational, decimal grossPrice, decimal orginPv) @@ -1354,7 +1323,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) @@ -1391,18 +1360,20 @@ namespace YLErp.Modules.SwapModule } var eodPosition = eodPositions.FirstOrDefault(x => x.PositionId == interest.id);//上一日日终利息信息 可能不存在 var tdEodPosition = todyEodPositions.FirstOrDefault(x => x.PositionId == interest.id);//当前结算日日终利息信息 - var insterval = interest.SwapIntervalList.FirstOrDefault(x => x.Date == settleDate && x.Settlement == 1);//自动互换观察日信息 + var observationInterval = InterestEodScenarioDispatch.FindObservationInterval(interest, settleDate);//自动互换观察日信息 List dealInterests = new List(); dealInterests.AddRange(flowEvents); var dealInterest = dealInterests.FirstOrDefault(n => n.PositionId == interest.id);//当日是否做过互换或平仓 var swapEvents = flowEvents.Where(x => (x.EventType == (int)SwapEventTypeEnum.互换 || x.EventType == (int)SwapEventTypeEnum.平仓) && x.PositionId == interest.id).ToList(); //如果当日有互换/当日有平仓 不再重新生成或更新 - Log.Info($"insterval is {insterval},hasSwap is {hasSwap},hasClose is {hasClose}"); - if (insterval != null && !hasSwap) + Log.Info($"observationInterval is {observationInterval},hasSwap is {hasSwap},hasClose is {hasClose}"); + // 分派优先级与粒度说明见 ResolveInterestScenario;8 组合表驱动覆盖见 InterestEodScenarioDispatchTest。 + // 仅观察日两个分支把返回值收进 autoInterests(→资金记录)——分派错序=静默少结。 + if (observationInterval != null && !hasSwap) { if (!hasClose)//当日无平仓 { - var _autoInterests = SaveAutoEodInterestPosition(eodPosition, tdEodPosition, interest, td, settleDate, insterval, lastEodSwap, posiLongNational, posiShortNational, grossPrice, orginPv); + var _autoInterests = SaveAutoEodInterestPosition(eodPosition, tdEodPosition, interest, td, settleDate, observationInterval, lastEodSwap, posiTotalNotional, grossPrice, orginPv); if (_autoInterests.Count > 0) { autoInterests.AddRange(_autoInterests); @@ -1410,7 +1381,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, observationInterval, posiTotalNotional, swapEvents, closeNational, autoSwap: true, grossPrice, orginPv); if (_autoInterests.Count > 0) { autoInterests.AddRange(_autoInterests); @@ -1423,14 +1394,15 @@ 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, observationInterval, posiTotalNotional, swapEvents, closeNational, autoSwap: 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); } } } + /// /// 处理浮动腿归档 /// @@ -1517,7 +1489,7 @@ namespace YLErp.Modules.SwapModule autoInterests.ForEach(x => x.PayDate = settleDate); - var premiumModes = new List() { (int)InterestModeEnum.初始预付金, (int)InterestModeEnum.追加预付金 }; + var premiumModes = MarginModes.ForLinq; var premiumInterests = autoInterests.Where(x => premiumModes.Contains(x.InterestMode)).ToList(); var interestLegs = autoInterests.Where(x => !premiumModes.Contains(x.InterestMode)).ToList(); @@ -1664,24 +1636,26 @@ namespace YLErp.Modules.SwapModule unwindData.ClientCashIds = clientCashIds; string data = JsonConvert.SerializeObject(unwindData); - var swapEvent = new SwapEventService(this).AddSwapEventDate(unwindData.ValueDate, unwindData.SwapTradeId, (int)SwapEventTypeEnum.自动互换, data, clientCashId, true, "系统操作-自动互换");//将互换总额存入事件 + // 走虚方法 AddSwapEvent(与 ComposePage:800 一致),让测试可 override 捕获事件; + // 默认实现仍是 new SwapEventService(this).AddSwapEventDate,生产行为不变。 + var swapEvent = AddSwapEvent(unwindData.ValueDate, unwindData.SwapTradeId, (int)SwapEventTypeEnum.自动互换, data, clientCashId, true, "系统操作-自动互换");//将互换总额存入事件 if (flowEvents!=null) { flowEvents.ForEach(x => { x.EventId = swapEvent.id; - DbContext.swap_flow_event.Add(x); + PersistFlowEvent(x); }); UpdateInitalPostion(flowEvents, td.id); } - + // 保存分红事件 if (dividendEvents != null) { dividendEvents.ForEach(x => { x.EventId = swapEvent.id; - DbContext.swap_flow_event.Add(x); + PersistFlowEvent(x); }); UpdateInitalPostion(dividendEvents, td.id); } @@ -1881,6 +1855,46 @@ namespace YLErp.Modules.SwapModule trans?.Dispose(); } } + /// + /// 利息腿字段拷贝(SaveEodInterestPosition / SaveAutoEodInterestPosition / SaveAutoEodWithCloseInterestPosition 共用)。 + /// FloatRate 来源随场景不同(手工互换=当日流水;自动互换/平仓=计息结果),由调用方算好传入,勿在本方法内统一。 + /// 场景差异字段(PosiStatus / InterestFeePending / TdInterestPrincipal / TdInterestRate)留在各调用点。 + /// + private static void CopyInterestLegFields(eod_swap_position newEodPayPosition, swap_position position, decimal floatRate) + { + newEodPayPosition.InterestDirection = position.InterestDirection; + newEodPayPosition.InterestMode = position.InterestMode; + newEodPayPosition.InterestPrincipalFix = position.InterestPrincipalFix; + newEodPayPosition.InterestRateDefault = position.InterestRateDefault; + newEodPayPosition.InterestSwapInterval = position.InterestSwapInterval; + newEodPayPosition.IsAnnualized = position.IsAnnualized; + newEodPayPosition.HappenDate = position.HappenDate; + newEodPayPosition.Currency = position.Currency; + newEodPayPosition.InterestType = position.InterestType; + newEodPayPosition.interest_rest_days = position.interest_rest_days; + newEodPayPosition.interest_rule = position.interest_rule; + newEodPayPosition.FloatRate = floatRate; + newEodPayPosition.FloatRateUnderlyingCode = position.FloatRateUnderlyingCode; + } + + /// + /// 利息腿日终滚存收尾(四个 Save* 共用):RollRealized 滚累计已实现 → SetFixedLegRealizedPnl → 汇率 → TdCurrency。 + /// RealizedInterest 只增不回滚:上日累计已实现 + 当日结息按方向后的金额。 + /// interestDirection 是 RateType 的方向来源——三个方法取 position.InterestDirection, + /// SaveEodInterestPositionCopy 取 eodPayPosition.InterestDirection(现状差异,勿统一)。 + /// PersistEodSwapPosition 与各自日志留在调用点(持久化边界 + 日志顺序各不相同)。 + /// + private void FinalizeInterestEodRoll(eod_swap_position eodPayPosition, eod_swap_position newEodPayPosition, int ratio, trade td, DateTime valueDate, int interestDirection) + { + var rolled = InterestIncomeCalc.RollRealized(eodPayPosition.RealizedInterest, eodPayPosition.RealizedInterestFee, newEodPayPosition.TdCloseInterest, newEodPayPosition.TdCloseInterestFee, ratio); + newEodPayPosition.RealizedInterest = rolled.Interest; + newEodPayPosition.RealizedInterestFee = rolled.Fee; + SetFixedLegRealizedPnl(newEodPayPosition); + var currencyRate = GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, valueDate, true, + DirectionRatio.RateType(interestDirection)); + newEodPayPosition.TdCurrency = Convert.ToDecimal(currencyRate); + } + /// /// 产生互换用 /// @@ -1924,20 +1938,8 @@ namespace YLErp.Modules.SwapModule UpdateDbOption(newEodPayPosition); newEodPayPosition.PosiStatus = 0; newEodPayPosition.Invalid = false; - //持仓内容-利息腿 - newEodPayPosition.InterestDirection = position.InterestDirection; - newEodPayPosition.InterestMode = position.InterestMode; - newEodPayPosition.InterestPrincipalFix = position.InterestPrincipalFix; - newEodPayPosition.InterestRateDefault = position.InterestRateDefault; - newEodPayPosition.InterestSwapInterval = position.InterestSwapInterval; - newEodPayPosition.IsAnnualized = position.IsAnnualized; - newEodPayPosition.HappenDate = position.HappenDate; - newEodPayPosition.Currency = position.Currency; - newEodPayPosition.InterestType = position.InterestType; - newEodPayPosition.interest_rest_days = position.interest_rest_days; - newEodPayPosition.interest_rule = position.interest_rule; - newEodPayPosition.FloatRate = flowEvents.FirstOrDefault()?.FloatRate ?? 0; - newEodPayPosition.FloatRateUnderlyingCode = position.FloatRateUnderlyingCode; + //持仓内容-利息腿(FloatRate 取当日互换/平仓流水) + CopyInterestLegFields(newEodPayPosition, position, flowEvents.FirstOrDefault()?.FloatRate ?? 0); newEodPayPosition.InterestFeePending = 0; //利息端估值用信息 newEodPayPosition.TdInterestPrincipal = flowEvents.FirstOrDefault()?.InterestPrincipal ?? 0; @@ -1956,8 +1958,8 @@ namespace YLErp.Modules.SwapModule var interestFeeBeforeSettlement = eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee; var isMaturityFinalSettlement = valueDate.Date >= td.ExerciseDate.Value.Date && flowEvents.Any() - && RoundMoney(interestIncomeBeforeSettlement) == RoundMoney(newEodPayPosition.TdCloseInterest) - && RoundMoney(interestFeeBeforeSettlement) == RoundMoney(newEodPayPosition.TdCloseInterestFee); + && EodPnlCalculator.RoundMoney(interestIncomeBeforeSettlement) == EodPnlCalculator.RoundMoney(newEodPayPosition.TdCloseInterest) + && EodPnlCalculator.RoundMoney(interestFeeBeforeSettlement) == EodPnlCalculator.RoundMoney(newEodPayPosition.TdCloseInterestFee); if (isMaturityFinalSettlement) { @@ -1968,21 +1970,15 @@ namespace YLErp.Modules.SwapModule } else { - newEodPayPosition.InterestIncomeSum = RoundEodInterest(interestIncomeBeforeSettlement - newEodPayPosition.TdCloseInterest); - newEodPayPosition.InterestFeeSum = RoundEodInterest(interestFeeBeforeSettlement - newEodPayPosition.TdCloseInterestFee); + newEodPayPosition.InterestIncomeSum = EodPnlCalculator.RoundEodInterest(interestIncomeBeforeSettlement - newEodPayPosition.TdCloseInterest); + newEodPayPosition.InterestFeeSum = EodPnlCalculator.RoundEodInterest(interestFeeBeforeSettlement - newEodPayPosition.TdCloseInterestFee); } newEodPayPosition.InterestProfitSum = newEodPayPosition.InterestIncomeSum + newEodPayPosition.InterestFeeSum; //持仓价值 newEodPayPosition.SwapPositionValue = PositionValueCalc.Calc(newEodPayPosition.InterestProfitSum, newEodPayPosition.PosiProfitSum, (int)ratio); - //累计已实现 - var rolled = InterestIncomeCalc.RollRealized(eodPayPosition.RealizedInterest, eodPayPosition.RealizedInterestFee, newEodPayPosition.TdCloseInterest, newEodPayPosition.TdCloseInterestFee, ratio); - newEodPayPosition.RealizedInterest = rolled.Interest; - newEodPayPosition.RealizedInterestFee = rolled.Fee; - SetFixedLegRealizedPnl(newEodPayPosition); - var currencyRate = GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, valueDate, true, - DirectionRatio.RateType(position.InterestDirection)); - newEodPayPosition.TdCurrency = Convert.ToDecimal(currencyRate); + //累计已实现(滚存收尾见 FinalizeInterestEodRoll) + FinalizeInterestEodRoll(eodPayPosition, newEodPayPosition, ratio, td, valueDate, position.InterestDirection); PersistEodSwapPosition(newEodPayPosition); } /// @@ -1997,7 +1993,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}"); @@ -2036,7 +2032,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) @@ -2056,12 +2052,14 @@ namespace YLErp.Modules.SwapModule positions.Add(position); List preEodPositions = new List(); preEodPositions.Add(eodPayPosition); + // orginPv 在此仅对固定值腿(mode 1)生效;保证金腿(5/6)的 orginPv 虽在此赋值, + // 但 GetInterests 保证金分支已走 CalcMarginInterest(内部自算 orginPv=PreviousBalance),忽略此处传入值。 var interestModes = MarginModes.FixedAmountAndMargin; if (interestModes.Contains(position.InterestMode)) { 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); @@ -2069,8 +2067,8 @@ namespace YLErp.Modules.SwapModule // 日终快照仍使用上面的高精度应结金额计算待实现尾差,避免把舍入差提前丢掉。 interests.ForEach(x => { - x.InterestAmount = RoundMoney(x.InterestAmount); - x.InterestClosePnL = RoundMoney(x.InterestClosePnL); + x.InterestAmount = EodPnlCalculator.RoundMoney(x.InterestAmount); + x.InterestClosePnL = EodPnlCalculator.RoundMoney(x.InterestClosePnL); }); decimal settledInterestAmount = interests.Sum(x => x.InterestAmount); @@ -2078,20 +2076,8 @@ namespace YLErp.Modules.SwapModule newEodPayPosition.PositionId = position.id; UpdateDbOption(newEodPayPosition); newEodPayPosition.Invalid = false; - //持仓内容-利息腿 - newEodPayPosition.InterestDirection = position.InterestDirection; - newEodPayPosition.InterestMode = position.InterestMode; - newEodPayPosition.InterestPrincipalFix = position.InterestPrincipalFix; - newEodPayPosition.InterestRateDefault = position.InterestRateDefault; - newEodPayPosition.InterestSwapInterval = position.InterestSwapInterval; - newEodPayPosition.IsAnnualized = position.IsAnnualized; - newEodPayPosition.HappenDate = position.HappenDate; - newEodPayPosition.Currency = position.Currency; - newEodPayPosition.InterestType = position.InterestType; - newEodPayPosition.interest_rest_days = position.interest_rest_days; - newEodPayPosition.interest_rule = position.interest_rule; - newEodPayPosition.FloatRate = interests.Count > 0 ? interests.First().FloatRate ?? 0 : 0; - newEodPayPosition.FloatRateUnderlyingCode = position.FloatRateUnderlyingCode; + //持仓内容-利息腿(FloatRate 取计息结果) + CopyInterestLegFields(newEodPayPosition, position, interests.Count > 0 ? interests.First().FloatRate ?? 0 : 0); newEodPayPosition.InterestFeePending = 0; //利息端估值用信息 newEodPayPosition.TdInterestPrincipal = interestModes.Contains(position.InterestMode) ? eodPayPosition.InterestPrincipalFix : posiNotionalValue; @@ -2107,22 +2093,16 @@ namespace YLErp.Modules.SwapModule // 到期自动互换是最后一次自动结算:两位实际金额已落流水/资金,待实现不再滚入下一日。 newEodPayPosition.InterestIncomeSum = isMaturityFinalAutoSettlement ? 0 - : RoundEodInterest(interestAmountBeforeSettlement - settledInterestAmount); + : EodPnlCalculator.RoundEodInterest(interestAmountBeforeSettlement - settledInterestAmount); newEodPayPosition.InterestFeeSum = isMaturityFinalAutoSettlement ? 0 - : RoundEodInterest(eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee - newEodPayPosition.TdCloseInterestFee); + : EodPnlCalculator.RoundEodInterest(eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee - newEodPayPosition.TdCloseInterestFee); newEodPayPosition.InterestProfitSum = newEodPayPosition.InterestIncomeSum + newEodPayPosition.InterestFeeSum; //持仓价值 newEodPayPosition.SwapPositionValue = PositionValueCalc.Calc(newEodPayPosition.InterestProfitSum, newEodPayPosition.PosiProfitSum, (int)ratio); - //累计已实现 - var rolled = InterestIncomeCalc.RollRealized(eodPayPosition.RealizedInterest, eodPayPosition.RealizedInterestFee, newEodPayPosition.TdCloseInterest, newEodPayPosition.TdCloseInterestFee, ratio); - newEodPayPosition.RealizedInterest = rolled.Interest; - newEodPayPosition.RealizedInterestFee = rolled.Fee; - SetFixedLegRealizedPnl(newEodPayPosition); - var currencyRate = GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, valueDate, true, - DirectionRatio.RateType(position.InterestDirection)); - newEodPayPosition.TdCurrency = Convert.ToDecimal(currencyRate); + //累计已实现(滚存收尾见 FinalizeInterestEodRoll) + FinalizeInterestEodRoll(eodPayPosition, newEodPayPosition, ratio, td, valueDate, position.InterestDirection); PersistEodSwapPosition(newEodPayPosition); Log.Info($"the last newEodPayPosition is {JsonHelper.Serialize(newEodPayPosition, false)}"); return interests; @@ -2146,7 +2126,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; @@ -2157,8 +2137,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); // 首次日终结算可能包含当日收盘,因此尚无先前的日终利息持仓。 @@ -2194,6 +2174,8 @@ namespace YLErp.Modules.SwapModule newEodPayPosition = eodPayPosition.Clone(); newEodPayPosition.id = 0; } + // orginPv 在此仅对固定值腿(mode 1)生效;保证金腿(5/6)的 orginPv 虽在此赋值, + // 但 GetInterests 保证金分支已走 CalcMarginInterest(内部自算 orginPv=PreviousBalance),忽略此处传入值。 var interestModes = MarginModes.FixedAmountAndMargin; if (interestModes.Contains(position.InterestMode)) { @@ -2219,10 +2201,19 @@ namespace YLErp.Modules.SwapModule positions.Add(position); 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); + var calcLast = tradeExtend?.CalcLast ?? true; + // 显式入口:平仓后剩余本金 + 实际平掉额 + 恒1全额结息(语义见 InterestCalcRequest.EodPostCloseSettle)。 + // 该组合触发 GetInterests 内共享计息器的模式2/9本金修正(见其"根因位置"注释,勿删)。 + // 恒1 重算的 InterestAmount 是结算现金流的直接输入(非无害中间值):系统端到端结算结果由 DI_EXCEL_SCENARIO4 家族对账确认书公式保障(最终全平=剩余额×∏利率,2026-08-18 手算复核)。改动本口径前必读该测试家族——任何破坏 ∏ 恒等式的调整都会被其拦截。 + // 口径选择常驻记录(快速定位第一入口):出问题先看这行确认当日本次事件的金额输入,再顺着 + // SwapCalcTrace 分段过程日志追计算;autoSwap=观察日结现路径。 + Log.Info($"[EOD平仓后收盘结息] tradeId={td.id} valueDate={valueDate:yyyy-MM-dd} autoSwap={autoSwap} " + + $"口径=全额结息(恒1惯例) " + + $"oriPosi(平仓前)={oriPosiNotionalValue} posi(剩余)={posiNotionalValue} close(平掉)={closeNational}"); + 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 实际落库的手工结息,金额已按分处理。 @@ -2232,7 +2223,7 @@ namespace YLErp.Modules.SwapModule decimal autoSettledInterestAmount = 0m; if (autoSwap && interests.Count > 0) { - autoSettledInterestAmount = RoundMoney(interestAmountBeforeSettlement - manualSettledInterestAmount); + autoSettledInterestAmount = EodPnlCalculator.RoundMoney(interestAmountBeforeSettlement - manualSettledInterestAmount); var autoInterest = interests[0]; autoInterest.InterestAmount = autoSettledInterestAmount; autoInterest.InterestClosePnL = autoSettledInterestAmount @@ -2242,22 +2233,23 @@ namespace YLErp.Modules.SwapModule newEodPayPosition.PositionId = position.id; UpdateDbOption(newEodPayPosition); newEodPayPosition.Invalid = false; - //持仓内容-利息腿 - newEodPayPosition.InterestDirection = position.InterestDirection; - newEodPayPosition.InterestMode = position.InterestMode; - // ResolveInterestLegPositions 已提供平仓后的实时剩余本金,日终不再重复扣减。 - newEodPayPosition.InterestPrincipalFix = position.InterestPrincipalFix; - // newEodPayPosition.InterestPrincipalFix *= (1 - closePercent); - newEodPayPosition.InterestRateDefault = position.InterestRateDefault; - newEodPayPosition.InterestSwapInterval = position.InterestSwapInterval; - newEodPayPosition.IsAnnualized = position.IsAnnualized; - newEodPayPosition.HappenDate = position.HappenDate; - newEodPayPosition.Currency = position.Currency; - newEodPayPosition.InterestType = position.InterestType; - newEodPayPosition.FloatRate = interests.Count > 0 ? interests.First().FloatRate ?? 0 : 0; - newEodPayPosition.FloatRateUnderlyingCode = position.FloatRateUnderlyingCode; - newEodPayPosition.interest_rest_days = position.interest_rest_days; - newEodPayPosition.interest_rule = position.interest_rule; + //持仓内容-利息腿(FloatRate 取计息结果)。InterestPrincipalFix 保持腿现值: + // ResolveInterestLegPositions 已提供平仓后的实时剩余本金,日终不再重复扣减(勿恢复 *(1-closePercent))。 + CopyInterestLegFields(newEodPayPosition, position, interests.Count > 0 ? interests.First().FloatRate ?? 0 : 0); + // ── 持仓延续腿重置日再定盘(EQD-6968 自洽化)── + // 排除日取价已收口为"纯跳过":事件利率=末段已消费利率。但剩余持仓自当日起进入新计息周期, + // 快照 FloatRate 是后续非重置日(ByEod 沿用 preEod.FloatRate)与当日应计(intersetAcmount)的 + // 利率载体——平仓日恰为重置日时必须显式取当日新定盘(与 ByEod 日增路径的重置日行为同构)。 + // 全平(剩余=0)/算尾(事件利率已是新定盘)/观察日(autoSwap 恒1已含当日)无需再定盘。 + if (!autoSwap && !calcLast && posiNotionalValue > 0m + && !string.IsNullOrEmpty(position.FloatRateUnderlyingCode) + && SwapDealService.IsResetDay(valueDate, td.StartDate.Value, position.interest_rest_days ?? 1)) + { + var ongoingFixing = ResolveOngoingResetFixing(position, valueDate); + SwapCalcTrace.Critical( + $"FIX EodCloseRefix p{position.id} {valueDate:yyyy-MM-dd} 平仓日=重置日→剩余持仓快照再定盘 {newEodPayPosition.FloatRate:P6}→{ongoingFixing:P6}"); + newEodPayPosition.FloatRate = ongoingFixing; + } //利息端估值用信息 // TdInterestPrincipal 是“下一日继续计息的收盘后本金”,不是原始合同规模,也不是本次平仓本金。 // 模式9单利直接取剩余名义本金;复利还要保留重置时已经并入本金的待实现利息。 @@ -2362,13 +2354,13 @@ namespace YLErp.Modules.SwapModule // InterestIncomeSum 是收盘后仍未结算的尾差/剩余利息。 // 部分平仓:扣款前待实现 - TdCloseInterest;最终全平且两位金额已覆盖时直接清零。 newEodPayPosition.InterestIncomeSum = closePercent == 1 - && RoundMoney(pendingInterestBeforeSettlement) == RoundMoney(newEodPayPosition.TdCloseInterest) + && EodPnlCalculator.RoundMoney(pendingInterestBeforeSettlement) == EodPnlCalculator.RoundMoney(newEodPayPosition.TdCloseInterest) ? 0m - : RoundEodInterest(pendingInterestBeforeSettlement - newEodPayPosition.TdCloseInterest); + : EodPnlCalculator.RoundEodInterest(pendingInterestBeforeSettlement - newEodPayPosition.TdCloseInterest); newEodPayPosition.InterestFeeSum = closePercent == 1 - && RoundMoney(pendingInterestFeeBeforeSettlement) == RoundMoney(newEodPayPosition.TdCloseInterestFee) + && EodPnlCalculator.RoundMoney(pendingInterestFeeBeforeSettlement) == EodPnlCalculator.RoundMoney(newEodPayPosition.TdCloseInterestFee) ? 0m - : RoundEodInterest(pendingInterestFeeBeforeSettlement - newEodPayPosition.TdCloseInterestFee); + : EodPnlCalculator.RoundEodInterest(pendingInterestFeeBeforeSettlement - newEodPayPosition.TdCloseInterestFee); //持仓内容-利息腿-损益统计(本方视角) // InterestProfitSum 是利息腿待实现总额,包含利息和费用;无费用时等于 InterestIncomeSum。 newEodPayPosition.InterestProfitSum = newEodPayPosition.InterestIncomeSum + newEodPayPosition.InterestFeeSum; @@ -2379,16 +2371,8 @@ namespace YLErp.Modules.SwapModule $",TdCloseInterest is {newEodPayPosition.TdCloseInterest}"); Log.Info($"InterestFeeSum is {eodPayPosition.InterestFeeSum},TdInterestFee is {newEodPayPosition.TdInterestFee}" + $",TdCloseInterestFee is {newEodPayPosition.TdCloseInterestFee}"); - //累计已实现 - // RealizedInterest 只增不回滚:上日累计已实现 + 当日结息按方向后的金额。 - // 收取腿的 -37119.14 会把累计已实现更新为 -37119.14;后续普通 EOD 保持该值。 - var rolled = InterestIncomeCalc.RollRealized(eodPayPosition.RealizedInterest, eodPayPosition.RealizedInterestFee, newEodPayPosition.TdCloseInterest, newEodPayPosition.TdCloseInterestFee, ratio); - newEodPayPosition.RealizedInterest = rolled.Interest; - newEodPayPosition.RealizedInterestFee = rolled.Fee; - SetFixedLegRealizedPnl(newEodPayPosition); - var currencyRate = GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, valueDate, true, - DirectionRatio.RateType(position.InterestDirection)); - newEodPayPosition.TdCurrency = Convert.ToDecimal(currencyRate); + //累计已实现(滚存语义见 FinalizeInterestEodRoll:只增不回滚) + FinalizeInterestEodRoll(eodPayPosition, newEodPayPosition, ratio, td, valueDate, position.InterestDirection); Log.Info($"即将插入数据库的 newEodPayPosition is {JsonHelper.Serialize(newEodPayPosition, false)}"); PersistEodSwapPosition(newEodPayPosition); return interests; @@ -2402,11 +2386,13 @@ 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; var tradeExtend = td.trade_extend.ExtendObj; + // orginPv 在此仅对固定值腿(mode 1)生效;保证金腿(5/6)的 orginPv 虽在此赋值, + // 但 GetInterests 保证金分支已走 CalcMarginInterest(内部自算 orginPv=PreviousBalance),忽略此处传入值。 var interestModes = MarginModes.FixedAmountAndMargin; if (eodPayPosition == null) { @@ -2424,7 +2410,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; @@ -2447,7 +2433,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) { @@ -2472,7 +2458,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; @@ -2500,14 +2486,8 @@ namespace YLErp.Modules.SwapModule //持仓价值 newEodPayPosition.SwapPositionValue = PositionValueCalc.Calc(newEodPayPosition.InterestProfitSum, newEodPayPosition.PosiProfitSum, (int)ratio); - //累计已实现 - var rolled = InterestIncomeCalc.RollRealized(eodPayPosition.RealizedInterest, eodPayPosition.RealizedInterestFee, newEodPayPosition.TdCloseInterest, newEodPayPosition.TdCloseInterestFee, ratio); - newEodPayPosition.RealizedInterest = rolled.Interest; - newEodPayPosition.RealizedInterestFee = rolled.Fee; - SetFixedLegRealizedPnl(newEodPayPosition); - var currencyRate = GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, valueDate, true, - DirectionRatio.RateType(eodPayPosition.InterestDirection)); - newEodPayPosition.TdCurrency = Convert.ToDecimal(currencyRate); + //累计已实现(滚存收尾见 FinalizeInterestEodRoll;方向源=eodPayPosition,与其他三方法不同,勿统一) + FinalizeInterestEodRoll(eodPayPosition, newEodPayPosition, ratio, td, valueDate, eodPayPosition.InterestDirection); Log.Info($"the last newEodPayPosition is {JsonHelper.Serialize(newEodPayPosition, false)}"); PersistEodSwapPosition(newEodPayPosition); @@ -2603,7 +2583,7 @@ namespace YLErp.Modules.SwapModule newEodPayPosition.RealizedFee = closeFee; newEodPayPosition.RealizedMtmPnL = newEodPayPosition.TdCloseMtmPnl; newEodPayPosition.RealizedDividend = newEodPayPosition.TdCloseDividend; - SetFloatingRealizedPnl(newEodPayPosition); + EodPnlCalculator.SetFloatingRealizedPnl(newEodPayPosition); newEodPayPosition.PosiStatus = payQty == 0 ? 1 : 0; UpdateDbOption(newEodPayPosition); @@ -2658,6 +2638,11 @@ namespace YLErp.Modules.SwapModule curretEod.TdPosiDividend = DividendCalc.AfterTax(payment, tax); } curretEod.PosiDividendSum = eod.PosiQuantity > 0 ? Math.Round(eod.PosiDividendSum + curretEod.TdPosiDividend, 2) : 0; + // 分红递推过程常驻记录(快速定位):窗口/数量/税率/当日新计/累计前后值—— + // 配合 BondPaymentService 的[分红-登记日口径]窗口命中日志,构成"命中哪些登记日→算出多少→账滚到多少"全链 + Log.Info($"[分红-EOD计提Copy] tradeId={td.id} posiId={eod.PositionId} valueDate={valueDate:yyyy-MM-dd} " + + $"window=({eod.ValueDate:yyyy-MM-dd},{valueDate:yyyy-MM-dd}] qty={curretEod.PosiQuantity} tax={tax} " + + $"TdPosiDividend={curretEod.TdPosiDividend} PosiDividendSum {eod.PosiDividendSum}->{curretEod.PosiDividendSum}"); curretEod.PosiQuantity = eod.PosiQuantity; if (curretEod.PosiStatus == 1) { @@ -2677,7 +2662,7 @@ namespace YLErp.Modules.SwapModule curretEod.RealizedMtmPnL = eod.RealizedMtmPnL + curretEod.TdCloseMtmPnl; curretEod.RealizedDividend = eod.RealizedDividend + curretEod.TdCloseDividend; curretEod.RealizedFee = eod.RealizedFee + curretEod.TdCloseFee; - SetFloatingRealizedPnl(curretEod); + EodPnlCalculator.SetFloatingRealizedPnl(curretEod); var currencyRate = new EodCurrencyRateService(UserInfo).GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, td.StartDate.Value , seekPreday: true, currencyRateType: DirectionRatio.RateType(curretEod.PosiDirection)); curretEod.TdCurrency = Convert.ToDecimal(currencyRate); @@ -2692,17 +2677,6 @@ namespace YLErp.Modules.SwapModule return curretEod; } - /// - /// 浮动腿累计已实现盈亏由盯市、分红和费用三个已实现组成项汇总。 - /// 各组成项已经按本方视角落库,此处不再额外转换方向。 - /// - private static void SetFloatingRealizedPnl(eod_swap_position position) - { - position.RealizedPnl = position.RealizedMtmPnL - + position.RealizedDividend - + position.RealizedFee; - } - /// /// 更新虚拟交易费用 /// @@ -2741,11 +2715,11 @@ namespace YLErp.Modules.SwapModule int shortRatio = DirectionRatio.LongShort(eod.PositionType); int directionRatio = DirectionRatio.ReceivePay(eod.PosiDirection); var price = GetSwapValuationPrice(eod.UnderlyingCode, dealDate, out decimal vobp); - var todayConsumedDividend = CalcConsumedDividend(curretEod, unwindEvents); - var originNotional = (decimal)td.OriginalStockEqvNotional / swapPosition.PosiNetPrice; - decimal totalPayment = CalcBondPayment(curretEod.UnderlyingCode, td.StartDate.Value, valueDate, (decimal)originNotional, shortRatio, directionRatio); + // 历史遗留死代码已删(2026-08-16,论证+边界测试见 DividendEodNoDoubleCountTest.脏数据边界_*): + // todayConsumedDividend / originNotional / totalPayment / totalInterest 自 0910969e(2026-07-02 + // 改递推式) 起计算结果从未被消费,仅残留一次全历史 CalcBondPayment 只读查询+日志副作用, + // 且构成脏数据(OriginalStockEqvNotional=null/PosiNetPrice=0)下的 EOD 崩溃点。回退=git revert 本提交。 decimal tax = um.ValueAddedTax ?? 0; - decimal totalInterest = DividendCalc.AfterTaxRaw(totalPayment, tax); SetPriceInfoByFlowEvent(eod, curretEod, unwindEvents, swapPosition); curretEod.dv01 = Dv01Helper.CalcDv01(eod.UnderlyingCode, curretEod.PosiQuantity, eod.PosiDirection, eod.PositionType, vobp); curretEod.UnderlyingPrice = price; @@ -2778,7 +2752,12 @@ namespace YLErp.Modules.SwapModule { curretEod.PosiDividendSum = 0; } - SetFloatingRealizedPnl(curretEod); + // 分红递推过程常驻记录(快速定位):当日事件路径含实现扣减(前日+新计-当日实现) + Log.Info($"[分红-EOD计提Update] tradeId={td.id} posiId={eod.PositionId} valueDate={valueDate:yyyy-MM-dd} " + + $"window=({eod.ValueDate:yyyy-MM-dd},{valueDate:yyyy-MM-dd}] qty={curretEod.PosiQuantity} tax={tax} " + + $"TdPosiDividend={curretEod.TdPosiDividend} TdCloseDividend={curretEod.TdCloseDividend} " + + $"PosiDividendSum {eod.PosiDividendSum}->{curretEod.PosiDividendSum}"); + EodPnlCalculator.SetFloatingRealizedPnl(curretEod); curretEod.SwapPositionValue -= curretEod.TdCloseDividend; curretEod.PosiProfitSum = MtmCalc.ReturnLegProfitSum(curretEod.PosiMtmPnL, curretEod.PosiDividendSum, curretEod.PosiFeePending); @@ -2800,20 +2779,6 @@ namespace YLErp.Modules.SwapModule return curretEod; } - private decimal CalcConsumedDividend(eod_swap_position curretEod, List events) - { - decimal consumedDividend = 0; - - List swapEventTypes = new List() { (int)SwapEventTypeEnum.互换, (int)SwapEventTypeEnum.自动互换 }; - //这里要剔除掉平仓产生的分红 - consumedDividend = events - .Where(x => x.SwapTradeId == curretEod.SwapTradeId - && swapEventTypes.Contains(x.EventType) - && x.DataState == (int)SwapFlowDateStateEnum.完成) - .Sum(s => s.DividendIn); - return consumedDividend; - } - /// /// 根据开平仓事件算价格及后付费用 /// @@ -2958,7 +2923,7 @@ namespace YLErp.Modules.SwapModule curretEod.RealizedMtmPnL = curretEod.TdCloseMtmPnl; curretEod.RealizedDividend = curretEod.TdCloseDividend; curretEod.RealizedFee = curretEod.TdCloseFee; - SetFloatingRealizedPnl(curretEod); + EodPnlCalculator.SetFloatingRealizedPnl(curretEod); curretEod.PosiStatus = curretEod.PosiQuantity == 0 ? 1 : 0; if (curretEod.PosiStatus == 1) { @@ -3037,7 +3002,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();//持仓腿 // 框架合约的方向约定:多头为正、空头为负;总名义本金取交易原始规模, @@ -3049,8 +3014,8 @@ namespace YLErp.Modules.SwapModule eod_Swap.BookId = td.AssetId; eod_Swap.ValueDate = settleDate; eod_Swap.StructureType = td.StructureType; - FillPositionLegSummary(eod_Swap, positions); - eod_Swap.InterestPnL = SumInterestPnL(interestPositions); + EodPnlCalculator.FillPositionLegSummary(eod_Swap, positions); + eod_Swap.InterestPnL = EodPnlCalculator.SumInterestPnL(interestPositions); eod_Swap.PostionValue = eodSwapPositions.Sum(s => s.SwapPositionValue); // 保证金腿的利息现金流方向与保证金本金方向相反。 // 不能直接汇总 RealizedPnl,否则“收取客户保证金”的腿会把应支付给客户的 @@ -3099,12 +3064,12 @@ 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); - FillPositionLegSummary(eod_Swap, positions); - eod_Swap.InterestPnL += SumInterestPnL(interestPositions); + EodPnlCalculator.FillPositionLegSummary(eod_Swap, positions); + eod_Swap.InterestPnL += EodPnlCalculator.SumInterestPnL(interestPositions); eodSwapPositions.ForEach(x => { var ratio = DirectionRatio.InterestLegPnl(x.InterestDirection, x.InterestMode); @@ -3130,38 +3095,7 @@ namespace YLErp.Modules.SwapModule /// 我方支付给对手方的成本计入,而不会错误增加框架合约已实现收益。 /// 抽为静态纯函数以支持无库单测(marginTypes 等价于 ConsTrade.InterestMarginModels)。 /// - public static decimal CalculateSwapRealizedPnl(eod_swap_position position) - { - var interestRatio = DirectionRatio.InterestLegPnl(position.InterestDirection, position.InterestMode); - - return position.RealizedMtmPnL - + position.RealizedDividend - + position.RealizedFee - + position.RealizedInterest * interestRatio - + position.RealizedInterestFee; - } - - /// 填充框架合约的持仓腿汇总字段(多空名义本金/市值/浮动盈亏/dv01/平仓量)。 - /// SaveEodSwap 与 UpdateEodSwap 共用,消除 ~10 行重复。 - private static void FillPositionLegSummary(eod_swap eod_Swap, List positions) - { - eod_Swap.NotionalValueLong = Math.Round(positions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.PosiNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - eod_Swap.NotionalValueShort = Math.Round(-Math.Abs(positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue)), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - eod_Swap.MarketValueLong = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.UnderlyingMarketValue); - eod_Swap.MarketValueShort = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.UnderlyingMarketValue); - eod_Swap.FloatingPnL = positions.Sum(s => s.PosiProfitSum); - eod_Swap.dv01 = positions.Sum(s => s.dv01 ?? 0); - eod_Swap.TdCloseQty = positions.Sum(s => s.TdCloseQty); - } - - /// 利息腿 PnL 汇总(按方向比例 + 保证金翻转)。原 SaveEodSwap/UpdateEodSwap 各一段 ForEach。 - private static decimal SumInterestPnL(List interestPositions) - { - decimal interestPnL = 0; - foreach (var x in interestPositions) - interestPnL += x.InterestProfitSum * DirectionRatio.InterestLegPnl(x.InterestDirection, x.InterestMode); - return interestPnL; - } + public static decimal CalculateSwapRealizedPnl(eod_swap_position position) => EodPnlCalculator.CalculateSwapRealizedPnl(position); /// /// 风险报表符号归一化:把历史两种符号口径的 TdCloseInterest/RealizedInterest @@ -3170,20 +3104,7 @@ namespace YLErp.Modules.SwapModule /// 抽为 public static 纯函数以支持无库单测(见 SwapReportInterestSignNormalizeTest)。 /// 仅当 InterestDirection > 0 时执行(与原内联逻辑等价)。 /// - public static void NormalizeInterestSignForReport(eod_swap_position position) - { - if (position.InterestDirection <= 0) return; - - if (position.InterestMode == (int)InterestModeEnum.标的期初全价) - { - return; - } - var interestRatio = DirectionRatio.InterestLegPnl(position.InterestDirection, position.InterestMode); - position.TdCloseInterest = Math.Abs(position.TdCloseInterest) * interestRatio; - position.RealizedInterest = Math.Abs(position.RealizedInterest) * interestRatio; - // 兼容修复前已落库的利息腿:当时只累计了明细字段,未同步写入 RealizedPnl。 - position.RealizedPnl = position.RealizedInterest + position.RealizedInterestFee; - } + public static void NormalizeInterestSignForReport(eod_swap_position position) => EodPnlCalculator.NormalizeInterestSignForReport(position); /// /// 获取多空组合 平仓详细 @@ -3194,7 +3115,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; @@ -3435,7 +3356,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(); } /// /// 获取互换交易日终持仓数据集合 @@ -3673,7 +3594,7 @@ namespace YLErp.Modules.SwapModule item.position.FloatRateUnderlyingCode = floatRateInterest?.FloatRateUnderlyingCode; item.position.FloatRate = floatRateInterest?.FloatRate ?? 0; item.OpenMarginAmount = initialMargins.Sum(s => s.InterestPrincipalFix * DirectionRatio.ReceivePay(s.InterestDirection)); - item.OpenMarginRate = CalculateWeightedMarginRate(tradeMargins); + item.OpenMarginRate = EodPnlCalculator.CalculateWeightedMarginRate(tradeMargins); item.AdditionalMarginAmount = additionalMargins.Sum(s => s.InterestPrincipalFix * DirectionRatio.ReceivePay(s.InterestDirection)); item.MarginInterestAmount = CalculateWeightedMarginInterest(eodMargins); item.InterestAmount = eodInterests.Sum(s => s.InterestIncomeSum * (-DirectionRatio.ReceivePay(s.InterestDirection))); @@ -3698,29 +3619,12 @@ namespace YLErp.Modules.SwapModule return retListResult; } - /// - /// 计算预付金利率。多条初始/追加预付金腿按本金绝对值加权, - /// 不按收付方向轧差,避免相反方向本金抵消后放大利率。 - /// - private static decimal CalculateWeightedMarginRate(IEnumerable margins) - { - var marginList = margins.ToList(); - var totalWeight = marginList.Sum(x => Math.Abs(x.InterestPrincipalFix)); - return totalWeight == 0 - ? 0 - : marginList.Sum(x => x.InterestRateDefault * Math.Abs(x.InterestPrincipalFix)) / totalWeight; - } - /// /// 计算预付金利息金额。InterestIncomeSum 已是各腿利息金额, /// 按收取为正、支付为负直接轧差求和,不做本金加权。 /// 抽为 public static 纯函数以支持无库单测(见 SwapWeightedMarginInterestTest)。 /// - public static decimal CalculateWeightedMarginInterest(IEnumerable margins) - { - return margins.Sum(x => - x.InterestIncomeSum * DirectionRatio.ReceivePay(x.InterestDirection)); - } + public static decimal CalculateWeightedMarginInterest(IEnumerable margins) => EodPnlCalculator.CalculateWeightedMarginInterest(margins); /// /// 固定利息腿的累计已实现盈亏 = 累计已实现利息 + 累计已实现利息费用。 @@ -3728,10 +3632,7 @@ namespace YLErp.Modules.SwapModule /// 抽为 public static 纯函数以支持无库单测(见 SwapFixedLegRealizedPnlTest), /// 并消除复制粘贴带来的笔误风险(如 L1296 历史双分号)。 /// - public static void SetFixedLegRealizedPnl(eod_swap_position position) - { - position.RealizedPnl = position.RealizedInterest + position.RealizedInterestFee; - } + public static void SetFixedLegRealizedPnl(eod_swap_position position) => EodPnlCalculator.SetFixedLegRealizedPnl(position); /// /// 将数据库中以公司/交易簿记方向保存的日终字段转换为客户视角。 /// 该转换必须在拆分浮动收益、费用和期间付息/分红之前完成, diff --git a/YLErpDAL/Modules/SwapModule/SwapFlowService.cs b/YLErpDAL/Modules/SwapModule/SwapFlowService.cs index ba2eb4b1..224a5495 100644 --- a/YLErpDAL/Modules/SwapModule/SwapFlowService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapFlowService.cs @@ -10,6 +10,7 @@ using YLErp.Helpers; using YLErp.Model; using YLErp.Model.Enum; using YLErp.Modules.EodModule; +using YLErp.Modules.DataProviderModule; using YLErp.Modules.SwapModule.Dto; using YLErp.Office; using YLErp.QdpModule; @@ -70,6 +71,7 @@ namespace YLErp.Modules.SwapModule } DbContext.eod_commodity_future_price.Remove(frdata); DbContext.SaveChanges(); + Fr007FixingCache.Invalidate(); // 写侧失效:删除当日定盘后缓存立即重载,防旧值沿用 return true; } /// @@ -108,16 +110,25 @@ namespace YLErp.Modules.SwapModule DbContext.Add(frdata); } frdata.UnderlyingId = EodPriceService.ResolveUnderlyingIdForCode(frdata.UnderlyingCode, frdata.UnderlyingId ?? 0, frUnderlying.id); - frdata.ClosePrice = Math.Round(price, 4); - frdata.SettlePrice = Math.Round(price, 4); - frdata.ReferencePrice = Math.Round(price, 4); + frdata.ClosePrice = RoundFr007Price(price); + frdata.SettlePrice = RoundFr007Price(price); + frdata.ReferencePrice = RoundFr007Price(price); frdata.OptId = UserInfo.UserId; frdata.OptName = UserInfo.UserName; frdata.OptDate = DateTime.Now; DbContext.SaveChanges(); + Fr007FixingCache.Invalidate(); // 写侧失效:新增/修改定盘后缓存立即重载 return true; } + /// + /// FR007 界面手工录入定盘的落库精度。FR007 官方发布为百分数下 4 位(如 1.4150%), + /// 小数口径需 6 位(0.014150)——原 Math.Round(,4) 只保留百分数下 2 位,1.4150% 被截成 + /// 1.4200%(丢 0.5bp)。取 6 位与前端 toNumber(value/100, 6) 对齐;DB 列 double(18,10) 容纳无虞; + /// bond-sync 自动同步链(BigDecimal 全精度透传)不经此函数。 + /// + internal static double RoundFr007Price(double price) => Math.Round(price, 6); + /// /// 查询互换流水导入 /// 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/SwapTradeBaseService.cs b/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs index e0068952..ce6d0e18 100644 --- a/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs @@ -376,19 +376,18 @@ namespace YLErp.Modules.SwapModule /// 计息方式 /// 计息开始日期 /// 计息结束日期 + /// true=计息窗口为空(interestStart>interestEnd,本次不计利息,调用方将利率与金额归零); + /// 典型触发=①不算头首日(valueDate==StartDate → StartDate+1>StartDate) ②不算尾到期日回拨后窗口翻转 + /// (interestEnd=到期日−1 < interestStart)。判定只看日期窗口,与事件类型无关。 + /// 注:当日已结息(preSettleDate==valueDate)日期相等时本函数返回 false——利息归零由 + /// GetInterests 的 closeList 净额层处理,不在本判定。 public bool InitInterestDate(DateTime valueDate, DateTime? preSettleDate, trade td, bool tdClose, out DateTime interestStart, out DateTime interestEnd) { interestStart = td.StartDate.Value; var exerciseDate = td.ExerciseDate.Value; interestEnd = valueDate > exerciseDate ? exerciseDate : valueDate; - bool calcFirst = true; - bool calcLast = true; - - if (td.trade_extend != null) - { - calcFirst = td.trade_extend.ExtendObj.InterestCalcMode.StartsWith("1");//算头 - calcLast = td.trade_extend.ExtendObj.InterestCalcMode.EndsWith("1");//算尾 - } + bool calcFirst = td.trade_extend?.ExtendObj.CalcFirst ?? true; + bool calcLast = td.trade_extend?.ExtendObj.CalcLast ?? true; interestStart = calcFirst ? interestStart : interestStart.AddDays(1); if (preSettleDate.HasValue && preSettleDate >= interestStart) { @@ -398,7 +397,9 @@ namespace YLErp.Modules.SwapModule { interestEnd = interestEnd.AddDays(-1); } - if (interestStart > interestEnd || td.StartDate > interestStart) + // 原第二 OR 子句 td.StartDate > interestStart 恒 false(interestStart 经上面调整恒 ≥ td.StartDate: + // =开始日 / 不算头+1天 / preSettleDate 且仅当 ≥interestStart 才覆盖),死代码已删(2026-08-19)。 + if (interestStart > interestEnd) { interestStart = interestEnd; return true;//不记利息 diff --git a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs index 1198ab79..8f6ecfa3 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/SwapModule/TEST-MATRIX.md b/YLErpDAL/Modules/SwapModule/TEST-MATRIX.md new file mode 100644 index 00000000..f4836a18 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/TEST-MATRIX.md @@ -0,0 +1,143 @@ +# 融资腿计息测试矩阵 + +> 配套 [ARCHITECTURE.md](ARCHITECTURE.md)。目的:把"覆盖"从用例计数变成格子坐标运算—— +> 每个用例/fix 显式登记命中坐标,空洞一眼可见。2026-08 建立,依据近 6 周 fix 热力图回溯登记。 + +## 0. 范围声明 + +- 本矩阵只覆盖**融资腿 FundingLeg(mode 1 固定值 / 2 合约名义本金规模 / 9 标的期初全价)**。 +- **mode 5/6(保证金/预付金)不属于本矩阵**(历史遗留:被错误建模为计息腿,概念上与融资腿无关, + 见 `Margin/MarginModes.cs` 注释)。保证金有独立的余额模型与专属黄金回放(96 库 60 条,0 差异)作为护栏。 + 禁止向本矩阵添加 5/6 格子。 +- 主力生产组合(确认书规定)**必须全格覆盖**,见 §1。 + +## 1. 主力族(第一优先级,必须全盖) + +``` +InterestMode = 9 标的期初全价 × FR007 浮动(±点差) × InterestType = 复利 × InterestCalcMode = "10"(算头不算尾) +``` + +代码锚点:`SwapDealService.GetInterests`(calcFirst=true / calcLast=false,SwapDealService.cs:646)。 +近 6 周 ≥9 个 fix 落在本族内——生产用得最多 = 人工测试打得最狠,fix 清单就是炸点热力图。 + +## 2. 维度定义 + +| 维度 | 取值 | 代码/数据锚点 | +|---|---|---| +| A 生命周期终点 | 持有至到期结算 / 盘中全平 / 盘中部分后持有 / 部分N次后全平 / EOD自动平仓(部分·全) / 互换(续作) | `SwapEventTypeEnum`;到期:`SwapEodPositionService` 到期结算路径 | +| B 重置几何 | 第1重置期内平仓 / 跨≥1完整重置期 / 第3重置期内 / **平仓日=重置日** / 重置日±1天 / **末段非整周期**(di<7) | 重置频率=7天(已定格,§8);契约重置期定义见 §8a | +| C 比例与次数 | 单次部分(30%) / 同日两次 / 跨重置期多次 / 全平(剩余=0) | `closePrecent`;双语义转换 `ClosePercentMath` | +| D 交收 | T+0 / T+1 | `valueDate` vs `unwindDate` | +| E FR007 形态 | 每重置日有价 / 加点(+0.25%) / 减点(-2.10%) / **取价日=重置日上一营业日**(契约规定) / 缺价分支 | `TryGetFloatRate` / `ResolveFloatRate`;66a97e03 对应此维 | +| F 入口 | 见 §3 | | +| G 断言投影 | ①最终利息金额 ②`TdInterestPrincipal` 逐日携带链 ③`InterestIncomeSum`+flow_event 全字段 ④方向/符号(报表口径) | 每格必须断言全部 4 个投影 | + +## 3. 入口枚举(F 维) + +| 入口 | 代码路径 | +|---|---| +| 盘中平仓/互换结息试算 | `SwapDealService.GetInterestsForUnwind`(SwapDealService.cs:617,settment:false → `CalcUnwindInterest`) | +| EOD 正常收盘 | `GetInterests(settment:true)` → `CalcEodInterest` | +| EOD 平仓后收盘 | `SwapEodPositionService.SaveAutoEodWithCloseInterestPosition`(:1246)→ `CalcSwapInterests`(:1579) | +| EOD 自动互换 | `CalcSwapInterests`(:1161,EventType=自动互换) | + +已知风险:`GetInterests` 参数语义随入口漂移(EOD 平仓后收盘传"剩余本金+percent=1", +盘中传"平仓前本金+实际比例"),`GetInterestsEntrySemanticsTest` 曾实测双入口复利口径分歧(b01b485e)。 + +## 4. fix 热力图(本族,近 6 周) + +| fix | 落点 | 格子坐标 | 自带测试 | +|---|---|---|---| +| 66a97e03 重置日=平仓日 calcLast 不跳过 FR007 取价 | SwapDealService:1249/1295 | B=重置日=平仓日 × E=取价边界 | GLMS20260805FR007UnderlyingIdDiagnoseTest(581行) | +| 48e84479 重置日部分平仓本金 | SwapEodPositionService:1422 | B=重置日=平仓日 × C=部分 | SwapCloseConversationCasesRegressionTest(358行) | +| d3afa6d2 T+1 部分平仓复利本金(算头不算尾快速路径) | SwapDealService:1243 | D=T+1 × C=部分 × A=部分后持有 | DealInterestsScenarioTest +36行 | +| aa5a5ed8 算头不算尾期初复利部分平仓 | SwapEodPositionService:1418-1573 | **本族正中心** | DealInterestsScenarioTest | +| a0be0eb0 复利平仓已结利息扣除 | SwapDealService:1294 | 已结利息差分(CalcDailyCompoundInterest 回放) | ConsumedInterestScenarioTest | +| 5539bd9c 复利部分平仓后 EOD 本金 | SwapEodPositionService:782/1381 | G=携带链投影 | DealInterestsScenarioTest +34行 | +| feffc196 Bug A/B/C 浮动部分/全平尾差 | SwapDealService:1214/1292 | C=部分/全平 × E=浮动 | **SwapInterestScenario3And4FloatingTest(24用例,Excel oracle)** | +| 2035e1df EOD 平仓后收盘结息本金语义 | SwapDealService:1293 / EodService:860,1360 | **F=EOD平仓后收盘 × C** | **无测试** | +| b01b485e 双入口口径分歧(实测发现) | — | F=入口 × 全族 | GetInterestsEntrySemanticsTest(字符化,非 oracle) | + +**规律:fix 全部落在 `CalcUnwindInterest`(SwapDealService 1240-1300)和 +`SaveAutoEodWithCloseInterestPosition` 族(SwapEodPositionService 1380-1580)两个带。** + +## 5. 现有用例登记 + +| 测试文件 | 覆盖格子 | oracle 类型 | +|---|---|---| +| SwapInterestScenario3And4FloatingTest(24) | 本族 A=全平/部分30%→全平 × B=第3重置期内 × D=T+0/T+1 × E=加减点 × F=EOD平仓后收盘 × G=仅金额投影 | Excel 手算(业务源) | +| SwapInterestScenario1And2Test(32) | A=收盘平仓 × B=第1重置期内 × E=固定/浮动 | Excel 手算 | +| DealInterestsScenarioTest(24 方法,工单逐个追加) | 部分平仓×复利族各点,含"10"×3 行 | 工单期望值 | +| ConsumedInterestScenarioTest | 已结利息差分族 | 工单期望值 | +| SwapUnwindSameDayDoublePartialTest | C=同日两次 | **字符化(非独立 oracle)** | +| GetInterestsEntrySemanticsTest | F=双入口一致性 | **字符化** | +| **ContractReferenceOracleTest(Accrual/,7)** | mode9/mode2 × 复利 × "10" × T+0 × 部分30% × B=跨12整期+末段(90/89天) × E=恒定利率(取价日免疫) | **契约公式参考实现(§7.4 第一级)**——引擎盘中重放已逐分对齐 oracle | +| GetInterestsUnitTest_T0/T1(89) | mode 1 固定值 T+0/T+1 族(非本族) | 单点断言 | +| GLMS20260805FR007UnderlyingIdDiagnoseTest | B=重置日=平仓日 × E | 诊断+断言 | + +## 6. 空洞清单(热力图 ∩ 未覆盖,按优先级) + +1. **F=EOD平仓后收盘 × C=部分平仓 × 本族** —— 2035e1df 无测试落地即合入,该入口×比例格子全裸。 +2. **G=携带链投影(全族)** —— 现有断言几乎全是最终金额;`TdInterestPrincipal` 逐日携带链无一处断言 + (7528670e 在单利上炸过同款,复利同投影裸奔)。 +3. **B=重置日±1天 / 跨重置期多次部分平仓** —— 热力图边缘未扫。 +4. **A=到期结算 × 本族** —— db46e48e 修过到期结算(28 断言),但非本族参数。 +5. **A=互换(续作) × 本族** —— 7411b9d2/421662a0 炸过续作初始化,本族续作无 oracle。 +6. **C=同日两次** —— 只有字符化测试,无独立 oracle(字符化=锁定现状,不证正确)。 +7. **E=缺价/取价日边界** —— 66a97e03 只修了取价跳过,缺价分支行为未钉。 + +## 7. 补盖执行顺序 + +1. 先铺**守恒不变量**(免 oracle,全格便宜):部分平仓后"期初=平掉+剩余"逐日守恒;全平后持仓=0; + 复利重置日动态本金=前段本金+利息;多次平仓 closePercent 连乘=累计比例。 +2. 空洞 1/2 优先:按 §2-G 四投影补 EOD平仓后收盘 × 部分 用例,oracle 用确认书公式 Excel 模板。 +3. 空洞 6 补独立 oracle(确认书公式),替换字符化地位(保留字符化作回归钉)。 +4. 每格期望值来源分级(已升级,见 §8a):**契约公式独立参考实现** > 生产已对账数字 > 业务签认 Excel > 新旧影子对比;**禁止当前代码输出充当 oracle**。 +5. 契约参考实现(§8a 公式)**已落地**(`UnitTestProject/Modules/SwapModule/Accrual/ContractReferenceCalc.cs`, + 独立于生产引擎,禁止引用计息类防同源),引擎对照首批 3 例全绿(mode9/mode2 × "10" × 部分30%, + `ContractReferenceOracleTest`)。后续补格直接复用:期望值 = `ClosedInterest(平掉额, ReferenceRateAbsolute(...))`。 + 待办:变利率引擎侧对照(取价日 E 维)、确认书生成器参数同源断言(`swap_position`)。 + +## 8. 生产参数(已确认,2026-08) + +- **重置频率 = 7 天**(确认书:"重置频率每【周】";完整重置期 di=7 天) +- **年化基数 = 365**(确认书:"计息基准 A/365",固定利率公式同除 365) +- 生产只有这一种组合,无 360/其他重置频率。现有测试参数 `ResetPeriod=7 / AnnualDays=365` **即为生产主力参数,格子按此定格**。 + +## 8a. 契约 oracle(确认书公式原文) + +模板:`Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/*.docx`(看多/看空 × 现券/债券ETF 共 4 份,计息条款一致); +变量替换:`Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs` +(`重置频率=interest_rest_days天`、`利差=InterestRateDefault×10000bp`,与计息引擎同源 `swap_position` 字段)。 + +**浮动利率复利公式(本族契约正文)**: + +``` +参考利率(绝对) = ∏[i=1..k] ( 1 + (FR007i + 利差) × di / 365 ) − 1 +``` + +- k = 计息期包含的重置期个数;di = 第 i 个基准利率适用的日历日数,**完整重置期 di=7,末段不足 7 按实际日历日**(测试必须盖非整周期:如持有 17 天 = 2×7+3) +- **利率确定日 = 每个重置期首日(重置日)的上一个营业日**,取该日 FR007;营业日准则=上一营业日(→ E 维度取值锚点,与 66a97e03 修复直接对应) +- FR007 取中国货币网每日公布值 + +**计息期定义(= 算头不算尾的契约原文)**:自起始日(含)至到期日(不含)的自然日天数。 +⚠ 债券ETF 模板变体:计息期自**期初观察日**(含)至**期末观察日**(不含)——观察日→代码日期字段的映射需单独核实,是一个潜在口径分叉点。 + +**重置期定义**:每个重置期自上一重置日(含)至下一重置日(不含);首个重置期始于计息期首日;最后一个重置期的最后一日为计息期最后一日(末段收口)。重置日从计息期首日按重置频率依次推算。 + +**固定利率公式**:参考利率(绝对) = 固定利率 × 计息期 / 365。 +**期初预付金利息**:支付日(含)至到期日(**含**)×利率/计息基准——注意预付金契约上是"含尾"的,与利率腿"不含尾"相反。 + +**oracle 使用方式(升级 §7)**:最强形式是**按契约公式写独立参考实现**(约 20 行:重置日推算 + 分段取价 + ∏ 公式 + 末段收口),作为测试 oracle 与生产引擎对照,容差 0.01。它比逐格 Excel 手算更便宜且零同源风险;Excel 模板退化为抽样校验参考实现本身。 + +## 9. 合入规则(硬约束) + +1. 计息类 fix:**先失败测试,后修代码**;测试须登记本矩阵坐标。 +2. 修一格必须**扫同矩阵行兄弟格子**(同 fix 家族的邻格)。 +3. 任何触碰 `GetInterests`/`CalcUnwindInterest`/`SaveAutoEodWithCloseInterestPosition` 的 PR: + 跑 `DealInterestsGoldenReplayTest` 全量 + 保证金黄金回放(防共享管线殃及)。 +4. 登记 fix 时发现同格已有用例而 bug 仍发生 → 先修断言投影,再修代码。 +5. **oracle 用例与裁决材料一律取 §8 生产参数**(7 天重置 / A365 / 真实点差 ±0.25%·−2.10% / + 千万级名义本金,如 5000 万)。玩具参数(千元级/重置 3 天/点差 1%)仅限字符化钉子测试—— + 其用途是锁行为防漂移,不承担"证明数字正确"职责;用玩具数字做裁决依据会掩盖金额量级 + (0.03 vs 0.06 看着"不大",同参数放大到生产即 7.6 万 vs 25 万/笔)。 diff --git a/YLErpDAL/Modules/SwapModule/TradingFeeCalc.cs b/YLErpDAL/Modules/SwapModule/TradingFeeCalc.cs new file mode 100644 index 00000000..3c9cea1c --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/TradingFeeCalc.cs @@ -0,0 +1,43 @@ +using YLErp.DBModels; +using YLErp.DBModels.Consts; + +namespace YLErp.Modules.SwapModule; + +/// +/// 平仓手续费计算——纯 static,无 this 依赖。 +/// 从 SwapDealService 提取,零行为变更。 +/// +public static class TradingFeeCalc +{ + public static decimal CalcInitTradingFee(swap_position oriPosition, UnwindData unwindData) + { + if (oriPosition == null || unwindData == null) + { + return 0; + } + + if (oriPosition.PosiFeeType == 1) + { + return Math.Round(oriPosition.PosiTradingFeeUnit * unwindData.CloseQty, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + } + + return Math.Round(oriPosition.PosiTradingFeeUnit / 100m * unwindData.CloseNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + } + + public static decimal CalcInitTradingFeePending(swap_position oriPosition, swap_position position, UnwindData unwindData) + { + if (oriPosition == null || unwindData == null || oriPosition.PosiTradingFeeUnit == 0) + { + return position?.PosiTradingFeePending ?? 0; + } + + var closeBase = oriPosition.PosiFeeType == 1 ? unwindData.CloseQty : unwindData.CloseNotionalValue; + var originalBase = oriPosition.PosiFeeType == 1 ? unwindData.NotionalQty : unwindData.NotionalValue; + if (originalBase <= 0) + { + return position?.PosiTradingFeePending ?? 0; + } + + return Math.Round(oriPosition.PosiTradingFeePending * closeBase / originalBase, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + } +} diff --git a/YLErpDAL/Modules/SwapModule/UnwindNormalizer.cs b/YLErpDAL/Modules/SwapModule/UnwindNormalizer.cs new file mode 100644 index 00000000..63db255c --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/UnwindNormalizer.cs @@ -0,0 +1,104 @@ +using YLErp.Helpers; +using YLErp.Modules.SwapModule.Margin; + +namespace YLErp.Modules.SwapModule; + +/// +/// 平仓数据(UnwindData)规范化——纯 static,无 this 依赖。 +/// 从 SwapDealService 提取,零行为变更。 +/// +internal static class UnwindNormalizer +{ + internal static void NormalizeNotionalValues(UnwindData unwindData) + { + unwindData.NotionalValue = Math.Round(unwindData.NotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + unwindData.PosiNotionalValue = Math.Round(unwindData.PosiNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + unwindData.CloseNotionalValue = Math.Round(unwindData.CloseNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + } + + internal static bool NormalizeFullCloseRequest(UnwindData unwindData) + { + if (unwindData.CloseMethod != (int)CloseMethodEnum.全部平仓 + && unwindData.ClosePercent < 1 + && !(unwindData.PositionQty > 0 && unwindData.CloseQty >= unwindData.PositionQty) + && !(unwindData.PosiNotionalValue > 0 && unwindData.CloseNotionalValue >= unwindData.PosiNotionalValue)) + { + return false; + } + + var closeQty = unwindData.CloseQty; + var closeNotionalValue = unwindData.CloseNotionalValue; + unwindData.ClosePercent = 1; + if (unwindData.PositionQty > 0) unwindData.CloseQty = unwindData.PositionQty; + if (unwindData.PosiNotionalValue > 0) unwindData.CloseNotionalValue = unwindData.PosiNotionalValue; + return closeQty != unwindData.CloseQty || closeNotionalValue != unwindData.CloseNotionalValue; + } + + internal static void RecalculateNormalizedUnwindAmounts(UnwindData unwindData) + { + var floatLeg = unwindData.FlowEvents.FirstOrDefault(x => !string.IsNullOrEmpty(x.UnderlyingCode)); + if (floatLeg == null || floatLeg.PosiGrossPrice == 0) return; + + var input = new UnwindInput + { + Multiplier = ConsGlobal.InstrumentType.IsBond(floatLeg.UnderlyingInstrumentType) ? 100 : 1, + PosiGrossPrice = floatLeg.PosiGrossPrice, + TradingAmountAvg = floatLeg.TradingAmountAvg, + CloseQty = unwindData.CloseQty, + PositionQty = unwindData.PositionQty, + ContractSize = floatLeg.ContractSize, + CloseNotionalValue = unwindData.CloseNotionalValue, + PayDirection = floatLeg.PayDirection, + PositionType = floatLeg.PositionType, + TradingFee = floatLeg.TradingFee.ToString(), + TradingFeePending = floatLeg.TradingFeePending.ToString(), + DividendIn = floatLeg.DividendIn.ToString() + }; + foreach (var leg in unwindData.FlowEvents.Where(x => string.IsNullOrEmpty(x.UnderlyingCode))) + { + var target = MarginModes.Contains(leg.InterestMode) + ? input.MarginLegs + : input.InterestLegs; + target.Add(new LegInput { InterestClosePnL = leg.InterestClosePnL }); + } + + var result = FrontendCalcReference.CalcUnwind(input); + floatLeg.MarkClosePnl = result.MarkClosePnl; + unwindData.SwapCloseAmount = result.SwapCloseAmount; + unwindData.SwapRealizedPnL = result.SwapRealizedPnL; + unwindData.SwapMarginRebatePnl = result.SwapMarginRebatePnl; + } + + internal static bool IsFullCloseAfterDeduction(UnwindData unwindData, double remainingNotional, double remainingQuantity) + { + return unwindData.ClosePercent == 1 || (remainingNotional == 0 && remainingQuantity == 0); + } + + /// + /// 手工平仓、手工互换及收益结算的利息事件按金额两位落库。 + /// 自动平仓保留原有计算与落库口径,不适用本阶段的手工结算规则。 + /// + internal static bool NormalizeSettledInterestAmounts(IEnumerable flowEvents, int eventType, string eventReason) + { + if ((eventType != (int)SwapEventTypeEnum.平仓 && eventType != (int)SwapEventTypeEnum.互换) + || eventReason == "系统操作_自动平仓") + { + return false; + } + + foreach (var flowEvent in flowEvents.Where(x => string.IsNullOrEmpty(x.UnderlyingCode))) + { + flowEvent.InterestPrincipal = Math.Round(flowEvent.InterestPrincipal, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + flowEvent.InterestAmount = Math.Round(flowEvent.InterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + flowEvent.TdInterestAmount = Math.Round(flowEvent.TdInterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + flowEvent.InterestClosePnL = Math.Round(flowEvent.InterestClosePnL, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + flowEvent.InterestFee = Math.Round(flowEvent.InterestFee, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + } + return true; + } + + internal static void NormalizeEventUnwindDate(UnwindData unwindData) + { + unwindData.UnwindDate = unwindData.ValueDate; + } +} diff --git a/YLErpDAL/Modules/SwapModule/docs/eod-continuation-proposal.md b/YLErpDAL/Modules/SwapModule/docs/eod-continuation-proposal.md new file mode 100644 index 00000000..3f31c83c --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/docs/eod-continuation-proposal.md @@ -0,0 +1,66 @@ +# 任务 4:盘中复利从 EOD 续接(而非 PosiStartDate 全程重放) + +## 状态:待立项(高风险,需专项验证) + +## 现状 + +`CalcDailyCompoundInterest` 从 `position.PosiStartDate` 全程重放到 `endDate`,每个重置日把累计利息并入本金(复利),最后扣 `consumedInterest * closePercent`。 + +**调用链**:`InitSwapDealInterest` → `CalcDailyCompoundInterest(endDate, PosiStartDate→endDate 全程重放)` + +**问题**:交易存续期长(数月~数年)时,每次盘中平仓都从起息日重放,计算量随天数线性增长。 + +## 提议 + +改为从上一日终快照(`preEodPosition`)续接: +- 起点 = `preEodPosition.ValueDate + 1` +- 初始本金 = `preEodPosition.TdInterestPrincipal`(已含历史滚入利息) +- 只算 `ValueDate+1` 到 `endDate` 的增量利息 + +## 风险分析(为什么不能直接改) + +### 风险 1:并本金起点不同导致终值不等 + +| | 全程重放(当前) | EOD 续接(提议) | +|---|---|---| +| 起点 | `principal`(原始平仓名义本金) | `preEod.TdInterestPrincipal`(已滚利息) | +| 滚法 | 每段 `basis = principal + accrued` | 每段 `basis = preEod.TdInterestPrincipal + segmentAccrued` | + +两段路径在**中间重置日的四舍五入路径不同**(精度 12 的 Round 作用在不同的中间值上),终值**不一定逐分相等**。 + +### 风险 2:consumedInterest 语义翻转 + +- 全程重放:总利息 - consumedInterest × closePercent = 增量 +- EOD 续接:直接算增量,**不需要**扣 consumedInterest + +如果 EOD 快照的 `InterestIncomeSum` 与 consumedInterest 口径不完全一致,直接去掉扣减会引入误差。 + +### 风险 3:resetCarryInterest 耦合 + +当前逻辑:`resetCarryInterest`(上一日终待实现 × remainingPercent)只在 `endDate` 恰好是重置日时并入本金。EOD 续接模式下,重置日的判定、remainingPercent 的计算、carry 的注入时机都不同。 + +### 风险 4:全平重放逻辑(lines 1293-1304) + +`InitSwapDealInterest` 在 `closePrecent == 1m` 时做 **两次** `CalcDailyCompoundInterest` 重放(截至平仓日 + 截至上一日终),取差值。EOD 续接模式下这段逻辑需要完全重新设计。 + +## 验证方案(立项前提) + +1. 构造测试用例:同一笔复利交易,跨越 ≥2 个重置周期,有 preEod 快照 +2. 用**旧全程重放**算出 `(InterestAmount, TdInterestAmount, finalBasis)` +3. 用**新 EOD 续接**算出同样三个值 +4. 断言差额 < 0.01(到分) +5. 覆盖场景: + - 部分平仓(closePercent < 1) + - 全平(closePercent == 1) + - 平仓日 = 重置日 + - 平仓日 ≠ 重置日 + - 有/无 consumedInterest + - 有/无 resetCarryInterest + +## 建议排期 + +单独 sprint 处理,不混入日常重构。改动范围: +- `CompoundInterestAccrual.AccruePeriod` 新增 `startBasis` 参数(或新方法 `AccrueFromEod`) +- `CalcDailyCompoundInterest` wrapper 改为传 `preEod.TdInterestPrincipal` 作为起点 +- `InitSwapDealInterest` 全平重放逻辑简化(不再需要两次重放取差值) +- `consumedInterest` 扣减逻辑移除或调整 diff --git a/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs b/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs index ae140dd0..4e9be366 100644 --- a/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs +++ b/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs @@ -7,6 +7,7 @@ using System.Linq.Expressions; using YLErp.BLL.Eod; using YLErp.DBModels; using YLErp.Helpers; +using YLErp.Model; using YLErp.Model.Enum; using YLErp.Modules.TradeModule; @@ -28,6 +29,17 @@ namespace YLErp.Modules.SystemModule { var clientQuery = DataCacheProvider.GetClientDataSource().AsQueryable(); + if (type == "ClientBlackProcess") + { + var clientdb = DbContextFactory.GetClientDbContext(OptUser); + if (clientdb.client_black.Any(x => x.State == client_black.新增审批中 || x.State == client_black.删除审批中)) + { + throw new ServiceException(data == null || data.Count == 0 + ? "有黑名单在审批中,不能删除审批流程!" + : "有黑名单在审批中,不能修改审批流程!"); + } + } + var delList = DbContext.approvalprocess.Where(s => s.processType == type).ToArray(); DbContext.approvalprocess.RemoveRange(delList); diff --git a/YLErpDAL/Modules/SystemModule/ClientDataModel.cs b/YLErpDAL/Modules/SystemModule/ClientDataModel.cs index 0d00ea90..3252d15c 100644 --- a/YLErpDAL/Modules/SystemModule/ClientDataModel.cs +++ b/YLErpDAL/Modules/SystemModule/ClientDataModel.cs @@ -1536,11 +1536,6 @@ namespace YLErp.Modules.SystemModule Text = SwapTypeEnum.普通.ToString(), Value = SwapTypeEnum.普通.ToString() }, - new SelectItem - { - Text = SwapTypeEnum.多空组合.ToString(), - Value = SwapTypeEnum.多空组合.ToString() - } }; } 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/YLErpDAL/Properties/AssemblyInfo.cs b/YLErpDAL/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..92f6e1ed --- /dev/null +++ b/YLErpDAL/Properties/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +// 测试工程访问 internal 接缝(如 Fr007FixingCache 的时钟/装载器注入),替代把测试钩子暴露为 public。 +[assembly: InternalsVisibleTo("UnitTestProject")] diff --git a/YLErpWeb/App_Data/FunctionRight.xml b/YLErpWeb/App_Data/FunctionRight.xml index eccba1c4..a19f257c 100644 --- a/YLErpWeb/App_Data/FunctionRight.xml +++ b/YLErpWeb/App_Data/FunctionRight.xml @@ -129,8 +129,11 @@ + + + diff --git a/YLErpWeb/App_Data/Menus.txt b/YLErpWeb/App_Data/Menus.txt index 506f2a79..5ab7f56e 100644 --- a/YLErpWeb/App_Data/Menus.txt +++ b/YLErpWeb/App_Data/Menus.txt @@ -63,6 +63,7 @@ {Name:"客户列表",Rights:["客户管理-客户查看"],Url:"client/ClientList"}, {Name:"客户审批",Rights:["客户管理-客户审批"],Url:"clientApproval/openingclientList"}, {Name:"黑名单客户",Rights:["客户管理-黑名单客户"],Url:"clientblack/clientblacklist"}, + {Name:"黑名单审批",Rights:["客户管理-黑名单审批"],Url:"clientblack/clientblackApproval"}, {Name:"授信管理",Rights:["客户管理-授信管理"],Url:"credit/creditList"}, {Name:"资信评级",Rights:["客户管理-资信评级"],Url:"client_rating/List"}, {Name:"机构账号设置",Rights:["客户管理-机构账号设置"],Url:"v3/client/account"} @@ -107,4 +108,4 @@ {Name:"做市账户",Rights:["系统管理-做市账户"],Url:"TrsAccountManage/Index"} ] } -] \ No newline at end of file +] diff --git a/YLErpWeb/Common/UserInfoRight.cs b/YLErpWeb/Common/UserInfoRight.cs index cf0989bd..30bc8b5a 100644 --- a/YLErpWeb/Common/UserInfoRight.cs +++ b/YLErpWeb/Common/UserInfoRight.cs @@ -251,6 +251,12 @@ namespace YLErp.Web /// public bool 黑名单客户管理 => _user.HasRight("客户管理-黑名单客户管理"); + public bool 黑名单审批 => _user.HasRight("客户管理-黑名单审批"); + + public bool 黑名单客户提交审批 => _user.HasRight("客户管理-黑名单客户提交审批"); + + public bool 黑名单客户撤回提交审批 => _user.HasRight("客户管理-黑名单客户撤回提交审批"); + /// /// 客户管理-黑名单客户 /// diff --git a/YLErpWeb/Controllers/AccountOpeningProcessController.cs b/YLErpWeb/Controllers/AccountOpeningProcessController.cs index 9d45614a..0b2af318 100644 --- a/YLErpWeb/Controllers/AccountOpeningProcessController.cs +++ b/YLErpWeb/Controllers/AccountOpeningProcessController.cs @@ -93,7 +93,8 @@ namespace YLErp.Web.Controllers var creditProcess = list.Where(s => s.processType == "CreditProcess").OrderBy(s => s.order).ToList(); var outCashProcess = list.Where(s => s.processType == "OutCashProcess").OrderBy(s => s.order).ToList(); var clientProcess = list.Where(s => s.processType == "ClientProcess").OrderBy(s => s.order).ThenBy(s => s.parentNode).ThenBy(s => s.node).ToList(); - return Json(new { OpenProcess = openProcess, TradeProcess = tradeProcess, CloseProcess = closeProcess, CreditProcess = creditProcess, OutCashProcess= outCashProcess,ClientProcess = clientProcess }); + var clientBlackProcess = list.Where(s => s.processType == "ClientBlackProcess").OrderBy(s => s.order).ThenBy(s => s.parentNode).ThenBy(s => s.node).ToList(); + return Json(new { OpenProcess = openProcess, TradeProcess = tradeProcess, CloseProcess = closeProcess, CreditProcess = creditProcess, OutCashProcess= outCashProcess,ClientProcess = clientProcess, ClientBlackProcess = clientBlackProcess }); } @@ -232,4 +233,4 @@ namespace YLErp.Web.Controllers return Json(sList); } } -} \ No newline at end of file +} diff --git a/YLErpWeb/Controllers/SwapTrade2Controller.cs b/YLErpWeb/Controllers/SwapTrade2Controller.cs index 78d7b2e2..33c82881 100644 --- a/YLErpWeb/Controllers/SwapTrade2Controller.cs +++ b/YLErpWeb/Controllers/SwapTrade2Controller.cs @@ -351,36 +351,6 @@ namespace YLErp.Web.Controllers return View(model); } /// - /// 收益互换 多空组合平仓 - /// - /// - /// - public ActionResult SwapLongShortUnwind(string enid, bool isUseApproval = false) - { - var intid = DecryptInt(enid); - var model = new SwapDealService(CurUser).InitLongShortUnwind(intid, SwapEventTypeEnum.平仓); - ViewBag.isUseApproval = isUseApproval; - var hasProcess = new SwapDealService(CurUser).HasTradeProcess(); - //需要审批或者复核的交易都会显示行权审核提交按钮 - ViewBag.IsShowReCheckClose = (valuedateBLL.SystemDate.CloseReCheck == 1) || (valuedateBLL.SystemDate.CloseReApprove == 1 && hasProcess); - return View(model); - } - /// - /// 收益互换 多空组合互换 - /// - /// - /// - public ActionResult SwapLongShortSwap(string enid, bool isUseApproval = false) - { - var intid = DecryptInt(enid); - var model = new SwapDealService(CurUser).InitLongShortUnwind(intid, SwapEventTypeEnum.互换); - ViewBag.isUseApproval = isUseApproval; - var hasProcess = new SwapDealService(CurUser).HasTradeProcess(); - //需要审批或者复核的交易都会显示行权审核提交按钮 - ViewBag.IsShowReCheckClose = (valuedateBLL.SystemDate.CloseReCheck == 1) || (valuedateBLL.SystemDate.CloseReApprove == 1 && hasProcess); - return View(model); - } - /// /// 操作历史 /// /// @@ -430,13 +400,13 @@ namespace YLErp.Web.Controllers /// /// /// - public JsonResult GetUnwindInterestList(DateTime valueDate,DateTime unwindDate, int tradeId, decimal closePercent, int eventType, decimal notionalValue = 0, decimal posiNotionalValue = 0) + public JsonResult GetUnwindInterestList(DateTime valueDate,DateTime unwindDate, int tradeId, decimal closePercent, int eventType, decimal notionalValue = 0, decimal posiNotionalValue = 0, bool isPenaltyInterest = false) { unwindDate = valueDate; // 前端按"占期初(original)"语义传 closePercent(A);后端 GetUnwindInterests 按"占剩余(remaining)"语义(B)计算。 // 多空互换前端不传 notionalValue/posiNotionalValue(默认 0),则跳过转换保持原行为。 var convertedClosePercent = SwapDealService.ToRemainingClosePercent(closePercent, notionalValue, posiNotionalValue); - var interests = new SwapDealService(CurUser).GetUnwindInterests(valueDate, unwindDate, tradeId, convertedClosePercent, eventType); + var interests = new SwapDealService(CurUser).GetUnwindInterests(valueDate, unwindDate, tradeId, convertedClosePercent, eventType, isPenaltyInterest); foreach (var interest in interests) { interest.TdInterestAmount=Math.Round(interest.TdInterestAmount, ConsGlobal.MoneyRound,MidpointRounding.AwayFromZero); @@ -456,26 +426,6 @@ namespace YLErp.Web.Controllers return JsonSuccess("平仓成功"); } /// - ///多空组合 平仓 - /// - /// - /// - public JsonResult SwapLongShortUnwindJson(UnwindData unwindData) - { - new SwapDealService(CurUser).SwapLongShortUnwind(unwindData); - return JsonSuccess("平仓成功"); - } - /// - ///多空组合 互换 - /// - /// - /// - public JsonResult SwapLongShortJson(UnwindData unwindData) - { - new SwapDealService(CurUser).SwapLongShort(unwindData); - return JsonSuccess("互换成功"); - } - /// /// 互换 /// /// diff --git a/YLErpWeb/Controllers/clientController.cs b/YLErpWeb/Controllers/clientController.cs index 93f8c645..f1aaae6d 100644 --- a/YLErpWeb/Controllers/clientController.cs +++ b/YLErpWeb/Controllers/clientController.cs @@ -2529,7 +2529,7 @@ namespace YLErp.Web.Controllers return JsonError(error); } var clientblack = clientDB.client_black.FirstOrDefault(c => c.Name == client.Name); - if (clientblack != null) + if (clientblack != null && YLErp.Modules.ClientModule.ClientBlackApprovalPolicy.IsEffective(clientblack.State)) { return JsonError("该客户为黑名单客户,禁止取消休眠"); } @@ -3402,4 +3402,4 @@ namespace YLErp.Web.Controllers return JsonSuccess(); } } -} \ No newline at end of file +} diff --git a/YLErpWeb/Controllers/clientblackController.cs b/YLErpWeb/Controllers/clientblackController.cs index fc912195..923c29ef 100644 --- a/YLErpWeb/Controllers/clientblackController.cs +++ b/YLErpWeb/Controllers/clientblackController.cs @@ -5,6 +5,19 @@ namespace YLErp.Web.Controllers { public class clientblackController : BaseController { + public static List GetClientBlackStates() + { + return new List + { + new() { Text = client_black.未提交, Value = client_black.未提交 }, + new() { Text = client_black.新增审批中, Value = client_black.新增审批中 }, + new() { Text = client_black.新增已拒绝, Value = client_black.新增已拒绝 }, + new() { Text = client_black.已加入, Value = client_black.已加入 }, + new() { Text = client_black.删除审批中, Value = client_black.删除审批中 }, + new() { Text = client_black.删除已拒绝, Value = client_black.删除已拒绝 } + }; + } + [MyAuthorize("客户管理-黑名单客户")] public ActionResult clientblacklist() { @@ -55,38 +68,80 @@ namespace YLErp.Web.Controllers } public ActionResult DeleteClientBlack(string ids) { - var datalist = ids.Split(','); - var list = new List(); - foreach (var item in datalist) + try { - var data = clientDB.client_black.Find(int.Parse(item)); - if (data == null) - { - return JsonError("未找到要删除的数据"); - } - else - { - var clitid = clientDB.client.Where(c => c.Name == data.Name).FirstOrDefault(); - if (clitid != null) - { - clientDB.ClientAuditLog.Add(new ClientAuditLog - { - ClientId = clitid.id, - OptType = "移除黑名单", - Changes = string.Empty, - DataType = "00", - OptId = UserId, - OptName = UserName, - OptDate = DateTime.Now - }); - } - - clientDB.client_black.Remove(data); - } - + var datalist = ids.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); + var service = new ClientBlackService(CurUser); + var submitRemovalApprovalCount = service.DeleteClientBlack(datalist); + var message = submitRemovalApprovalCount == 0 + ? "删除成功" + : submitRemovalApprovalCount == datalist.Count + ? "已经提交删除审批!" + : "删除成功,部分记录已提交删除审批!"; + return JsonSuccess(message); } - clientDB.SaveChanges(); - return JsonSuccess("删除成功"); + catch (Exception ex) + { + return JsonError(ex.GetBaseException().Message); + } + } + + [MyAuthorize("客户管理-黑名单审批")] + public ActionResult clientblackApproval() + { + return View(); + } + + [HttpPost, MyAuthorize("客户管理-黑名单审批")] + public JsonResult clientblackApprovalQuery(ClientBlackReq req) + { + return Json(new ClientBlackService(CurUser).ClientBlackApprovalQuery(req)); + } + + [HttpPost, MyAuthorize("客户管理-黑名单客户提交审批")] + public JsonResult clientblackSubmit(string ids) + { + var idList = ids.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); + new ClientBlackService(CurUser).SubmitApprovalClientBlack(idList); + return JsonSuccess("提交审批成功"); + } + + [HttpPost, MyAuthorize("客户管理-黑名单客户撤回提交审批")] + public JsonResult clientblackWithdraw(string ids) + { + var idList = ids.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); + new ClientBlackService(CurUser).WithdrawApprovalClientBlack(idList, out var withdrawCount, out var msg); + if (withdrawCount == 0) + { + return JsonError(string.IsNullOrWhiteSpace(msg) + ? "所选记录当前状态无法撤回审批" + : $"以下记录已进入后续节点无法撤回:{msg}"); + } + return JsonSuccess("撤回审批成功" + (string.IsNullOrWhiteSpace(msg) ? "" : $",以下记录已进入后续节点无法撤回:{msg}")); + } + + [HttpPost, MyAuthorize("客户管理-黑名单审批")] + public JsonResult Auditclientblack(ClientBlackAuditReq req) + { + new ClientBlackService(CurUser).AuditClientBlack(req); + return JsonSuccess("审批成功"); + } + + [MyAuthorize("客户管理-黑名单审批")] + public ActionResult clientblackView(string enid) + { + var id = DataProtectHelper.DecryptInt(enid); + var item = clientDB.client_black.FirstOrDefault(x => x.id == id); + return View(item); + } + + [MyAuthorize("客户管理-黑名单客户")] + public ActionResult clientblackLogList(int id) + { + var logs = clientDB.client_blacklog.Where(x => x.ClientBlackId == id) + .OrderByDescending(x => x.id) + .ToList(); + return View(logs); } @@ -101,4 +156,4 @@ namespace YLErp.Web.Controllers return File(bytes, xlsxMimeType, $"黑名单导出-{DateTime.Now:yyyy-MM-dd}.xlsx"); } } -} \ No newline at end of file +} diff --git a/YLErpWeb/Views/AccountOpeningProcess/Index.cshtml b/YLErpWeb/Views/AccountOpeningProcess/Index.cshtml index 2ecdfefc..a2bbcced 100644 --- a/YLErpWeb/Views/AccountOpeningProcess/Index.cshtml +++ b/YLErpWeb/Views/AccountOpeningProcess/Index.cshtml @@ -786,6 +786,46 @@ +
+
黑名单审批流程
+
+
+
+
申请人
+
+
+
+ +
+
+
+ +
+
+
结束流程
+
+
+
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 @@ - + - + - - - - - -} -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
-
-
-
-
-
-
- - - - - - - - - - - - - - - -
收支方向利息金额其他费用预付金/预付金平仓盈亏
{{item.InterestDirection==1?"收取":"支付"}} - - - - {{formatAmount(item.InterestClosePnL)}}
-
-
-
-
-
-
-
-
- - - - - - - - - - - - - - - -
收支方向利息金额其他费用利息端平仓盈亏
{{item.InterestDirection==1?"收取":"支付"}} - - - - {{formatAmount(item.InterestClosePnL)}}
-
-
- @if (isUseApproval) - { - - - } - else - { - - - } -
-
-
-
-
diff --git a/YLErpWeb/Views/SwapTrade2/SwapLongShortUnwind.cshtml b/YLErpWeb/Views/SwapTrade2/SwapLongShortUnwind.cshtml deleted file mode 100644 index bdbc1601..00000000 --- a/YLErpWeb/Views/SwapTrade2/SwapLongShortUnwind.cshtml +++ /dev/null @@ -1,138 +0,0 @@ -@model UnwindData -@{ - ViewBag.Title = "交易 | 交易平仓"; - Layout = "~/Views/Shared/_InfoLayout.cshtml"; - bool isUseApproval = ViewBag.isUseApproval; - bool isShowReCheckClose = ViewBag.IsShowReCheckClose; -} -@section CSS{ - -} -@section JS - { - - - - - - -} -
-
-
-
- - -
-
- - -
-
- - - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
-
-
-
-
-
-
- - - - - - - - - - - - - - - - - -
收支方向资金类别应返还本金利息金额预付金/预付金平仓盈亏
{{item.InterestDirection==1?"收取":"支付"}}{{item.InterestModeStr}} - - - - {{formatAmount(item.InterestClosePnL)}}
-
-
-
-
-
-
-
-
- - - - - - - - - - - - - -
收支方向利息金额利息端平仓盈亏
{{item.InterestDirection==1?"收取":"支付"}} - - {{formatAmount(item.InterestClosePnL)}}
-
-
- @if (isUseApproval) - { - - - } - else - { - - - } -
-
-
-
-
diff --git a/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml b/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml index 3ef08042..04dd36f2 100644 --- a/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml +++ b/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml @@ -90,6 +90,13 @@
+
+ + +
@@ -145,7 +152,7 @@ 计息开始日 计息结算日 利率(年化)*@ - 其他费用 + 其他费用含罚息 利息金额 利息端平仓盈亏 diff --git a/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml b/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml index 6561cbfe..111dd427 100644 --- a/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml +++ b/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml @@ -244,6 +244,13 @@
+
+ + +
@*
+ @MyControls.SearchBtn() +
+@Html.Raw(JqGridSimple.OutTable()) diff --git a/YLErpWeb/Views/clientblack/clientblackLogList.cshtml b/YLErpWeb/Views/clientblack/clientblackLogList.cshtml new file mode 100644 index 00000000..6ccf41ad --- /dev/null +++ b/YLErpWeb/Views/clientblack/clientblackLogList.cshtml @@ -0,0 +1,14 @@ +@model IEnumerable +@{ + ViewBag.Title = "黑名单操作历史"; + Layout = "~/Views/Shared/_InfoLayout.cshtml"; +} + + + + @foreach (var item in Model ?? Enumerable.Empty()) + { + + } + +
时间操作人操作内容说明
@item.OptDate.ToString("yyyy-MM-dd HH:mm:ss")@item.OptName@item.OptType@item.Changes
diff --git a/YLErpWeb/Views/clientblack/clientblackView.cshtml b/YLErpWeb/Views/clientblack/clientblackView.cshtml new file mode 100644 index 00000000..1192edf4 --- /dev/null +++ b/YLErpWeb/Views/clientblack/clientblackView.cshtml @@ -0,0 +1,45 @@ +@using YLErp.Modules.ClientModule +@model YLErp.Model.client_black +@{ + ViewBag.Title = "黑名单客户审批"; + Layout = "~/Views/Shared/_InfoLayout.cshtml"; + var process = new ClientBlackService(CurUser).ProcessList(); + var currentNode = process.FirstOrDefault(x => x.order == Model?.ApprovalProcess); + var canAudit = currentNode != null && UserBLL.GetRolesByUserId(CurUser.UserId).Any(x => x.Id == currentNode.roleId); +} +@section JS { + +} +
+
+ @if (canAudit) + { + @MyControls.Btn("审批通过", "audit('pass');") + @MyControls.Btn("拒绝", "audit('reject');") + } +
+
+
+ + + + + + + + + + + +
客户名称@Model?.Name
黑名单备注@Model?.Remarks
审批状态@Model?.State
提交审批人@Model?.ApprovalOptName
提交审批时间@Model?.ApprovalOptDate?.ToString("yyyy-MM-dd HH:mm:ss")
审批说明
+
diff --git a/YLErpWeb/Views/clientblack/clientblacklist.cshtml b/YLErpWeb/Views/clientblack/clientblacklist.cshtml index 7ab08bb0..bb8a10e4 100644 --- a/YLErpWeb/Views/clientblack/clientblacklist.cshtml +++ b/YLErpWeb/Views/clientblack/clientblacklist.cshtml @@ -51,10 +51,10 @@ var colModelGrid = [{ name: 'id', label: 'id', index: 'id', width: 0, hidden: true, optionHide: true }, { - name: 'opt', label: '操作', index: 'opt', width: 150, align: 'left', hidden: !page.canEdit, optionHide: !page.canEdit, sortable: false, + name: 'opt', label: '操作', index: 'opt', width: 200, align: 'left', hidden: !page.canEdit, optionHide: !page.canEdit, sortable: false, formatter: function (cellValue, options, rowObject) { if (page.canEdit) { - var html = ("") + var html = ("") .template(rowObject.id); return html; } @@ -65,7 +65,9 @@ }, { name: 'Name', label: '客户名称', index: 'Name', width: 260 }, { - name: 'Remarks', label: '备注', index: 'Remarks', width: 500 + name: 'Remarks', label: '备注', index: 'Remarks', width: 500 + }, { + name: 'State', label: '状态', index: 'State', width: 120 }, { name: 'OptName', label: '操作人', index: 'OptName', width: 150 }, { @@ -146,7 +148,7 @@ function SearchClick(isSearchclick) { var listGrid = $('#listGrid'); listGrid.appendPostData({ Name: $("#Name").val() }); - listGrid.appendPostData({ OptName: $("#OptName").val() }); + listGrid.appendPostData({ ClientBlackStates: $("#ClientBlackStates").val()?.join(',') || '' }); if (typeof (isSearchclick) != "undefined" && isSearchclick) { //点击搜索时默认第一页 listGrid.jqGrid('setGridParam', {page: 1}); @@ -241,6 +243,19 @@ }); }) } + function ClientBlackSubmit() { + var ids = main.GetGridIds($('#listGrid')); + if (!ids.length) { main.message('请选择要提交的数据!'); return; } + main.post('/clientblack/clientblackSubmit', { ids: ids.toString() }).done(function () { SearchClick(); }); + } + function ClientBlackWithdraw() { + var ids = main.GetGridIds($('#listGrid')); + if (!ids.length) { main.message('请选择要撤回的数据!'); return; } + main.post('/clientblack/clientblackWithdraw', { ids: ids.toString() }).done(function () { SearchClick(); }); + } + function clientblackLogView(id) { + main.open('操作历史', '/clientblack/clientblackLogList?id=' + id, { area: ['1000px', '75%'] }); + } } @@ -251,7 +266,7 @@
- +
@@ -267,6 +282,7 @@ + @Html.MyAceDropdownInput("ClientBlackStates", "状态", clientblackController.GetClientBlackStates()) @if (CurUser.客户管理.黑名单客户管理) { @@ -275,6 +291,14 @@ } + @if (CurUser.客户管理.黑名单客户提交审批) + { + + } + @if (CurUser.客户管理.黑名单客户撤回提交审批) + { + + }
-@Html.Raw(JqGridSimple.OutTable()) \ No newline at end of file +@Html.Raw(JqGridSimple.OutTable()) diff --git a/YLErpWeb/fe-tests/fixtures/historical_swap_events.json b/YLErpWeb/fe-tests/fixtures/historical_swap_events.json index f84e52d5..16cec7a2 100644 --- a/YLErpWeb/fe-tests/fixtures/historical_swap_events.json +++ b/YLErpWeb/fe-tests/fixtures/historical_swap_events.json @@ -43,43 +43,5 @@ "SwapMarginRebatePnl": 200, "SwapMarginAmount": 40000 } - }, - { - "id": "LONGSHORT_UNWIND", - "desc": "多空组合平仓(unwindLongShort):从0起,不含浮动盈亏(FloatPnlSum=0)", - "eventType": 4, - "deal": { "CloseQty": 0 }, - "floatPosition": { "FloatPnlSum": 0, "PositionType": 1, "PayDirection": 1, "TradingFee": 0 }, - "interestList": [], - "marginList": [ - { "InterestClosePnL": 150, "InterestDirection": 1, "InterestPrincipal": 20000 } - ], - "expected": { - "SwapCloseAmount": 150, - "SwapRealizedPnL": 150, - "SwapMarginRebatePnl": 150, - "SwapMarginAmount": -20000 - } - }, - { - "id": "LONGSHORT_SWAP", - "desc": "多空组合互换(swapLongShort):SwapMarginAmount 用裸 InterestPrincipal(无符号) —— 已知差异", - "eventType": 5, - "deal": { "CloseQty": 0 }, - "floatPosition": { "FloatPnlSum": 0, "PositionType": 1, "PayDirection": 1, "TradingFee": 0 }, - "interestList": [], - "marginList": [ - { "InterestClosePnL": 100, "InterestDirection": 1, "InterestPrincipal": 18000 } - ], - "expected": { - "SwapCloseAmount": 100, - "SwapRealizedPnL": 100, - "SwapMarginRebatePnl": 100, - "SwapMarginAmount": 18000 - }, - "knownDiscrepancies": { - "SwapMarginAmount": 36000 - } - } - ] + } ] } diff --git a/YLErpWeb/fe-tests/swapPrecisionConfig.test.js b/YLErpWeb/fe-tests/swapPrecisionConfig.test.js index 05fc793d..ecef4b39 100644 --- a/YLErpWeb/fe-tests/swapPrecisionConfig.test.js +++ b/YLErpWeb/fe-tests/swapPrecisionConfig.test.js @@ -163,8 +163,6 @@ describe('swap price precision common wiring', () => { edit: read('wwwroot/Scripts/app/swaptrade/swapTradeEdit.js'), income: read('wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js'), unwind: read('wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js'), - longShort: read('wwwroot/Scripts/app/swaptrade/swapLongShort.js'), - unwindLongShort: read('wwwroot/Scripts/app/swaptrade/unwindLongShort.js'), view: read('wwwroot/Scripts/app/swaptrade/swapTradeView.js'), flow: read('wwwroot/Scripts/app/swaptrade/SwapflowList.js'), helper: precisionHelperSrc diff --git a/YLErpWeb/wwwroot/Scripts/app/pricing/structure.js b/YLErpWeb/wwwroot/Scripts/app/pricing/structure.js index 5652bb7f..efb5a424 100644 --- a/YLErpWeb/wwwroot/Scripts/app/pricing/structure.js +++ b/YLErpWeb/wwwroot/Scripts/app/pricing/structure.js @@ -109,9 +109,52 @@ const vueTradeType = function () { }; }; -//标的选择组件 +//标的选择组件(EQD-7049:改为服务端搜索,不再依赖全量 ylotc.underlyings,避免十几万标的整段下载卡死) const vueUnderlying = function () { const _suggestionTpl = _.template($('#underlyingSuggestionTpl').html()); + // 标的缓存:按 品种|关键词 隔离;乱序响应由 token 丢弃(helper 收在函数内,避免全局绑定冲突) + const _cache = {}; + const _tokens = {}; + function _fetch(varietyId, query, cb) { + var key = (varietyId || 0) + '|' + (query || ''); + var token = (_tokens[key] = (_tokens[key] || 0) + 1); + var postData = { + FilterCode: (query || '').toUpperCase(), + VarietyId: varietyId || 0, + MaxShowLength: 20, + BlackLimit: 1, + UseForTrading: true, + IncludeMatured: true, + CheckLaunch: true + }; + main.post('/frontdata/AjaxGetUnderlyingSelect', postData).done(function (res) { + if (_tokens[key] !== token) return; // 丢弃过期响应 + var arr = (res && (res.obj || res.data)) || []; + var norm = arr.map(function (x) { + return { + Code: x.Code, + Name: x.Name, + InstrumentType: x.InstrumentType, + VarietyId: x.VarietyId, + Disallow: !!x.Disallow, + IsCombined: !!x.IsSynthetic || !!x.IsBasket, + BlackWhiteState: x.BlackWhiteState || 0, + PinYin: x.PinYin || '' + }; + }); + cb && cb(norm); + }); + } + function _filter(list, query, varietyId) { + if (!query) return (list || []).slice(0, 20); + query = query.toUpperCase(); + return (list || []).filter(function (x) { + if (varietyId && x.VarietyId !== varietyId) return false; + if (x.IsCombined) return false; // 与原逻辑一致:搜索时排除组合标的 + return (x.Code && x.Code.toUpperCase().indexOf(query) !== -1) + || (x.PinYin && x.PinYin.toUpperCase().indexOf(query) !== -1); + }).slice(0, 20); + } return { props: ['underlying'], data() { @@ -120,27 +163,24 @@ const vueUnderlying = function () { mounted() { var self = this; this.jqInput = $(this.$el).children(0); + // EQD-7049:预拉默认20条(当前品种),避免下拉空白 + _fetch(self.underlying.VarietyId, '', function (list) { + _cache[self.underlying.VarietyId || 0] = list; + try { $(self.jqInput).autocomplete('search', ''); } catch (e) {} + }); this.autoctrl = FastVue.autocomplete(this.jqInput, { valueField: 'Code', lookup(query, callback) { - var arr = []; - if (!query) { - var varietyId = self.underlying.VarietyId; - ylotc.underlyings.forEach(x => { - (!varietyId || x.VarietyId === varietyId) && arr.push(x); - }); - } else { - query = query.toUpperCase(); - ylotc.underlyings.forEach(x => { - if (x.Code.toUpperCase().indexOf(query) !== -1 || x.PinYin && x.PinYin.indexOf(query) !== -1 && !x.IsCombined) { - arr.push(x); - } + var varietyId = self.underlying.VarietyId; + var cached = _cache[varietyId || 0] || []; + var immediate = _filter(cached, query, varietyId); + if (query) { + // 有输入时异步向服务端搜索并刷新缓存(乱序响应由 token 丢弃) + _fetch(varietyId, query, function (list) { + _cache[varietyId || 0] = list; }); } - if (arr.length < 30) { - arr = _.sortBy(arr, x => x.Code); - } - return arr; + return immediate; }, onSelect(data) { if (self.underlying !== data) { @@ -1430,6 +1470,12 @@ const vueTrade = function () { //更新标的 updateUnderlying(reqData, fromSelect) { let self = this; + // EQD-7049:新建空白页未选标的/品种/类型时,跳过必然失败的后端默认标的查询,避免报“标的信息缺失” + var hasQueryKey = !!(reqData.UnderlyingCode || reqData.InstrumentType || reqData.VarietyId > 0); + if (!hasQueryKey) { + !fromSelect && (self.viewState.underlying = tradeHelper.getEmptyUnderlying()); + return; + } var instTypeChanged = !!reqData.InstrumentType; !fromSelect && (self.viewState.underlying = tradeHelper.getEmptyUnderlying()); main.post("/pricing/AjaxGetUnderlying", reqData).done(function (resp) { diff --git a/YLErpWeb/wwwroot/Scripts/app/pricing/structure_dz.js b/YLErpWeb/wwwroot/Scripts/app/pricing/structure_dz.js index 4cf2f0fc..e5636152 100644 --- a/YLErpWeb/wwwroot/Scripts/app/pricing/structure_dz.js +++ b/YLErpWeb/wwwroot/Scripts/app/pricing/structure_dz.js @@ -108,9 +108,52 @@ const vueTradeType = function () { }; }; -//标的选择组件 +//标的选择组件(EQD-7049:改为服务端搜索,不再依赖全量 ylotc.underlyings,避免十几万标的整段下载卡死) const vueUnderlying = function () { const _suggestionTpl = _.template($('#underlyingSuggestionTpl').html()); + // 标的缓存:按 品种|关键词 隔离;乱序响应由 token 丢弃(helper 收在函数内,避免全局绑定冲突) + const _cache = {}; + const _tokens = {}; + function _fetch(varietyId, query, cb) { + var key = (varietyId || 0) + '|' + (query || ''); + var token = (_tokens[key] = (_tokens[key] || 0) + 1); + var postData = { + FilterCode: (query || '').toUpperCase(), + VarietyId: varietyId || 0, + MaxShowLength: 20, + BlackLimit: 1, + UseForTrading: true, + IncludeMatured: true, + CheckLaunch: true + }; + main.post('/frontdata/AjaxGetUnderlyingSelect', postData).done(function (res) { + if (_tokens[key] !== token) return; // 丢弃过期响应 + var arr = (res && (res.obj || res.data)) || []; + var norm = arr.map(function (x) { + return { + Code: x.Code, + Name: x.Name, + InstrumentType: x.InstrumentType, + VarietyId: x.VarietyId, + Disallow: !!x.Disallow, + IsCombined: !!x.IsSynthetic || !!x.IsBasket, + BlackWhiteState: x.BlackWhiteState || 0, + PinYin: x.PinYin || '' + }; + }); + cb && cb(norm); + }); + } + function _filter(list, query, varietyId) { + if (!query) return (list || []).slice(0, 20); + query = query.toUpperCase(); + return (list || []).filter(function (x) { + if (varietyId && x.VarietyId !== varietyId) return false; + if (x.IsCombined) return false; // 与原逻辑一致:搜索时排除组合标的 + return (x.Code && x.Code.toUpperCase().indexOf(query) !== -1) + || (x.PinYin && x.PinYin.toUpperCase().indexOf(query) !== -1); + }).slice(0, 20); + } return { props: ['underlying'], data() { @@ -119,27 +162,24 @@ const vueUnderlying = function () { mounted() { var self = this; this.jqInput = $(this.$el).children(0); + // EQD-7049:预拉默认20条(当前品种),避免下拉空白 + _fetch(self.underlying.VarietyId, '', function (list) { + _cache[self.underlying.VarietyId || 0] = list; + try { $(self.jqInput).autocomplete('search', ''); } catch (e) {} + }); this.autoctrl = FastVue.autocomplete(this.jqInput, { valueField: 'Code', lookup(query, callback) { - var arr = []; - if (!query) { - var varietyId = self.underlying.VarietyId; - ylotc.underlyings.forEach(x => { - (!varietyId || x.VarietyId === varietyId) && arr.push(x); - }); - } else { - query = query.toUpperCase(); - ylotc.underlyings.forEach(x => { - if (x.Code.toUpperCase().indexOf(query) !== -1 || x.PinYin && x.PinYin.indexOf(query) !== -1 && !x.IsCombined) { - arr.push(x); - } + var varietyId = self.underlying.VarietyId; + var cached = _cache[varietyId || 0] || []; + var immediate = _filter(cached, query, varietyId); + if (query) { + // 有输入时异步向服务端搜索并刷新缓存(乱序响应由 token 丢弃) + _fetch(varietyId, query, function (list) { + _cache[varietyId || 0] = list; }); } - if (arr.length < 30) { - arr = _.sortBy(arr, x => x.Code); - } - return arr; + return immediate; }, onSelect(data) { if (self.underlying !== data) { @@ -1044,6 +1084,12 @@ const vueTrade = function () { //更新标的 updateUnderlying(reqData, fromSelect) { let self = this; + // EQD-7049:新建空白页未选标的/品种/类型时,跳过必然失败的后端默认标的查询,避免报“标的信息缺失” + var hasQueryKey = !!(reqData.UnderlyingCode || reqData.InstrumentType || reqData.VarietyId > 0); + if (!hasQueryKey) { + !fromSelect && (self.viewState.underlying = tradeHelper.getEmptyUnderlying()); + return; + } var instTypeChanged = !!reqData.InstrumentType; !fromSelect && (self.viewState.underlying = tradeHelper.getEmptyUnderlying()); main.post("/pricing/AjaxGetUnderlying", reqData).done(function (resp) { diff --git a/YLErpWeb/wwwroot/Scripts/app/riskHedging/riskIndex2.js b/YLErpWeb/wwwroot/Scripts/app/riskHedging/riskIndex2.js index df78ce5d..b6097e32 100644 --- a/YLErpWeb/wwwroot/Scripts/app/riskHedging/riskIndex2.js +++ b/YLErpWeb/wwwroot/Scripts/app/riskHedging/riskIndex2.js @@ -2000,9 +2000,6 @@ const subView = (function (jqGridMgr) { if (tradeType === "收益互换") { srcurl = "/swaptrade2/SwapUnwind/?enid=" + encryptId; - if (StructureType=="多空组合") { - srcurl = "/swaptrade2/SwapLongShortUnwind/?enid=" + encryptId; - } area = ["1300px", "720px"]; } if (tradeType.indexOf("远期") >= 0) { diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js index eb8e50b9..d160d50e 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js @@ -112,7 +112,7 @@ const vue = new Vue({ this.multiplier, isUseApproval); this.interestList = model.FlowEvents.filter((item) => { - return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9; + return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 9; }); this.marginList = model.FlowEvents.filter((item) => { return item.InterestMode == 5 || item.InterestMode == 6; @@ -255,7 +255,7 @@ const vue = new Vue({ var postData = { ValueDate: thisObj.deal.ValueDate, unwindDate: thisObj.deal.UnwindDate, tradeId: thisObj.deal.SwapTradeId, closePercent: 1, eventType:3 } main.post("/swaptrade2/GetUnwindInterestList", postData, { async: true }).done(function (resp) { thisObj.interestList = resp.obj.filter((item) => { - return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9; + return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 9; }); thisObj.marginList = resp.obj.filter((item) => { return item.InterestMode == 5 || item.InterestMode == 6; diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapLongShort.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapLongShort.js deleted file mode 100644 index 60bcb46b..00000000 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapLongShort.js +++ /dev/null @@ -1,228 +0,0 @@ -//otcformat禁止千分位分组 -window.otcformat.options.disableGrouping = true; -const inputFormatEqvNotional = swapPricePrecision.getCommonInputFormat('amount', { append: '' }); -const swapInstrumentType = (model.FlowEvents || []).find(item => item && item.UnderlyingInstrumentType)?.UnderlyingInstrumentType || model.UnderlyingInstrumentType || ''; -const formatSwapAmount = value => swapPricePrecision.normalizeCommon('amount', value); -const formatSwapQuantity = value => swapPricePrecision.normalizeCommon('quantity', value, swapInstrumentType); -let ValueDate = model.ValueDate; -const vue = new Vue({ - el: '#vueDiv', - data: { - deal: model, - interestList: [], - marginList: [], - }, - computed: { - maxUnwindDate() { - return ValueDate; - }, - minStartDate() { - return this.deal.StartDate; - } - }, - created() { - this.initDeal(); - this.setValueDate(); - }, - methods: { - formatAmount(value) { - return swapPricePrecision.formatCommon('amount', value); - }, - formatQuantity(value) { - return swapPricePrecision.formatCommon('quantity', value, swapInstrumentType); - }, - initDeal() { - this.interestList = model.FlowEvents.filter((item) => { - return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9; - }); - this.marginList = model.FlowEvents.filter((item) => { - return item.InterestMode == 5 || item.InterestMode == 6; - }); - }, - setValueDate(e) {//修改平仓日期 - if (e) { - this.deal.ValueDate = e; - this.deal.UnwindDate = e; - } - if (!isUseApproval) { - this.getInterestList(); - } else { - this.dataFormat(); - } - }, - dataFormat() { - this.deal.NotionalValue = formatSwapAmount(this.deal.NotionalValue); - this.deal.PosiNotionalValue = formatSwapAmount(this.deal.PosiNotionalValue); - this.deal.NotionalQty = formatSwapQuantity(this.deal.NotionalQty); - this.deal.PositionQty = formatSwapQuantity(this.deal.PositionQty); - this.deal.SwapCloseAmount = formatSwapAmount(this.deal.SwapCloseAmount); - this.interestList.forEach(x => { - //x.Principal = formatSwapAmount(x.Principal); - //x.Rate = otcformat.fixed6(x.Rate); - x.InterestFee = formatSwapAmount(x.InterestFee); - x.InterestAmount = formatSwapAmount(x.InterestAmount); - x.InterestClosePnL = formatSwapAmount(x.InterestClosePnL); - //x.InterestStartDate = x.InterestStartDate ? x.InterestStartDate.substr(0, 10) : ""; - //x.InterestEndDate = x.InterestEndDate ? x.InterestEndDate.substr(0, 10) : ""; - }); - this.marginList.forEach(x => { - x.InterestFee = formatSwapAmount(x.InterestFee); - x.InterestAmount = formatSwapAmount(x.InterestAmount); - x.InterestClosePnL = formatSwapAmount(x.InterestClosePnL); - }); - }, - changeInterestAmount(item) {//修改利息金额 - let interestRatio = item.InterestDirection == 1 ? 1 : -1; - item.InterestClosePnL = formatSwapAmount(parseFloat(item.InterestAmount) * interestRatio + parseFloat(item.InterestFee)); - this.calcCloseAmount(); - }, - calcCloseAmount() {//计算平仓总额=浮动收取+利息收取-浮动支付-利息支付 - let thisObj = this; - thisObj.deal.SwapCloseAmount = 0; - thisObj.deal.SwapRealizedPnL = 0; - thisObj.deal.SwapMarginRebatePnl = 0; - thisObj.deal.SwapMarginAmount = 0; - this.interestList.forEach(x => { - /*let interestRatio = x.InterestDirection == 1 ? 1 : -1;*/ - let interestAmount = parseFloat(x.InterestClosePnL); - thisObj.deal.SwapCloseAmount = parseFloat(thisObj.deal.SwapCloseAmount) + interestAmount; - thisObj.deal.SwapRealizedPnL = parseFloat(thisObj.deal.SwapRealizedPnL) + interestAmount; - }); - this.marginList.forEach(x => { - let interestAmount = parseFloat(x.InterestClosePnL); - thisObj.deal.SwapCloseAmount = parseFloat(thisObj.deal.SwapCloseAmount) + interestAmount; - thisObj.deal.SwapMarginRebatePnl = parseFloat(thisObj.deal.SwapMarginRebatePnl) + interestAmount; - thisObj.deal.SwapRealizedPnL = parseFloat(thisObj.deal.SwapRealizedPnL) + interestAmount; - thisObj.deal.SwapMarginAmount = parseFloat(thisObj.deal.SwapMarginAmount) + parseFloat(x.InterestPrincipal); - }); - thisObj.deal.SwapCloseAmount = formatSwapAmount(thisObj.deal.SwapCloseAmount); - thisObj.deal.SwapRealizedPnL = formatSwapAmount(thisObj.deal.SwapRealizedPnL); - thisObj.deal.SwapMarginRebatePnl = formatSwapAmount(thisObj.deal.SwapMarginRebatePnl); - thisObj.deal.SwapMarginAmount = formatSwapAmount(thisObj.deal.SwapMarginAmount); - }, - getInterestList() {//根据平仓日期获取利息腿信息 - var thisObj = this; - var postData = { valueDate: thisObj.deal.ValueDate, unwindDate: thisObj.deal.UnwindDate, tradeId: thisObj.deal.SwapTradeId, closePercent: 1, eventType: 3 } - main.post("/swaptrade2/GetUnwindInterestList", postData, { async: true }).done(function (resp) { - thisObj.interestList = resp.obj.filter((item) => { - return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9; - }); - thisObj.marginList = resp.obj.filter((item) => { - return item.InterestMode == 5 || item.InterestMode == 6; - }); - thisObj.calcCloseAmount(); - thisObj.dataFormat(); - }); - }, - incomeTrade() {//互换 - var thisObj = this; - if (main.isEmpty(thisObj.deal.ValueDate)) { - main.message("请输入平仓日期"); - return; - } - let reqObj = _.cloneDeep(thisObj.deal); - let marginCloneList = _.cloneDeep(thisObj.marginList); - - reqObj.FlowEvents = _.cloneDeep(thisObj.interestList); - marginCloneList.forEach((item) => { - reqObj.FlowEvents.push(item); - }) - var postData = { unwindData: reqObj }; - var msg = "确认提交收益结算?"; - var postUrl = "/swaptrade2/SwapLongShortJson"; - if (g_isShowReCheckClose) { - msg = "确认提交收益结算审核?"; - postUrl = "/swaptrade2/ApplyUnwind"; - postData.eventType = 3;//互换3,平仓2 - } - main.confirm(msg, - function () { - //重新计算百分比 - var thisObj2 = thisObj; - main.post(postUrl, { unwindData: reqObj }).done(function (res) { - if (res.success) { - thisObj2.closetrade_cashWindow(); - } - else { - try { - thisObj2.closetrade_cashWindow(); - } catch (e) { - } - } - }); - }); - }, - getSumbitText: function () { - return g_isShowReCheckClose ? "审核提交" : "保存"; - }, - submitApproval(status) { - var pop = ''; - if (status === 'pass') { - pop = "确认通过审批?"; - } - if (status === 'reject') { - pop = "确认拒绝?"; - } - var confirmFunc = function (additionalProcessing) { - var pData = { tradeId: trade.id, status: status, text: "" }; - if (!main.isEmpty(additionalProcessing)) { - pData.additionalProcessing = additionalProcessing; - } - var thisObj2 = thisObj; - main.post("/processtradelog/UpdateTradeProcessLog", pData).done( - function (data) { - if (data.obj && data.obj.proccessType == "AdditionalProcessing") { - if (data.obj.type == "LackOfMoney") { - var htmlContent = `
${data.obj.message}
`; - var lackMoneyConfirmLayer = main.open2("提示", - htmlContent, - { - area: ["430px", "175px"], - btn: ['交易特批', '取消'], - yes: function (index, layero) { - var layerIndex = lackMoneyConfirmLayer; - main.confirm("客户资金或授信不足,强制成交会导致本机构产生风险!要继续审批通过?", function () { - layer.close(layerIndex); - confirmFunc("LackOfMoney"); - }); - }, - cancel: function (index, layero) { - if (window.parent && window.parent.reloadtrade) { - thisObj2.closetrade_cashWindow(); - } - (parent || window).layer.closeAll(); - } - }); - } - return; - } - (parent || window).main.message(data.msg); - try { thisObj2.closetrade_cashWindow(); } - catch (e) { } - if (parent) { - parent.layer.closeAll(); - } - }); - } - main.confirm(pop, confirmFunc); - }, - closetrade_cashWindow: function () { - layer.closeMe('reloadData'); - }, - closeCurrentWindow: function () { - try { - if (window.parent && window.parent.reload) window.parent.reload(); - } catch (e) { - } - try { - var layer = window.parent.layer; - layer.close(layer.getFrameIndex(window.name)); - } catch (e) { - } - } - }, - components: { - 'vue-datepicker': FastVue.vueDatePicker(), - 'vue-number-input': FastVue.vueNumberInput(), - } -}); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js index bf8d7285..d73ea0ab 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js @@ -868,9 +868,6 @@ const vue = new Vue({ } }, changeInterestMode(item) { - if (item.InterestMode == 7 || item.InterestMode == 8) { - item.InterestType = 0; - } }, changeInterestType(item) { if (!item.interest_rest_days) { @@ -1462,28 +1459,7 @@ const vue = new Vue({ }, // 多空组合利息腿校验 checkSwapRateList() { - var thisObj = this; - var longInterestModelCount = 0; - var shortInterestModelCount = 0; var check = true; - if (thisObj.getSwapList != null) { - thisObj.getSwapList.forEach((val, num, arr) => { - if (val.InterestMode==7) { - longInterestModelCount++; - } - if (val.InterestMode == 8) { - shortInterestModelCount++; - } - }); - } - if (longInterestModelCount > 1) { - check = false; - main.message("计息基本类型为多头存续名义本金的利息腿只能有一条"); - } - if (shortInterestModelCount > 1) { - check = false; - main.message("计息基本类型为空头存续名义本金的利息腿只能有一条"); - } return check; }, //观察日起始日期跟交易起始日期检查 @@ -1619,7 +1595,7 @@ const vue = new Vue({ //初始化利息端列表 initSwapRateList() { var thisObj = this; - thisObj.getSwapList = thisObj.trade.swap_positions.filter(x => { if ((x.UnderlyingCode == null || x.UnderlyingCode.length == 0) && x.IsInitial && (x.InterestMode == 1 || x.InterestMode == 2 || x.InterestMode == 7 || x.InterestMode == 8 || x.InterestMode == 9)) return x; }); + thisObj.getSwapList = thisObj.trade.swap_positions.filter(x => { if ((x.UnderlyingCode == null || x.UnderlyingCode.length == 0) && x.IsInitial && (x.InterestMode == 1 || x.InterestMode == 2 || x.InterestMode == 9)) return x; }); thisObj.getSwapList.forEach((val, num, arr) => { arr[num].index = num; arr[num].category_tag = arr[num].category_tag || '互换利率'; diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js index 0cd7bc04..2f4b1d8b 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js @@ -330,46 +330,6 @@ function unWindSwap(id) { } }); } -function unWindSwapLongShort(id) { - var title = "交易平仓"; - var srcurl = "/swaptrade2/SwapLongShortUnwind/?enid=" + id; - main.post("/swaptrade2/CheckEodTrade?enid=" + id).done(function (res) { - if (res.success) { - main.open(title, - srcurl, - { - area: ["1300px", "780px"], - end: function () { - if (window.parent && window.parent.reloadtrade) { - window.parent.reloadtrade(); - } - } - }); - } else { - main.message(res.message); - } - }); -} -function unWindLongShortSwap(id) { - var title = "期间互换"; - var srcurl = "/swaptrade2/SwapLongShortSwap/?enid=" + id; - main.post("/swaptrade2/CheckEodTrade?enid=" + id).done(function (res) { - if (res.success) { - main.open(title, - srcurl, - { - area: ["1300px", "780px"], - end: function () { - if (window.parent && window.parent.reloadtrade) { - window.parent.reloadtrade(); - } - } - }); - } else { - main.message(res.message); - } - }); -} function extensionTime(id) { var title = "展期信息"; var srcurl = "/swaptrade2/ExtensionTime/?enid=" + id; diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindLongShort.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindLongShort.js deleted file mode 100644 index ed32fced..00000000 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindLongShort.js +++ /dev/null @@ -1,197 +0,0 @@ -//otcformat禁止千分位分组 -window.otcformat.options.disableGrouping = true; -const inputFormatEqvNotional = swapPricePrecision.getCommonInputFormat('amount', { append: '' }); -const swapInstrumentType = (model.FlowEvents || []).find(item => item && item.UnderlyingInstrumentType)?.UnderlyingInstrumentType || model.UnderlyingInstrumentType || ''; -const formatSwapAmount = value => swapPricePrecision.normalizeCommon('amount', value); -const formatSwapQuantity = value => swapPricePrecision.normalizeCommon('quantity', value, swapInstrumentType); -let dealDate = model.DealDate; -const vue = new Vue({ - el: '#vueDiv', - data: { - deal: model, - interestList: [], - marginList: [], - oriClosePercent: model.ClosePercent - }, - computed: { - minStartDate() { - return dealDate; - } - }, - created() { - this.initDeal(); - this.calcCloseAmount(); - this.dataFormat(); - }, - methods: { - formatAmount(value) { - return swapPricePrecision.formatCommon('amount', value); - }, - formatQuantity(value) { - return swapPricePrecision.formatCommon('quantity', value, swapInstrumentType); - }, - initDeal() { - this.interestList = model.FlowEvents.filter((item) => { - return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9; - }); - this.marginList = model.FlowEvents.filter((item) => { - return item.InterestMode == 5 || item.InterestMode == 6; - }); - }, - dataFormat() { - this.deal.NotionalValue = formatSwapAmount(this.deal.NotionalValue); - this.deal.PosiNotionalValue = formatSwapAmount(this.deal.PosiNotionalValue); - this.deal.NotionalQty = formatSwapQuantity(this.deal.NotionalQty); - this.deal.SwapCloseAmount = formatSwapAmount(this.deal.SwapCloseAmount); - this.deal.PositionQty = formatSwapQuantity(this.deal.PositionQty); - this.interestList.forEach(x => { - //x.Principal = formatSwapAmount(x.Principal); - //x.Rate = otcformat.fixed6(x.Rate); - x.InterestAmount = formatSwapAmount(x.InterestAmount); - x.InterestClosePnL = formatSwapAmount(x.InterestClosePnL); - //x.InterestStartDate = x.InterestStartDate ? x.InterestStartDate.substr(0, 10) : ""; - //x.InterestEndDate = x.InterestEndDate ? x.InterestEndDate.substr(0, 10) : ""; - }); - this.marginList.forEach(x => { - x.InterestAmount = formatSwapAmount(x.InterestAmount); - x.InterestClosePnL = formatSwapAmount(x.InterestClosePnL); - }); - }, - changeInterestAmount(item) {//修改利息金额 - let interestRatio = item.InterestDirection == 1 ? 1 : -1; - item.InterestClosePnL = formatSwapAmount(parseFloat(item.InterestAmount) * interestRatio+ parseFloat(item.InterestFee)); - this.calcCloseAmount(); - }, - calcCloseAmount() {//计算平仓总额=浮动收取+利息收取-浮动支付-利息支付 - let thisObj = this; - thisObj.deal.SwapCloseAmount = 0; - thisObj.deal.SwapRealizedPnL = 0; - thisObj.deal.SwapMarginRebatePnl = 0; - thisObj.deal.SwapMarginAmount = 0; - this.interestList.forEach(x => { - /*let interestRatio = x.InterestDirection == 1 ? 1 : -1;*/ - let interestAmount = parseFloat(x.InterestClosePnL); - thisObj.deal.SwapCloseAmount = parseFloat(thisObj.deal.SwapCloseAmount) + interestAmount; - thisObj.deal.SwapRealizedPnL = parseFloat(thisObj.deal.SwapRealizedPnL) + interestAmount; - }); - this.marginList.forEach(x => { - let interestAmount = parseFloat(x.InterestClosePnL); - let interestRatio = x.InterestDirection == 1 ? -1 : 1; - thisObj.deal.SwapCloseAmount = parseFloat(thisObj.deal.SwapCloseAmount) + interestAmount; - thisObj.deal.SwapMarginRebatePnl = parseFloat(thisObj.deal.SwapMarginRebatePnl) + interestAmount; - thisObj.deal.SwapRealizedPnL = parseFloat(thisObj.deal.SwapRealizedPnL) + interestAmount; - thisObj.deal.SwapMarginAmount = parseFloat(thisObj.deal.SwapMarginAmount) + parseFloat(x.InterestPrincipal) * interestRatio; - }); - thisObj.deal.SwapCloseAmount = formatSwapAmount(thisObj.deal.SwapCloseAmount); - thisObj.deal.SwapRealizedPnL = formatSwapAmount(thisObj.deal.SwapRealizedPnL); - thisObj.deal.SwapMarginRebatePnl = formatSwapAmount(thisObj.deal.SwapMarginRebatePnl); - thisObj.deal.SwapMarginAmount = formatSwapAmount(thisObj.deal.SwapMarginAmount); - }, - closeTrade() {//平仓 - var thisObj = this; - let reqObj = _.cloneDeep(thisObj.deal); - let marginCloneList = _.cloneDeep(thisObj.marginList); - reqObj.FlowEvents = _.cloneDeep(thisObj.interestList); - marginCloneList.forEach((item) => { - reqObj.FlowEvents.push(item); - }) - var postData = { unwindData: reqObj }; - var msg = "确认提交平仓?"; - var postUrl = "/swaptrade2/SwapLongShortUnwindJson"; - if (g_isShowReCheckClose) { - msg = "确认提交平仓审核?"; - postUrl = "/swaptrade2/ApplyUnwind"; - postData.eventType = 2;//互换3,平仓2 - } - main.confirm(msg, - function () { - //重新计算百分比 - var thisObj2 = thisObj; - main.post(postUrl, postData).done(function (res) { - if (res.success) { - thisObj2.closetrade_cashWindow(); - } - else { - try { - thisObj2.closetrade_cashWindow(); - } catch (e) { - } - } - }); - }); - }, - getSumbitText: function () { - return g_isShowReCheckClose ? "审核提交" : "保存"; - }, - submitApproval(status) { - var pop = ''; - if (status === 'pass') { - pop = "确认通过审批?"; - } - if (status === 'reject') { - pop = "确认拒绝?"; - } - let thisObj = this; - var confirmFunc = function (additionalProcessing) { - var pData = { tradeId: thisObj.deal.SwapTradeId, status: status, text: "" }; - if (!main.isEmpty(additionalProcessing)) { - pData.additionalProcessing = additionalProcessing; - } - var thisObj2 = thisObj; - main.post("/processtradelog/UpdateTradeProcessLog", pData).done( - function (data) { - if (data.obj && data.obj.proccessType == "AdditionalProcessing") { - if (data.obj.type == "LackOfMoney") { - var htmlContent = `
${data.obj.message}
`; - var lackMoneyConfirmLayer = main.open2("提示", - htmlContent, - { - area: ["430px", "175px"], - btn: ['交易特批', '取消'], - yes: function (index, layero) { - var layerIndex = lackMoneyConfirmLayer; - main.confirm("客户资金或授信不足,强制成交会导致本机构产生风险!要继续审批通过?", function () { - layer.close(layerIndex); - confirmFunc("LackOfMoney"); - }); - }, - cancel: function (index, layero) { - if (window.parent && window.parent.reloadtrade) { - thisObj2.closetrade_cashWindow(); - } - (parent || window).layer.closeAll(); - } - }); - } - return; - } - (parent || window).main.message(data.msg); - try { thisObj2.closetrade_cashWindow(); } - catch (e) { } - if (parent) { - parent.layer.closeAll(); - } - }); - } - main.confirm(pop, confirmFunc); - }, - closetrade_cashWindow: function () { - layer.closeMe('reloadData'); - }, - closeCurrentWindow: function () { - try { - if (window.parent && window.parent.reload) window.parent.reload(); - } catch (e) { - } - try { - var layer = window.parent.layer; - layer.close(layer.getFrameIndex(window.name)); - } catch (e) { - } - } - }, - components: { - 'vue-datepicker': FastVue.vueDatePicker(), - 'vue-number-input': FastVue.vueNumberInput(), - } -}); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js index 4655829d..1f70d833 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js @@ -110,7 +110,7 @@ const vue = new Vue({ this.floatPosition = positions[0]; this.initPosiNetPrice = this.floatPosition.PosiGrossPrice; this.interestList = model.FlowEvents.filter((item) => { - return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9; + return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 9; }); this.marginList = model.FlowEvents.filter((item) => { return item.InterestMode == 5 || item.InterestMode == 6; @@ -368,14 +368,14 @@ const vue = new Vue({ getInterestList() {//根据平仓日期获取利息腿信息 var thisObj = this; // closePercent 按"占期初(original)"语义(A)传给后端,由 GetUnwindInterestList 转为"占剩余(B)"计算 - var postData = { valueDate: thisObj.deal.ValueDate, unwindDate: thisObj.deal.ValueDate, tradeId: thisObj.deal.SwapTradeId, closePercent: thisObj.deal.ClosePercent, eventType: 2, notionalValue: thisObj.deal.NotionalValue, posiNotionalValue: thisObj.deal.PosiNotionalValue } + var postData = { valueDate: thisObj.deal.ValueDate, unwindDate: thisObj.deal.ValueDate, tradeId: thisObj.deal.SwapTradeId, closePercent: thisObj.deal.ClosePercent, eventType: 2, notionalValue: thisObj.deal.NotionalValue, posiNotionalValue: thisObj.deal.PosiNotionalValue, isPenaltyInterest: thisObj.deal.IsPenaltyInterest == true } // EQD-6977 是否罚息:预览即含罚息(其他费用含罚息) // 调试埋点(?otcdebug=1):记录实际发给后端的平仓比例——未来若"改比例利息腿不动", // 对比此处请求比例 与 下方返回各腿 principal/amount 是否随比例变化,即可定位是前端没传对还是后端没缩放。 if (window.otcDebug) window.otcDebug.log('[unwind] getInterestList → POST closePercent=', thisObj.deal.ClosePercent, ' closeNotionalValue=', thisObj.deal.CloseNotionalValue, ' posiNotionalValue=', thisObj.deal.PosiNotionalValue); main.post("/swaptrade2/GetUnwindInterestList", postData, { async: true }).done(function (resp) { thisObj.interestList = resp.obj.filter((item) => { - return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9; + return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 9; }); thisObj.marginList = resp.obj.filter((item) => { return item.InterestMode == 5 || item.InterestMode == 6; diff --git a/YLErpWeb/wwwroot/Scripts/app/system/Approvalprocess.js b/YLErpWeb/wwwroot/Scripts/app/system/Approvalprocess.js index cff6d111..68c4b3a8 100644 --- a/YLErpWeb/wwwroot/Scripts/app/system/Approvalprocess.js +++ b/YLErpWeb/wwwroot/Scripts/app/system/Approvalprocess.js @@ -53,7 +53,8 @@ var app = new Vue({ { text: '交易新增与修改', value: '2' }, { text: '交易了结', value: '6' }, /* { text: '资信与授信', value: '3' },*/ - { text: '出金', value: '4' } + { text: '出金', value: '4' }, + { text: '黑名单', value: '7' } ], isOpen: false, @@ -62,12 +63,14 @@ var app = new Vue({ isCredit: false, isOutCash: false, isClient: false, + isClientBlack: false, openItems: [], clientItems: [], tradeItems: [], closeItems: [], creditItems: [], outCashItems: [], + clientBlackItems: [], openCounter: 0, tradeCounter: 0, creditCounter: 0, @@ -140,6 +143,7 @@ var app = new Vue({ thisObj.isCredit = false; thisObj.isOutCash = false; thisObj.isClient = false; + thisObj.isClientBlack = false; } else if (thisObj.selected === '2') { thisObj.isOpen = false; thisObj.isTrade = true; @@ -147,6 +151,7 @@ var app = new Vue({ thisObj.isCredit = false; thisObj.isOutCash = false; thisObj.isClient = false; + thisObj.isClientBlack = false; } else if (thisObj.selected === '6') { // 需求②:交易了结流程 thisObj.isOpen = false; thisObj.isTrade = false; @@ -154,6 +159,7 @@ var app = new Vue({ thisObj.isCredit = false; thisObj.isOutCash = false; thisObj.isClient = false; + thisObj.isClientBlack = false; } else if (thisObj.selected === '3') { thisObj.isOpen = false; thisObj.isTrade = false; @@ -161,6 +167,7 @@ var app = new Vue({ thisObj.isCredit = true; thisObj.isOutCash = false; thisObj.isClient = false; + thisObj.isClientBlack = false; } else if (thisObj.selected === '4') { thisObj.isOpen = false; @@ -169,6 +176,7 @@ var app = new Vue({ thisObj.isCredit = false; thisObj.isOutCash = true; thisObj.isClient = false; + thisObj.isClientBlack = false; } else if (thisObj.selected === '5') { thisObj.isOpen = false; @@ -177,6 +185,16 @@ var app = new Vue({ thisObj.isCredit = false; thisObj.isOutCash = false; thisObj.isClient = true; + thisObj.isClientBlack = false; + } + else if (thisObj.selected === '7') { + thisObj.isOpen = false; + thisObj.isTrade = false; + thisObj.isClose = false; + thisObj.isCredit = false; + thisObj.isOutCash = false; + thisObj.isClient = false; + thisObj.isClientBlack = true; } else { thisObj.isOpen = false; @@ -185,6 +203,7 @@ var app = new Vue({ thisObj.isCredit = false; thisObj.isOutCash = false; thisObj.isClient = false; + thisObj.isClientBlack = false; } thisObj.getProcess(); }, @@ -385,6 +404,18 @@ var app = new Vue({ thisObj.addCloseNode(index, child, node); return; } + else if (selectType === "7") { //黑名单 + var item = { + Type: 'ClientBlackProcess', + Index: index + 1, + SelectValue: 0 + }; + thisObj.clientBlackItems.splice(index, 0, item); + thisObj.clientBlackItems.forEach(function (x, itemIndex) { + x.Index = itemIndex + 1; + }); + return; + } }, delProcess: function (openItem) { @@ -417,6 +448,14 @@ var app = new Vue({ }); return; } + else if (selectType === "7") {//黑名单 + var index = thisObj.clientBlackItems.indexOf(openItem); + thisObj.clientBlackItems.splice(index, 1); + thisObj.clientBlackItems.forEach(function (x, itemIndex) { + x.Index = itemIndex + 1; + }); + return; + } }, addOpenProcess(index, child, node) { var thisObj = this; @@ -528,6 +567,10 @@ var app = new Vue({ thisObj.saveCloseProcess(); return; } + else if (selectType === "7") { //黑名单 + thisObj.clientBlackOk(); + return; + } }, openOk() { var thisObj = this; @@ -857,6 +900,38 @@ var app = new Vue({ }); } }, + clientBlackOk() { + var thisObj = this; + var items = thisObj.clientBlackItems; + for (var i = 0; i < items.length; i++) { + if (items[i].SelectValue === "" || items[i].SelectValue === 0) { + main.message('流程中断,请重新选择'); + return; + } + for (var j = i + 1; j < items.length; j++) { + if (parseInt(items[i].SelectValue) === parseInt(items[j].SelectValue)) { + main.message('流程包含重复项,请重新选择'); + return; + } + } + } + + if (items.length > 0) { + main.confirm("确认修改黑名单审批流程?", function () { + main.post("/AccountOpeningProcess/AddProcess", + { type: "ClientBlackProcess", data: items }, + { async: false }).done(function () { + thisObj.getProcess(); + }); + }); + } else { + main.confirm("删除审批流程后,黑名单变更会直接生效,确认删除?", function () { + main.post("/AccountOpeningProcess/AddProcess", + { type: "ClientBlackProcess" }, + { async: false }); + }); + } + }, getProcess() { var thisObj = this; thisObj.openItems = []; @@ -865,6 +940,7 @@ var app = new Vue({ thisObj.creditItems = []; thisObj.outCashItems = []; thisObj.clientItems = []; + thisObj.clientBlackItems = []; main.post("/AccountOpeningProcess/GetProcess", {}, { async: false }).done( @@ -945,6 +1021,14 @@ var app = new Vue({ triggerCondition: value.triggerCondition }); }); + (res.ClientBlackProcess || []).forEach(function (value) { + thisObj.clientBlackItems.push({ + id: value.id, + Type: value.processType, + Index: value.order, + SelectValue: value.roleId + }); + }); // 需求①:加载后把 triggerCondition(JSON)解析为结构化对象供 UI 编辑 ['openItems', 'tradeItems', 'closeItems', 'clientItems'].forEach(function (arr) { thisObj[arr].forEach(function (item) { diff --git a/YLErpWeb/wwwroot/Scripts/app/trade/tradeApproval.js b/YLErpWeb/wwwroot/Scripts/app/trade/tradeApproval.js index ecbd7a7d..21bd8878 100644 --- a/YLErpWeb/wwwroot/Scripts/app/trade/tradeApproval.js +++ b/YLErpWeb/wwwroot/Scripts/app/trade/tradeApproval.js @@ -731,9 +731,6 @@ function passorreinfo(eid, tradeType, status, isGroup, StructureType) { else if (tradeType === "收益互换") { weight = "95%"; url = "/swaptrade2/SwapUnwind/?enid=" + eid + "&isUseApproval=" + true; - if (StructureType == "多空组合") { - url = "/swaptrade2/SwapLongShortUnwind/?enid=" + eid + "&isUseApproval=" + true; - } height = "800px"; } else { @@ -744,9 +741,6 @@ function passorreinfo(eid, tradeType, status, isGroup, StructureType) { weight = "95%"; title = "互换审批"; url = "/swaptrade2/SwapIncome/?enid=" + eid + "&isUseApproval=" + true; - if (StructureType == "多空组合") { - url = "/swaptrade2/SwapLongShortSwap/?enid=" + eid + "&isUseApproval=" + true; - } height = "800px"; } else if (status === "行权待复核") { @@ -883,9 +877,6 @@ function unWindSelect(id, tradeType, isReCheck, StructureType) { }); } else if (tradeType == "收益互换") { var url = "/swaptrade2/SwapUnwind/?enid=" + id + "&isUseApproval=" + true;; - if (StructureType == "多空组合") { - url = "/swaptrade2/SwapLongShortUnwind/?enid=" + id + "&isUseApproval=" + true;; - } main.open(title, url, { area: ["1300px", "720px"], end: function () { diff --git a/corp-action-refactor-proposal.md b/corp-action-refactor-proposal.md index b21d529b..6ec51e4e 100644 --- a/corp-action-refactor-proposal.md +++ b/corp-action-refactor-proposal.md @@ -99,7 +99,7 @@ curretEod.PosiQuantity = qty < 0 ? 0 : Math.Abs(qty); | **数量递推** | `SwapEodPositionService.cs:1897`(qty 递推)、`:1723`/`:1905`(无事件日结转)、`:1631`(首次归档) | 需新增「公司行为数量」第三来源项 | `SwapEodPositionService.cs` | | **`TdChangedQty`** | 定义 `EodSwapPosition.cs:300`(DisplayName "当日公司行为数量");唯一赋值 `SwapEodPositionService.cs:1650`(恒=0) | 挂进 :1897 递推式(与 `TdCloseQty`:1942 对称),否则与 `PosiQuantity` 永久不自洽 | `SwapEodPositionService.cs` | | **成本均价** | `SwapEodPositionService.cs:1926-1936`(加权重算,TRS 无 `CostPrice` 字段,等价字段 `PosiGrossPrice`/`PosiNetPrice`) | 送股无成交金额(分子+0、分母增)→ 走 `:1912 else if` 分支价不摊薄,污染盯市;配股有现金需加 `RationedSharesAmount×Price` | `SwapEodPositionService.cs:1912-1937` | -| **计息基准** | `SwapDealService.cs:893 CalcNotionalByMode`(五种模式分流)、`:1372` `dynomicPrincipal`、`:2384 InterestPrincipalFix` 仅平仓递减 | `posiLong/Short` 经 `PosiNotionalValue` 可自动跟随;**`InterestPrincipalFix` 是独立存量,与数量解耦**,配股缴款需新写入点 | `SwapDealService.cs` | +| **计息基准** | mode 分流已重构为 `FundingLegs/FundingLegStrategyFactory`(原 `SwapDealService CalcNotionalByMode` 已删;`dynomicPrincipal` 亦随重构消失)、`InterestPrincipalFix` 仅平仓递减 | `posiLong/Short` 经 `PosiNotionalValue` 可自动跟随;**`InterestPrincipalFix` 是独立存量,与数量解耦**,配股缴款需新写入点 | `SwapDealService.cs` + `FundingLegs/` | | **盯市盈亏** | `SwapEodPositionService.cs:1657/1730/1815/2019`(4 份同构副本 `PosiMtmPnL`)、`:1713 GetSwapValuationPrice`(取除权后价) | 数量突变日若 `PosiQuantity`/`PosiGrossPrice` 未同步除权 → 虚假巨亏;**4 处副本必须一致改** | `SwapEodPositionService.cs` | | **数据源** | `DividendService.cs:752 GetPositionAmount`(现成 `amount*(1+GiveShareAmount/10)` 送股调整)、`:730 GetRatio`(现成除权价公式) | SwapModule 未复用,需建调用边 | 新增 SwapModule→DividendService 调用 | diff --git a/nuget.config b/nuget.config new file mode 100644 index 00000000..604ef056 --- /dev/null +++ b/nuget.config @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/项目文档/多租户死代码清理执行计划.md b/项目文档/多租户死代码清理执行计划.md new file mode 100644 index 00000000..74645370 --- /dev/null +++ b/项目文档/多租户死代码清理执行计划.md @@ -0,0 +1,103 @@ +# 多租户死代码清理执行计划(国联民生独家分支 · 校正版) + +> 分支:`glms/feature/1.4.2` +> 编制:2026-08-15 +> 状态:**计划已查明边界,多处需"部署配置确认"后方可执行删除**。本文件是执行清单,不是批准书。 + +## 0. 最关键的两条纠偏(决定本计划能否落地) + +1. **本分支的"公司"不是编译期硬编码,而是运行期配置。** + `BizLogicSingleton` / `MarginCalculation` 的派发依据是 `PS.Config.Company`,其值来自 **DB `appconfig` 表的 `IErpConfig.ErpElement.Company`**,不是 git 分支。所以"国联独家分支"只表示**当前 GLMS 部署把 Company 配成国联**;理论上同一份代码换 Company 配置即可服务别家。 + → 删除任何"非国联"代码,等于**永久剥夺该部署切换/回退到其它家的能力**。这是业务逻辑决策,不是纯技术清理。 + +2. **真实死活取决于独立开关,不止 Company。** + - **衡泰对接**:不受 Company 门控,由 `HedgingSource`(对冲数据源,衡泰=1)与"接收衡泰修改回执"两个独立开关控制。衡泰模型层 26 文件是否死,取决于 GLMS 部署是否开启这两个开关——**不能一律当死代码删**。 + - **保证金 per-company 计算器**:是否死,取决于 GLMS 把 Company 配成哪家 + 是否走 `UniversalMarginCalculation`(见阶段 1 待确认项)。 + +> ⚠️ 因此本计划所有删除动作前,必须先确认两件事: +> (a) GLMS 生产 `appconfig` 的 `Company` 实际值; +> (b) GLMS 生产是否开启衡泰 `HedgingSource` / 接收回执开关、是否配置浙商等 DataSource。 +> 这两点只能问部署负责人或查生产配置,**grep 代码无法判定**。 + +## 1. 死代码总览(按"确定性"分级) + +| 区域 | 规模 | 确定性 | 死因 | +|---|---|---|---| +| 衡泰模型层 `HengTaiModel/` | 26 文件(实测 1712 行) | **中**——取决于衡泰开关 | `HedgingSource`/回执开关未开则死 | +| 衡泰 DataCompare DTO | 4 文件 | 中 | 同上 | +| 6 处 `using HengTaiModel` 未用 | 6 文件各 1 行 | **高** | 确认未引用任何 HengTai 类型 | +| 保证金 per-company 计算器 | 35 生产文件 ≈17,600 行 + 测试副本 ≈7,000 行 | **中**——取决于 Company 配置 + 是否走 Universal | switch 不含国联→default;Universal 生产无调用 | +| 其他家 BizLogic | 34 文件(含 BizLogicZheShang 等) | 中 | Company 非国联则不可达 | +| 浙商空插件 `Plugins/YLErp.Plugins.ZheShang/` | 目录仅 obj/ | **高**(目录已空) | 无源码/无 csproj/无 sln 引用 | +| 前端"衡泰"JS 引用 | 2 处(etradingRule/etradeAccountList、tradeConfirmBook) | 高 | 衡泰相关 | +| 前端 `Is{券商}` 分支 | 少量(**待核实**,见阶段 3) | 中 | 公司/开关门控 | +| 浙商监管报表 `ExtendReport/ZheShang/` | 2 文件 197 行 | **低**——按 DataSource 派发 | 国联是否配置该 DataSource 未知 | + +> 与"上帝类"的关系:实测 `SwapDealService`/`SwapEodPositionService` 内 **0 个 `Is{券商}` 分支**;全仓 66 个券商分支点泄漏在 `RealtimePnlCalc`/`QuotaMonitorService` 等 EOD/风控文件。→ 删死代码**几乎不缩减上帝类的领域密度**,只降"仓库表面积/误接风险"。上帝类治理见阶段 5(领域 seam 抽取),与本案正交。 + +## 2. 删除前通用检查清单(每文件必做) + +1. `grep -rn "" --include=*.cs` 全仓(含 `Tools/`、`UnitTestProject/`、`YLErpUnitTest/`、`Plugins/`),确认除自身定义 + 派发 switch/工厂外无第二引用。 +2. 若在 `.csproj` 有 `` 显式引用(非 SDK 通配),删文件同步移除该行。 +3. 若被其它**待删死文件**引用,可一并删并在 commit 说明。 +4. 删除后必须编译验证(**本机无 dotnet SDK**,交 CI 或你本地 `dotnet build`)。编译器比 grep 可靠。 + +## 3. 分阶段执行计划 + +### 阶段 0 — 清 6 处衡泰死 import【零风险,可立即做】 +仅删 `using YLErp.Model.HengTaiModel;` 行(已确认未引用任何类型): +- `YLErpWeb/Controllers/riskController.cs:5` +- `YLErpWeb/App/KafkaTask/ClientReskCheckKafkaTask.cs:5` +- `YLErpDAL/Modules/SwapModule/TRSHedgingOrderService.cs:16` +- `YLErpDAL/Modules/SwapModule/SwapConsumerService.cs:12` +- `YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs:37` +- `YLErpDAL/BLL/EodSettlement/RealtimePnlCalc.cs:26` + +### 阶段 1 — 保证金 per-company 计算器【最高体量,但需先确认】 +**待确认(执行前必答):** +- GLMS `appconfig.Company` 实际值是否为 `国联`? +- `MarginCalculation.cs` 的 `default` 分支对国联是否真的走 `DefaultMarginCalculation`?还是团队意图走 `UniversalMarginCalculation`(当前生产无调用,疑似未接线)? +- 若国联本应走 `Universal`,则 `UniversalMarginCalculation` 是**活代码、须保留并接线**,而非死代码。 + +**确认后,若国联走 Default:** +- **保留**:`DefaultMarginCalculation.cs`(19) `MarginCalculation.cs`(803,调度器) `MarginCalculationBase.cs`(374) `MarginCalcHelper.cs`(662,partial base) `MarginCalcRequests.cs`(182) `MarginCalcException.cs`(13) +- **删除**(生产 `YLErpDAL/BLL/MarginCalculation/`): + GTJAMarginCalculation(2718) ChangJiangMarginCalculation(1369) GFSMMarginCalculation(905) GuoXinJinYangMarginCalculation(800) HuaAnMarginCalculation(749) DongZhengRunHeMarginCalculation(697) FDMarginCalculation(674) BHRSMarginCalculation(550) XiangCaiMarginCalculation(545) RDMarginCalculation(541) GDGZMarginCalculation(513) GLDHMarginCalculation(513,`#if DEBUG` 才可达) DongWuMarginCalculation(507)+`DongWu/`子目录(115+258+95+42) HongYuanMarginCalculation(500) GuoTouMarginCalculation(501) XingZhengMarginCalculation(494) HaiTongMarginCalculation(479) XMXYMarginCalculation(422) ZhaoZhengMarginCalculation(389) ZheQiMarginCalculation(409) HongYeMarginCalculation(327) GQMarginCalculation(333) ZhongLiangMarginCalculation(338) BXMarginCalculation(320) MaoChuanMarginCalculation(318) ZhongJinMarginCalculation(294) SYWGMarginCalculation(254) XingYeMarginCalculation(226) ALQHMarginCalculation(191) GuoHaiMarginCalculation(82) SQMarginCalculation(20) ZhongCaiMarginCalculation(20) **UniversalMarginCalculation(139,待确认)** +- **测试副本同步删**:`YLErpUnitTest/Modules/MarginModule/MarginCalculation/` 下所有 per-company 副本 + `UnitTestProject/Modules/CalcModules/MarginCalculationTest.cs` 中 `ZhaoZheng`/`GuoTou` 用例;并移除 `YLErpUnitTest.csproj` 对应 `` 行。 +- 体量:生产 ≈17,600 行 + 测试 ≈7,000 行。**这是唯一同时降死代码 + 类蔓延的动作。** + +### 阶段 2 — 其他家 BizLogic【需 Company 确认】 +- **保留**:`BizLogicDefault.cs`(基类) `BizLogicSingleton.cs`(派发器) `BizLogicException.cs` `IBizLogic.cs` `BizLogicGuoLian.cs`(活) +- **删除 34 个公司类**:`BizLogicALQH BHRS BX DX DZ FD GDGZ GFSM GT Gldh Gtja GXJY GuangQi GuoMao HaiTong HongYe HongYuan HuaAn HuaXi RD ShanXiGuShou SQ Sywg WCZD XiangYu XingYe XingZheng ZJ ZhaoZheng ZL ZheQi ZhongJiShiHua MaoChuan ZheShang` +- 收口:`BizLogicSingleton.cs` switch 精简为仅 `国联`(+default→GuoLian),消除多租户 dispatch 异味。 + +### 阶段 3 — 前端 + 浙商插件/报表【中风险,需核实】 +- **前端衡泰(高确定)**:`wwwroot/Scripts/app/etradingRule/etradeAccountList.js` + `tradeConfirmBook.js` 中"衡泰"引用删除;`Views/EtradeAccount/Index.cshtml`「衡泰簿记账户」整块删(确认无其它活内容)。 +- **前端 `Is{券商}` 分支(待核实)**:计划初稿列的 `eodExecV2.cshtml:14/67/134`、`TradeMarketReport_Collateral.cshtml:413` 等,**需先 grep 确认是 `CompanyEnum` 还是 `Is浙商`/`Is国元固收` 等开关**,再决定删法。前端实际券商分支极少。 +- **浙商空插件(高确定)**:`Plugins/YLErp.Plugins.ZheShang/` 整目录删(已空,无 sln/csproj 引用;删前 grep 确认)。 +- **浙商报表(低确定)**:`ExtendReport/ZheShang/` 按 **DataSource** 派发,国联是否配置该 DataSource 未知 → **先查生产菜单/配置确认不可达再删**。同类 `DongWu/GeLin/ZheQi` 报表同理。 + +### 阶段 4 — 衡泰模型层【中风险,需开关确认】 +- 仅当确认 GLMS **未开启** `HedgingSource=衡泰` 与"接收衡泰修改回执"开关时,才删 `HengTaiModel/`(26 文件) + `DataCompare/Dto/`(4 文件)。否则保留。 +- 删除前先确认 `DataCompare` 模块整体是否还有非衡泰调用方。 + +### 阶段 5(独立轨道,不在本计划执行)— 上帝类 seam 抽取 +`SwapDealService`/`SwapEodPositionService` 瘦身**不靠删文件**,按 `互换模块独立化最终方案.md` + `互换模块可测性改造Seam实践指南.md` 做领域 seam 抽取;并优先把泄漏在 `RealtimePnlCalc`/`QuotaMonitorService` 的 66 个 `Is{券商}` 分支收回 `BizLogic` 子类。与阶段 0–4 解耦、可并行。 + +## 4. 风险与回滚 +- **编译验证硬门槛**:本机无 SDK,每阶段交 CI 编译通过;错误几乎都来自漏删引用或误删共享基础设施(`MarginCalculationBase`/`MarginCalcHelper`/`MarginCalcRequests`/`MarginCalcException` 务必保留)。 +- **业务决策风险**:删非国联代码 = 永久丧失该部署切换/回退能力。必须业务/架构负责人拍板,不可技术单方面决定。 +- **报表 DataSource 误判**:阶段 3C 须运行时确认。 +- 每阶段独立 commit(`git rm`),便于单阶段回退。 + +## 5. 预估收益(删除全部确认项后) +| 阶段 | 删除行数(估) | 风险 | 类型 | +|---|---|---|---| +| 0 衡泰死 import | 6 行 | 零 | 纯死代码 | +| 1 保证金 | ≈17,600 生产 +7,000 测试 | 低(确认后) | 死代码 + 类蔓延 | +| 2 BizLogic | 34 文件 ≈3,000+ 行 | 低(确认后) | 多租户残留 | +| 3 前端/插件/报表 | 数百行 + 空目录 | 中 | 公司/开关门控 | +| 4 衡泰模型 | 26+4 文件 ≈1,700 行 | 中(开关确认后) | 供应商集成 | +| **合计** | **≈ 29,000+ 行** | — | — | + +> 结论给用户:清死代码是 hygiene 末道工序,能显著降表面积与误接风险,但**不是**上帝类的最佳方向;上帝类最佳方向仍是领域 seam 抽取 + 收回泄漏的券商分支。删前须先确认 GLMS 部署的 Company 值与衡泰/浙商开关。 diff --git a/项目文档/缺陷分析-利息部分平仓尾差20260807.md b/项目文档/缺陷分析-利息部分平仓尾差20260807.md deleted file mode 100644 index 5e88e9bc..00000000 --- a/项目文档/缺陷分析-利息部分平仓尾差20260807.md +++ /dev/null @@ -1,103 +0,0 @@ -# 缺陷分析:利息部分平仓尾差(业务场景3 / 业务场景4 浮动利率) - -> 数据来源:`缺陷测试-利息20260807晚.xlsx`(独立手算 oracle,非代码 re-baseline) -> 分析日期:2026-08-08 | 关联分支:`glms/feature/1.4.2` - -## 1. 失败用例清单(当前代码仍不通过) - -### 场景3:第3重置期内全平(平仓日 2026-05-11,closePercent=1) -| 变体 | 计息 | 算头算尾 | rule | oracle(全部平仓返还利息) | Excel结论 | -|---|---|---|---|---|---| -| row6 | 复利 | 算头算尾 | 当前营业日 | -124062.54 | **不通过** | -| row7 | 复利 | 算头算尾 | 当前营业日 | 280303.16 | **不通过** | -| row8 | 复利 | 算头不算尾 | 当前营业日 | -117918.47 | 通过 | -| row9 | 复利 | 算头不算尾 | 当前营业日 | 266674.35 | 通过 | -| row10 | 复利 | 算头算尾 | 前一营业日 | -123730.45 | **不通过** | -| row11 | 复利 | 算头算尾 | 前一营业日 | 279733.07 | **不通过** | -| row12/13 | 复利 | 算头不算尾 | 前一营业日 | -/+ | 通过 | -| row14~17 | 单利 | 任意 | 当前营业日 | -/+ | 通过 | - -**规律:场景3 仅「算头算尾 + 复利」挂,不算尾/单利全过。** - -### 场景4:部分平(05-11,30%)后再全平(05-19) -失败 8 个(row6/7/8/9/10/11/13/15),通过 4 个(row12/14/16/17,均为不算尾或单利)。 -Excel 备注(row6)原文: -> 不通过,部分平仓时,利息端平仓金额没有跟随平仓比例变化。全部平仓时,居然没有考虑已经过大支付了利息。 - -场景4 row6 量化:oracle 最终全平 = -124093.74,系统 = -124122.96,**系统多算 29.22**; -部分用例偏差更大(row8 多算 3685.96,row15 多算 3961.58)。说明部分平仓后再全平的尾差链路在复利下整体脆弱。 - -## 2. 根因(代码实证) - -### Bug A:部分平仓利息端未随平仓比例缩放 -`SwapDealService.cs` 盘中平仓(复利分支,`CalcDailyCompoundInterest` 内 `daysFromPreEod==1` 早退分支): -``` -InterestAmount = preEodPosition.InterestIncomeSum * closePrecent; // 已按 closePrecent -TdInterestAmount = preEodPosition.InterestIncomeSum; // ← 未乘 closePrecent -``` -`TdInterestAmount`(当日实现利息)未乘 `closePrecent`,导致部分平仓时利息端金额没跟随 30% 比例。 -对应场景4 备注第一条「利息端平仓金额没有跟随平仓比例变化」。 - -### Bug B:全部平仓未扣减已部分平仓已付利息 -`SwapEodPositionService.cs` EOD 平仓结算: -``` -TdCloseInterest = flowEvents.Sum(x => x.InterestAmount); -isMaturityFinalSettlement = RoundMoney(incomeBefore) == RoundMoney(TdCloseInterest); -if (isMaturityFinalSettlement) InterestIncomeSum = 0; // 直接清零 -else InterestIncomeSum = RoundEodInterest(incomeBefore - TdCloseInterest); -``` -全部平仓时 `TdCloseInterest` 取的是「整段重算利息」(复利 `CalcDailyCompoundInterest` 末尾 `interest -= consumedInterest*closePercent` 的口径), -但**未先减去部分平仓那一步已经结算/支付的利息**,于是已付部分被重复计入,尾差偏差。 -对应场景4 备注第二条「全部平仓时没考虑已大支付了利息」。 - -### Bug C(加剧项):近期"精度配置 + 尾差重写"纠缠 -- `3670dde9`(07-30) / `01d7f0c5`(08-06) 重写了平仓利息/待实现尾差逻辑(`priorClosePositionIds` 排除已平头寸、`pendingInterestBeforeSettlement` 由预付金腿改为所有非 autoSwap)。 -- 同期 `bff3e920`(07-29) `swappriceprecision.js`:`yield 6→4`、`price 11→9`;`a4906010` 净价/全价精度分开。 -- 尾差 = 高精度应结 − 结算(2位)。精度配置改变 → 舍入残差落点变 → 与重写后的尾差逻辑在"部分平后再全平"长链路(场景4)上交互出错。固定利率 4-2 路径短未触发,浮动 4-2 路径长直接爆。 - -## 3. 为什么现有测试没护住好代码 -1. **测试被 re-baseline 到代码**:`01d7f0c5` 把期望常量从 `0.006383561644` 改成 `-0.010438356164`,拿新代码输出当期望值 → 测试只是复述代码行为。 -2. **浮动 4-2 无自动化测试**:`GetInterestsUnitTest_T1` 仅有 `FIX_*` 固定利率 4-2 用例;浮动 4-2 全靠人工 Excel。 -3. **断言容差太松**:既有 `AssertInterestEqual` 用 `ConsGlobal.PriceRound-2` 容差(约 0.01),尾差差在 4~6 位小数全被放过。 -4. **真 oracle 躺在 Excel 未自动化**:「善洁方法二」30%/70% 守恒检查是极佳 golden,但人肉比对,CI 不响。 - -## 4. 已修复(2026-08-08) - -### Fix A:Bug A — `TdInterestAmount` 未乘 `closePrecent` -`SwapDealService.cs` `CalcDailyCompoundInterest` 内 `daysFromPreEod==1` 早退分支: -```csharp -// 修复前(Bug A): -TdInterestAmount = preEodPosition.InterestIncomeSum; -// 修复后: -TdInterestAmount = preEodPosition.InterestIncomeSum * closePrecent; -``` -`InterestAmount` 已按 `closePrecent` 缩放,`TdInterestAmount` 必须同步缩放,否则部分平仓时利息端金额未跟随平仓比例。 - -### Fix B:根因 — `resetCarryInterest` 使用 EOD 舍入快照导致精度偏差 -`SwapDealService.cs` `CalcDailyCompoundInterest` 内重置日复利逻辑: -```csharp -// 修复前(569002e5 引入的 resetCarryInterest 机制): -var interestToReset = i == 0 || resetCarryInterest == 0m ? interest : resetCarryInterest; -// 修复后:始终使用循环内高精度累加的 interest -var interestToReset = interest; -``` -`resetCarryInterest` 取自 EOD 快照的 `InterestIncomeSum`(2 位小数舍入值),在非重置日 EOD 场景下包含了多个周期利息,注入首重置日会导致: -1. 精度损失(舍入值 vs 循环高精度累加值) -2. 多周期利息错误注入(EOD 的 InterestIncomeSum 是整段累计,不是当前周期利息) - -此修复与 `253a89b7` 对 `CalcDailyCompoundInterestByEod`(EOD 路径)的修复逻辑一致。 - -### Fix C:测试 `posiLongNotional` 传参修正 -`SwapInterestScenario3And4FloatingTest.cs` 中 `ExecuteClose` 调用的 `posiLongNotional` 应为**平仓后剩余**名义本金(非平仓前): -- 场景3全平:`posiLongNotional = 0`(全平后无剩余) -- 场景4部分平:`posiLongNotional = Notional - partialCloseNotional`(70% 剩余) -- 场景4全平:`posiLongNotional = 0`(全平后无剩余) - -这使得 `oriPosiNotionalValue = remaining_after + close = original`,`closePercent` 计算正确。 - -## 5. 验证手段 -- **C# 测试**:`UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs` - — 24 个 Excel oracle 用例,通过 EOD 结算路径(`SaveAutoEodWithCloseInterestPosition`)复现, - 断言 `TdCloseInterest`(容差 0.01)。修复前 → RED(偏差 29~3961 元),修复后 → 预期 GREEN。 - ⚠️ 需在 Windows + VS 运行验证。 -- **禁止 re-baseline**:今后任何 fix 改测试期望值常量,必须附注来源(本 Excel 手算 or 文档公式),否则评审红线。