From c35befbe8f660880c3baa866b6ebe915dc489411 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=94=A6=E9=BA=9F=20=E7=8E=8B?= Date: Mon, 24 Aug 2026 16:34:18 +0800 Subject: [PATCH] =?UTF-8?q?#EQD-6948=20=E5=9B=BD=E8=81=94=E6=B0=91?= =?UTF-8?q?=E7=94=9F-=E5=AE=9E=E7=8E=B0=E4=BF=9D=E8=AF=81=E9=87=91?= =?UTF-8?q?=E8=A7=84=E5=88=99=EF=BC=882=EF=BC=89=E8=BF=BD=E4=BF=9D?= =?UTF-8?q?=E9=87=91=E9=A2=9D=E7=9A=84=E4=BA=A7=E7=94=9F=E4=B8=8E=E6=94=B6?= =?UTF-8?q?=E7=9B=98=E8=AE=A1=E7=AE=97=20=E6=94=B6=E7=9B=98=E8=AE=A1?= =?UTF-8?q?=E7=AE=97=E5=92=8C=E6=8E=88=E4=BF=A1=E5=8D=A0=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DBModels/ClientCashInCashOut.cs | 13 + .../DBModels/Consts/ConsMarginTerm.cs | 10 +- Framework/YLErp.Core/DBModels/CreditTable.cs | 2 +- .../YLErp.Core/Models/ClientSettleBalance.cs | 14 + .../TradeConfirmationGenerator.cs | 305 ++++++++++-------- .../MarginTemplateV2RateHelperFallbackTest.cs | 192 +++++++++++ .../MarginTemplateV2RateHelperTest.cs | 21 ++ .../SwapModule/ClientCreditInoutSignTest.cs | 91 ++++++ .../Modules/SwapModule/DbColumnNamingTest.cs | 52 +++ .../Modules/SwapModule/FundTagCalcTest.cs | 210 ++++++++++++ .../SwapAdditionalMarginCalcTest.cs | 125 +++++++ .../SwapModule/SwapSpanBalanceCalcTest.cs | 131 ++++++++ .../SwapModule/SwapSpanMarginCalcTest.cs | 225 +++++++++++++ .../SwapModule/SwapSpanMarginEngineTest.cs | 187 +++++++++++ .../SwapModule/SwapSpanPriceSourceTest.cs | 57 ++++ .../SwapModule/TestableSwapDealService.cs | 6 + .../TestableSwapEodPositionService.cs | 9 + .../BLL/EodSettlement/ClientBalanceUtility.cs | 57 +++- .../RealTimeClientBanlanceService.cs | 70 +++- .../MarginCalculationBase.cs | 144 +++++++-- .../ClientModule/CreditTableService.cs | 5 + .../SettlementModule/EodClientBalanceCalc.cs | 8 +- .../MarginTemplateV2RateHelper.cs | 222 ++++++++++++- .../SettlementReportModule/FundReportModel.cs | 13 + .../SettlementReportService.cs | 8 + .../SwapModule/ClientCreditInoutService.cs | 54 +++- .../Margin/SwapAdditionalMarginCalc.cs | 43 +++ .../Margin/SwapAdditionalMarginService.cs | 163 ++++++++++ .../SwapModule/Margin/SwapSpanBalanceCalc.cs | 58 ++++ .../Margin/SwapSpanBalanceQueryService.cs | 115 +++++++ .../SwapModule/Margin/SwapSpanMarginCalc.cs | 159 +++++++++ .../Modules/SwapModule/SwapFundTagService.cs | 18 +- .../Modules/SwapModule/SwapTradeService.cs | 5 +- .../TradeModule/ClientCashInCashOutService.cs | 7 +- YLErpWeb/App_Data/Menus.txt | 13 +- YLErpWeb/Controllers/creditController.cs | 2 +- .../margin_template_v2Controller.cs | 43 ++- YLErpWeb/Controllers/tradeController.cs | 29 +- .../clientbalance/TradeMarketReport.cshtml | 10 +- .../TradeMarketReport_BalanceStatement.cshtml | 2 +- YLErpWeb/Views/entryexit/entryexitList.cshtml | 4 + .../margin_template_v2ClientEdit.cshtml | 2 +- .../margin_template_v2DefaultEdit.cshtml | 2 +- .../margin_template_v2Edit.cshtml | 2 +- .../Scripts/app/client/tradeMarketReport.js | 8 +- .../marginTemplateV2DefaultEdit.js | 4 +- .../marginTemplate/marginTemplateV2Edit.js | 6 +- .../Scripts/app/swaptrade/swapTradeList.js | 80 ++++- .../Scripts/app/trade/swapTradeList.js | 80 ++++- 49 files changed, 2814 insertions(+), 272 deletions(-) create mode 100644 UnitTestProject/Modules/CalcModules/MarginTemplateV2RateHelperFallbackTest.cs create mode 100644 UnitTestProject/Modules/CalcModules/MarginTemplateV2RateHelperTest.cs create mode 100644 UnitTestProject/Modules/SwapModule/ClientCreditInoutSignTest.cs create mode 100644 UnitTestProject/Modules/SwapModule/DbColumnNamingTest.cs create mode 100644 UnitTestProject/Modules/SwapModule/FundTagCalcTest.cs create mode 100644 UnitTestProject/Modules/SwapModule/SwapAdditionalMarginCalcTest.cs create mode 100644 UnitTestProject/Modules/SwapModule/SwapSpanBalanceCalcTest.cs create mode 100644 UnitTestProject/Modules/SwapModule/SwapSpanMarginCalcTest.cs create mode 100644 UnitTestProject/Modules/SwapModule/SwapSpanMarginEngineTest.cs create mode 100644 UnitTestProject/Modules/SwapModule/SwapSpanPriceSourceTest.cs create mode 100644 YLErpDAL/Modules/SwapModule/Margin/SwapAdditionalMarginCalc.cs create mode 100644 YLErpDAL/Modules/SwapModule/Margin/SwapAdditionalMarginService.cs create mode 100644 YLErpDAL/Modules/SwapModule/Margin/SwapSpanBalanceCalc.cs create mode 100644 YLErpDAL/Modules/SwapModule/Margin/SwapSpanBalanceQueryService.cs create mode 100644 YLErpDAL/Modules/SwapModule/Margin/SwapSpanMarginCalc.cs diff --git a/Framework/YLErp.Core/DBModels/ClientCashInCashOut.cs b/Framework/YLErp.Core/DBModels/ClientCashInCashOut.cs index edb11156..9122a710 100644 --- a/Framework/YLErp.Core/DBModels/ClientCashInCashOut.cs +++ b/Framework/YLErp.Core/DBModels/ClientCashInCashOut.cs @@ -259,6 +259,11 @@ namespace YLErp.DBModels public const string 系统操作_互换 = "系统操作-互换"; public const string 系统操作_应付预付金 = "系统操作-应付预付金"; public const string 系统操作_预付金返息 = "系统操作-预付金返息"; + /// + /// 阶段四 §4.1 合约维度(MarginWatchRule==0)EOD 结算产生的追加保证金资金记录: + /// 交易级单条累计值(TradeId+Action+Deal=0 幂等 upsert),负数=客户应付追加;写入见 SwapAdditionalMarginService。 + /// + public const string 系统操作_追加保证金 = "系统操作-追加保证金"; public const string 人工操作_其他 = "人工操作-其他"; public const string 人工操作_预付金 = "人工操作-预付金"; public const string 系统 = "系统"; @@ -319,6 +324,10 @@ namespace YLErp.DBModels { action = "预付金返息"; } + else if (Action == "系统操作-追加保证金") + { + action = "追加保证金"; + } if (action != "") { return $"{ action}{(Money > 0 ? "收入" : "支出")}"; @@ -372,6 +381,10 @@ namespace YLErp.DBModels { action = "预付金返息"; } + else if (Action == "系统操作-追加保证金") + { + action = "追加保证金"; + } if (action != "") { return $"{ action}{(Money > 0 ? "支出" : "收入")}"; diff --git a/Framework/YLErp.Core/DBModels/Consts/ConsMarginTerm.cs b/Framework/YLErp.Core/DBModels/Consts/ConsMarginTerm.cs index ac664dcc..301df0a0 100644 --- a/Framework/YLErp.Core/DBModels/Consts/ConsMarginTerm.cs +++ b/Framework/YLErp.Core/DBModels/Consts/ConsMarginTerm.cs @@ -57,17 +57,17 @@ namespace YLErp.DBModels } /// - /// 获取期限的显示名称 + /// 获取期限的显示名称(与设置页 bondTermLabel 同口径的开闭区间文案:≤5y / (5y-10y] / (10y-30y] / >30y;空=全部兜底档) /// public static string GetDisplayName(string term) { return term switch { - UnderFiveYear => "<5y", - FiveToTenYear => "5y-10y", - TenToThirtyYear => "10y-30y", + UnderFiveYear => "≤5y", + FiveToTenYear => "(5y-10y]", + TenToThirtyYear => "(10y-30y]", OverThirtyYear => ">30y", - _ => term ?? "<5y" + _ => string.IsNullOrEmpty(term) ? "全部" : term }; } } diff --git a/Framework/YLErp.Core/DBModels/CreditTable.cs b/Framework/YLErp.Core/DBModels/CreditTable.cs index ec42e885..be113f59 100644 --- a/Framework/YLErp.Core/DBModels/CreditTable.cs +++ b/Framework/YLErp.Core/DBModels/CreditTable.cs @@ -40,7 +40,7 @@ namespace YLErp.DBModels public double? MaxCreditUseRatio { get; set; } /// - /// 已使用授信(R4,非持久化:授信出入表 SUM(占用)−SUM(释放)+SUM(调整),列表展示用) + /// 已使用授信(R4,非持久化:授信出入表 Σ(amount),占用记正/释放记负,列表展示用) /// [DisplayName("已使用授信")] [NotMapped] diff --git a/Framework/YLErp.Core/Models/ClientSettleBalance.cs b/Framework/YLErp.Core/Models/ClientSettleBalance.cs index 3945bafe..087ea1c1 100644 --- a/Framework/YLErp.Core/Models/ClientSettleBalance.cs +++ b/Framework/YLErp.Core/Models/ClientSettleBalance.cs @@ -223,8 +223,22 @@ namespace YLErp.Models } + /// + /// 已使用授信(R2/R4,阶段三 §3.2):授信出入表 client_credit_inout 按 Σ(amount) 汇总(与资金流水同号口径), + /// 估值报告展示与可用资金公式共用。 + /// + public double UsedCredit { get; set; } + /// + /// 互换初始保证金(R2,阶段三 §3.2,净收取为正):客户 应付预付金 流水收付净额(平仓返还自动冲减)。 + /// + public double SwapInitMargin { get; set; } + /// + /// 交易维度追加保证金合计(R2/R3 合约维度公式输入):Σ(维持保证金 − 累计保证金), + /// 累计保证金=该交易 应付预付金+追加保证金 流水收付净额;阶段四产生追加保证金资金记录后口径自然完整。 + /// + public double SwapAdditionalMarginTotal { get; set; } private double _AvailableAmount; diff --git a/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs b/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs index f6ee4d83..7b864e62 100644 --- a/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs +++ b/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs @@ -1,4 +1,4 @@ -using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Linq; using YLErp.Core.Helpers; using YLErp.DBModels; using YLErp.DBModels.Consts; @@ -63,6 +63,53 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator return !isOurLong; } + /// + /// 阶段三 §3.3 决策①:方案B手工录入结构直接赋值(不再用 100∓n×(x−y) 推导)。 + /// 模板追保表 4 行 ↔ SpanConfig 4 层:多头 第1层=[Lower,+∞)(模板行0只用下限)、第n层=[Lower,Upper); + /// 空头 第1层=(−∞,Upper](模板行0只用上限)、第n层=(Lower,Upper]; + /// 追保金额比例 = AmountRate×100(累计到该层的追保比例,直接取值),金额文字口径 n%×期初全价×券面总额 由模板静态文字承载; + /// 预警线/平仓线直接取录入值,未录时按 docx 口径取边界(平仓线=最深层边界、预警线=次深层边界); + /// 配置层不足 4 层时余下行置空;返还追保事件表与追保事件同值(模板静态文字区分方向措辞)。 + /// + private static void FillSpanConfigLines(Dictionary dic, SpanConfig spanCfg, bool posiLong) + { + var tiers = (posiLong ? spanCfg.LongSpans : spanCfg.ShortSpans) ?? new List(); + var valid = tiers.Where(t => t != null && (t.Lower.HasValue || t.Upper.HasValue)).ToList(); + var last = valid.LastOrDefault(); + + var warnLine = spanCfg.WarnLine; + var closeLine = spanCfg.CloseLine; + if (last != null) + { + closeLine ??= posiLong ? last.Lower : last.Upper; + warnLine ??= posiLong ? last.Upper : last.Lower; + } + dic["预警线"] = warnLine.HasValue ? (warnLine.Value * 100).ToString("0.##") : ""; + dic["平仓线"] = closeLine.HasValue ? (closeLine.Value * 100).ToString("0.##") : ""; + + //档位n = 第n层触发线(多头取各层 Lower、空头取各层 Upper),现用模板无档位占位符,保留赋值兼容旧模板 + for (int n = 1; n <= 4; n++) + { + var tier = n <= valid.Count ? valid[n - 1] : null; + var line = tier == null ? null : (posiLong ? tier.Lower : tier.Upper); + dic[$"档位{n}"] = line.HasValue ? (line.Value * 100).ToString("0.##") : ""; + } + + //追保事件/返还追保事件区间表:行0=第1层,行1..3=第2..4层; + //与上方档位循环同用过滤后的 valid 列表(BUG-26:配置中间夹空层时两类占位符不错位) + for (int i = 0; i <= 3; i++) + { + var tier = i < valid.Count ? valid[i] : null; + var hasTier = tier != null && (tier.Lower.HasValue || tier.Upper.HasValue); + dic[$"追保区间{i}下限"] = hasTier && tier.Lower.HasValue ? (tier.Lower.Value * 100).ToString("0.##") : ""; + dic[$"追保区间{i}上限"] = hasTier && tier.Upper.HasValue ? (tier.Upper.Value * 100).ToString("0.##") : ""; + dic[$"追保金额比例{i}"] = hasTier && tier.AmountRate.HasValue ? (tier.AmountRate.Value * 100).ToString("0.##") : ""; + dic[$"返还区间{i}下限"] = dic[$"追保区间{i}下限"]; + dic[$"返还区间{i}上限"] = dic[$"追保区间{i}上限"]; + dic[$"返还金额比例{i}"] = dic[$"追保金额比例{i}"]; + } + } + /// /// 获取收益互换模板路径 /// @@ -254,158 +301,142 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator : swapRatePosition != null ? "" : "0.0000"; // 获取客户适用的保证金率 - //保证金模板V2迁移:优先按交易绑定的预付金模板V2(区间追保结构)取 初始率x/维持率y; - //取不到(未绑定/模板无效/无匹配明细)时回退旧的互换预付金率维护(client_margin_config)链路 + //阶段三 §3.3 决策①:档位/预警线/平仓线/追保区间统一按交易绑定的预付金模板V2参数赋值—— + //方案B手工录入结构(SpanConfig.WarnLine/CloseLine/LongSpans/ShortSpans)直接取录入值,不再用 100∓n×(x−y) 推导; + //旧互换预付金率维护(client_margin_config / UnderlyingHelper.GetApplicableMarginRate)回退链路已移除 + //(本注释保留一个观察周期后删除):取不到模板参数时率显示 0、档位与区间置空。 var templateRate = MarginTemplateV2RateHelper.GetTradeMarginRate( trade.id, swapPosition.UnderlyingCode, swapPosition.UnderlyingInstrumentType, trade.TradeDate ?? DateTime.Now); - var clientMarginRate = templateRate != null - ? new client_margin_detail { init_rate = templateRate.InitRate ?? 0, maintain_rate = templateRate.MaintainRate ?? 0 } - : UnderlyingHelper.GetApplicableMarginRate( - client.id, swapPosition.UnderlyingCode, trade.TradeDate ?? DateTime.Now); + var initRate = templateRate?.InitRate ?? 0m; + var maintainRate = templateRate?.MaintainRate ?? 0m; + var spanCfg = templateRate?.Detail?.SpanConfig; - if (clientMarginRate != null) - { - dic["初始保障金率"] = ((double)clientMarginRate.init_rate * 100).ToString("N4"); - dic["维持保障金率"] = ((double)clientMarginRate.maintain_rate * 100).ToString("N4"); + dic["初始保障金率"] = ((double)initRate * 100).ToString("N4"); + dic["维持保障金率"] = ((double)maintainRate * 100).ToString("N4"); - // 期初预付比例和金额 - dic["期初预付比例"] = ((double)clientMarginRate.init_rate * 100).ToString("0.##"); - dic["期初预付金额"] = ((trade.OriginalStockEqvNotional ?? 0) * (double)clientMarginRate.init_rate).ToString("N2"); - } - else - { - dic["初始保障金率"] = "0.0000"; - dic["维持保障金率"] = "0.0000"; - dic["期初预付比例"] = "0"; - dic["期初预付金额"] = "0.00"; - } + // 期初预付比例和金额 + dic["期初预付比例"] = ((double)initRate * 100).ToString("0.##"); + dic["期初预付金额"] = ((trade.OriginalStockEqvNotional ?? 0) * (double)initRate).ToString("N2"); bool posiLong = IsCustomerLong(swapPosition); - // 计算平仓线、预警线、档位值(利率债TRS,固定4档) - // A = 维持保证金率(%),B = 初始保证金率(%),diff = B - A - var maintainRatePercent = (double)(clientMarginRate?.maintain_rate ?? 0) * 100; // A(%) - var initRatePercent = (double)(clientMarginRate?.init_rate ?? 0) * 100; // B(%) - var diff = initRatePercent - maintainRatePercent; // B - A - if (maintainRatePercent > 0) + var maintainRatePercent = (double)maintainRate * 100; // A(%) + + if (YLErp.Modules.SwapModule.Margin.SwapSpanMarginCalc.HasSpanConfig(spanCfg)) { - if (posiLong) - { - // 客户看多:档位n = 100 - n * (B-A) - dic["档位1"] = (100 - 1 * diff).ToString("0.##"); - dic["档位2"] = (100 - 2 * diff).ToString("0.##"); - dic["档位3"] = (100 - 3 * diff).ToString("0.##"); - dic["档位4"] = (100 - 4 * diff).ToString("0.##"); - dic["预警线"] = dic["档位4"]; // 预警线 = 最高档位 - dic["平仓线"] = (100 - 5 * diff).ToString("0.##"); // 平仓线 = 最高档位 - (B-A) - } - else - { - // 客户看空:档位n = 100 + n * (B-A) - dic["档位1"] = (100 + 1 * diff).ToString("0.##"); - dic["档位2"] = (100 + 2 * diff).ToString("0.##"); - dic["档位3"] = (100 + 3 * diff).ToString("0.##"); - dic["档位4"] = (100 + 4 * diff).ToString("0.##"); - dic["预警线"] = dic["档位4"]; // 预警线 = 最高档位 - dic["平仓线"] = (100 + 5 * diff).ToString("0.##"); // 平仓线 = 最高档位 + (B-A) - } + //方案B:直接取录入的预警/平仓线与多空区间(未录线时按 docx 口径取最深/次深档位边界) + FillSpanConfigLines(dic, spanCfg, posiLong); } else { - dic["平仓线"] = ""; - dic["预警线"] = ""; - dic["档位1"] = ""; - dic["档位2"] = ""; - dic["档位3"] = ""; - dic["档位4"] = ""; - } - - // 追保事件/返还追保事件 价格区间和金额比例 - if (maintainRatePercent > 0) - { - if (posiLong) + //存量 x/y 配置:维持 100∓n×(B−A) 推导(B=初始率,A=维持率) + var initRatePercent = (double)initRate * 100; // B(%) + var diff = initRatePercent - maintainRatePercent; // B - A + if (maintainRatePercent > 0) { - // === 客户看多 追保事件 === - // 区间0:当前净价 ≥ {100-(B-A)}% × 期初净价,追保0% - dic["追保区间0下限"] = (100 - 1 * diff).ToString("0.##"); - dic["追保金额比例0"] = "0"; - // 区间1:{100-2(B-A)}% ≤ 当前净价 < {100-(B-A)}%,追保{B-A}% - dic["追保区间1下限"] = (100 - 2 * diff).ToString("0.##"); - dic["追保区间1上限"] = (100 - 1 * diff).ToString("0.##"); - dic["追保金额比例1"] = (1 * diff).ToString("0.##"); - // 区间2:{100-3(B-A)}% ≤ 当前净价 < {100-2(B-A)}%,追保{2(B-A)}% - dic["追保区间2下限"] = (100 - 3 * diff).ToString("0.##"); - dic["追保区间2上限"] = (100 - 2 * diff).ToString("0.##"); - dic["追保金额比例2"] = (2 * diff).ToString("0.##"); - // 区间3:{100-4(B-A)}% ≤ 当前净价 < {100-3(B-A)}%,追保{3(B-A)}% - dic["追保区间3下限"] = (100 - 4 * diff).ToString("0.##"); - dic["追保区间3上限"] = (100 - 3 * diff).ToString("0.##"); - dic["追保金额比例3"] = (3 * diff).ToString("0.##"); - - // === 客户看多 返还追保事件 === - // 区间0:当前净价 ≥ {100-(B-A)}% × 期初净价,返还0% - dic["返还区间0下限"] = (100 - 1 * diff).ToString("0.##"); - dic["返还金额比例0"] = "0"; - // 区间1:{100-2(B-A)}% ≤ 当前净价 < {100-(B-A)}%,返还{B-A}% - dic["返还区间1下限"] = (100 - 2 * diff).ToString("0.##"); - dic["返还区间1上限"] = (100 - 1 * diff).ToString("0.##"); - dic["返还金额比例1"] = (1 * diff).ToString("0.##"); - // 区间2:{100-3(B-A)}% ≤ 当前净价 < {100-2(B-A)}%,返还{2(B-A)}% - dic["返还区间2下限"] = (100 - 3 * diff).ToString("0.##"); - dic["返还区间2上限"] = (100 - 2 * diff).ToString("0.##"); - dic["返还金额比例2"] = (2 * diff).ToString("0.##"); - // 区间3:{100-4(B-A)}% ≤ 当前净价 < {100-3(B-A)}%,返还{3(B-A)}% - dic["返还区间3下限"] = (100 - 4 * diff).ToString("0.##"); - dic["返还区间3上限"] = (100 - 3 * diff).ToString("0.##"); - dic["返还金额比例3"] = (3 * diff).ToString("0.##"); + if (posiLong) + { + // 客户看多:档位n = 100 - n * (B-A) + dic["档位1"] = (100 - 1 * diff).ToString("0.##"); + dic["档位2"] = (100 - 2 * diff).ToString("0.##"); + dic["档位3"] = (100 - 3 * diff).ToString("0.##"); + dic["档位4"] = (100 - 4 * diff).ToString("0.##"); + dic["预警线"] = dic["档位4"]; // 预警线 = 最高档位 + dic["平仓线"] = (100 - 5 * diff).ToString("0.##"); // 平仓线 = 最高档位 - (B-A) + } + else + { + // 客户看空:档位n = 100 + n * (B-A) + dic["档位1"] = (100 + 1 * diff).ToString("0.##"); + dic["档位2"] = (100 + 2 * diff).ToString("0.##"); + dic["档位3"] = (100 + 3 * diff).ToString("0.##"); + dic["档位4"] = (100 + 4 * diff).ToString("0.##"); + dic["预警线"] = dic["档位4"]; // 预警线 = 最高档位 + dic["平仓线"] = (100 + 5 * diff).ToString("0.##"); // 平仓线 = 最高档位 + (B-A) + } } else { - // === 客户看空 追保事件 === - // 区间0:当前净价 ≤ {100+(B-A)}% × 期初净价,追保0% - dic["追保区间0上限"] = (100 + 1 * diff).ToString("0.##"); - dic["追保金额比例0"] = "0"; - // 区间1:{100+(B-A)}% < 当前净价 ≤ {100+2(B-A)}%,追保{B-A}% - dic["追保区间1下限"] = (100 + 1 * diff).ToString("0.##"); - dic["追保区间1上限"] = (100 + 2 * diff).ToString("0.##"); - dic["追保金额比例1"] = (1 * diff).ToString("0.##"); - // 区间2:{100+2(B-A)}% < 当前净价 ≤ {100+3(B-A)}%,追保{2(B-A)}% - dic["追保区间2下限"] = (100 + 2 * diff).ToString("0.##"); - dic["追保区间2上限"] = (100 + 3 * diff).ToString("0.##"); - dic["追保金额比例2"] = (2 * diff).ToString("0.##"); - // 区间3:{100+3(B-A)}% < 当前净价 ≤ {100+4(B-A)}%,追保{3(B-A)}% - dic["追保区间3下限"] = (100 + 3 * diff).ToString("0.##"); - dic["追保区间3上限"] = (100 + 4 * diff).ToString("0.##"); - dic["追保金额比例3"] = (3 * diff).ToString("0.##"); - - // === 客户看空 返还追保事件 === - // 区间0:当前净价 ≤ {100+(B-A)}% × 期初净价,返还0% - dic["返还区间0上限"] = (100 + 1 * diff).ToString("0.##"); - dic["返还金额比例0"] = "0"; - // 区间1:{100+(B-A)}% < 当前净价 ≤ {100+2(B-A)}%,返还{B-A}% - dic["返还区间1下限"] = (100 + 1 * diff).ToString("0.##"); - dic["返还区间1上限"] = (100 + 2 * diff).ToString("0.##"); - dic["返还金额比例1"] = (1 * diff).ToString("0.##"); - // 区间2:{100+2(B-A)}% < 当前净价 ≤ {100+3(B-A)}%,返还{2(B-A)}% - dic["返还区间2下限"] = (100 + 2 * diff).ToString("0.##"); - dic["返还区间2上限"] = (100 + 3 * diff).ToString("0.##"); - dic["返还金额比例2"] = (2 * diff).ToString("0.##"); - // 区间3:{100+3(B-A)}% < 当前净价 ≤ {100+4(B-A)}%,返还{3(B-A)}% - dic["返还区间3下限"] = (100 + 3 * diff).ToString("0.##"); - dic["返还区间3上限"] = (100 + 4 * diff).ToString("0.##"); - dic["返还金额比例3"] = (3 * diff).ToString("0.##"); + dic["平仓线"] = ""; + dic["预警线"] = ""; + dic["档位1"] = ""; + dic["档位2"] = ""; + dic["档位3"] = ""; + dic["档位4"] = ""; } - } - else - { - // 清空追保/返还相关字典值 - for (int i = 0; i <= 3; i++) + + if (maintainRatePercent > 0) { - dic[$"追保区间{i}下限"] = ""; - dic[$"追保区间{i}上限"] = ""; - dic[$"追保金额比例{i}"] = ""; - dic[$"返还区间{i}下限"] = ""; - dic[$"返还区间{i}上限"] = ""; - dic[$"返还金额比例{i}"] = ""; + if (posiLong) + { + // === 客户看多 追保事件 === + // 区间0:当前净价 ≥ {100-(B-A)}% × 期初净价,追保0% + dic["追保区间0下限"] = (100 - 1 * diff).ToString("0.##"); + dic["追保金额比例0"] = "0"; + // 区间1:{100-2(B-A)}% ≤ 当前净价 < {100-(B-A)}%,追保{B-A}% + dic["追保区间1下限"] = (100 - 2 * diff).ToString("0.##"); + dic["追保区间1上限"] = (100 - 1 * diff).ToString("0.##"); + dic["追保金额比例1"] = (1 * diff).ToString("0.##"); + // 区间2:{100-3(B-A)}% ≤ 当前净价 < {100-2(B-A)}%,追保{2(B-A)}% + dic["追保区间2下限"] = (100 - 3 * diff).ToString("0.##"); + dic["追保区间2上限"] = (100 - 2 * diff).ToString("0.##"); + dic["追保金额比例2"] = (2 * diff).ToString("0.##"); + // 区间3:{100-4(B-A)}% ≤ 当前净价 < {100-3(B-A)}%,追保{3(B-A)}% + dic["追保区间3下限"] = (100 - 4 * diff).ToString("0.##"); + dic["追保区间3上限"] = (100 - 3 * diff).ToString("0.##"); + dic["追保金额比例3"] = (3 * diff).ToString("0.##"); + + // === 客户看多 返还追保事件 === + for (int n = 1; n <= 3; n++) + { + dic[$"返还区间{n}下限"] = (100 - (n + 1) * diff).ToString("0.##"); + dic[$"返还区间{n}上限"] = (100 - n * diff).ToString("0.##"); + dic[$"返还金额比例{n}"] = (n * diff).ToString("0.##"); + } + dic["返还区间0下限"] = (100 - 1 * diff).ToString("0.##"); + dic["返还金额比例0"] = "0"; + } + else + { + // === 客户看空 追保事件 === + // 区间0:当前净价 ≤ {100+(B-A)}% × 期初净价,追保0% + dic["追保区间0上限"] = (100 + 1 * diff).ToString("0.##"); + dic["追保金额比例0"] = "0"; + // 区间1:{100+(B-A)}% < 当前净价 ≤ {100+2(B-A)}%,追保{B-A}% + dic["追保区间1下限"] = (100 + 1 * diff).ToString("0.##"); + dic["追保区间1上限"] = (100 + 2 * diff).ToString("0.##"); + dic["追保金额比例1"] = (1 * diff).ToString("0.##"); + // 区间2:{100+2(B-A)}% < 当前净价 ≤ {100+3(B-A)}%,追保{2(B-A)}% + dic["追保区间2下限"] = (100 + 2 * diff).ToString("0.##"); + dic["追保区间2上限"] = (100 + 3 * diff).ToString("0.##"); + dic["追保金额比例2"] = (2 * diff).ToString("0.##"); + // 区间3:{100+3(B-A)}% < 当前净价 ≤ {100+4(B-A)}%,追保{3(B-A)}% + dic["追保区间3下限"] = (100 + 3 * diff).ToString("0.##"); + dic["追保区间3上限"] = (100 + 4 * diff).ToString("0.##"); + dic["追保金额比例3"] = (3 * diff).ToString("0.##"); + + // === 客户看空 返还追保事件 === + for (int n = 1; n <= 3; n++) + { + dic[$"返还区间{n}下限"] = (100 + n * diff).ToString("0.##"); + dic[$"返还区间{n}上限"] = (100 + (n + 1) * diff).ToString("0.##"); + dic[$"返还金额比例{n}"] = (n * diff).ToString("0.##"); + } + dic["返还区间0上限"] = (100 + 1 * diff).ToString("0.##"); + dic["返还金额比例0"] = "0"; + } + } + else + { + // 清空追保/返还相关字典值 + for (int i = 0; i <= 3; i++) + { + dic[$"追保区间{i}下限"] = ""; + dic[$"追保区间{i}上限"] = ""; + dic[$"追保金额比例{i}"] = ""; + dic[$"返还区间{i}下限"] = ""; + dic[$"返还区间{i}上限"] = ""; + dic[$"返还金额比例{i}"] = ""; + } } } diff --git a/UnitTestProject/Modules/CalcModules/MarginTemplateV2RateHelperFallbackTest.cs b/UnitTestProject/Modules/CalcModules/MarginTemplateV2RateHelperFallbackTest.cs new file mode 100644 index 00000000..c4d864a8 --- /dev/null +++ b/UnitTestProject/Modules/CalcModules/MarginTemplateV2RateHelperFallbackTest.cs @@ -0,0 +1,192 @@ +using YLErp.BLL; +using YLErp.DBModels; +using YLErp.Enums; +using YLErp.Modules.MarginModule; + +namespace YLErp.Modules.CalcModules +{ + /// + /// R1 模板三层级找到即停回退集成测试(连 dev 库,MarginTemplateV2RateHelper.ResolveTieredTemplate/GetTradeMarginRate): + /// 交易绑定(自定义)→ 客户默认(client_margin_template 按客户)→ 全局默认(IsDefault&&!IsForClient)。 + /// 测试数据全部带 "ZZZ-R1回退测试-" 名称前缀,TestInitialize/TestCleanup 双向清理,不触碰真实交易。 + /// + [TestClass] + public class MarginTemplateV2RateHelperFallbackTest + { + private const string Marker = "ZZZ-R1回退测试-"; + private const int SentinelTradeId = 1900000001; + private const int SentinelTradeId2 = 1900000002; + private DateTime EffectiveDate = new DateTime(2000, 1, 1); + + private YLContext db; + private int clientId; + + [TestInitialize] + public void Init() + { + db = new YLContext(); + Cleanup(); + //取一个真实客户做客户级绑定(只写 client_margin_template,不动客户数据) + using (var clientDb = DbContextFactory.GetClientDbContext(OptUserInfo.SystemUser)) + { + clientId = clientDb.client.Where(c => c.id > 0).OrderBy(c => c.id).Select(c => c.id).First(); + } + } + + [TestCleanup] + public void CleanupFixture() + { + Cleanup(); + db.Dispose(); + } + + private void Cleanup() + { + var templateIds = db.margin_template_v2.Where(x => x.Name.StartsWith(Marker)).Select(x => x.id).ToList(); + if (templateIds.Count > 0) + { + db.margin_template_detail.RemoveRange(db.margin_template_detail.Where(x => templateIds.Contains(x.MarginTemplateId))); + db.trade_margin_template.RemoveRange(db.trade_margin_template.Where(x => templateIds.Contains(x.MarginTemplateId))); + db.client_margin_template.RemoveRange(db.client_margin_template.Where(x => templateIds.Contains(x.MarginTemplateId))); + db.margin_template_v2.RemoveRange(db.margin_template_v2.Where(x => templateIds.Contains(x.id))); + db.SaveChanges(); + } + db.trade_margin_template.RemoveRange(db.trade_margin_template.Where(x => x.TradeId == SentinelTradeId || x.TradeId == SentinelTradeId2)); + db.SaveChanges(); + } + + private margin_template_v2 AddTemplate(string name, bool isDefault, bool isForClient, string tradeTypes = "收益互换", bool isValid = true, int ruleType = (int)MarginRuleTypeEnum.区间追保结构) + { + var t = new margin_template_v2 + { + Name = name, + IsDefault = isDefault, + IsForClient = isForClient, + IsValid = isValid, + TradeTypes = tradeTypes, + RuleType = ruleType, + ValueDate = EffectiveDate + }; + db.margin_template_v2.Add(t); + db.SaveChanges(); + return t; + } + + [TestMethod] + public void TF_001_交易绑定优先_压过客户与全局() + { + var custom = AddTemplate(Marker + "自定义", isDefault: false, isForClient: false); + var clientTpl = AddTemplate(Marker + "客户", isDefault: true, isForClient: true); + var globalTpl = AddTemplate(Marker + "全局", isDefault: true, isForClient: false); + db.trade_margin_template.Add(new trade_margin_template { TradeId = SentinelTradeId, MarginTemplateId = custom.id, ValueDate = EffectiveDate, IsLatest = true }); + db.client_margin_template.Add(new client_margin_template { ClientId = clientId, MarginTemplateId = clientTpl.id, ValueDate = EffectiveDate, ClientLevel = "" }); + db.SaveChanges(); + + var resolved = MarginTemplateV2RateHelper.ResolveTieredTemplate(SentinelTradeId, clientId, DateTime.Today, db); + Assert.IsNotNull(resolved); + Assert.AreEqual(custom.id, resolved.id, "交易绑定(自定义)应找到即停,压过客户与全局默认"); + } + + [TestMethod] + public void TF_002_无交易绑定_落到客户默认() + { + var clientTpl = AddTemplate(Marker + "客户", isDefault: true, isForClient: true); + var globalTpl = AddTemplate(Marker + "全局", isDefault: true, isForClient: false); + db.client_margin_template.Add(new client_margin_template { ClientId = clientId, MarginTemplateId = clientTpl.id, ValueDate = EffectiveDate, ClientLevel = "" }); + db.SaveChanges(); + + var resolved = MarginTemplateV2RateHelper.ResolveTieredTemplate(tradeId: null, clientId, DateTime.Today, db); + Assert.IsNotNull(resolved); + Assert.AreEqual(clientTpl.id, resolved.id, "无交易绑定时应命中客户默认,压过全局默认"); + } + + [TestMethod] + public void TF_003_无交易无客户绑定_落到全局默认() + { + var globalTpl = AddTemplate(Marker + "全局", isDefault: true, isForClient: false); + AddTemplate(Marker + "客户", isDefault: true, isForClient: true); //未绑定不应被取到 + + var resolved = MarginTemplateV2RateHelper.ResolveTieredTemplate(tradeId: null, clientId, DateTime.Today, db); + AssertGlobal(resolved, globalTpl); + } + + [TestMethod] + public void TF_004_客户绑定指向非互换模板_跳过落全局() + { + var globalTpl = AddTemplate(Marker + "全局", isDefault: true, isForClient: false); + var optionTpl = AddTemplate(Marker + "期权客户模板", isDefault: true, isForClient: true, tradeTypes: "香草期权"); + db.client_margin_template.Add(new client_margin_template { ClientId = clientId, MarginTemplateId = optionTpl.id, ValueDate = EffectiveDate, ClientLevel = "" }); + db.SaveChanges(); + + var resolved = MarginTemplateV2RateHelper.ResolveTieredTemplate(tradeId: null, clientId, DateTime.Today, db); + AssertGlobal(resolved, globalTpl); + } + + /// + /// 同日并存"互换绑定+期权绑定"(页面互斥只挡适用结构重叠,此组合允许保存): + /// 应命中互换绑定,不因先取到期权绑定被过滤而误穿透到全局。 + /// + [TestMethod] + public void TF_007_同日并存互换与期权绑定_命中间换绑定() + { + var swapTpl = AddTemplate(Marker + "客户", isDefault: true, isForClient: true); + var optionTpl = AddTemplate(Marker + "期权客户模板", isDefault: true, isForClient: true, tradeTypes: "香草期权"); + var globalTpl = AddTemplate(Marker + "全局", isDefault: true, isForClient: false); + db.client_margin_template.Add(new client_margin_template { ClientId = clientId, MarginTemplateId = swapTpl.id, ValueDate = EffectiveDate, ClientLevel = "" }); + db.client_margin_template.Add(new client_margin_template { ClientId = clientId, MarginTemplateId = optionTpl.id, ValueDate = EffectiveDate, ClientLevel = "" }); + db.SaveChanges(); + + var resolved = MarginTemplateV2RateHelper.ResolveTieredTemplate(tradeId: null, clientId, DateTime.Today, db); + Assert.IsNotNull(resolved); + Assert.AreEqual(swapTpl.id, resolved.id, "同日期权+互换绑定并存时应命中互换绑定(过滤后再取最新),而非穿透全局"); + } + + [TestMethod] + public void TF_005_绑定模板已失效_找到即停返回null() + { + var invalidTpl = AddTemplate(Marker + "已失效", isDefault: false, isForClient: false, isValid: false); + var globalTpl = AddTemplate(Marker + "全局", isDefault: true, isForClient: false); + db.trade_margin_template.Add(new trade_margin_template { TradeId = SentinelTradeId2, MarginTemplateId = invalidTpl.id, ValueDate = EffectiveDate, IsLatest = true }); + db.SaveChanges(); + + var resolved = MarginTemplateV2RateHelper.ResolveTieredTemplate(SentinelTradeId2, clientId, DateTime.Today, db); + Assert.IsNull(resolved, "交易绑定指向已失效模板时应找到即停(不向下回退到全局默认)"); + } + + /// + /// 端到端:交易绑定(区间追保结构 + 明细 x/y)经 GetTradeMarginRate 完整取到率—— + /// 兼容回归交易级取数路径在新回退结构下行为不变。 + /// + [TestMethod] + public void TF_006_交易绑定端到端取率() + { + var custom = AddTemplate(Marker + "自定义", isDefault: false, isForClient: false); + db.margin_template_detail.Add(new margin_template_detail + { + MarginTemplateId = custom.id, + ValueDate = EffectiveDate, + UnderlyingType = UnderlyingTypeEnum.None, + MarginRatio1 = 0.05, + MarginRatio2 = 0.03 + }); + db.trade_margin_template.Add(new trade_margin_template { TradeId = SentinelTradeId, MarginTemplateId = custom.id, ValueDate = EffectiveDate, IsLatest = true }); + db.SaveChanges(); + + var rate = MarginTemplateV2RateHelper.GetTradeMarginRate(SentinelTradeId, "240004.IB", "TBonds", DateTime.Today, db); + Assert.IsNotNull(rate); + Assert.AreEqual(custom.id, rate.Template.id); + Assert.AreEqual(0.05m, rate.InitRate.Value); + Assert.AreEqual(0.03m, rate.MaintainRate.Value); + } + + private void AssertGlobal(margin_template_v2 resolved, margin_template_v2 expected) + { + Assert.IsNotNull(resolved); + if (resolved.Name.StartsWith(Marker)) + { + Assert.AreEqual(expected.id, resolved.id, "应命中本用例创建的全局默认模板"); + } + //dev 库存在其他真实全局默认模板时,按 ValueDate 最新者胜出,不做更严格断言 + } + } +} diff --git a/UnitTestProject/Modules/CalcModules/MarginTemplateV2RateHelperTest.cs b/UnitTestProject/Modules/CalcModules/MarginTemplateV2RateHelperTest.cs new file mode 100644 index 00000000..e43197d7 --- /dev/null +++ b/UnitTestProject/Modules/CalcModules/MarginTemplateV2RateHelperTest.cs @@ -0,0 +1,21 @@ +using YLErp.Modules.MarginModule; + +namespace YLErp.Modules.CalcModules +{ + /// + /// 实现方案阶段一 §1.6 测试要点:GetTradeMarginRate 分类判定钩子默认行为回归。 + /// 钩子本期默认返回 null → 走"全部/通配行"兜底,取数行为与现状一致(阶段二标签功能不改变取数链路)。 + /// 完整 GetTradeMarginRate 依赖 YLContext(MySQL),无法纯内存测试,此处覆盖钩子契约。 + /// + [TestClass] + public class MarginTemplateV2RateHelperTest + { + [TestMethod] + public void MT_001_分类判定钩子_默认返回null_走通配兜底() + { + Assert.IsNull(MarginTemplateV2RateHelper.GetUnderlyingCategory("019546.XSHG", null)); + Assert.IsNull(MarginTemplateV2RateHelper.GetUnderlyingCategory("200002.XSHE", "Fund")); + Assert.IsNull(MarginTemplateV2RateHelper.GetUnderlyingCategory(null, null)); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/ClientCreditInoutSignTest.cs b/UnitTestProject/Modules/SwapModule/ClientCreditInoutSignTest.cs new file mode 100644 index 00000000..3e20e725 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/ClientCreditInoutSignTest.cs @@ -0,0 +1,91 @@ +using YLErp.BLL; +using YLErp.DBModels; +using YLErp.Modules.SwapModule; + +namespace YLErp.Modules.SwapModule +{ + /// + /// BUG-01 方向守护(2026-08-24):授信出入表金额口径 占用记正/释放记负—— + /// 已使用授信 = SUM(amount) 随占用上升、随释放回落;可用授信 = 有效授信 − 已使用授信 随占用收缩。 + /// 连 dev 库闭环验证 Occupy/Release/GetUsedCredit(哨兵 client_id 自建自清,不触碰真实客户)。 + /// + [TestClass] + public class ClientCreditInoutSignTest + { + private const int SentinelClientId = 99064001; //哨兵客户号(不存在的客户,仅授信出入表按 client_id 记账) + private YLContext db; + private ClientCreditInoutService service; + + [TestInitialize] + public void Init() + { + db = new YLContext(); + Cleanup(); + service = new ClientCreditInoutService(OptUserInfo.SystemUser); + } + + [TestCleanup] + public void Teardown() + { + Cleanup(); + db.Dispose(); + } + + private void Cleanup() + { + var olds = db.client_credit_inout.Where(x => x.client_id == SentinelClientId).ToList(); + if (olds.Count > 0) + { + db.client_credit_inout.RemoveRange(olds); + db.SaveChanges(); + } + } + + /// 占用记正数:占用越多已使用授信越大(修复前为负——"越占越多"BUG-01) + [TestMethod] + public void CR_001_占用记正_已使用授信上升() + { + service.Occupy(SentinelClientId, 900001L, 800001, 10000, DateTime.Today, "方向守护-占用1"); + Assert.AreEqual(10000, ClientCreditInoutService.GetUsedCredit(SentinelClientId, db), 1e-6); + + service.Occupy(SentinelClientId, 900002L, 800002, 5000, DateTime.Today, "方向守护-占用2"); + Assert.AreEqual(15000, ClientCreditInoutService.GetUsedCredit(SentinelClientId, db), 1e-6); + } + + /// 释放记负数:已使用授信回落,快照 used_after 同口径 + [TestMethod] + public void CR_002_释放记负_已使用授信回落() + { + service.Occupy(SentinelClientId, 900001L, 800001, 10000, DateTime.Today, "方向守护-占用"); + service.Release(SentinelClientId, 900001L, 800001, 4000, DateTime.Today, "方向守护-释放"); + + var used = ClientCreditInoutService.GetUsedCredit(SentinelClientId, db); + Assert.AreEqual(6000, used, 1e-6); + //快照列与求和口径一致(最后一次写完后的已使用授信) + var last = db.client_credit_inout.Where(x => x.client_id == SentinelClientId).OrderByDescending(x => x.id).First(); + Assert.AreEqual(6000, last.used_after, 1e-6); + Assert.AreEqual(-4000, last.amount, 1e-6, "释放行 amount 记负数"); + } + + /// 方向传导:可用授信 = 有效授信 − 已使用授信,占用使其收缩(BUG-01 修复后的业务语义) + [TestMethod] + public void CR_003_可用授信随占用收缩() + { + service.Occupy(SentinelClientId, null, 800003, 10000, DateTime.Today, "方向守护-占用"); + var used = ClientCreditInoutService.GetUsedCredit(SentinelClientId, db); + var effective = 30000d; //有效授信给定量 + Assert.AreEqual(20000, effective - used, 1e-6, "占用 10000 后可用授信应从 30000 收缩到 20000"); + } + + /// Occupy/Release 入口对传入符号容错(一律按数量取绝对值定方向),防调用方残留旧负号 + [TestMethod] + public void CR_004_语义入口符号容错() + { + //即使调用方按旧习惯传负数,Occupy 也记正(数量口径),防止新代码库中残留旧符号调用点 + var rec = service.Occupy(SentinelClientId, null, 800004, -7000, DateTime.Today, "方向守护-负号容错"); + Assert.AreEqual(7000, rec.amount, 1e-6); + var rel = service.Release(SentinelClientId, null, 800004, -3000, DateTime.Today, "方向守护-负号容错"); + Assert.AreEqual(-3000, rel.amount, 1e-6); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/DbColumnNamingTest.cs b/UnitTestProject/Modules/SwapModule/DbColumnNamingTest.cs new file mode 100644 index 00000000..b00a39cd --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/DbColumnNamingTest.cs @@ -0,0 +1,52 @@ +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage; +using YLErp.BLL; +using YLErp.DBModels; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 新建表列名规范测试(2026-08-20 约定:新建表所有数据库列一律小写下划线)。 + /// 只构建 EF 模型(纯内存,不连库),断言映射列名不含大写字母: + /// client_credit_inout 全表、swap_position.fund_tag、credit.original_credit/max_credit_use_ratio。 + /// 基类操作人列(OptId/OptName/OptTime)经 override+[Column] 覆写为小写,此处一并守护。 + /// + [TestClass] + public class DbColumnNamingTest + { + [TestMethod] + public void DB_001_授信出入表_全部列名小写() + { + using var db = new YLContext(); + var entity = db.Model.FindEntityType(typeof(client_credit_inout)); + Assert.IsNotNull(entity, "client_credit_inout 未注册到 YLContext"); + var table = StoreObjectIdentifier.Table("client_credit_inout", null); + foreach (var property in entity.GetProperties()) + { + var column = property.GetColumnName(table); + Assert.IsNotNull(column, $"属性 {property.Name} 未映射列名"); + Assert.IsFalse(column.Any(char.IsUpper), $"列名应全小写: {column}(属性 {property.Name})"); + } + } + + [TestMethod] + public void DB_002_互换持仓与授信表_新增列名小写() + { + using var db = new YLContext(); + AssertColumn(db, typeof(swap_position), nameof(swap_position.FundTag), "fund_tag"); + AssertColumn(db, typeof(CreditTable), nameof(CreditTable.OriginalCredit), "original_credit"); + AssertColumn(db, typeof(CreditTable), nameof(CreditTable.MaxCreditUseRatio), "max_credit_use_ratio"); + } + + private static void AssertColumn(YLContext db, Type entityType, string propertyName, string expectedColumn) + { + var entity = db.Model.FindEntityType(entityType); + Assert.IsNotNull(entity, $"{entityType.Name} 未注册到 YLContext"); + var property = entity.FindProperty(propertyName); + Assert.IsNotNull(property, $"属性 {propertyName} 不在 {entityType.Name} 映射中"); + var table = StoreObjectIdentifier.Create(entity, StoreObjectType.Table).Value; + var column = property.GetColumnName(table); + Assert.AreEqual(expectedColumn, column, $"{entityType.Name}.{propertyName} 映射列名不符"); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/FundTagCalcTest.cs b/UnitTestProject/Modules/SwapModule/FundTagCalcTest.cs new file mode 100644 index 00000000..319f40d1 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/FundTagCalcTest.cs @@ -0,0 +1,210 @@ +using YLErp.DBModels; +using YLErp.Modules.SwapModule.Margin; + +namespace YLErp.Modules.SwapModule +{ + /// + /// R4 授信/现金标签 分配与返还分流纯函数测试(实现方案阶段二 §2.3/§2.4 测试要点)。 + /// 资金来源是预付金腿上的录入项(逐腿选择),覆盖:标签赋值四种情形、拆单金额核对、 + /// 平仓按标签返还、存量无标签按现金。 + /// + [TestClass] + public class FundTagCalcTest + { + private static LegAmount Leg(long id, double amount, bool preferCredit) + => new() { Leg = new swap_position { id = id }, Amount = amount, PreferCredit = preferCredit }; + + // ================================================================ + // §2.3 标签赋值四种情形(FundTagCalc.AllocateByLegPreference,逐腿) + // ================================================================ + + [TestMethod] + public void FT_001_腿未选资金来源_全额现金() + { + var plans = FundTagCalc.AllocateByLegPreference(new List { Leg(101, 1000, preferCredit: false) }, 5000, ignoreMoneyCheck: false); + Assert.AreEqual(0, plans[0].CreditAmount); + Assert.AreEqual(1000, plans[0].CashAmount); + } + + [TestMethod] + public void FT_002_腿选现金_全额现金_不消耗额度() + { + var plans = FundTagCalc.AllocateByLegPreference(new List { Leg(101, 1000, preferCredit: false) }, 0, ignoreMoneyCheck: false); + Assert.AreEqual(0, plans[0].CreditAmount); + Assert.AreEqual(1000, plans[0].CashAmount); + } + + [TestMethod] + public void FT_003_腿选授信_额度充足_全额授信() + { + var plans = FundTagCalc.AllocateByLegPreference(new List { Leg(101, 1000, preferCredit: true) }, 5000, ignoreMoneyCheck: false); + Assert.AreEqual(1000, plans[0].CreditAmount); + Assert.AreEqual(0, plans[0].CashAmount); + Assert.IsFalse(plans[0].NeedSplit); + } + + [TestMethod] + public void FT_004_腿选授信_额度不足_拆分为剩余授信加现金差额() + { + var plans = FundTagCalc.AllocateByLegPreference(new List { Leg(101, 1000, preferCredit: true) }, 300, ignoreMoneyCheck: false); + Assert.AreEqual(300, plans[0].CreditAmount); + Assert.AreEqual(700, plans[0].CashAmount); + Assert.IsTrue(plans[0].NeedSplit); + } + + [TestMethod] + public void FT_005_腿选授信_额度为零_全额现金() + { + var plans = FundTagCalc.AllocateByLegPreference(new List { Leg(101, 1000, preferCredit: true) }, 0, ignoreMoneyCheck: false); + Assert.AreEqual(0, plans[0].CreditAmount); + Assert.AreEqual(1000, plans[0].CashAmount); + } + + [TestMethod] + public void FT_006_特批_忽略腿上选择_全额现金不占授信() + { + var plans = FundTagCalc.AllocateByLegPreference(new List { Leg(101, 1000, preferCredit: true) }, 5000, ignoreMoneyCheck: true); + Assert.AreEqual(0, plans[0].CreditAmount); + Assert.AreEqual(1000, plans[0].CashAmount); + } + + [TestMethod] + public void FT_007_客户净收取的腿_即使选授信也不占用授信() + { + var plans = FundTagCalc.AllocateByLegPreference(new List { Leg(101, -500, preferCredit: true) }, 5000, ignoreMoneyCheck: false); + Assert.AreEqual(0, plans[0].CreditAmount); + Assert.AreEqual(-500, plans[0].CashAmount); + } + + [TestMethod] + public void FT_008_混合偏好_现金腿不消耗授信额度() + { + var plans = FundTagCalc.AllocateByLegPreference(new List + { + Leg(101, 600, preferCredit: false), //现金腿 + Leg(102, 400, preferCredit: true), //授信腿 + }, 500, ignoreMoneyCheck: false); + Assert.AreEqual(0, plans[0].CreditAmount); + Assert.AreEqual(600, plans[0].CashAmount); + //授信腿可用额度仍是 500(现金腿未消耗) + Assert.AreEqual(400, plans[1].CreditAmount); + Assert.AreEqual(0, plans[1].CashAmount); + } + + // ================================================================ + // §2.3 拆单:多腿授信额度按顺序消耗(FundTagCalc.AllocateByLegPreference) + // ================================================================ + + [TestMethod] + public void FT_010_多条授信腿_额度跨在第二腿_第二腿拆单() + { + var plans = FundTagCalc.AllocateByLegPreference(new List + { + Leg(101, 600, preferCredit: true), + Leg(102, 600, preferCredit: true), + }, creditAvailable: 1000, ignoreMoneyCheck: false); + + Assert.AreEqual(2, plans.Count); + //第一腿全额授信 + Assert.AreEqual(600, plans[0].CreditAmount); + Assert.AreEqual(0, plans[0].CashAmount); + Assert.IsFalse(plans[0].NeedSplit); + //第二腿跨界拆单:授信400 + 现金200 + Assert.AreEqual(400, plans[1].CreditAmount); + Assert.AreEqual(200, plans[1].CashAmount); + Assert.IsTrue(plans[1].NeedSplit); + //金额守恒:授信合计=可用额度,现金合计=差额 + Assert.AreEqual(1000, plans.Sum(p => p.CreditAmount)); + Assert.AreEqual(200, plans.Sum(p => p.CashAmount)); + } + + [TestMethod] + public void FT_011_额度耗尽后_后续授信腿全额现金() + { + var plans = FundTagCalc.AllocateByLegPreference(new List + { + Leg(101, 600, preferCredit: true), + Leg(102, 600, preferCredit: true), + Leg(103, 600, preferCredit: true), + }, creditAvailable: 600, ignoreMoneyCheck: false); + + Assert.AreEqual(600, plans[0].CreditAmount); + Assert.AreEqual(0, plans[1].CreditAmount); + Assert.AreEqual(600, plans[1].CashAmount); + Assert.AreEqual(0, plans[2].CreditAmount); + Assert.AreEqual(600, plans[2].CashAmount); + } + + // ================================================================ + // §2.4 平仓/到期按原标签返还(FundTagCalc.SplitUnwindByTag) + // ================================================================ + + [TestMethod] + public void FT_020_平仓返还_授信腿不产生现金_按腿生成释放明细() + { + var settlements = new List + { + //授信腿:返还本金 400,返息 50 → 全部不进资金,写释放 + new() { PositionId = 101, Tag = ConsFundTag.Credit, MarginAmount = 400m, RebateAmount = 50m }, + //现金腿:返还本金 300,返息 20 → 正常资金流水 + new() { PositionId = 102, Tag = ConsFundTag.Cash, MarginAmount = 300m, RebateAmount = 20m }, + //存量无标签(EffectiveTag 后按现金) + new() { PositionId = 103, Tag = ConsFundTag.Cash, MarginAmount = 100m, RebateAmount = 0m }, + }; + var split = FundTagCalc.SplitUnwindByTag(settlements); + + Assert.AreEqual(300 + 100, split.CashMargin); + Assert.AreEqual(400, split.CreditMargin); + Assert.AreEqual(20, split.CashRebate); + Assert.AreEqual(50, split.CreditRebate); + //释放明细按 position_id 匹配原占用记录 + Assert.AreEqual(1, split.Releases.Count); + Assert.AreEqual(101L, split.Releases[0].PositionId); + Assert.AreEqual(400, split.Releases[0].Amount); + } + + [TestMethod] + public void FT_021_平仓返还_全部授信_现金部分为零() + { + var settlements = new List + { + new() { PositionId = 201, Tag = ConsFundTag.Credit, MarginAmount = 800m, RebateAmount = 30m }, + }; + var split = FundTagCalc.SplitUnwindByTag(settlements); + Assert.AreEqual(0, split.CashMargin); + Assert.AreEqual(0, split.CashRebate); + Assert.AreEqual(800, split.CreditMargin); + Assert.AreEqual(1, split.Releases.Count); + } + + // ================================================================ + // 平仓利息事件 → 逐腿结算额构造(MarginSettlementBuilder) + // ================================================================ + + [TestMethod] + public void FT_030_由利息事件构造结算额_方向比与标签过滤正确() + { + var events = new List + { + //预付金腿(InterestMode=5,无标的代码):InterestDirection=2 → 返还本金 = principal × 1 + new() { PositionId = 101, InterestMode = (int)InterestModeEnum.初始预付金, InterestDirection = 2, InterestPrincipal = 400m, InterestClosePnL = 50m }, + //预付金腿:InterestDirection=1(收取)→ 返还本金 = principal × -1 + new() { PositionId = 102, InterestMode = (int)InterestModeEnum.初始预付金, InterestDirection = 1, InterestPrincipal = 300m, InterestClosePnL = 0m }, + //浮动腿(有标的代码):不参与 + new() { PositionId = 103, InterestMode = 0, UnderlyingCode = "123456.SH", InterestPrincipal = 999m }, + //利息腿(非预付金、无标的):不参与 + new() { PositionId = 104, InterestMode = (int)InterestModeEnum.固定值, InterestPrincipal = 555m }, + }; + var tags = new Dictionary { { 101, ConsFundTag.Credit } }; + var settlements = MarginSettlementBuilder.Build(tags, events); + + Assert.AreEqual(2, settlements.Count); + Assert.AreEqual(ConsFundTag.Credit, settlements[0].Tag); + Assert.AreEqual(400m, settlements[0].MarginAmount); + Assert.AreEqual(50m, settlements[0].RebateAmount); + //102 标签字典缺失(存量无标签)→ 按现金;方向比 -1 + Assert.AreEqual(ConsFundTag.Cash, settlements[1].Tag); + Assert.AreEqual(-300m, settlements[1].MarginAmount); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/SwapAdditionalMarginCalcTest.cs b/UnitTestProject/Modules/SwapModule/SwapAdditionalMarginCalcTest.cs new file mode 100644 index 00000000..f1d27fa9 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapAdditionalMarginCalcTest.cs @@ -0,0 +1,125 @@ +using YLErp.DBModels; +using YLErp.Modules.SwapModule; +using YLErp.Modules.SwapModule.Margin; + +namespace YLErp.Modules.SwapModule +{ + /// + /// R3 阶段四 §4.1 合约维度追加保证金计算测试(纯函数): + /// 目标 = 维持保证金 − 应付预付金净收额(≤0 不追);当日新增 = 目标 − 已补足(现金记录累计+授信占用累计); + /// 授信优先分配;重跑幂等(新增=0 不写);追保回落不返还(负缺口走可用资金公式)。 + /// + [TestClass] + public class SwapAdditionalMarginCalcTest + { + [TestMethod] + public void AM_001_目标计算_维持大于应付() + { + //维持 180 − 应付净收 100 = 80 + Assert.AreEqual(80, SwapAdditionalMarginCalc.CalcTarget(180, 100), 1e-6); + } + + [TestMethod] + public void AM_002_目标计算_应付覆盖维持_不追() + { + Assert.AreEqual(0, SwapAdditionalMarginCalc.CalcTarget(80, 100), 1e-6); + } + + [TestMethod] + public void AM_003_目标计算_维持为负_我方净支付方向不追() + { + Assert.AreEqual(0, SwapAdditionalMarginCalc.CalcTarget(-50, 100), 1e-6); + } + + [TestMethod] + public void AM_004_分配_授信充足_全额授信() + { + var (credit, cash) = SwapAdditionalMarginCalc.Allocate(80, 100); + Assert.AreEqual(80, credit, 1e-6); + Assert.AreEqual(0, cash, 1e-6); + } + + [TestMethod] + public void AM_005_分配_授信不足_拆为授信加现金() + { + var (credit, cash) = SwapAdditionalMarginCalc.Allocate(80, 30); + Assert.AreEqual(30, credit, 1e-6); + Assert.AreEqual(50, cash, 1e-6); + } + + [TestMethod] + public void AM_006_分配_无授信_全额现金() + { + var (credit, cash) = SwapAdditionalMarginCalc.Allocate(80, 0); + Assert.AreEqual(0, credit, 1e-6); + Assert.AreEqual(80, cash, 1e-6); + } + + [TestMethod] + public void AM_007_分配_新增为零或负_不产生() + { + Assert.AreEqual((0d, 0d), SwapAdditionalMarginCalc.Allocate(0, 100)); + Assert.AreEqual((0d, 0d), SwapAdditionalMarginCalc.Allocate(-5, 100)); + } + + /// + /// 幂等与跨日增量(资金记录存累计值的口径验证): + /// 第1日 维持180/应付100 → 新增80;第2日 维持不变、已补足80 → 新增0(重跑不写); + /// 第3日 维持升到200 → 目标100 − 已补足80 = 新增20(记录累计值更新为100,历史增量自然承载)。 + /// + [TestMethod] + public void AM_008_跨日增量与幂等() + { + double fundedCash = 0, fundedCredit = 0; + var payableNet = 100d; + + var day1 = Math.Round(SwapAdditionalMarginCalc.CalcTarget(180, payableNet) - fundedCash - fundedCredit, 2, MidpointRounding.AwayFromZero); + Assert.AreEqual(80, day1, 1e-6); + var (c1, m1) = SwapAdditionalMarginCalc.Allocate(day1, 30); + fundedCredit += c1; + fundedCash += m1; + Assert.AreEqual(30, fundedCredit, 1e-6); + Assert.AreEqual(50, fundedCash, 1e-6); + + //重跑同日:目标/已补足不变 → 新增 0 + var day1Rerun = Math.Round(SwapAdditionalMarginCalc.CalcTarget(180, payableNet) - fundedCash - fundedCredit, 2, MidpointRounding.AwayFromZero); + Assert.AreEqual(0, day1Rerun, 1e-6); + + //第2日 维持不变 → 已补足=目标 → 新增 0 + var day2 = Math.Round(SwapAdditionalMarginCalc.CalcTarget(180, payableNet) - fundedCash - fundedCredit, 2, MidpointRounding.AwayFromZero); + Assert.AreEqual(0, day2, 1e-6); + + //第3日 维持上升 → 只补差额 + var day3 = Math.Round(SwapAdditionalMarginCalc.CalcTarget(200, payableNet) - fundedCash - fundedCredit, 2, MidpointRounding.AwayFromZero); + Assert.AreEqual(20, day3, 1e-6); + } + + /// + /// 追保回落(维持下降到应付之下)不返还:目标为 0 → 已补足保持,新增 0; + /// 超付部分由可用资金公式的负缺口(Σ维持−累计)体现,不产生返还记录。 + /// + [TestMethod] + public void AM_009_追保回落不返还() + { + var target = SwapAdditionalMarginCalc.CalcTarget(50, 100); + Assert.AreEqual(0, target, 1e-6); + var (credit, cash) = SwapAdditionalMarginCalc.Allocate(target - 80, 100); + Assert.AreEqual(0, credit, 1e-6); + Assert.AreEqual(0, cash, 1e-6); + } + + /// + /// 追加保证金授信出入记录的 remark 前缀识别(RemoveByTrade 保留判定与累计口径共用)。 + /// + [TestMethod] + public void AM_010_追加保证金记录识别() + { + Assert.IsTrue(ClientCreditInoutService.IsAdditionalMarginRecord( + new client_credit_inout { change_type = client_credit_inout.ChangeTypeOccupy, remark = "追加保证金占用" })); + Assert.IsFalse(ClientCreditInoutService.IsAdditionalMarginRecord( + new client_credit_inout { change_type = client_credit_inout.ChangeTypeOccupy, remark = "簿记授信占用" })); + Assert.IsFalse(ClientCreditInoutService.IsAdditionalMarginRecord( + new client_credit_inout { remark = null })); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/SwapSpanBalanceCalcTest.cs b/UnitTestProject/Modules/SwapModule/SwapSpanBalanceCalcTest.cs new file mode 100644 index 00000000..06535673 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapSpanBalanceCalcTest.cs @@ -0,0 +1,131 @@ +using YLErp.Modules.SwapModule.Margin; + +namespace YLErp.Modules.SwapModule +{ + /// + /// R2 阶段三 §3.2 可用资金公式测试(需求拆分 R2 口径,实时/EOD/报告三处共用 SwapSpanBalanceCalc): + /// 客户维度 = Max(现金结存 + 授信 − 已使用授信 + 初始保证金 − 维持保证金, 0); + /// 合约维度 = Max(现金结存 + 授信 − 已使用授信 − 交易维度追加保证金合计, 0)。 + /// 授信额度为 credit.Credit 合计(保存时已折算比例),现金结存=期末结存(阶段二起授信不进资金)。 + /// + [TestClass] + public class SwapSpanBalanceCalcTest + { + [TestMethod] + public void SB_001_客户维度_现金充足() + { + //现金200 + 授信100 − 已使用20 + 初始100 − 维持150 = 230 + Assert.AreEqual(230, SwapSpanBalanceCalc.CalcClientDimensionAvailable( + cashBalance: 200, totalCredit: 100, usedCredit: 20, initialMargin: 100, maintenanceMargin: 150), 1e-6); + } + + [TestMethod] + public void SB_002_客户维度_现金不足_截断为0() + { + //现金20 + 0 − 0 + 100 − 130 = −10 → 0(追加10未补足时无可用资金) + Assert.AreEqual(0, SwapSpanBalanceCalc.CalcClientDimensionAvailable( + cashBalance: 20, totalCredit: 0, usedCredit: 0, initialMargin: 100, maintenanceMargin: 130), 1e-6); + } + + [TestMethod] + public void SB_003_客户维度_授信可用额参与覆盖() + { + //现金20 + 授信100 − 已使用20 + 100 − 130 = 70 + Assert.AreEqual(70, SwapSpanBalanceCalc.CalcClientDimensionAvailable( + cashBalance: 20, totalCredit: 100, usedCredit: 20, initialMargin: 100, maintenanceMargin: 130), 1e-6); + } + + [TestMethod] + public void SB_004_合约维度_按交易追加合计扣减() + { + //现金20 + 授信100 − 已使用20 − 追加合计30 = 70 + Assert.AreEqual(70, SwapSpanBalanceCalc.CalcContractDimensionAvailable( + cashBalance: 20, totalCredit: 100, usedCredit: 20, tradeAdditionalMarginSum: 30), 1e-6); + } + + [TestMethod] + public void SB_005_合约维度_扣尽截断为0() + { + Assert.AreEqual(0, SwapSpanBalanceCalc.CalcContractDimensionAvailable( + cashBalance: 20, totalCredit: 0, usedCredit: 0, tradeAdditionalMarginSum: 30), 1e-6); + } + + /// + /// 阶段三口径一致性:无追加保证金流水时(阶段四前),两维度公式数值一致—— + /// 交易追加合计 = Σ(维持−累计) = 维持 − 初始,与客户维度的 (初始−维持) 项互相抵消。 + /// + [TestMethod] + public void SB_006_阶段三两维度数值一致() + { + var cash = 20d; + var credit = 100d; + var used = 20d; + var initial = 100d; + var maintenance = 130d; + var clientDimension = SwapSpanBalanceCalc.CalcClientDimensionAvailable(cash, credit, used, initial, maintenance); + var contractDimension = SwapSpanBalanceCalc.CalcContractDimensionAvailable(cash, credit, used, maintenance - initial); + Assert.AreEqual(clientDimension, contractDimension, 1e-9); + } + + /// + /// 阶段四 §4.2 客户维度双向追保:正数=需追保,不以 0 截断。 + /// + [TestMethod] + public void SB_010_客户维度追保金额_正数需追保() + { + //追保差额 (维持150 − 初始100)=50;资金 现金5+授信0−已使用0=5 → 追保 = 50 − 5 = 45 + Assert.AreEqual(45, SwapSpanBalanceCalc.CalcClientDimensionCallMargin( + cashBalance: 5, totalCredit: 0, usedCredit: 0, initialMargin: 100, maintenanceMargin: 150), 1e-6); + } + + [TestMethod] + public void SB_011_客户维度追保金额_负数可返还() + { + //差额 (维持130−初始100)=30,资金 20+100−20=100 → 追保 = 30 − 100 = −70(可返还,不截断为 0) + Assert.AreEqual(-70, SwapSpanBalanceCalc.CalcClientDimensionCallMargin( + cashBalance: 20, totalCredit: 100, usedCredit: 20, initialMargin: 100, maintenanceMargin: 130), 1e-6); + } + + /// + /// 客户维度追保金额与可用资金公式互为反向(去 Max 截断):追保 = −(未截断可用资金)。 + /// + [TestMethod] + public void SB_012_客户维度追保与可用资金反向一致() + { + var cash = 20d; + var credit = 100d; + var used = 20d; + var initial = 100d; + var maintenance = 130d; + var available = SwapSpanBalanceCalc.CalcClientDimensionAvailable(cash, credit, used, initial, maintenance); + var callMargin = SwapSpanBalanceCalc.CalcClientDimensionCallMargin(cash, credit, used, initial, maintenance); + var availableUnfloored = cash + credit - used + initial - maintenance; + Assert.AreEqual(-availableUnfloored, callMargin, 1e-9); + //可用资金被 0 截断时追保为正(需追保),两者不矛盾 + if (availableUnfloored < 0) + { + Assert.AreEqual(0, available, 1e-9); + Assert.IsTrue(callMargin > 0); + } + } + + /// + /// 合约维度追保金额(需求原文 现金+授信−已使用 的应追加方向取值):账户透支为正=应补足,盈余为负。 + /// + [TestMethod] + public void SB_013_合约维度追保金额_透支为正() + { + //现金−80 + 授信100 − 已使用30 = −10 → 追保 = 10(应补足) + Assert.AreEqual(10, SwapSpanBalanceCalc.CalcContractDimensionCallMargin( + cashBalance: -80, totalCredit: 100, usedCredit: 30), 1e-6); + } + + [TestMethod] + public void SB_014_合约维度追保金额_盈余为负() + { + //现金50 + 授信100 − 已使用20 = 130 → 追保 = −130(盈余可返还方向) + Assert.AreEqual(-130, SwapSpanBalanceCalc.CalcContractDimensionCallMargin( + cashBalance: 50, totalCredit: 100, usedCredit: 20), 1e-6); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/SwapSpanMarginCalcTest.cs b/UnitTestProject/Modules/SwapModule/SwapSpanMarginCalcTest.cs new file mode 100644 index 00000000..9407a1c6 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapSpanMarginCalcTest.cs @@ -0,0 +1,225 @@ +using YLErp.DBModels; +using YLErp.Modules.SwapModule.Margin; + +namespace YLErp.Modules.SwapModule +{ + /// + /// R2 阶段三 §3.1 区间追保纯函数测试:落档边界值(恰好等于档界)、看多/看空方向、 + /// 追保金额=AmountRate×期初全价×券面总额(累计到所落档位,直取)、 + /// 维持保证金=(初始+总追加)×我方净收取方向。 + /// 区间结构(docx 确认书追保表同型):多头 第1层[Lower,+∞)、第n层[Lower,Upper);空头 第1层(−∞,Upper]、第n层(Lower,Upper]。 + /// + [TestClass] + public class SwapSpanMarginCalcTest + { + private static SpanTierConfig Tier(double? lower, double? upper, double? amountRate) + => new() { Lower = lower, Upper = upper, AmountRate = amountRate }; + + /// 客户看多 4 层(A=3% 口径:97/94/91/88,比例 0/3%/6%/9%) + private static List LongTiers() => new() + { + Tier(0.97, null, 0.00), + Tier(0.94, 0.97, 0.03), + Tier(0.91, 0.94, 0.06), + Tier(0.88, 0.91, 0.09), + }; + + /// 客户看空 4 层(A=3% 口径:103/106/109/112,比例 0/3%/6%/9%) + private static List ShortTiers() => new() + { + Tier(null, 1.03, 0.00), + Tier(1.03, 1.06, 0.03), + Tier(1.06, 1.09, 0.06), + Tier(1.09, 1.12, 0.09), + }; + + // ================================================================ + // HasSpanConfig:方案B结构判定(存量 x/y 配置回落旧公式) + // ================================================================ + + [TestMethod] + public void SS_001_空配置_视为存量xy() + { + Assert.IsFalse(SwapSpanMarginCalc.HasSpanConfig(null)); + Assert.IsFalse(SwapSpanMarginCalc.HasSpanConfig(new SpanConfig())); + Assert.IsFalse(SwapSpanMarginCalc.HasSpanConfig(new SpanConfig + { + LongSpans = new List { new SpanTierConfig(), null }, + ShortSpans = new List { new SpanTierConfig() } + })); + } + + [TestMethod] + public void SS_002_任一方向或线有值_视为新结构() + { + Assert.IsTrue(SwapSpanMarginCalc.HasSpanConfig(new SpanConfig { WarnLine = 0.91 })); + Assert.IsTrue(SwapSpanMarginCalc.HasSpanConfig(new SpanConfig { LongSpans = new List { Tier(0.97, null, 0) } })); + Assert.IsTrue(SwapSpanMarginCalc.HasSpanConfig(new SpanConfig { ShortSpans = new List { Tier(null, 1.03, 0) } })); + } + + // ================================================================ + // IsCustomerLong:与确认书同规则(收取端同向、支付端反向,客户取反) + // ================================================================ + + [TestMethod] + public void SS_003_客户方向判定() + { + //我方收取端:PositionType 同向 → 我方多头 → 客户看空 + Assert.IsFalse(SwapSpanMarginCalc.IsCustomerLong(posiDirection: 1, positionType: 1)); + Assert.IsTrue(SwapSpanMarginCalc.IsCustomerLong(posiDirection: 1, positionType: 2)); + //我方支付端:PositionType 反向 → 我方空头 → 客户看多 + Assert.IsTrue(SwapSpanMarginCalc.IsCustomerLong(posiDirection: 2, positionType: 1)); + Assert.IsFalse(SwapSpanMarginCalc.IsCustomerLong(posiDirection: 2, positionType: 2)); + } + + // ================================================================ + // MatchTier 多头:[Lower, Upper),第1层上不封顶;边界恰好等于档界 + // ================================================================ + + [TestMethod] + public void SS_004_多头落档_区间内与边界值() + { + var tiers = LongTiers(); + //未触发(价格充足)→ 第1层 + Assert.AreSame(tiers[0], SwapSpanMarginCalc.MatchTier(tiers, true, 1.00)); + Assert.AreSame(tiers[0], SwapSpanMarginCalc.MatchTier(tiers, true, 0.97)); //恰好=第1层下界 + //第2层 [0.94, 0.97) + Assert.AreSame(tiers[1], SwapSpanMarginCalc.MatchTier(tiers, true, 0.9699)); + Assert.AreSame(tiers[1], SwapSpanMarginCalc.MatchTier(tiers, true, 0.94)); //恰好=第2层下界 + //第3层 [0.91, 0.94) + Assert.AreSame(tiers[2], SwapSpanMarginCalc.MatchTier(tiers, true, 0.9399)); + Assert.AreSame(tiers[2], SwapSpanMarginCalc.MatchTier(tiers, true, 0.91)); + //第4层 [0.88, 0.91) + Assert.AreSame(tiers[3], SwapSpanMarginCalc.MatchTier(tiers, true, 0.9099)); + Assert.AreSame(tiers[3], SwapSpanMarginCalc.MatchTier(tiers, true, 0.88)); //恰好=最深层下界 + } + + [TestMethod] + public void SS_005_多头穿出最深层_按最深层计() + { + var tiers = LongTiers(); + Assert.AreSame(tiers[3], SwapSpanMarginCalc.MatchTier(tiers, true, 0.8799)); + Assert.AreSame(tiers[3], SwapSpanMarginCalc.MatchTier(tiers, true, 0.50)); + } + + // ================================================================ + // MatchTier 空头:(Lower, Upper],第1层下不设限;边界恰好等于档界 + // ================================================================ + + [TestMethod] + public void SS_006_空头落档_区间内与边界值() + { + var tiers = ShortTiers(); + Assert.AreSame(tiers[0], SwapSpanMarginCalc.MatchTier(tiers, false, 1.00)); + Assert.AreSame(tiers[0], SwapSpanMarginCalc.MatchTier(tiers, false, 1.03)); //恰好=第1层上界 + //第2层 (1.03, 1.06] + Assert.AreSame(tiers[1], SwapSpanMarginCalc.MatchTier(tiers, false, 1.0301)); + Assert.AreSame(tiers[1], SwapSpanMarginCalc.MatchTier(tiers, false, 1.06)); //恰好=第2层上界 + //第3层 (1.06, 1.09] + Assert.AreSame(tiers[2], SwapSpanMarginCalc.MatchTier(tiers, false, 1.0601)); + Assert.AreSame(tiers[2], SwapSpanMarginCalc.MatchTier(tiers, false, 1.09)); + //第4层 (1.09, 1.12] + Assert.AreSame(tiers[3], SwapSpanMarginCalc.MatchTier(tiers, false, 1.0901)); + Assert.AreSame(tiers[3], SwapSpanMarginCalc.MatchTier(tiers, false, 1.12)); + } + + [TestMethod] + public void SS_007_空头穿出最深层_按最深层计() + { + var tiers = ShortTiers(); + Assert.AreSame(tiers[3], SwapSpanMarginCalc.MatchTier(tiers, false, 1.1201)); + Assert.AreSame(tiers[3], SwapSpanMarginCalc.MatchTier(tiers, false, 2.00)); + } + + [TestMethod] + public void SS_008_无可用层_返回null() + { + Assert.IsNull(SwapSpanMarginCalc.MatchTier(null, true, 0.9)); + Assert.IsNull(SwapSpanMarginCalc.MatchTier(new List(), false, 1.0)); + Assert.IsNull(SwapSpanMarginCalc.MatchTier(new List { new SpanTierConfig() }, true, 0.9)); + } + + // ================================================================ + // 追保金额与维持保证金公式 + // ================================================================ + + [TestMethod] + public void SS_009_追保金额_比例乘期初全价乘券面() + { + //第2层 3%:0.03 × 期初全价101.5 × 券面10000 = 30450 + Assert.AreEqual(30450, SwapSpanMarginCalc.CalcAdditionalMargin(Tier(0.94, 0.97, 0.03), 101.5, 10000), 1e-6); + //第1层 0% → 0 + Assert.AreEqual(0, SwapSpanMarginCalc.CalcAdditionalMargin(Tier(0.97, null, 0), 101.5, 10000), 1e-6); + //未配置比例/null 层 → 0 + Assert.AreEqual(0, SwapSpanMarginCalc.CalcAdditionalMargin(null, 101.5, 10000), 1e-6); + Assert.AreEqual(0, SwapSpanMarginCalc.CalcAdditionalMargin(Tier(0.94, 0.97, null), 101.5, 10000), 1e-6); + } + + [TestMethod] + public void SS_010_维持保证金_初始加追加乘方向() + { + //我方净收取 +1 + Assert.AreEqual(80450, SwapSpanMarginCalc.CalcMaintenanceMargin(50000, 30450, 1), 1e-6); + //我方净支付 −1(负值=客户应收) + Assert.AreEqual(-80450, SwapSpanMarginCalc.CalcMaintenanceMargin(50000, 30450, -1), 1e-6); + } + + /// docx 数值复算(客户看多,A=3%):期初净价98.5 → 当前净价92.0 落第3层,追加=6%×101×100000 + [TestMethod] + public void SS_011_客户看多数值复算() + { + var tiers = LongTiers(); + var ratio = 92.0 / 98.5; + var tier = SwapSpanMarginCalc.MatchTier(tiers, isCustomerLong: true, priceRatio: ratio); + Assert.AreSame(tiers[2], tier); + var additional = SwapSpanMarginCalc.CalcAdditionalMargin(tier, initPrice: 101.0, quantity: 100000); + Assert.AreEqual(0.06 * 101.0 * 100000, additional, 1e-6); + var maintenance = SwapSpanMarginCalc.CalcMaintenanceMargin(500000, additional, direction: 1); + Assert.AreEqual(500000 + 0.06 * 101.0 * 100000, maintenance, 1e-6); + } + + /// docx 数值复算(客户看空,A=3%):期初价98.5 → 收盘价104.0 落第2层,追加=3%×101×100000 + [TestMethod] + public void SS_012_客户看空数值复算() + { + var tiers = ShortTiers(); + var ratio = 104.0 / 98.5; + var tier = SwapSpanMarginCalc.MatchTier(tiers, isCustomerLong: false, priceRatio: ratio); + Assert.AreSame(tiers[1], tier); + var additional = SwapSpanMarginCalc.CalcAdditionalMargin(tier, initPrice: 101.0, quantity: 100000); + Assert.AreEqual(0.03 * 101.0 * 100000, additional, 1e-6); + } + + /// + /// BUG-25 回归:配置层未按浅→深排序时,穿出最深层的回落不依赖数组顺序—— + /// 多头取最小下界层、空头取最大上界层(Last() 假定有序,乱序时回落错层)。 + /// + [TestMethod] + public void SS_013_穿层回落_乱序配置按边界取最深层() + { + //多头乱序(浅→深:0.97/0.94/0.91/0.88 → 打乱为 0.91/0.97/0.88/0.94) + var longShuffled = new List + { + Tier(0.91, 0.94, 0.06), + Tier(0.97, null, 0.00), + Tier(0.88, 0.91, 0.09), + Tier(0.94, 0.97, 0.03), + }; + var longDeepest = SwapSpanMarginCalc.MatchTier(longShuffled, isCustomerLong: true, priceRatio: 0.85); + Assert.AreEqual(0.88, longDeepest.Lower); + Assert.AreEqual(0.09, longDeepest.AmountRate); + + //空头乱序(浅→深:1.03/1.06/1.09/1.12 → 打乱为 1.09/1.03/1.12/1.06) + var shortShuffled = new List + { + Tier(1.06, 1.09, 0.06), + Tier(null, 1.03, 0.00), + Tier(1.09, 1.12, 0.09), + Tier(1.03, 1.06, 0.03), + }; + var shortDeepest = SwapSpanMarginCalc.MatchTier(shortShuffled, isCustomerLong: false, priceRatio: 1.15); + Assert.AreEqual(1.12, shortDeepest.Upper); + Assert.AreEqual(0.09, shortDeepest.AmountRate); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/SwapSpanMarginEngineTest.cs b/UnitTestProject/Modules/SwapModule/SwapSpanMarginEngineTest.cs new file mode 100644 index 00000000..7a2c21de --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapSpanMarginEngineTest.cs @@ -0,0 +1,187 @@ +using YLErp.DBModels; +using YLErp.Modules.SwapModule.Margin; + +namespace YLErp.Modules.SwapModule +{ + /// + /// R2 阶段三 §3.1 引擎单笔计算场景测试(SwapSpanMarginCalc.CalcTradeMaintenanceMargin,收盘价由调用方解析后注入, + /// 覆盖"需要收盘价"的各场景:取到价落档、未取到价兜底、试算初始、期初价缺省、方向与腿缺失)。 + /// 收盘价解析(债券中债估值净价/ETF收盘价/取不到置0)在 MarginCalculationBase.CalcSwapSpanMaintenanceMargin 胶水层, + /// 价格源本身的读库行为见 SwapSpanPriceSourceTest。 + /// + [TestClass] + public class SwapSpanMarginEngineTest + { + private static SpanTierConfig Tier(double? lower, double? upper, double? amountRate) + => new() { Lower = lower, Upper = upper, AmountRate = amountRate }; + + private static SpanConfig LongShortCfg() => new() + { + LongSpans = new List + { + Tier(0.97, null, 0.00), Tier(0.94, 0.97, 0.03), Tier(0.91, 0.94, 0.06), Tier(0.88, 0.91, 0.09) + }, + ShortSpans = new List + { + Tier(null, 1.03, 0.00), Tier(1.03, 1.06, 0.03), Tier(1.06, 1.09, 0.06), Tier(1.09, 1.12, 0.09) + } + }; + + /// 标的腿(多空),posiDirection/positionType 组合出方向 + private static swap_position UnderlyingLeg(int posiDirection, int positionType, + decimal grossPrice, decimal? netPrice, decimal quantity, string code = "240004.IB") + => new() + { + PosiDirection = posiDirection, + PositionType = positionType, + UnderlyingCode = code, + PosiGrossPrice = grossPrice, + PosiNetNoFeePrice = netPrice, + PosiQuantity = quantity + }; + + /// 初始预付金腿(InterestMode=5),interestDirection 1=收取 2=支付 + private static swap_position MarginLeg(int interestDirection, decimal principal) + => new() { InterestMode = 5, InterestDirection = interestDirection, InterestPrincipalFix = principal }; + + // ================================================================ + // 收盘价取到 → 落档计算 + // ================================================================ + + /// 债券客户看多:收取端标的腿 PositionType=Short → 客户看多;净价 92.0/期初净价 98.5 落第3层 + [TestMethod] + public void SE_001_债券客户看多_净价落第3层() + { + var legs = new List + { + UnderlyingLeg(posiDirection: 1, positionType: 2, grossPrice: 101.0m, netPrice: 98.5m, quantity: 100000m), + MarginLeg(interestDirection: 1, principal: 500000m) + }; + var maintenance = SwapSpanMarginCalc.CalcTradeMaintenanceMargin( + tradeInitialMargin: null, spanCfg: LongShortCfg(), legs, isInitialCalc: false, closePrice: 92.0); + //追加 = 6% × 期初全价101 × 券面100000 = 606000;维持 = 500000 + 606000 + Assert.AreEqual(1106000, maintenance.Value, 1e-6); + } + + /// ETF客户看空:收取端标的腿 PositionType=Long → 客户看空;收盘 1.32/期初净价 1.25 落第2层 + [TestMethod] + public void SE_002_ETF客户看空_收盘价落第2层() + { + var legs = new List + { + UnderlyingLeg(posiDirection: 1, positionType: 1, grossPrice: 1.25m, netPrice: 1.25m, quantity: 1000000m, code: "511010.SH"), + MarginLeg(interestDirection: 1, principal: 200000m) + }; + var maintenance = SwapSpanMarginCalc.CalcTradeMaintenanceMargin( + tradeInitialMargin: null, spanCfg: LongShortCfg(), legs, isInitialCalc: false, closePrice: 1.32); + //ratio=1.056 ∈ (1.03,1.06] → 追加 = 3% × 1.25 × 1000000 = 37500;维持 = 200000 + 37500 + Assert.AreEqual(237500, maintenance.Value, 1e-6); + } + + /// 收盘价大幅下跌穿出最深层:按最深层比例计(追加=9%),不叠加也不归零 + [TestMethod] + public void SE_003_价格穿出最深层_按最深层计() + { + var legs = new List + { + UnderlyingLeg(1, 2, grossPrice: 101.0m, netPrice: 98.5m, quantity: 100000m), + MarginLeg(1, 500000m) + }; + var maintenance = SwapSpanMarginCalc.CalcTradeMaintenanceMargin(null, LongShortCfg(), legs, false, closePrice: 80.0); + Assert.AreEqual(500000 + 0.09 * 101.0 * 100000, maintenance.Value, 1e-6); + } + + // ================================================================ + // 收盘价未取到 / 试算 —— 兜底行为 + // ================================================================ + + /// 当天无收盘价(closePrice=0):不抛错,追加按0、维持=初始保证金(引擎侧另记告警日志) + [TestMethod] + public void SE_004_无收盘价_追加为0维持等于初始() + { + var legs = new List + { + UnderlyingLeg(1, 2, 101.0m, 98.5m, 100000m), + MarginLeg(1, 500000m) + }; + var maintenance = SwapSpanMarginCalc.CalcTradeMaintenanceMargin(null, LongShortCfg(), legs, isInitialCalc: false, closePrice: 0); + Assert.AreEqual(500000, maintenance.Value, 1e-6); + } + + /// 试算初始(isInitialCalc=true):即使有收盘价也只出初始项——追加保证金为收盘后口径,试算不产出 + [TestMethod] + public void SE_005_试算初始_不参与追加() + { + var legs = new List + { + UnderlyingLeg(1, 2, 101.0m, 98.5m, 100000m), + MarginLeg(1, 500000m) + }; + var maintenance = SwapSpanMarginCalc.CalcTradeMaintenanceMargin(null, LongShortCfg(), legs, isInitialCalc: true, closePrice: 92.0); + Assert.AreEqual(500000, maintenance.Value, 1e-6); + } + + /// 期初净价缺失:比基回落期初全价(close/initGross) + [TestMethod] + public void SE_006_期初净价缺省_回落期初全价() + { + var legs = new List + { + UnderlyingLeg(1, 2, grossPrice: 98.5m, netPrice: null, quantity: 100000m), + MarginLeg(1, 500000m) + }; + //close=92.0/98.5 落第3层 + var maintenance = SwapSpanMarginCalc.CalcTradeMaintenanceMargin(null, LongShortCfg(), legs, false, closePrice: 92.0); + Assert.AreEqual(500000 + 0.06 * 98.5 * 100000, maintenance.Value, 1e-6); + } + + // ================================================================ + // 腿数据缺失与方向 + // ================================================================ + + /// 无标的腿(缺期初价):返回 null,引擎跳过该交易不产出 trade_span + [TestMethod] + public void SE_007_无标的腿_返回null() + { + var legs = new List { MarginLeg(1, 500000m) }; + Assert.IsNull(SwapSpanMarginCalc.CalcTradeMaintenanceMargin(null, LongShortCfg(), legs, false, closePrice: 92.0)); + Assert.IsNull(SwapSpanMarginCalc.CalcTradeMaintenanceMargin(null, LongShortCfg(), new List(), false, 92.0)); + } + + /// 我方支付初始保证金(InterestDirection=支付):方向取 −1,维持保证金为负(客户应收) + [TestMethod] + public void SE_008_我方支付预付金_维持为负() + { + var legs = new List + { + UnderlyingLeg(1, 2, 101.0m, 98.5m, 100000m), + MarginLeg(interestDirection: 2, principal: 500000m) + }; + var maintenance = SwapSpanMarginCalc.CalcTradeMaintenanceMargin(null, LongShortCfg(), legs, false, closePrice: 92.0); + Assert.AreEqual(-(500000 + 0.06 * 101.0 * 100000), maintenance.Value, 1e-6); + } + + /// 无预付金腿:初始保证金回落交易录入值 trade.InitialMargin(客户应付常态,方向+1) + [TestMethod] + public void SE_009_无预付金腿_回落交易录入初始保证金() + { + var legs = new List { UnderlyingLeg(1, 2, 101.0m, 98.5m, 100000m) }; + var maintenance = SwapSpanMarginCalc.CalcTradeMaintenanceMargin(300000, LongShortCfg(), legs, false, closePrice: 92.0); + Assert.AreEqual(300000 + 0.06 * 101.0 * 100000, maintenance.Value, 1e-6); + } + + /// 多条预付金腿按收付净额定初始与方向(净支付 → −1) + [TestMethod] + public void SE_010_多预付金腿_按净收取定方向() + { + var legs = new List + { + UnderlyingLeg(1, 2, 101.0m, 98.5m, 100000m), + MarginLeg(1, 200000m), //收取 20万 + MarginLeg(2, 500000m) //支付 50万 → 净支付 30万 + }; + var maintenance = SwapSpanMarginCalc.CalcTradeMaintenanceMargin(null, LongShortCfg(), legs, false, closePrice: 0); + Assert.AreEqual(-300000, maintenance.Value, 1e-6); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/SwapSpanPriceSourceTest.cs b/UnitTestProject/Modules/SwapModule/SwapSpanPriceSourceTest.cs new file mode 100644 index 00000000..17cd942e --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapSpanPriceSourceTest.cs @@ -0,0 +1,57 @@ +using YLErp.DBModels; +using YLErp.Modules.DataProviderModule; + +namespace YLErp.Modules.SwapModule +{ + /// + /// R2 阶段三 §3.1 收盘价取数链路冒烟测试(只读 dev 库,不构造数据): + /// 债券净价源(china_bond_valuation,GetBondPrice 口径 SettlePrice=净价、ClosePrice=全价,≤计算日 取最近——盘中/非交易日回退到最近已有估值); + /// 指数/ETF收盘价源(eod_stock_price)。 + /// 两个源在"价格同步作业跑完前"决定引擎行为:取不到 → 追加按0、维持=初始(见 SwapSpanMarginEngineTest.SE_004)。 + /// + [TestClass] + public class SwapSpanPriceSourceTest + { + [TestMethod] + public void SP_001_债券估值净价源_可取且净价为正() + { + using var db = DbContextFactory.GetYLDbContext(); + var latest = db.china_bond_valuation + .Where(x => x.net_price > 0 && x.dirty_price_close > 0) + .OrderByDescending(x => x.valuation_date) + .Select(x => new { x.valuation_date, x.bond_id }) + .FirstOrDefault(); + if (latest == null) + { + Assert.Inconclusive("dev 库无中债估值数据,跳过"); + } + + //当日可取 + Assert.IsTrue(EodPriceQueryService.TryGetBondEodPrice(latest.valuation_date, latest.bond_id, out var price)); + Assert.IsTrue(price.SettlePrice > 0, "净价(SettlePrice)应为正"); + Assert.IsTrue(price.ClosePrice > 0, "全价(ClosePrice)应为正"); + + //≤计算日 取最近:往未来多取几天仍回退到最近一条估值(盘中跑引擎即此语义) + Assert.IsTrue(EodPriceQueryService.TryGetBondEodPrice(latest.valuation_date.AddDays(5), latest.bond_id, out var fallback)); + Assert.AreEqual(price.SettlePrice, fallback.SettlePrice, 1e-9); + } + + [TestMethod] + public void SP_002_ETF收盘价源_可取() + { + using var db = DbContextFactory.GetYLDbContext(); + var latest = db.eod_stock_price + .Where(x => x.ClosePrice > 0) + .OrderByDescending(x => x.ValueDate) + .Select(x => new { x.ValueDate, x.UnderlyingCode }) + .FirstOrDefault(); + if (latest == null) + { + Assert.Inconclusive("dev 库无股票/ETF日终价格数据,跳过"); + } + + Assert.IsTrue(EodPriceQueryService.TryGetEodPrice(latest.ValueDate, latest.UnderlyingCode, out var price)); + Assert.IsTrue(price.GetPrice(SettlementTypeEnum.ClosePrice) > 0); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs b/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs index 3e852ba8..8e93c8b1 100644 --- a/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs +++ b/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs @@ -1,6 +1,7 @@ using Newtonsoft.Json; using YLErp.DBModels; using YLErp.DBModels.Enums; +using YLErp.Modules.SwapModule.Margin; namespace YLErp.Modules.SwapModule { @@ -113,6 +114,11 @@ namespace YLErp.Modules.SwapModule CloseReCheckCallCount++; } + // R4 按标签分流释放:纯内存测试不连库,stub 为全现金(与既有断言语义一致) + protected override UnwindTagSplit ReleaseMarginByFundTag(trade td, DateTime valueDate, List interestEvents, + decimal marginAmount, decimal marginRebate) + => new UnwindTagSplit { CashMargin = Convert.ToDouble(marginAmount), CashRebate = Convert.ToDouble(marginRebate) }; + protected override void SaveAllChanges() { SaveAllChangesCount++; } protected override void ExecuteInTransaction(Action action) => action(); // 不包事务,直接执行 protected override void CallSaveSwapTradeClientCash(trade td, DateTime valueDate) { } // 空操作 diff --git a/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs b/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs index 38128373..02c04591 100644 --- a/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs +++ b/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs @@ -122,5 +122,14 @@ namespace YLErp.Modules.SwapModule ClientCashCalls.Add((amount, action)); return _nextId++; } + + /// + /// R4 自动互换返息按标签分流:纯内存测试不连库(查 swap_position 标签), + /// stub 为全额现金返息(与既有断言语义一致)。 + /// + protected override decimal GetAutoSwapCashRebate(trade td, List flowEvents, decimal totalRebate) + { + return totalRebate; + } } } diff --git a/YLErpDAL/BLL/EodSettlement/ClientBalanceUtility.cs b/YLErpDAL/BLL/EodSettlement/ClientBalanceUtility.cs index 4a64222f..52ffb2ca 100644 --- a/YLErpDAL/BLL/EodSettlement/ClientBalanceUtility.cs +++ b/YLErpDAL/BLL/EodSettlement/ClientBalanceUtility.cs @@ -11,6 +11,8 @@ using YLErp.Model; using YLErp.Models; using YLErp.Modules; using YLErp.Modules.ClientModule; +using YLErp.Modules.SwapModule; +using YLErp.Modules.SwapModule.Margin; namespace YLErp.BLL.EodSettlement { @@ -296,6 +298,12 @@ namespace YLErp.BLL.EodSettlement var endDatas = endQuery.ToArray(); + //阶段三 §3.2 估值报告口径输入:已使用授信(授信出入表)、互换初始保证金(应付预付金净额)、 + //交易维度追加保证金合计(Σ(维持−累计),规则15交易)——与实时口径共用同一查询(SwapSpanBalanceQueryService) + var usedCreditDic = ClientCreditInoutService.GetUsedCreditByClients(clientIdS, db); + var swapInitMarginDic = SwapSpanBalanceQueryService.GetSwapInitMarginByClients(clientIdS, lastDate, db); + var swapAdditionalDic = SwapSpanBalanceQueryService.GetTradeAdditionalMarginByClients(clientIdS, lastDate, db); + foreach (var data in endDatas) { if (dic.TryGetValue(data.ClientId, out var balance)) @@ -360,12 +368,49 @@ namespace YLErp.BLL.EodSettlement balance.VmOutFundSum = data.VmOutFundSum ?? 0; //预付金金额=期末结存-初始预付金金额 balance.MarginBalance = balance.AmountFund - balance.MySideMargin; - // 可用资金 = 期末结存 - 追保账户余额 - 初始保证金 - balance.AvailableAmount = balance.MarginBalance - (balance.VmInFundSum - balance.VmOutFundSum); - // 是否追保=盯市金额小于维持保证金额 - balance.NeedAddMargin = balance.SwapMarketAmount < balance.MaintenanceMargin; - // 追保金额=初始保证金金额-盯市金额 - balance.MarginByPayableMarginTotal = balance.NeedAddMargin ? (balance.MySideMargin - balance.SwapMarketAmount) : 0; + //阶段三 §3.2:已使用授信/互换初始保证金/交易维度追加保证金(估值报告字段与可用资金公式共用输入) + balance.UsedCredit = usedCreditDic.TryGetValue(data.ClientId, out var usedCredit) ? usedCredit : 0; + balance.SwapInitMargin = swapInitMarginDic.TryGetValue(data.ClientId, out var swapInit) ? swapInit : 0; + balance.SwapAdditionalMarginTotal = swapAdditionalDic.TryGetValue(data.ClientId, out var swapAdd) ? swapAdd : 0; + // 可用资金:配置了追保规则维度的客户按维度分流(R2公式,实时/EOD三处一致); + // 未配置(MarginWatchRule=NULL,存量客户)维持旧口径:期末结存-追保账户余额 + if (client?.MarginWatchRule == 0) + { + balance.AvailableAmount = SwapSpanBalanceCalc.CalcContractDimensionAvailable( + balance.AmountFund, balance.TotalCredit, balance.UsedCredit, balance.SwapAdditionalMarginTotal); + } + else if (client?.MarginWatchRule == 1) + { + //维持保证金(净收取为正)= -MySideMargin(client_span 维持保证金反号聚合) + balance.AvailableAmount = SwapSpanBalanceCalc.CalcClientDimensionAvailable( + balance.AmountFund, balance.TotalCredit, balance.UsedCredit, + balance.SwapInitMargin, -balance.MySideMargin); + } + else + { + balance.AvailableAmount = balance.MarginBalance - (balance.VmInFundSum - balance.VmOutFundSum); + } + // 是否追保/追保金额(阶段四 §4.2 按维度分流,允许负值=双向,不以 0 截断): + // 客户维度(==1)= (维持−初始) − (现金+授信−已使用),负=可返还; + // 合约维度(==0)= −(现金+授信−已使用)(需求原文公式的应追加方向取值,开放问题2,两值均有产出); + // 未配置(NULL 存量)维持旧口径:盯市低于维持时 = 初始保证金金额−盯市金额,否则 0 + if (client?.MarginWatchRule == 1) + { + balance.MarginByPayableMarginTotal = SwapSpanBalanceCalc.CalcClientDimensionCallMargin( + balance.AmountFund, balance.TotalCredit, balance.UsedCredit, balance.SwapInitMargin, -balance.MySideMargin); + balance.NeedAddMargin = balance.MarginByPayableMarginTotal > 0; + } + else if (client?.MarginWatchRule == 0) + { + balance.MarginByPayableMarginTotal = SwapSpanBalanceCalc.CalcContractDimensionCallMargin( + balance.AmountFund, balance.TotalCredit, balance.UsedCredit); + balance.NeedAddMargin = balance.MarginByPayableMarginTotal > 0; + } + else + { + balance.NeedAddMargin = balance.SwapMarketAmount < balance.MaintenanceMargin; + balance.MarginByPayableMarginTotal = balance.NeedAddMargin ? (balance.MySideMargin - balance.SwapMarketAmount) : 0; + } // 可取资金=Math.Max(期末结存-min(持仓盈亏,0)-初始保证金,0) balance.DesirableFund = Math.Max(balance.MarginBalance + Math.Min(balance.RoundedPositionPnl, 0), 0); diff --git a/YLErpDAL/BLL/EodSettlement/RealTimeClientBanlanceService.cs b/YLErpDAL/BLL/EodSettlement/RealTimeClientBanlanceService.cs index 4140b9bd..804d3c98 100644 --- a/YLErpDAL/BLL/EodSettlement/RealTimeClientBanlanceService.cs +++ b/YLErpDAL/BLL/EodSettlement/RealTimeClientBanlanceService.cs @@ -234,6 +234,14 @@ namespace YLErp.BLL.Eod Dictionary dicIntradayTradePosition = DbContext.intraday_trade_position.Where(x => clientIds.Contains(x.ClientId)).AsEnumerable().GroupBy(p => p.ClientId).Select(p => new { p.Key, OptDate = p.Max(d => d.OptDate) }) .ToDictionary(p => p.Key, p => p.OptDate); Dictionary dicCashInOut = DbContext.ClientCashInCashOut.Where(x => clientIds.Contains((int)x.ClientId)).AsEnumerable().GroupBy(p => (int)p.ClientId).Select(p => new { p.Key, OptDate = p.Max(d => d.OptDate) }).ToDictionary(p => p.Key, p => p.OptDate); + + //阶段三 §3.2 估值报告口径输入(与 ClientBalanceUtility EOD/报告路径共用同一查询,保证三处一致): + //已使用授信(授信出入表)、互换初始保证金(应付预付金净额)、交易维度追加保证金合计(Σ(维持−累计)) + var clientIdList = clientIds.ToList(); + var usedCreditDic = Modules.SwapModule.ClientCreditInoutService.GetUsedCreditByClients(clientIdList, DbContext); + var swapInitMarginDic = SwapSpanBalanceQueryService.GetSwapInitMarginByClients(clientIdList, calcDate.Value, DbContext); + var swapAdditionalDic = SwapSpanBalanceQueryService.GetTradeAdditionalMarginByClients(clientIdList, calcDate.Value, DbContext); + foreach (var item in _clientBalanceDic.Values) { #region 判断实时持仓数据是否最新(包含了最新交易操作的数据) @@ -269,12 +277,51 @@ namespace YLErp.BLL.Eod item.AvailableStockEqvNotional = item.TotalCreditStockEqvNotional - item.PositionNotionalPrincipal; //预付金金额=期末结存-初始预付金金额 item.MarginBalance = item.AmountFund - item.MySideMargin; - // 可用资金 = 期末结存 - 追保账户余额 - 初始保证金 - item.AvailableAmount = item.MarginBalance - item.FrozenMarginMoney; - // 是否追保=盯市金额小于维持保证金额 - item.NeedAddMargin = item.SwapMarketAmount < item.MaintenanceMargin; - // 追保金额=初始保证金金额-盯市金额 - item.MarginByPayableMarginTotal = item.NeedAddMargin ? (item.MySideMargin - item.SwapMarketAmount) : 0; + //阶段三 §3.2:已使用授信/互换初始保证金/交易维度追加保证金(与 EOD/报告口径一致) + item.UsedCredit = usedCreditDic.TryGetValue(clientId, out var usedCredit) ? usedCredit : 0; + item.SwapInitMargin = swapInitMarginDic.TryGetValue(clientId, out var swapInit) ? swapInit : 0; + item.SwapAdditionalMarginTotal = swapAdditionalDic.TryGetValue(clientId, out var swapAdd) ? swapAdd : 0; + // 可用资金:配置了追保规则维度的客户按维度分流(R2公式,实时/EOD三处一致); + // 未配置(MarginWatchRule=NULL,存量客户)维持旧口径:期末结存-冻结资金 + var ruleClient = DataCacheProvider.GetClientDataSource().GetData(clientId); + if (ruleClient?.MarginWatchRule == 0) + { + item.AvailableAmount = SwapSpanBalanceCalc.CalcContractDimensionAvailable( + item.AmountFund, item.TotalCredit, item.UsedCredit, item.SwapAdditionalMarginTotal); + } + else if (ruleClient?.MarginWatchRule == 1) + { + //维持保证金(净收取为正)= -MySideMargin(client_span 维持保证金反号聚合) + item.AvailableAmount = SwapSpanBalanceCalc.CalcClientDimensionAvailable( + item.AmountFund, item.TotalCredit, item.UsedCredit, + item.SwapInitMargin, -item.MySideMargin); + } + else + { + item.AvailableAmount = item.MarginBalance - item.FrozenMarginMoney; + } + // 是否追保/追保金额(阶段四 §4.2 按维度分流,与 ClientBalanceUtility 报告口径一致): + // 客户维度(==1)双向追保 = (维持−初始) − (现金+授信−已使用),允许负值(负=可返还); + // 合约维度(==0)= −(现金+授信−已使用);NULL 存量维持旧口径 + if (ruleClient?.MarginWatchRule == 1) + { + item.MarginByPayableMarginTotal = SwapSpanBalanceCalc.CalcClientDimensionCallMargin( + item.AmountFund, item.TotalCredit, item.UsedCredit, item.SwapInitMargin, -item.MySideMargin); + item.NeedAddMargin = item.MarginByPayableMarginTotal > 0; + } + else if (ruleClient?.MarginWatchRule == 0) + { + item.MarginByPayableMarginTotal = SwapSpanBalanceCalc.CalcContractDimensionCallMargin( + item.AmountFund, item.TotalCredit, item.UsedCredit); + item.NeedAddMargin = item.MarginByPayableMarginTotal > 0; + } + else + { + // 是否追保=盯市金额小于维持保证金额 + item.NeedAddMargin = item.SwapMarketAmount < item.MaintenanceMargin; + // 追保金额=初始保证金金额-盯市金额 + item.MarginByPayableMarginTotal = item.NeedAddMargin ? (item.MySideMargin - item.SwapMarketAmount) : 0; + } // 可取资金=max(期末结存+min(持仓盈亏,0)-初始保证金,0) item.DesirableFund =Math.Max( item.MarginBalance - item.FrozenMarginMoney + Math.Min(item.RoundedPositionPnl, 0),0); } @@ -798,7 +845,9 @@ namespace YLErp.BLL.Eod { clientEntryexitsSwap.ForEach(clientEntryexit => { - if (ClientCashInCashOut.系统操作_期权费.Equals(clientEntryexit.Action) || ClientCashInCashOut.系统操作_应付预付金.Equals(clientEntryexit.Action) || ClientCashInCashOut.系统操作_预付金返息.Equals(clientEntryexit.Action)) + if (ClientCashInCashOut.系统操作_期权费.Equals(clientEntryexit.Action) || ClientCashInCashOut.系统操作_应付预付金.Equals(clientEntryexit.Action) || ClientCashInCashOut.系统操作_预付金返息.Equals(clientEntryexit.Action) + //阶段四 §4.1:追加保证金(EOD 结算产生的合约维度追保入金)与应付预付金同口径计入当日资金变动 + || ClientCashInCashOut.系统操作_追加保证金.Equals(clientEntryexit.Action)) { //CurrPnl += clientEntryexit.Money ?? 0.0; CurrChangeAmount += clientEntryexit.Money ?? 0.0; @@ -1227,9 +1276,12 @@ namespace YLErp.BLL.Eod { //资金净流入 + 权利金收支 + 期权费收支 var balance = _clientBalanceDic[data.ClientId]; - if (data.action == "应收^" + ClientCashInCashOut.系统操作_期权费 || data.action == "应收^" + ClientCashInCashOut.系统操作_应付预付金 || data.action == "应收^" + ClientCashInCashOut.系统操作_预付金返息) + if (data.action == "应收^" + ClientCashInCashOut.系统操作_期权费 || data.action == "应收^" + ClientCashInCashOut.系统操作_应付预付金 || data.action == "应收^" + ClientCashInCashOut.系统操作_预付金返息 + //阶段四 §4.1:追加保证金与应付预付金同桶(互换应收),当日已结算/已确认记录计入 + || data.action == "应收^" + ClientCashInCashOut.系统操作_追加保证金) { - balance.OptionPremiumSwap = data.moneySum; + //同桶多 Action(应付预付金+追加保证金等)并存须累加——与多币种分支(OptionPremiumSwap +=)一致(BUG-08) + balance.OptionPremiumSwap += data.moneySum; } else if (data.action == "应收^" + ClientCashInCashOut.系统操作_互换) { diff --git a/YLErpDAL/BLL/MarginCalculation/MarginCalculationBase.cs b/YLErpDAL/BLL/MarginCalculation/MarginCalculationBase.cs index f97045f3..4c84413d 100644 --- a/YLErpDAL/BLL/MarginCalculation/MarginCalculationBase.cs +++ b/YLErpDAL/BLL/MarginCalculation/MarginCalculationBase.cs @@ -4,9 +4,11 @@ using YLErp.Enums; using YLErp.Helpers; using YLErp.Model.Enum; using YLErp.Modules; +using YLErp.DBModels.Enums; using YLErp.Modules.CalculationModule; using YLErp.Modules.DataProviderModule; using YLErp.Modules.MarginModule; +using YLErp.Modules.SwapModule.Margin; using YLErp.Modules.TradeModule; namespace YLErp.BLL.MarginCalculation @@ -366,32 +368,34 @@ namespace YLErp.BLL.MarginCalculation } //收益互换预付金计算 - //预付金模板V2迁移:绑定了模板的互换交易按模板规则计算——无预付金=0;区间追保结构=名义本金×x(初始)/名义本金×y(持仓), - //x/y 由 MarginTemplateV2RateHelper 按 期限档+资产类型 匹配明细取得。未绑定或绑定其他规则的互换交易维持现状(不产出 trade_span)。 + //预付金模板V2迁移:按 R1 三层级(交易绑定→客户默认→全局默认,ResolveTieredTemplates 找到即停)解析模板—— + //无预付金=0;区间追保结构=名义本金×x(初始)/名义本金×y(持仓),x/y 由 MarginTemplateV2RateHelper 按 期限档+资产类型 匹配明细取得。 + //三级均未命中或命中其他规则的互换交易维持现状(不产出 trade_span),与确认书/取数口径一致(BUG-02 修正)。 + //阶段三 §3.1:区间追保结构明细按方案B录入新区间结构(SpanConfig.LongSpans/ShortSpans)时切换新引擎—— + //每日取标的收盘价落档(债券净价/指数及ETF收盘价),维持保证金 = (初始保证金 + 总追加保证金) × 我方净收取方向; + //存量 x/y 配置(无新区间结构)维持 名义×y 公式不变。 private static List SwapTradeMarginCalculation(RunMarginCalculationReq req) { var result = new List(); - var swapTradeIds = req.tradeList.Where(t => t.ParentTradeId == 0 && t.id > 0).Select(t => t.id).ToList(); - if (swapTradeIds.Count == 0) + var calcTrades = req.tradeList.Where(t => t.ParentTradeId == 0 && t.id > 0).ToList(); + if (calcTrades.Count == 0) { return result; } + var swapTradeIds = calcTrades.Select(t => t.id).ToList(); using (var db = new YLContext()) { - var bindings = db.trade_margin_template.AsNoTracking() - .Where(x => swapTradeIds.Contains(x.TradeId) && x.ValueDate <= req.settleDate) - .ToList(); - if (bindings.Count == 0) - { - return result; - } + //R1 三层级找到即停(BUG-02 修正):交易绑定 → 客户默认 → 全局默认 统一解析, + //只配了客户/全局默认模板的交易与交易级绑定同权生效,与确认书/取数口径一致 + var templatesByTrade = MarginTemplateV2RateHelper.ResolveTieredTemplates(calcTrades, req.settleDate, db); - var templateIds = bindings.Select(x => x.MarginTemplateId).Distinct().ToList(); - var templates = db.margin_template_v2.AsNoTracking() - .Where(x => templateIds.Contains(x.id) && x.IsValid) + //期初腿数据:标的腿(多空)取 期初价格/数量/方向,初始预付金腿取 初始保证金与收取方向 + var swapPositions = db.swap_position.AsNoTracking() + .Where(x => swapTradeIds.Contains(x.SwapTradeId) && !x.Invalid && x.IsInitial) .ToList(); + var positionsByTrade = swapPositions.GroupBy(x => x.SwapTradeId).ToDictionary(g => g.Key, g => g.ToList()); foreach (var trade in req.tradeList) { @@ -401,29 +405,40 @@ namespace YLErp.BLL.MarginCalculation continue; } - var binding = bindings.Where(x => x.TradeId == trade.id).OrderByDescending(x => x.ValueDate).FirstOrDefault(); - var template = binding == null ? null : templates.FirstOrDefault(x => x.id == binding.MarginTemplateId); - if (template == null) + if (!templatesByTrade.TryGetValue(trade.id, out var template)) { continue; } - double rate; + double? margin; if (template.RuleType == (int)MarginRuleTypeEnum.无预付金) { - rate = 0; + margin = 0; } else if (template.RuleType == (int)MarginRuleTypeEnum.区间追保结构) { - var rateResult = MarginTemplateV2RateHelper.GetTradeMarginRate(trade.id, trade.UnderlyingCode, trade.UnderlyingInstrumentType, req.settleDate, db); + var rateResult = MarginTemplateV2RateHelper.GetRateByTemplate(template, trade.UnderlyingCode, trade.UnderlyingInstrumentType, req.settleDate, db); if (rateResult == null) { continue; } - //初始预付金=名义本金×x;持仓预付金=名义本金×y - rate = (double)(req.CalcMarginType == CalcMarginTypeEnum.InitialMargin - ? rateResult.InitRate ?? 0m - : rateResult.MaintainRate ?? 0m); + + var spanCfg = rateResult.Detail?.SpanConfig; + if (SwapSpanMarginCalc.HasSpanConfig(spanCfg)) + { + //方案B新结构:收盘价落档 → 维持保证金 = (初始 + 总追加) × 方向 + margin = CalcSwapSpanMaintenanceMargin(trade, spanCfg, + positionsByTrade.TryGetValue(trade.id, out var legs) ? legs : new List(), + req.CalcMarginType == CalcMarginTypeEnum.InitialMargin, req.settleDate); + } + else + { + //存量 x/y 配置:初始预付金=名义本金×x;持仓预付金=名义本金×y + var rate = (double)(req.CalcMarginType == CalcMarginTypeEnum.InitialMargin + ? rateResult.InitRate ?? 0m + : rateResult.MaintainRate ?? 0m); + margin = rate * trade.StockEqvNotional; + } } else { @@ -431,7 +446,12 @@ namespace YLErp.BLL.MarginCalculation continue; } - var margin = rate * trade.StockEqvNotional; + if (margin == null) + { + //新引擎无法计算(缺标的腿等),不产出 trade_span,与未匹配明细行为一致 + continue; + } + result.Add(new trade_span() { TradeId = trade.id, @@ -442,16 +462,16 @@ namespace YLErp.BLL.MarginCalculation UnderlyingId = trade.UnderlyingId, UnderlyingCode = trade.UnderlyingCode, ValueDate = req.settleDate, - Spv1 = margin, - Spv2 = margin, - Spv3 = margin, - Spv4 = margin, - Spv5 = margin, - Spv6 = margin, - Spv7 = margin, - Spv8 = margin, - Spv = margin, - WorstCastClientPayable = margin, + Spv1 = margin.Value, + Spv2 = margin.Value, + Spv3 = margin.Value, + Spv4 = margin.Value, + Spv5 = margin.Value, + Spv6 = margin.Value, + Spv7 = margin.Value, + Spv8 = margin.Value, + Spv = margin.Value, + WorstCastClientPayable = margin.Value, Margin = 0 }); } @@ -459,5 +479,59 @@ namespace YLErp.BLL.MarginCalculation return result; } + + /// + /// 阶段三 §3.1 新引擎:规则15 方案B配置的维持保证金(DB 胶水层——只负责解析收盘价,计算在 SwapSpanMarginCalc.CalcTradeMaintenanceMargin 纯函数,单测覆盖)。 + /// 收盘价口径:债券取中债估值净价(GetBondPrice 口径 SettlePrice=净价;取 ≤计算日 最近一条,盘中跑用最近已有估值), + /// 指数/ETF取收盘价(InnerGetEodPrice 将日期规整到最近交易日);未取到 → closePrice=0(追加按0、维持=初始),仅记日志不抛错。 + /// + private static double? CalcSwapSpanMaintenanceMargin(trade trade, SpanConfig spanCfg, List legs, bool isInitialCalc, DateTime settleDate) + { + //标的腿(多空):期初价格、数量、客户方向 + var underlyingLeg = legs.FirstOrDefault(x => x.PositionType == (int)PositionTypeFlag.Long || x.PositionType == (int)PositionTypeFlag.Short); + if (underlyingLeg == null || underlyingLeg.PosiGrossPrice <= 0) + { + logger.Info($"【警告】规则15新引擎:交易{trade.id}无有效标的腿(期初价),跳过新公式计算"); + return null; + } + + double closePrice; + if (isInitialCalc) + { + //试算初始不依赖收盘价 + closePrice = 0; + } + else + { + var underlyingCode = !string.IsNullOrEmpty(underlyingLeg.UnderlyingCode) ? underlyingLeg.UnderlyingCode : trade.UnderlyingCode; + var isBond = DataCacheProvider.GetUnderlyingDataSource().GetData(underlyingCode)?.IsBond() ?? false; + if (isBond) + { + if (!EodPriceQueryService.TryGetBondEodPrice(settleDate, underlyingCode, out var bondPrice)) + { + logger.Info($"【警告】规则15新引擎:交易{trade.id}标的{underlyingCode}无债券估值净价,追加保证金按0计(维持=初始)"); + closePrice = 0; + } + else + { + closePrice = bondPrice.GetPrice(SettlementTypeEnum.SettlePrice); + } + } + else + { + if (!EodPriceQueryService.TryGetEodPrice(settleDate, underlyingCode, out var eodPrice)) + { + logger.Info($"【警告】规则15新引擎:交易{trade.id}标的{underlyingCode}无收盘价,追加保证金按0计(维持=初始)"); + closePrice = 0; + } + else + { + closePrice = eodPrice.GetPrice(SettlementTypeEnum.ClosePrice); + } + } + } + + return SwapSpanMarginCalc.CalcTradeMaintenanceMargin(trade.InitialMargin, spanCfg, legs, isInitialCalc, closePrice); + } } } diff --git a/YLErpDAL/Modules/ClientModule/CreditTableService.cs b/YLErpDAL/Modules/ClientModule/CreditTableService.cs index 61697521..8ee1b754 100644 --- a/YLErpDAL/Modules/ClientModule/CreditTableService.cs +++ b/YLErpDAL/Modules/ClientModule/CreditTableService.cs @@ -23,6 +23,11 @@ namespace YLErp.Modules.ClientModule //原始授信值 × 最大授信可用比例(NULL按1) 折算出最终授信值; //OriginalCredit 为空时不触碰 Credit,维持手工授信值(存量兼容) + //比例服务端校验 0-1(BUG-10:此前仅前端校验,绕过前端可存负值或>1放大授信) + if (req.MaxCreditUseRatio.HasValue && (req.MaxCreditUseRatio.Value < 0 || req.MaxCreditUseRatio.Value > 1)) + { + throw new ServiceException("最大授信可用比例须在0-1之间"); + } if (req.OriginalCredit.HasValue) { req.Credit = req.OriginalCredit.Value * (req.MaxCreditUseRatio ?? 1); diff --git a/YLErpDAL/Modules/EodModule/SettlementModule/EodClientBalanceCalc.cs b/YLErpDAL/Modules/EodModule/SettlementModule/EodClientBalanceCalc.cs index cc0200a7..0b09383c 100644 --- a/YLErpDAL/Modules/EodModule/SettlementModule/EodClientBalanceCalc.cs +++ b/YLErpDAL/Modules/EodModule/SettlementModule/EodClientBalanceCalc.cs @@ -37,6 +37,10 @@ namespace YLErp.Modules.EodModule.SettlementModule var balanceDate = _context.SettleDate; var reqClientIds = _context.Request.ClientIds; + //阶段四 §4.1:合约维度(MarginWatchRule==0)规则15 交易按 维持−累计 产生"追加保证金"资金记录(授信优先,幂等)。 + //必须在下方出入金查询之前执行:当日新记录计入当日结算窗口并翻"已结算",重跑时 目标/已补足 不变→同值覆盖,首跑与重跑口径一致 + new SwapAdditionalMarginService(this).SettleAdditionalMargin(balanceDate, reqClientIds?.ToList()); + var clientDb = DbContextFactory.GetClientDbContext(OptUser); var currencyCodes = _context.CurrencyCodes; @@ -654,7 +658,9 @@ namespace YLErp.Modules.EodModule.SettlementModule } } - if (ClientCashInCashOut.系统操作_期权费.Equals(clientEntryexit.Action) || ClientCashInCashOut.系统操作_应付预付金.Equals(clientEntryexit.Action) || ClientCashInCashOut.系统操作_预付金返息.Equals(clientEntryexit.Action)) + if (ClientCashInCashOut.系统操作_期权费.Equals(clientEntryexit.Action) || ClientCashInCashOut.系统操作_应付预付金.Equals(clientEntryexit.Action) || ClientCashInCashOut.系统操作_预付金返息.Equals(clientEntryexit.Action) + //阶段四 §4.1:追加保证金(EOD 结算产生的合约维度追保入金)与应付预付金同口径计入当日资金变动 + || ClientCashInCashOut.系统操作_追加保证金.Equals(clientEntryexit.Action)) { CurrChangeAmount += clientEntryexit.Money ?? 0.0; OptionPremiumSwap += clientEntryexit.Money ?? 0.0; diff --git a/YLErpDAL/Modules/MarginModule/MarginTemplateV2RateHelper.cs b/YLErpDAL/Modules/MarginModule/MarginTemplateV2RateHelper.cs index b19c878e..9a7b854f 100644 --- a/YLErpDAL/Modules/MarginModule/MarginTemplateV2RateHelper.cs +++ b/YLErpDAL/Modules/MarginModule/MarginTemplateV2RateHelper.cs @@ -4,6 +4,8 @@ using System.Linq; using YLErp.BLL; using YLErp.DBModels; using YLErp.Enums; +using YLErp.Helpers; +using YLErp.Models; using YLErp.Modules.UnderlyingModule; namespace YLErp.Modules.MarginModule @@ -15,6 +17,8 @@ namespace YLErp.Modules.MarginModule /// public static class MarginTemplateV2RateHelper { + private static readonly IYcLogger logger = LogFactory.GetLogger("预付金模板取数"); + /// /// 取数结果。率为小数(如 0.1 表示 10%,与数据库一致),调用方直接用。 /// @@ -59,25 +63,29 @@ namespace YLErp.Modules.MarginModule /// /// 按交易绑定的模板取 初始/维持预付金率(调用方传入 DbContext,供批量场景复用连接)。 + /// R1 三层级找到即停(2026-08-21 补齐客户/全局两级):交易绑定(自定义)→ 客户默认(client_margin_template, + /// 按客户或客户等级)→ 全局默认(margin_template_v2 IsDefault&&!IsForClient,适用结构需含收益互换); + /// 三级均未命中返回 null(由调用方兜底,此处留日志:视为无预付金要求)。 /// public static MarginRateResult GetTradeMarginRate(int tradeId, string underlyingCode, string underlyingInstrumentType, DateTime valueDate, YLContext db) { - //1.交易绑定(ValueDate 最新) - var binding = db.trade_margin_template.AsNoTracking() - .Where(x => x.TradeId == tradeId && x.ValueDate <= valueDate) - .OrderByDescending(x => x.ValueDate) - .FirstOrDefault(); - if (binding == null) - { - return null; - } - - var template = db.margin_template_v2.AsNoTracking().FirstOrDefault(x => x.id == binding.MarginTemplateId); - if (template == null || !template.IsValid) + //R1 三层级找到即停:交易绑定(自定义)→ 客户默认 → 全局默认 + var clientId = db.trade.AsNoTracking().Where(t => t.id == tradeId).Select(t => (int?)t.ClientId).FirstOrDefault() ?? 0; + var template = ResolveTieredTemplate(tradeId, clientId, valueDate, db); + if (template == null) { + logger.Info($"【警告】预付金模板取数:交易{tradeId}(客户{clientId})三级(交易绑定/客户默认/全局默认)均未命中有效模板,视为无预付金要求"); return null; } + return GetRateByTemplate(template, underlyingCode, underlyingInstrumentType, valueDate, db); + } + /// + /// 已解析模板的取率(GetTradeMarginRate 去掉三层级解析的部分;引擎批量解析后逐交易调用,避免二次解析)。 + /// 规则非 无预付金/区间追保结构 或明细无匹配行时返回 null(由调用方兜底)。 + /// + public static MarginRateResult GetRateByTemplate(margin_template_v2 template, string underlyingCode, string underlyingInstrumentType, DateTime valueDate, YLContext db) + { //2.无预付金规则:率直接为 0 if (template.RuleType == (int)MarginRuleTypeEnum.无预付金) { @@ -149,6 +157,196 @@ namespace YLErp.Modules.MarginModule }; } + /// + /// R1 模板三层级找到即停解析(public 供集成测试与后续消费方复用): + /// 1.交易绑定 trade_margin_template(自定义/录入时显式选择,ValueDate 最新 ≤ 业务日)——找到即停, + /// 绑定指向已失效模板时同样停止(不向下回退,避免"改绑定失效后悄悄换成别的规则"),返回 null 并留日志; + /// 2.客户默认 client_margin_template(按客户 ClientId 或按客户等级 ClientId=0+ClientLevel, + /// 客户等级经 client.LevelId → clientlevel.LevelName 解析;ValueDate 最新 ≤ 业务日), + /// 模板须有效且适用结构含收益互换; + /// 3.全局默认 margin_template_v2(IsDefault && !IsForClient && IsValid && 适用收益互换,ValueDate 最新)。 + /// + public static margin_template_v2 ResolveTieredTemplate(int? tradeId, int clientId, DateTime valueDate, YLContext db) + { + //1.交易绑定(找到即停) + if (tradeId > 0) + { + var bindingTemplateId = db.trade_margin_template.AsNoTracking() + .Where(x => x.TradeId == tradeId.Value && x.ValueDate <= valueDate) + .OrderByDescending(x => x.ValueDate) + .ThenByDescending(x => x.id) + .Select(x => (int?)x.MarginTemplateId) + .FirstOrDefault(); + if (bindingTemplateId > 0) + { + var bound = db.margin_template_v2.AsNoTracking().FirstOrDefault(x => x.id == bindingTemplateId.Value && x.IsValid); + if (bound != null) + { + return bound; + } + logger.Info($"【警告】预付金模板取数:交易{tradeId}绑定的模板{bindingTemplateId}已失效或不存在,按找到即停不向下回退"); + return null; + } + } + + //2.客户默认(client_margin_template:按客户 或 按客户等级;客户表在独立客户库) + //先按 有效+适用收益互换 过滤再取绑定日期最新——同日并存"互换绑定+期权绑定"(页面互斥只挡适用结构重叠)时, + //若先取绑定再过滤会随机命中期权绑定而漏掉互换绑定、误穿透到全局 + if (clientId > 0) + { + string levelName = null; + using (var clientDb = DbContextFactory.GetClientDbContext(OptUserInfo.SystemUser)) + { + var levelId = clientDb.client.AsNoTracking().Where(c => c.id == clientId).Select(c => c.LevelId).FirstOrDefault(); + if (levelId > 0) + { + levelName = clientDb.clientlevel.AsNoTracking().Where(l => l.id == levelId).Select(l => l.LevelName).FirstOrDefault(); + } + } + + var clientTemplate = (from cmt in db.client_margin_template.AsNoTracking() + join mt in db.margin_template_v2.AsNoTracking() on cmt.MarginTemplateId equals mt.id + where cmt.ValueDate <= valueDate && cmt.MarginTemplateId > 0 + && (cmt.ClientId == clientId || (cmt.ClientId == 0 && cmt.ClientLevel != "" && cmt.ClientLevel == levelName)) + && mt.IsValid && mt.TradeTypes.Contains("收益互换") + orderby cmt.ValueDate descending, cmt.id descending + select mt).FirstOrDefault(); + if (clientTemplate != null) + { + return clientTemplate; + } + } + + //3.全局默认 + return db.margin_template_v2.AsNoTracking() + .Where(x => x.IsDefault && !x.IsForClient && x.IsValid && x.TradeTypes.Contains("收益互换") && x.ValueDate <= valueDate) + .OrderByDescending(x => x.ValueDate) + .ThenByDescending(x => x.id) + .FirstOrDefault(); + } + + /// + /// R1 三层级找到即停解析(批量版,BUG-02 修正:EOD 引擎与结算查询按交易列表解析, + /// 只配了客户默认/全局默认模板的交易与交易级绑定同权生效): + /// 语义与单笔 ResolveTieredTemplate 一致—— + /// 1.交易绑定(ValueDate 最新 ≤ 业务日,同日多条按 id 最新兜底)找到即停,绑定指向失效模板同样停止(不回退); + /// 2.客户默认(client_margin_template 按客户或按客户等级,模板须有效且适用结构含收益互换,先过滤有效再取绑定最新); + /// 3.全局默认(IsDefault&&!IsForClient&&IsValid&&适用收益互换,ValueDate/id 最新)。 + /// 返回 tradeId → 命中模板;未命中或因失效绑定停止的交易不在结果中。 + /// + public static Dictionary ResolveTieredTemplates(List trades, DateTime valueDate, YLContext db) + { + var result = new Dictionary(); + if (trades == null || trades.Count == 0) + { + return result; + } + var tradeIds = trades.Select(t => t.id).Distinct().ToList(); + + //1.交易绑定(批量取回后内存取每交易最新一条,同日多条按 id 最新兜底——与单笔解析一致) + var bindingRows = db.trade_margin_template.AsNoTracking() + .Where(x => tradeIds.Contains(x.TradeId) && x.ValueDate <= valueDate) + .Select(x => new { x.TradeId, x.MarginTemplateId, x.ValueDate, x.id }) + .ToList(); + var bindingByTrade = bindingRows + .GroupBy(x => x.TradeId) + .ToDictionary(g => g.Key, + g => g.OrderByDescending(x => x.ValueDate).ThenByDescending(x => x.id).First().MarginTemplateId); + var boundTemplates = new Dictionary(); + if (bindingByTrade.Values.Distinct().Any()) + { + var boundTemplateIds = bindingByTrade.Values.Distinct().ToList(); + boundTemplates = db.margin_template_v2.AsNoTracking() + .Where(x => boundTemplateIds.Contains(x.id) && x.IsValid) + .ToDictionary(x => x.id); + } + + //2.客户默认(client_margin_template:按客户 或 按客户等级;客户表在独立客户库,等级名批量解析) + var clientIds = trades.Select(t => t.ClientId).Where(c => c > 0).Distinct().ToList(); + var clientTemplateByClient = new Dictionary(); + if (clientIds.Any()) + { + var levelNames = new Dictionary(); + using (var clientDb = DbContextFactory.GetClientDbContext(OptUserInfo.SystemUser)) + { + var clientLevels = clientDb.client.AsNoTracking() + .Where(c => clientIds.Contains(c.id) && c.LevelId > 0) + .Select(c => new { c.id, LevelId = c.LevelId ?? 0 }) + .ToList(); + var levelIds = clientLevels.Select(c => c.LevelId).Distinct().ToList(); + var levelNameById = clientDb.clientlevel.AsNoTracking() + .Where(l => levelIds.Contains(l.id)) + .Select(l => new { l.id, l.LevelName }) + .ToList() + .ToDictionary(l => l.id, l => l.LevelName); + foreach (var c in clientLevels) + { + levelNames[c.id] = levelNameById.TryGetValue(c.LevelId, out var name) ? name : null; + } + } + + //先按 有效+适用收益互换 过滤再按绑定取最新(与单笔解析一致,同日互换/期权并存不误穿透) + var clientBindingRows = (from cmt in db.client_margin_template.AsNoTracking() + join mt in db.margin_template_v2.AsNoTracking() on cmt.MarginTemplateId equals mt.id + where cmt.ValueDate <= valueDate && cmt.MarginTemplateId > 0 + && (clientIds.Contains(cmt.ClientId) || (cmt.ClientId == 0 && cmt.ClientLevel != "")) + && mt.IsValid && mt.TradeTypes.Contains("收益互换") + orderby cmt.ValueDate descending, cmt.id descending + select new { cmt.ClientId, cmt.ClientLevel, Template = mt }).ToList(); + foreach (var clientId in clientIds) + { + var levelName = levelNames.TryGetValue(clientId, out var name) ? name : null; + //每客户找到即停:按客户 或 按客户等级 同查询取绑定最新(与单笔解析一致; + //直接绑定与等级绑定并存的优先级方案未定义(BUG-16),维持"绑定日期最新"现状不改语义) + var hit = clientBindingRows + .FirstOrDefault(r => r.ClientId == clientId || (r.ClientId == 0 && r.ClientLevel == levelName)); + if (hit != null) + { + clientTemplateByClient[clientId] = hit.Template; + } + } + } + + //3.全局默认(全部交易共享,一次查询) + var globalDefault = db.margin_template_v2.AsNoTracking() + .Where(x => x.IsDefault && !x.IsForClient && x.IsValid && x.TradeTypes.Contains("收益互换") && x.ValueDate <= valueDate) + .OrderByDescending(x => x.ValueDate) + .ThenByDescending(x => x.id) + .FirstOrDefault(); + + foreach (var trade in trades) + { + if (result.ContainsKey(trade.id)) + { + continue; + } + if (bindingByTrade.TryGetValue(trade.id, out var templateId)) + { + //一级找到即停:绑定指向失效模板时不向下回退(与单笔解析一致,仅留日志) + if (boundTemplates.TryGetValue(templateId, out var bound)) + { + result[trade.id] = bound; + } + else + { + logger.Info($"【警告】预付金模板取数:交易{trade.id}绑定的模板{templateId}已失效或不存在,按找到即停不向下回退"); + } + continue; + } + if (clientTemplateByClient.TryGetValue(trade.ClientId, out var clientTemplate)) + { + result[trade.id] = clientTemplate; + continue; + } + if (globalDefault != null) + { + result[trade.id] = globalDefault; + } + } + + return result; + } + /// /// 标的细分分类判定钩子(转债ETF/科创债ETF/中债指数等),public 供单元测试回归默认行为。 /// 本期默认返回 null → 走通配行兜底,行为与现状一致;后续需求按业务给的判定规则(代码段/标的维护字段)实现。 diff --git a/YLErpDAL/Modules/ReportModule/SettlementReportModule/FundReportModel.cs b/YLErpDAL/Modules/ReportModule/SettlementReportModule/FundReportModel.cs index a24c42a6..9cb766ab 100644 --- a/YLErpDAL/Modules/ReportModule/SettlementReportModule/FundReportModel.cs +++ b/YLErpDAL/Modules/ReportModule/SettlementReportModule/FundReportModel.cs @@ -319,6 +319,19 @@ namespace YLErp.Modules.ReportModule } } + /// + /// 已使用授信(R2 阶段三 §3.2):授信出入表 Σ(amount) + /// + public double? UsedCredit { get; set; } + + public string UsedCreditString + { + get + { + return ((UsedCredit == null || UsedCredit == -0) ? 0 : UsedCredit.Value).ToString("0.00"); + } + } + /// /// 授信占用 /// diff --git a/YLErpDAL/Modules/ReportModule/SettlementReportModule/SettlementReportService.cs b/YLErpDAL/Modules/ReportModule/SettlementReportModule/SettlementReportService.cs index f8de8f79..85c0de5c 100644 --- a/YLErpDAL/Modules/ReportModule/SettlementReportModule/SettlementReportService.cs +++ b/YLErpDAL/Modules/ReportModule/SettlementReportModule/SettlementReportService.cs @@ -107,6 +107,10 @@ namespace YLErp.Modules.ReportModule AvailableFund = clientBalance?.AvailableAmount ?? 0, TotalMargin = clientBalance?.TotalMarginTotal, Credit = clientBalance?.TotalCredit ?? 0, + UsedCredit = clientBalance?.UsedCredit ?? 0, + //阶段三 §3.2 R2 口径:初始保证金=应付预付金流水净额(净收取为正);维持保证金=client_span 维持保证金(−MySideMargin) + MySideMargin = clientBalance?.SwapInitMargin ?? 0, + MaintenanceMargin = -(clientBalance?.MySideMargin ?? 0), CreditRatio = clientBalance?.CreditUsed ?? 0, Margin = clientBalance?.MarginByPayableMarginTotal ?? 0, Amount = (PS.Config.IsPVRounded ? clientBalance?.RoundedTotalAmountTotal : clientBalance?.TotalAmountTotal) ?? 0, @@ -408,6 +412,10 @@ namespace YLErp.Modules.ReportModule case "预付金占用": return report.FundReportModel.WorstCastClientPayableString; case "可用资金": return report.FundReportModel.AvailableFundString; case "授信额度": return report.FundReportModel.CreditString; + //阶段三 §3.2 R2 估值报告字段口径 + case "已使用授信": return report.FundReportModel.UsedCreditString; + case "初始保证金金额": return report.FundReportModel.MySideMarginString; + case "维持保证金金额": return report.FundReportModel.MaintenanceMarginString; case "应付了结交易款": return report.FundReportModel.ClosedTradePayableFundString; case "应付存续交易款": return report.FundReportModel.PositionTradePayableFundString; case "存续交易期权费净额": return report.FundReportModel.PositionPremiumNetCashString; diff --git a/YLErpDAL/Modules/SwapModule/ClientCreditInoutService.cs b/YLErpDAL/Modules/SwapModule/ClientCreditInoutService.cs index 02db251a..4af6dfa0 100644 --- a/YLErpDAL/Modules/SwapModule/ClientCreditInoutService.cs +++ b/YLErpDAL/Modules/SwapModule/ClientCreditInoutService.cs @@ -6,12 +6,20 @@ namespace YLErp.Modules.SwapModule /// /// 客户授信出入服务(R4 授信/现金标签体系,决策④)。 /// 授信出入表的写入口集中在 标签赋值(占用)与 平仓/到期返还(释放)两个链路内,禁止散落调用。 - /// 金额符号口径(2026-08-20 业务确认):与资金流水同号——入金为负、出金为正; - /// 已使用授信 = SUM(amount) 直接求和(入金使已使用授信下降、可用授信=有效授信−已使用授信 上升;出金反之)。 + /// 金额符号口径(BUG-01 修正,2026-08-24):**占用记正数、释放记负数**(与 §2.2 原表"占用/调整为正"一致)—— + /// 已使用授信 = SUM(amount) 直接求和(占用使其上升、释放使其回落),可用授信 = 有效授信 − 已使用授信(占用使其收缩)。 + /// 2026-08-20 曾裁定"与资金流水同号(入金负/出金正)"——但授信不入资金流水(§2.1),同号对齐的对手方记录并不存在, + /// 且该口径下"占用越多已使用授信越负、可用授信越大"(越占越多),已废弃;历史同号行由迁移脚本翻符号。 /// change_type(占用/释放/调整)仅作分类审计,不参与求和方向。 /// public class ClientCreditInoutService : YLBaseService { + /// + /// 追加保证金相关授信出入记录的 remark 前缀(阶段四 §4.1 EOD 占用写入"追加保证金占用"; + /// 累计保证金/缺口口径与清理链路以此识别,后续如写释放类记录沿用同一前缀)。 + /// + public const string AdditionalMarginRemark = "追加保证金"; + public ClientCreditInoutService(OptUserInfo userInfo) : base(userInfo) { } @@ -21,9 +29,19 @@ namespace YLErp.Modules.SwapModule } /// - /// 客户已使用授信 = SUM(amount)(金额与资金流水同号:入金负、出金正)。 - /// 可用授信 = 有效授信 − 本值;入金使可用授信增长、出金使其收缩。 - /// 供交易确认校验(RealtimePnlCalc.TradeCanBeConfirm)等静态上下文直接调用。 + /// 是否追加保证金相关记录(remark 前缀标识,阶段四 EOD 写入)。 + /// + public static bool IsAdditionalMarginRecord(client_credit_inout record) + { + return record != null && !string.IsNullOrEmpty(record.remark) + && record.remark.StartsWith(AdditionalMarginRemark); + } + + /// + /// 客户已使用授信 = SUM(amount)(占用为正、释放为负)。 + /// 可用授信 = 有效授信 − 本值:占用使其收缩、释放使其回升。 + /// 供交易确认校验(RealtimePnlCalc.TradeCanBeConfirm)、拆单额度(GetAvailableCredit)、 + /// 估值报告可用资金(阶段三 §3.2)等消费方统一调用。 /// public static double GetUsedCredit(int clientId, YLContext db) { @@ -40,8 +58,8 @@ namespace YLErp.Modules.SwapModule } /// - /// 写入一条授信变化记录(占用/释放/调整统一入口),并冗余记录变更后已使用授信。 - /// amount 带符号:入金(客户付)为负、出金(客户收)为正,与资金流水 Money 同号。 + /// 写入一条授信变化记录(统一入口),并冗余记录变更后已使用授信。 + /// amount 带符号直写(人工调整类可正可负);占用/释放请走 Occupy/Release 语义入口(内部定方向)。 /// public client_credit_inout Record(int clientId, long? positionId, int? tradeId, int changeType, double amount, DateTime happenDate, string remark) @@ -51,7 +69,7 @@ namespace YLErp.Modules.SwapModule return null; } var amountRounded = Math.Round(amount, 2, MidpointRounding.AwayFromZero); - //变更后已使用授信快照:直接求和口径(符号已含方向) + //变更后已使用授信快照:直接求和口径(占用正/释放负,符号已含方向) var usedAfter = Math.Round(GetUsedCredit(clientId) + amountRounded, 2, MidpointRounding.AwayFromZero); var record = new client_credit_inout { @@ -74,31 +92,37 @@ namespace YLErp.Modules.SwapModule /// /// 占用:预付金腿标 Credit 的簿记入金(含拆单的授信部分;交易级占用 positionId 为空)。 - /// amount 传负数(入金方向,与资金流水同号)。 + /// amount 传正数(占用数量),内部记正——已使用授信随之上升。 /// public client_credit_inout Occupy(int clientId, long? positionId, int tradeId, double amount, DateTime happenDate, string remark) { - return Record(clientId, positionId, tradeId, client_credit_inout.ChangeTypeOccupy, amount, happenDate, remark); + return Record(clientId, positionId, tradeId, client_credit_inout.ChangeTypeOccupy, Math.Abs(amount), happenDate, remark); } /// /// 释放:平仓/到期按原标签返还授信部分(按腿的 position_id 匹配原占用记录)。 - /// amount 传正数(出金方向,与资金流水同号)。 + /// amount 传正数(释放数量),内部记负——已使用授信随之回落。 /// public client_credit_inout Release(int clientId, long? positionId, int tradeId, double amount, DateTime happenDate, string remark) { - return Record(clientId, positionId, tradeId, client_credit_inout.ChangeTypeRelease, amount, happenDate, remark); + return Record(clientId, positionId, tradeId, client_credit_inout.ChangeTypeRelease, -Math.Abs(amount), happenDate, remark); } /// - /// 删除某交易的全部授信出入记录:与资金记录同生命周期—— + /// 删除某交易的授信出入记录:与资金记录同生命周期—— /// 交易回退到开仓(DeleteTradeCashInCashOut)、修改清除(ClearSwapPositions)、删除交易时同步清理, /// 重新确认/重补时按最新标签重写,避免占用悬挂。 - /// 注意:阶段四追加保证金占用落地后,此处需区分保留追加部分。 + /// keepAdditionalMargin=true 时保留追加保证金部分(阶段四落地):重确认自愈/修改清除只重写 应付预付金 相关占用, + /// 追加部分由 EOD 幂等维护(该两处不删除追加资金记录,授信占用须同步保留,否则已使用授信被低估); + /// 删除交易(资金记录全删)传 false 全清。 /// - public void RemoveByTrade(int tradeId) + public void RemoveByTrade(int tradeId, bool keepAdditionalMargin = false) { var records = DbContext.client_credit_inout.Where(x => x.trade_id == tradeId).ToList(); + if (keepAdditionalMargin) + { + records = records.Where(x => !IsAdditionalMarginRecord(x)).ToList(); + } DbContext.client_credit_inout.RemoveRange(records); } diff --git a/YLErpDAL/Modules/SwapModule/Margin/SwapAdditionalMarginCalc.cs b/YLErpDAL/Modules/SwapModule/Margin/SwapAdditionalMarginCalc.cs new file mode 100644 index 00000000..006efbb2 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/Margin/SwapAdditionalMarginCalc.cs @@ -0,0 +1,43 @@ +namespace YLErp.Modules.SwapModule.Margin +{ + /// + /// R3 阶段四 §4.1 合约维度追加保证金的纯函数(金额判定与授信/现金分配,DB 胶水在 SwapAdditionalMarginService)。 + /// 口径(实现方案 §0 基线 + 需求拆分 R3): + /// - 累计保证金(净收取为正)= 该交易 应付预付金+追加保证金 资金记录收付净额 + 追加保证金的授信占用部分(授信不进资金、无流水); + /// - 目标追加保证金(累计到当日)= 维持保证金 − 应付预付金净收额,≤0 时为 0(追保回落不返还,负缺口在可用资金公式体现); + /// - 已补足额 = 追加资金记录累计值(单条 upsert 存累计值,Money 为负)+ 授信占用累计(amount 为负); + /// - 当日新增 = 目标 − 已补足,>0 时按阶段二 §2.3 授信优先逻辑分配:授信部分只写授信出入表,差额走资金记录。 + /// 幂等:同一结算日重跑时 目标/已补足 均不变 → 新增=0,不产生任何写入。 + /// + public static class SwapAdditionalMarginCalc + { + /// + /// 目标追加保证金(累计到当日,净收取为正)= 维持保证金 − 应付预付金净收额;差值 ≤0 返回 0(无需追加; + /// 维持为负=我方净支付方向,同样不追)。 + /// + public static double CalcTarget(double maintenanceMargin, double payableMarginNet) + { + return Round(Math.Max(maintenanceMargin - payableMarginNet, 0)); + } + + /// + /// 当日新增追加保证金的授信/现金分配(阶段二 §2.3 授信优先的交易级版本): + /// 授信部分 = min(新增, 剩余可用授信),差额为现金部分;新增 ≤0 时两者皆 0(不产生)。 + /// + public static (double CreditAmount, double CashAmount) Allocate(double increment, double creditAvailable) + { + increment = Round(increment); + if (increment <= 0) + { + return (0, 0); + } + var credit = Math.Min(increment, Round(Math.Max(creditAvailable, 0))); + return (credit, Round(increment - credit)); + } + + private static double Round(double value) + { + return Math.Round(value, 2, MidpointRounding.AwayFromZero); + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/Margin/SwapAdditionalMarginService.cs b/YLErpDAL/Modules/SwapModule/Margin/SwapAdditionalMarginService.cs new file mode 100644 index 00000000..08e26da3 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/Margin/SwapAdditionalMarginService.cs @@ -0,0 +1,163 @@ +using YLErp.BLL; +using YLErp.DBModels; +using YLErp.Enums; +using YLErp.Modules.MarginModule; +using YLErp.Modules.TradeModule; + +namespace YLErp.Modules.SwapModule.Margin +{ + /// + /// R3 阶段四 §4.1:合约维度(MarginWatchRule==0)规则15 交易日终结算产生"追加保证金"资金记录。 + /// 交易维度追加保证金 = 维持保证金(阶段三引擎 trade_span 产出)− 累计保证金(应付预付金+追加保证金 流水净额 + 追加授信占用); + /// 现金部分为逐结算日增量记录(BUG-03 修正:每结算日一条、Money=−increment,键 TradeId+Action+Deal+HappenDate 幂等), + /// 需求上升只增不减;授信优先(阶段二规则):授信部分只写授信出入表(remark 前缀=追加保证金,position_id 空、冗余 trade_id)。 + /// 由 EOD 在客户资金计算之前调用:当日新记录计入当日出入金窗口并翻"已结算",重跑时 目标/已补足 不变 → 新增为 0 不重复写。 + /// 客户维度(MarginWatchRule=1/NULL)不产生资金记录(§0 占用口径),不在本服务范围。 + /// + public class SwapAdditionalMarginService : YLBaseService + { + public SwapAdditionalMarginService(OptUserInfo userInfo) : base(userInfo) + { + } + + public SwapAdditionalMarginService(YLBaseService baseService) : base(baseService) + { + } + + /// + /// 结算日逐客户逐交易产生追加保证金(clientFilter 为部分结算的客户过滤,与 EOD 请求一致)。 + /// + public void SettleAdditionalMargin(DateTime settleDate, List clientFilter = null) + { + //合约维度盯市客户 + var watchClientIds = DbContextFactory.GetClientDbContext(OptUser).client.AsNoTracking() + .Where(t => t.ProcessStatus != "未提交" && t.MarginWatchRule == 0) + .Select(t => t.id) + .ToList(); + if (clientFilter != null && clientFilter.Any()) + { + watchClientIds = watchClientIds.Where(t => clientFilter.Contains(t)).ToList(); + } + if (watchClientIds.Count == 0) + { + return; + } + + //存续中的互换交易(状态口径与 eodSwapQuery 一致,含当日已了结) + var tradeStatuses = ConsTrade.TradeStatusAfterConfirmed; + var trades = DbContext.trade.AsNoTracking() + .Where(t => tradeStatuses.Contains(t.TradeStatus) + && t.ValidState != ConsGlobal.InValid + && t.TradeType == "收益互换" + && t.TradeDate <= settleDate + && watchClientIds.Contains(t.ClientId)) + .ToList(); + if (trades.Count == 0) + { + return; + } + + //规则15(区间追保结构)交易:R1 三层级解析(BUG-02 修正,与引擎/确认书同口径)—— + //交易绑定→客户默认→全局默认 找到即停,只配客户/全局默认模板的交易同样纳入追保结算 + var templatesByTrade = MarginTemplateV2RateHelper.ResolveTieredTemplates(trades, settleDate, DbContext); + trades = trades.Where(t => templatesByTrade.TryGetValue(t.id, out var tpl) + && tpl.RuleType == (int)MarginRuleTypeEnum.区间追保结构).ToList(); + var tradeIds = trades.Select(t => t.id).ToList(); + if (tradeIds.Count == 0) + { + return; + } + + //当日维持保证金(引擎产出,我方净收取为正),按交易合计(与 SwapSpanBalanceQueryService 缺口口径一致) + var maintenanceByTrade = DbContext.trade_span.AsNoTracking() + .Where(x => x.ValueDate == settleDate && tradeIds.Contains(x.TradeId) && x.Spv != null) + .GroupBy(x => x.TradeId) + .Select(g => new { TradeId = g.Key, Spv = g.Sum(x => x.Spv ?? 0d) }) + .ToDictionary(x => x.TradeId, x => x.Spv); + + //应付预付金净收额(客户付钱记负 → 取反为正;平仓返还自动冲减;口径与 EOD canonical 一致:非作废+已确认/已结算) + var payableNetByTrade = DbContext.ClientCashInCashOut.AsNoTracking() + .Where(x => x.TradeId != null && tradeIds.Contains(x.TradeId ?? 0) + && x.Action == ClientCashInCashOut.系统操作_应付预付金 + && x.HappenDate <= settleDate + && x.ValidState != ConsGlobal.InValid + && (x.State == ClientCashInCashOut.已确认 || x.State == ClientCashInCashOut.已结算) + && x.Money != null) + .GroupBy(x => x.TradeId) + .Select(g => new { TradeId = g.Key ?? 0, Sum = -g.Sum(x => x.Money ?? 0d) }) + .ToDictionary(x => x.TradeId, x => x.Sum); + + //追加保证金资金记录累计值(逐日增量记录求和即累计,BUG-03;Deal=0 交易级,口径与 EOD canonical 一致:非作废+已确认/已结算) + var addRecordByTrade = DbContext.ClientCashInCashOut.AsNoTracking() + .Where(x => x.TradeId != null && tradeIds.Contains(x.TradeId ?? 0) + && x.Action == ClientCashInCashOut.系统操作_追加保证金 + && x.ValidState != ConsGlobal.InValid + && (x.State == ClientCashInCashOut.已确认 || x.State == ClientCashInCashOut.已结算) + && x.Deal == 0 + && x.Money != null) + .GroupBy(x => x.TradeId) + .Select(g => new { TradeId = g.Key ?? 0, Funded = -g.Sum(x => x.Money ?? 0d) }) + .ToDictionary(x => x.TradeId, x => x.Funded); + + //追加保证金授信占用累计(amount 占用记正 → 直接求和(BUG-01 修正口径);remark 前缀标识,见 ClientCreditInoutService) + var addCreditByTrade = DbContext.client_credit_inout.AsNoTracking() + .Where(x => x.trade_id != null && tradeIds.Contains(x.trade_id ?? 0) + && x.remark.StartsWith(ClientCreditInoutService.AdditionalMarginRemark)) + .GroupBy(x => x.trade_id) + .Select(g => new { TradeId = g.Key ?? 0, Funded = g.Sum(x => x.amount) }) + .ToDictionary(x => x.TradeId, x => x.Funded); + + var fundTagService = new SwapFundTagService(this); + var cashService = new ClientCashInCashOutService(this); + var creditService = new ClientCreditInoutService(this); + //客户剩余可用授信逐笔扣减缓存(同一次结算内多笔追加按顺序消耗额度,与阶段二逐腿分配同语义) + var creditRemaining = new Dictionary(); + + foreach (var clientGroup in trades.GroupBy(t => t.ClientId).OrderBy(g => g.Key)) + { + foreach (var td in clientGroup.OrderBy(t => t.id)) + { + if (!maintenanceByTrade.TryGetValue(td.id, out var maintenance) || maintenance <= 0) + { + continue; + } + var target = SwapAdditionalMarginCalc.CalcTarget(maintenance, + payableNetByTrade.TryGetValue(td.id, out var payableNet) ? payableNet : 0); + if (target <= 0) + { + continue; + } + var fundedCash = addRecordByTrade.TryGetValue(td.id, out var cash) ? cash : 0; + var fundedCredit = addCreditByTrade.TryGetValue(td.id, out var credit) ? credit : 0; + var increment = Math.Round(target - fundedCash - fundedCredit, 2, MidpointRounding.AwayFromZero); + if (increment <= 0) + { + //已补足;追保回落(目标下降)不返还——负缺口在可用资金公式(Σ维持−累计)体现 + continue; + } + + if (!creditRemaining.TryGetValue(td.ClientId, out var remain)) + { + remain = fundTagService.GetAvailableCredit(td.ClientId, settleDate); + creditRemaining[td.ClientId] = remain; + } + var (creditPart, cashPart) = SwapAdditionalMarginCalc.Allocate(increment, remain); + if (creditPart > 0) + { + //授信部分不产生资金流水,只写授信出入表占用(占用记正数,BUG-01 修正口径) + creditService.Occupy(td.ClientId, null, td.id, creditPart, settleDate, + ClientCreditInoutService.AdditionalMarginRemark + "占用"); + creditRemaining[td.ClientId] = Math.Round(remain - creditPart, 2, MidpointRounding.AwayFromZero); + } + if (cashPart > 0) + { + //现金部分按结算日逐笔增量记录(BUG-03 修正:每结算日一条、Money=−increment、键含日期幂等), + //避免单条累计值覆盖 + HappenDate 前移使 EOD 差分窗口跨日全额重复计入;负数=客户应付追加 + cashService.SaveSwapTradeClientCash(td, -cashPart, settleDate, 0, + ClientCashInCashOut.系统操作_追加保证金, matchDate: true); + } + } + } + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/Margin/SwapSpanBalanceCalc.cs b/YLErpDAL/Modules/SwapModule/Margin/SwapSpanBalanceCalc.cs new file mode 100644 index 00000000..c42a12da --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/Margin/SwapSpanBalanceCalc.cs @@ -0,0 +1,58 @@ +namespace YLErp.Modules.SwapModule.Margin +{ + /// + /// R2 阶段三 §3.2 可用资金公式(需求拆分 R2 口径,实时/EOD/报告三处共用保证一致)。 + /// 授信额度取 credit.Credit 合计(阶段一 §1.1 已在保存时折算 原始授信值×最大授信可用比例,消费方不再乘比例); + /// 现金结存 = 期末结存 AmountFund(阶段二起授信不进资金,流水天然不含授信部分,无需排除); + /// 已使用授信 = 授信出入表 Σ(amount); + /// 初始保证金(净收取为正)= 应付预付金流水收付净额(平仓返还自动冲减); + /// 维持保证金(净收取为正)= −MySideMargin(client_span 维持保证金写入 trade_span 后经 CalcClientMargin 反号聚合)。 + /// 公式整体待业务校验(EQD-6948),参数化集中在此便于校验后调整。 + /// + public static class SwapSpanBalanceCalc + { + /// + /// 可用资金(客户维度,MarginWatchRule=1): + /// Max(现金结存 + 授信额度 − 已使用授信 + 初始保证金 − 维持保证金, 0)(现金结存已扣初始保证金,故加回)。 + /// maintenanceMargin/initialMargin 均为净收取为正;未配置维度的存量客户不走本公式(调用方保证)。 + /// + public static double CalcClientDimensionAvailable(double cashBalance, double totalCredit, double usedCredit, + double initialMargin, double maintenanceMargin) + { + return Math.Max(cashBalance + totalCredit - usedCredit + initialMargin - maintenanceMargin, 0); + } + + /// + /// 可用资金(合约维度,MarginWatchRule=0): + /// Max(现金结存 + 授信额度 − 已使用授信 − 交易维度追加保证金合计, 0); + /// 交易维度追加保证金合计 = Σ(维持保证金 − 累计保证金)(阶段三:累计=应付预付金净额;阶段四含追加保证金流水)。 + /// + public static double CalcContractDimensionAvailable(double cashBalance, double totalCredit, double usedCredit, + double tradeAdditionalMarginSum) + { + return Math.Max(cashBalance + totalCredit - usedCredit - tradeAdditionalMarginSum, 0); + } + + /// + /// 追保金额(客户维度,MarginWatchRule=1,阶段四 §4.2 双向——不以 0 截断): + /// (维持保证金 − 初始保证金) − (现金结存 + 授信额度 − 已使用授信),正=需追保、负=可返还。 + /// 即客户维度可用资金公式的反向值(去 Max 截断)——与需求原文"现金结存+授信额度−(维持−初始)"数值互为相反数, + /// 此处按 MarginByPayableMarginTotal 字段既有口径(正数=应追加,估值报告"应追加预付金X元")定向。 + /// + public static double CalcClientDimensionCallMargin(double cashBalance, double totalCredit, double usedCredit, + double initialMargin, double maintenanceMargin) + { + return Math.Round(maintenanceMargin - initialMargin - (cashBalance + totalCredit - usedCredit), 2, MidpointRounding.AwayFromZero); + } + + /// + /// 追保金额(合约维度,MarginWatchRule=0,阶段四 §4.2):−(现金结存 + 授信额度 − 已使用授信)。 + /// 需求原文公式为 现金结存+授信额度−已使用授信(与"交易维度追加保证金"的关系为开放问题2,两值均有产出), + /// 此处取负对齐字段"正数=应追加"口径:账户透支(现金+授信不足)为正=应补足,盈余为负。 + /// + public static double CalcContractDimensionCallMargin(double cashBalance, double totalCredit, double usedCredit) + { + return Math.Round(-(cashBalance + totalCredit - usedCredit), 2, MidpointRounding.AwayFromZero); + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/Margin/SwapSpanBalanceQueryService.cs b/YLErpDAL/Modules/SwapModule/Margin/SwapSpanBalanceQueryService.cs new file mode 100644 index 00000000..a599469b --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/Margin/SwapSpanBalanceQueryService.cs @@ -0,0 +1,115 @@ +using YLErp.BLL; +using YLErp.DBModels; +using YLErp.Enums; +using YLErp.Modules.MarginModule; + +namespace YLErp.Modules.SwapModule.Margin +{ + /// + /// R2 阶段三 §3.2 估值报告/可用资金查询输入(静态查询,供 ClientBalanceUtility 与 RealTimeClientBanlanceService 共用,保证三处口径一致)。 + /// 口径: + /// - 互换初始保证金(净收取为正)= 客户 应付预付金 流水收付净额取反(客户应付入金记负、平仓返还为正,取负号后净收取为正); + /// - 交易维度追加保证金(合约维度)= Σ(维持保证金 − 累计保证金): + /// 维持保证金取当日 trade_span.Spv(区间追保结构引擎产出,我方净收取为正); + /// 累计保证金 = 该交易 应付预付金+追加保证金 流水收付净额 + 追加保证金授信占用净额(§0 口径,阶段四 §4.1 起); + /// 仅统计规则15(区间追保结构,R1 三层级解析,与引擎同口径)且有当日 trade_span 的交易。 + /// + public static class SwapSpanBalanceQueryService + { + /// + /// 客户维度输入:互换初始保证金(净收取为正,按客户汇总)。 + /// + public static Dictionary GetSwapInitMarginByClients(List clientIds, DateTime valueDate, YLContext db) + { + if (clientIds == null || clientIds.Count == 0) + { + return new Dictionary(); + } + + var flows = db.ClientCashInCashOut.AsNoTracking() + .Where(x => clientIds.Contains(x.ClientId ?? 0) + && x.Action == ClientCashInCashOut.系统操作_应付预付金 + && x.HappenDate <= valueDate + && x.ValidState != ConsGlobal.InValid + && (x.State == ClientCashInCashOut.已确认 || x.State == ClientCashInCashOut.已结算) + && x.Money != null) + .Select(x => new { ClientId = x.ClientId ?? 0, Money = x.Money ?? 0d }) + .ToList(); + + return flows.GroupBy(x => x.ClientId) + .ToDictionary(g => g.Key, g => -g.Sum(x => x.Money)); + } + + /// + /// 合约维度输入:交易维度追加保证金合计 = Σ(维持保证金 − 累计保证金),按客户汇总。 + /// + public static Dictionary GetTradeAdditionalMarginByClients(List clientIds, DateTime valueDate, YLContext db) + { + var result = new Dictionary(); + if (clientIds == null || clientIds.Count == 0) + { + return result; + } + + //当日维持保证金(引擎产出:我方净收取为正)——先取当日有 span 的客户交易,再按规则15过滤 + var maintenance = db.trade_span.AsNoTracking() + .Where(x => x.ValueDate == valueDate && x.Spv != null + && x.ClientId != null && clientIds.Contains(x.ClientId.Value)) + .Select(x => new { x.TradeId, x.ClientId, Spv = x.Spv ?? 0d }) + .ToList(); + if (maintenance.Count == 0) + { + return result; + } + var spanTradeIds = maintenance.Select(x => x.TradeId).Distinct().ToList(); + + //规则15(区间追保结构)交易:R1 三层级解析(BUG-02 修正,与引擎/结算判定同口径)—— + //交易绑定→客户默认→全局默认 找到即停;无预付金等其他规则产出/留存的 span 行不计入追保缺口 + var spanTrades = db.trade.AsNoTracking().Where(t => spanTradeIds.Contains(t.id)).ToList(); + var templatesByTrade = MarginTemplateV2RateHelper.ResolveTieredTemplates(spanTrades, valueDate, db); + var rule15TradeIds = spanTrades + .Where(t => templatesByTrade.TryGetValue(t.id, out var tpl) + && tpl.RuleType == (int)MarginRuleTypeEnum.区间追保结构) + .Select(t => t.id) + .ToHashSet(); + maintenance = maintenance.Where(x => rule15TradeIds.Contains(x.TradeId)).ToList(); + if (maintenance.Count == 0) + { + return result; + } + + //累计保证金:该交易 应付预付金+追加保证金 流水收付净额取反(收取为正); + //追加保证金的授信占用部分不产生资金流水(阶段二口径),阶段四 §4.1 起一并计入—— + //否则结算后缺口残留(=授信部分),与可用资金公式里的 −已使用授信 形成双扣 + var marginActions = new List { ClientCashInCashOut.系统操作_应付预付金, ClientCashInCashOut.系统操作_追加保证金 }; + var accumulated = db.ClientCashInCashOut.AsNoTracking() + .Where(x => x.TradeId != null && spanTradeIds.Contains(x.TradeId ?? 0) + && marginActions.Contains(x.Action) + && x.HappenDate <= valueDate + && x.ValidState != ConsGlobal.InValid + && (x.State == ClientCashInCashOut.已确认 || x.State == ClientCashInCashOut.已结算) + && x.Money != null) + .GroupBy(x => x.TradeId) + .Select(g => new { TradeId = g.Key ?? 0, Sum = -g.Sum(x => x.Money ?? 0d) }) + .ToDictionary(x => x.TradeId, x => x.Sum); + + //追加保证金的授信占用净额(amount 占用记正 → 直接求和(BUG-01 修正口径);remark 前缀标识) + var addCreditOccupied = db.client_credit_inout.AsNoTracking() + .Where(x => x.trade_id != null && spanTradeIds.Contains(x.trade_id ?? 0) + && x.remark.StartsWith(ClientCreditInoutService.AdditionalMarginRemark)) + .GroupBy(x => x.trade_id) + .Select(g => new { TradeId = g.Key ?? 0, Sum = g.Sum(x => x.amount) }) + .ToDictionary(x => x.TradeId, x => x.Sum); + + foreach (var group in maintenance.GroupBy(x => x.ClientId ?? 0)) + { + var total = group.Sum(x => x.Spv + - (accumulated.TryGetValue(x.TradeId, out var acc) ? acc : 0d) + - (addCreditOccupied.TryGetValue(x.TradeId, out var occupied) ? occupied : 0d)); + result[group.Key] = total; + } + + return result; + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/Margin/SwapSpanMarginCalc.cs b/YLErpDAL/Modules/SwapModule/Margin/SwapSpanMarginCalc.cs new file mode 100644 index 00000000..93c2155c --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/Margin/SwapSpanMarginCalc.cs @@ -0,0 +1,159 @@ +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule.Margin +{ + /// + /// R2 区间追保结构(规则15)维持保证金纯函数计算(实现方案阶段三 §3.1)。 + /// 数据来源约定(与阶段一 SpanConfig 结构、docx 确认书模板一致): + /// - 多头(客户看多):第1层 [Lower, +∞)(上不封顶),第n层 [Lower, Upper);各层 Lower 逐层严格递减; + /// - 空头(客户看空):第1层 (−∞, Upper](下不设限),第n层 (Lower, Upper];各层 Upper 逐层严格递增; + /// - 区间边界为"×期初净价的百分比"小数(0.95=95%),价格口径:债券用净价,指数/ETF 用收盘价; + /// - AmountRate 为累计到该层的追保金额比例(与确认书追保表每行"合计追保金额"同口径,直取不求和), + /// 追保金额 = AmountRate × 期初全价 × 券面总额(债券)/ × 期初价格 × 名义份额(指数/ETF); + /// - 维持保证金 = (初始保证金 + 总追加保证金) × 方向(我方净收取 +1 / 净支付 −1); + /// - 价格跌破最深一层(平仓线外)按最深层计(追保金额不再上升)。 + /// 公式参数化(EQD-6948 待业务校验),所有口径集中在本类便于校验后调整。 + /// + public static class SwapSpanMarginCalc + { + /// + /// 判断 SpanConfig 是否已按方案B录入新区间结构(任一方向有边界/比例或预警/平仓线)。 + /// 全空视为存量 x/y 配置,引擎回落旧 名义×y 公式。 + /// + public static bool HasSpanConfig(SpanConfig cfg) + { + if (cfg == null) + { + return false; + } + return cfg.WarnLine.HasValue || cfg.CloseLine.HasValue + || HasTierValue(cfg.LongSpans) || HasTierValue(cfg.ShortSpans); + } + + private static bool HasTierValue(List tiers) + { + return tiers != null && tiers.Any(t => t != null && (t.Lower.HasValue || t.Upper.HasValue || t.AmountRate.HasValue)); + } + + /// + /// 客户方向判定(与确认书 IsCustomerLong 同规则): + /// 我方方向 = 收取端(PosiDirection=收取)与 PositionType 同向、支付端反向;客户方向取反。 + /// + public static bool IsCustomerLong(int posiDirection, int positionType) + { + var isOurLong = posiDirection == (int)SwapDirectionEnum.支付 + ? positionType == (int)PositionTypeFlag.Short + : positionType == (int)PositionTypeFlag.Long; + return !isOurLong; + } + + /// + /// 按当前价格相对期初价的比例落档。返回命中的层;价格在所有层区间之外(平仓线外)返回最深一层,无可用层返回 null。 + /// 多头:priceRatio ∈ [Lower, Upper)(第1层无上界);空头:priceRatio ∈ (Lower, Upper](第1层无下界)。 + /// + public static SpanTierConfig MatchTier(List tiers, bool isCustomerLong, double priceRatio) + { + if (tiers == null) + { + return null; + } + var valid = tiers.Where(t => t != null && (t.Lower.HasValue || t.Upper.HasValue)).ToList(); + if (valid.Count == 0) + { + return null; + } + foreach (var tier in valid) + { + var inLower = !tier.Lower.HasValue || (isCustomerLong ? priceRatio >= tier.Lower.Value : priceRatio > tier.Lower.Value); + var inUpper = !tier.Upper.HasValue || (isCustomerLong ? priceRatio < tier.Upper.Value : priceRatio <= tier.Upper.Value); + if (inLower && inUpper) + { + return tier; + } + } + //未落任何层:价格已穿出最深一层边界(低于多头最深层下界/高于空头最深层上界),按最深层计; + //最深层按边界值取(多头=最小下界、空头=最大上界),不依赖配置数组顺序(BUG-25 引擎侧防御) + return isCustomerLong + ? valid.OrderBy(t => t.Lower ?? double.MinValue).First() + : valid.OrderByDescending(t => t.Upper ?? double.MaxValue).First(); + } + + /// + /// 总追加保证金 = 命中层 AmountRate × 期初全价 × 券面总额(债券)/ × 期初价格 × 名义份额(指数/ETF)。 + /// 数据上两者同为 期初价(PosiGrossPrice) × 数量(PosiQuantity),口径差异由调用方注释说明。 + /// + public static double CalcAdditionalMargin(SpanTierConfig tier, double initPrice, double quantity) + { + if (tier == null || !tier.AmountRate.HasValue) + { + return 0; + } + return tier.AmountRate.Value * initPrice * quantity; + } + + /// + /// 维持保证金 = (初始保证金 + 总追加保证金) × 方向(我方净收取 +1 / 净支付 −1)。 + /// + public static double CalcMaintenanceMargin(double initialMargin, double additionalMargin, double direction) + { + return (initialMargin + additionalMargin) * direction; + } + + /// + /// 阶段三 §3.1 单笔交易引擎计算(纯函数,收盘价由调用方解析后传入——债券取中债估值净价、指数/ETF取收盘价)。 + /// closePrice<=0 视为未取到收盘价:追加保证金按 0、维持保证金=初始保证金(不抛错,由调用方记日志)。 + /// isInitialCalc=true(试算初始):只出初始项,追加保证金为收盘后口径不参与。 + /// 返回 null 表示缺有效标的腿(期初价),调用方跳过该交易不产出 trade_span。 + /// + public static double? CalcTradeMaintenanceMargin(double? tradeInitialMargin, SpanConfig spanCfg, + List legs, bool isInitialCalc, double closePrice) + { + //标的腿(多空):期初价格、数量、客户方向 + var underlyingLeg = legs?.FirstOrDefault(x => x.PositionType == (int)PositionTypeFlag.Long || x.PositionType == (int)PositionTypeFlag.Short); + if (underlyingLeg == null || underlyingLeg.PosiGrossPrice <= 0) + { + return null; + } + var isCustomerLong = IsCustomerLong(underlyingLeg.PosiDirection, underlyingLeg.PositionType); + + //初始保证金与方向:初始预付金腿(InterestMode=5)收付净额(多腿按净收取定方向);无腿时回落交易录入值(客户应付常态) + var initMarginLegs = legs.Where(x => x.InterestMode == (int)InterestModeEnum.初始预付金).ToList(); + double initialMargin; + double direction; + if (initMarginLegs.Any()) + { + var netReceive = initMarginLegs.Sum(x => x.InterestDirection == (int)SwapDirectionEnum.收取 ? x.InterestPrincipalFix : -x.InterestPrincipalFix); + initialMargin = Math.Abs((double)netReceive); + direction = netReceive >= 0 ? 1 : -1; + } + else + { + initialMargin = tradeInitialMargin ?? 0; + direction = 1; + } + + if (isInitialCalc) + { + return CalcMaintenanceMargin(initialMargin, 0, direction); + } + + var additional = 0.0; + //期初价比基:债券/指数/ETF统一为期初净价(PosiNetNoFeePrice,确认书"参考标的期初净价"同源),缺省回落期初全价 + var initNetPrice = (double)(underlyingLeg.PosiNetNoFeePrice ?? 0m); + if (initNetPrice <= 0) + { + initNetPrice = (double)underlyingLeg.PosiGrossPrice; + } + if (closePrice > 0 && initNetPrice > 0) + { + var tiers = isCustomerLong ? spanCfg.LongSpans : spanCfg.ShortSpans; + var tier = MatchTier(tiers, isCustomerLong, closePrice / initNetPrice); + //追保金额基数:期初全价×券面总额(债券)/ 期初价格×名义份额(指数/ETF),同为 期初价×数量 + additional = CalcAdditionalMargin(tier, (double)underlyingLeg.PosiGrossPrice, (double)underlyingLeg.PosiQuantity); + } + + return CalcMaintenanceMargin(initialMargin, additional, direction); + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/SwapFundTagService.cs b/YLErpDAL/Modules/SwapModule/SwapFundTagService.cs index 73f05a1a..f6e188a2 100644 --- a/YLErpDAL/Modules/SwapModule/SwapFundTagService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapFundTagService.cs @@ -43,7 +43,7 @@ namespace YLErp.Modules.SwapModule /// /// 剩余可用授信 = 有效授信 − 已使用授信。 - /// 已使用授信 = 授信出入表 Σ(amount)(入金负/出金正)——入金使可用授信上升、出金使其收缩(业务口径)。 + /// 已使用授信 = 授信出入表 Σ(amount)(占用正/释放负,BUG-01 修正口径)——占用使可用授信收缩、释放使其回升。 /// public double GetAvailableCredit(int clientId, DateTime valueDate) { @@ -54,16 +54,16 @@ namespace YLErp.Modules.SwapModule /// 簿记确认时对预付金腿定稿资金标签并产生资金记录(§2.3 四种情形,逐腿)。 /// fund_tag 单列:录入时存用户选择(Credit/Cash/NULL),本方法读取选择后在同列定稿—— /// 特批全现金;选授信按剩余额度分配(跨界腿拆单为 授信+现金 两条),未选/现金直接现金。 - /// 授信腿只写授信出入表占用(入金方向记负数,绑定腿 position_id,冗余 trade_id),不产生资金流水; + /// 授信腿只写授信出入表占用(占用记正数,绑定腿 position_id,冗余 trade_id),不产生资金流水; /// 现金腿走 SaveSwapTradeClientCash 幂等 upsert 产生 应付预付金 记录。 /// marginLegs 需为已过滤(IsDeductPrincipal 等)的预付金腿(InterestMode=5/6)。 /// public void ApplyMarginFundTags(trade td, List marginLegs, ClientCashInCashOutService cashService, bool ignoreMoneyCheck) { var creditService = new ClientCreditInoutService(this); - //重确认/重补场景自愈:清掉本交易旧占用记录后按腿上当前选择与最新额度重写 - //(阶段四追加保证金占用落地后需区分保留追加部分) - creditService.RemoveByTrade(td.id); + //重确认/重补场景自愈:清掉本交易旧占用记录后按腿上当前选择与最新额度重写; + //追加保证金占用(阶段四 EOD 写入)不随重写清除——其资金记录不在本方法删除范围,由 EOD 幂等维护 + creditService.RemoveByTrade(td.id, keepAdditionalMargin: true); var valueDate = td.TradeDate ?? DateTime.Now; var creditAvailable = GetAvailableCredit(td.ClientId, valueDate); @@ -111,8 +111,8 @@ namespace YLErp.Modules.SwapModule if (plan != null && plan.CreditAmount > 0) { //整腿授信 或 拆单后的授信部分:不产生资金流水,只写占用(拆单绑新拆出的授信腿)。 - //入金方向记负数(业务口径:出入表金额与资金流水同号,入金负/出金正;入金使可用授信上升) - creditService.Occupy(td.ClientId, plan.CreditLeg?.id ?? leg.id, td.id, -plan.CreditAmount, happenDate, + //占用记正数(BUG-01 修正:已使用授信=Σ(amount) 占用上升;2026-08-20"与资金流水同号入金负"口径已废弃) + creditService.Occupy(td.ClientId, plan.CreditLeg?.id ?? leg.id, td.id, plan.CreditAmount, happenDate, plan.NeedSplit ? "簿记拆单授信部分" : "簿记授信占用"); } //资金记录沿用既有符号口径(客户付钱为负 = -应付额):授信部分不产生流水,现金部分按差额产生 @@ -136,13 +136,15 @@ namespace YLErp.Modules.SwapModule var leg = plan.Leg; //应付额 = fix × (dir==1 ? 1 : -1),反推 fix 用同一比例(±1 自反) var payableRatio = leg.InterestDirection == 1 ? 1 : -1; + var originalFix = leg.InterestPrincipalFix; leg.InterestPrincipalFix = Math.Round(Convert.ToDecimal(plan.CashAmount) * payableRatio, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); leg.FundTag = ConsFundTag.Cash; var creditLeg = leg.Clone(); creditLeg.id = 0; creditLeg.PositionId = 0; - creditLeg.InterestPrincipalFix = Math.Round(Convert.ToDecimal(plan.CreditAmount) * payableRatio, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + //授信腿倒挤 = 原 fix − 现金 fix(BUG-20:两腿分别独立舍入会有分位尾差,倒挤保证两腿合计与原 fix 守恒) + creditLeg.InterestPrincipalFix = originalFix - leg.InterestPrincipalFix; creditLeg.FundTag = ConsFundTag.Credit; creditLeg.OptId = UserId; creditLeg.OptName = UserName; diff --git a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs index 54ec002b..7daf7770 100644 --- a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs @@ -1514,8 +1514,9 @@ namespace YLErp.Modules.SwapModule var eodSwapPositions = DbContext.eod_swap_position.Where(x => x.SwapTradeId == tradeId); var eodSwaps = DbContext.eod_swap.Where(x => x.SwapTradeId == tradeId); var clientcashinouts = DbContext.ClientCashInCashOut.Where(x => x.TradeId == tradeId && x.Action == ClientCashInCashOut.系统操作_应付预付金); - // R4:修改清除资金记录时同步清理授信出入记录,重确认时按最新标签重写 - new ClientCreditInoutService(this).RemoveByTrade(tradeId); + // R4:修改清除 应付预付金 资金记录时同步清理对应授信出入记录,重确认时按最新标签重写; + // 追加保证金资金记录(阶段四 EOD 产生)不在本方法删除范围,其授信占用一并保留 + new ClientCreditInoutService(this).RemoveByTrade(tradeId, keepAdditionalMargin: true); swapFlowEvents.ForEach(x => { x.DataState = (int)SwapFlowDateStateEnum.废弃; diff --git a/YLErpDAL/Modules/TradeModule/ClientCashInCashOutService.cs b/YLErpDAL/Modules/TradeModule/ClientCashInCashOutService.cs index 24885bff..2f84b0ba 100644 --- a/YLErpDAL/Modules/TradeModule/ClientCashInCashOutService.cs +++ b/YLErpDAL/Modules/TradeModule/ClientCashInCashOutService.cs @@ -227,10 +227,12 @@ namespace YLErp.Modules.TradeModule /// /// 新版互换 期权费 + /// matchDate=true 时 upsert 键追加 HappenDate(按结算日幂等):用于追加保证金等逐日增量记录, + /// 每个结算日一条独立记录,避免单条累计值覆盖 + HappenDate 前移导致跨日差分窗口全额重复计入(BUG-03)。 /// /// /// - public void SaveSwapTradeClientCash(trade swapTrade,double money, DateTime HappenDate,long positionId,string action= ClientCashInCashOut.系统操作_期权费) + public void SaveSwapTradeClientCash(trade swapTrade,double money, DateTime HappenDate,long positionId,string action= ClientCashInCashOut.系统操作_期权费, bool matchDate = false) { if (swapTrade is null) { @@ -242,7 +244,8 @@ namespace YLErp.Modules.TradeModule } var valueDate = valuedateBLL.ValueDate; var client = DataCacheProvider.GetClientDataSource().GetData(swapTrade.ClientId); - var cic = DbContext.ClientCashInCashOut.FirstOrDefault(t => t.TradeId == swapTrade.id && t.ValidState != ConsGlobal.InValid && t.Action == action &&t.Deal==positionId); + var cic = DbContext.ClientCashInCashOut.FirstOrDefault(t => t.TradeId == swapTrade.id && t.ValidState != ConsGlobal.InValid && t.Action == action &&t.Deal==positionId + && (!matchDate || t.HappenDate == HappenDate)); bool addClientCash = false; if (cic == null) { diff --git a/YLErpWeb/App_Data/Menus.txt b/YLErpWeb/App_Data/Menus.txt index ce9c2139..e572691f 100644 --- a/YLErpWeb/App_Data/Menus.txt +++ b/YLErpWeb/App_Data/Menus.txt @@ -15,8 +15,10 @@ {Name:"互换开平仓事件流水",Rights:["交易管理-互换开平仓事件流水"],Url:"swapTrade2/EventList"}, {Name:"交易审批",Rights:["交易管理-交易审批"],Url:"trade/TradeApproval"}, {Name:"凭证审批",Rights:["交易管理-凭证审批"],Url:"v3/product/approve"}, - {Name:"标的交易",Rights:["交易管理-标的交易"],Url:"exchangetrade/tradelist"}, - {Name:"变更记录",Rights:["交易管理-变更记录"],Url:"trade/TradeChange"}, + {Name:"标的交易",Rights:["交易管理-标的交易"],Url:"exchangetrade/tradelist"}, + {Name:"变更记录",Rights:["交易管理-变更记录"],Url:"trade/TradeChange"}, + //R1 三层级(交易绑定→客户默认→全局默认,2026-08-24):交易维度逐笔查看/调整模板绑定的入口 + {Name:"交易预付金配置",Rights:["交易管理-交易预付金配置"],Url:"trade/tradeMarginTemplateList"}, ] }, {Name:"风险管理",Rights:["风险控制"],Icon:"menu-icon iconseven" @@ -66,9 +68,10 @@ {Name:"黑名单审批",Rights:["客户管理-黑名单审批"],Url:"clientblack/clientblackApproval"}, {Name:"授信管理",Rights:["客户管理-授信管理"],Url:"credit/creditList"}, {Name:"资信评级",Rights:["客户管理-资信评级"],Url:"client_rating/List"}, - {Name:"机构账号设置",Rights:["客户管理-机构账号设置"],Url:"v3/client/account"} - //保证金模板在交易页直接选择,客户预付金配置入口不展示(页面代码保留) - //{Name:"客户预付金配置",Rights:["客户管理-客户预付金配置"],Url:"client_margin_template/client_margin_templateList"}, + {Name:"机构账号设置",Rights:["客户管理-机构账号设置"],Url:"v3/client/account"}, + //R1 三层级(2026-08-24):客户默认模板绑定入口恢复——二级(client_margin_template)生效的必需配置页; + //原下线理由"保证金模板在交易页直接选择"只覆盖交易级,客户/全局兜底落地后不再成立 + {Name:"客户预付金配置",Rights:["客户管理-客户预付金配置"],Url:"client_margin_template/client_margin_templateList"}, ] }, {Name:"互换簿记预设",Rights:["互换簿记预设"],Icon:"menu-icon iconfour" diff --git a/YLErpWeb/Controllers/creditController.cs b/YLErpWeb/Controllers/creditController.cs index 9a4dbee8..bc004ee8 100644 --- a/YLErpWeb/Controllers/creditController.cs +++ b/YLErpWeb/Controllers/creditController.cs @@ -80,7 +80,7 @@ namespace YLErp.Web.Controllers { c.VarietyName = GetWhiteListVarietyNames(c); } - // R4:已使用授信(授信出入表 SUM(占用)−SUM(释放)+SUM(调整),按客户汇总) + // R4:已使用授信(授信出入表 Σ(amount),占用记正/释放记负,按客户汇总) var usedCredits = Modules.SwapModule.ClientCreditInoutService.GetUsedCreditByClients( sList.rows.Where(x => x.ClientId > 0).Select(x => x.ClientId ?? 0).Distinct().ToList(), yldb); foreach (var c in sList.rows) diff --git a/YLErpWeb/Controllers/margin_template_v2Controller.cs b/YLErpWeb/Controllers/margin_template_v2Controller.cs index 9f2cc888..80fcb5d0 100644 --- a/YLErpWeb/Controllers/margin_template_v2Controller.cs +++ b/YLErpWeb/Controllers/margin_template_v2Controller.cs @@ -317,6 +317,19 @@ namespace YLErp.Web.Controllers } } + //BUG-13:期限档值须为合法四档(页面为固定区块正常到不了这里,接口直传非法档值会成取数侧永远匹配不到的死配置) + if (marginTemplate.RuleType == (int)MarginRuleTypeEnum.区间追保结构) + { + foreach (var detail in marginTemplate.Details) + { + var bondTerm = detail.SpanConfig?.BondTerm; + if (!string.IsNullOrEmpty(bondTerm) && !ConsMarginTerm.IsValidTerm(bondTerm)) + { + throw new Exception("非法的利率债期限档:" + bondTerm + "(允许值:" + string.Join(" / ", ConsMarginTerm.AllowedTerms) + ")"); + } + } + } + //区间追保结构:客户原型全量手工录入的预警线/平仓线与多空各4层追保区间校验。 //预警线/平仓线/两层区间全部为空视为未配置(兼容存量 x/y 过渡),任一值存在即按规则校验: //数值非负;同层上下边界都有时上边界>下边界;单层部分填写报错提示完整填写 @@ -359,13 +372,29 @@ namespace YLErp.Web.Controllers { if (marginTemplate.RuleType == (int)MarginRuleTypeEnum.区间追保结构) { - //区间追保结构:按 (利率债期限档, ETF细分) 元组分组校验(EtfKind 为预留扩展键,本期无 UI 写入,等价于仅按期限档分组), - //同一分组内标的类型不允许重复,不同分组允许相同标的类型 + //区间追保结构:按 (利率债期限档, ETF细分) 元组分组校验(EtfKind 为预留扩展键,本期无 UI 写入,等价于仅期限档分组), + //同一分组内标的类型不允许重复,不同分组允许相同标的类型; + //通配行(空/全部,UnderlyingType=None/All)两两之间位与恒为 0 检不出,须单独拦截(BUG-09)—— + //否则取数侧通配兜底 matched.First() 命中不确定 foreach (var termGroup in marginTemplate.Details.GroupBy(x => (x.SpanConfig?.BondTerm ?? "", x.SpanConfig?.EtfKind ?? ""))) { var termUnderlyingType = UnderlyingTypeEnum.None; + var wildcardSeen = false; foreach (var detail in termGroup) { + var isWildcard = detail.UnderlyingType == UnderlyingTypeEnum.None || detail.UnderlyingType == UnderlyingTypeEnum.All; + if (isWildcard && wildcardSeen) + { + if (marginTemplate.UnderlyingSeperateType == (int)UnderlyingSeperateTypeEnum.None) + { + throw new Exception("同一利率债期限档下存在重复的参数组"); + } + throw new Exception("同一分组下存在重复的兜底参数组(标的类型为空或全部)"); + } + if (isWildcard) + { + wildcardSeen = true; + } if ((detail.UnderlyingType & termUnderlyingType) > 0) { if (marginTemplate.UnderlyingSeperateType == (int)UnderlyingSeperateTypeEnum.None) @@ -444,6 +473,15 @@ namespace YLErp.Web.Controllers yldb.SaveChanges(); + //BUG-14:明细生效日期回写——前端新增行只带占位日期(2020-01-01 前后),此前不回写使新行(含模板拷贝) + //落库 0001-01-01,被取数侧 Max(ValueDate) 分组静默丢弃;占位日期对齐本次提交行的最新生效日期,无有效行时用模板生效日期 + var placeholderDate = new DateTime(2020, 1, 1); + var effectiveValueDate = marginTemplate.Details + .Where(d => d.ValueDate > placeholderDate) + .Select(d => d.ValueDate) + .DefaultIfEmpty(marginTemplate.ValueDate > placeholderDate ? marginTemplate.ValueDate : placeholderDate) + .Max(); + marginTemplate.Details.ForEach(x => { var detail = yldb.margin_template_detail.Find(x.id); @@ -453,6 +491,7 @@ namespace YLErp.Web.Controllers detail = new margin_template_detail(); yldb.margin_template_detail.Add(detail); } + detail.ValueDate = x.ValueDate > placeholderDate ? x.ValueDate : effectiveValueDate; detail.MarginTemplateId = marginTemplate.id; detail.GroupRatio = x.GroupRatio; detail.MarginRatio1 = x.MarginRatio1; diff --git a/YLErpWeb/Controllers/tradeController.cs b/YLErpWeb/Controllers/tradeController.cs index 7873be69..cbe29186 100644 --- a/YLErpWeb/Controllers/tradeController.cs +++ b/YLErpWeb/Controllers/tradeController.cs @@ -7972,7 +7972,9 @@ namespace YLErp.Web.Controllers public ActionResult tradeMarginTemplateEdit(int tradeId) { var trade = yldb.trade.Find(tradeId); - var marginTemplates = yldb.margin_template_v2.Where(x => x.TradeTypes.Contains(trade.TradeType) && !x.IsDefault).ToList(); + //R1 三层级(2026-08-24 配合修改):候选只列有效模板——已作废模板一旦绑定, + //解析一级"找到即停"会让该交易直接视为无预付金(仅留日志),必须从源头挡住 + var marginTemplates = yldb.margin_template_v2.Where(x => x.IsValid && x.TradeTypes.Contains(trade.TradeType) && !x.IsDefault).ToList(); marginTemplates.ForEach(x => x.Details = yldb.margin_template_detail.Where(y => y.MarginTemplateId == x.id).ToList()); var latestTradeMarginTemplate = yldb.trade_margin_template.Where(x => x.TradeId == tradeId).OrderByDescending(x => x.ValueDate).FirstOrDefault(); @@ -8005,6 +8007,9 @@ namespace YLErp.Web.Controllers } + //BUG-05 修正:接口补权限注解(与同组 tradeMarginTemplateList/Edit 一致),任何登录用户不可直改交易模板绑定 + [MyAuthorize("交易管理-交易预付金配置")] + [HttpPost] public JsonResult saveTradeMarginTemplate(trade_margin_template tradeMarginTemplate) { try @@ -8030,14 +8035,20 @@ namespace YLErp.Web.Controllers #region 重置初始预付金 var trade = yldb.trade.Find(tradeMarginTemplate.TradeId); - trade.InitialMargin = MarginDefault.GetInitialMarginWithPreHandle(trade); - yldb.SaveChanges(); - - if (trade.IsGroup == 2) + //R1 三层级(2026-08-24 配合修改):收益互换跳过系统重算——互换初始预付金为手工录入/预付金腿承载 + //(方案B 引擎回落链消费 trade.InitialMargin),且试算链路(GetInitialMarginWithPreHandle 克隆 id=0) + //对互换取不到模板绑定恒得 0,无条件重置会把手工录入值清零;与 TradeSaveService 保存守卫语义对齐 + if (trade.TradeType != "收益互换") { - var parentTrade = yldb.trade.Find(trade.ParentTradeId); - parentTrade.InitialMargin = yldb.trade.Where(x => x.ValidState != "InValid" && x.ParentTradeId == trade.ParentTradeId).Sum(y => y.InitialMargin); + trade.InitialMargin = MarginDefault.GetInitialMarginWithPreHandle(trade); yldb.SaveChanges(); + + if (trade.IsGroup == 2) + { + var parentTrade = yldb.trade.Find(trade.ParentTradeId); + parentTrade.InitialMargin = yldb.trade.Where(x => x.ValidState != "InValid" && x.ParentTradeId == trade.ParentTradeId).Sum(y => y.InitialMargin); + yldb.SaveChanges(); + } } #endregion @@ -8049,6 +8060,8 @@ namespace YLErp.Web.Controllers return JsonError(ex.Message); } } + //BUG-05 修正(同组接口一并收紧):批量重设初始预付金同样要求预付金配置权限 + [MyAuthorize("交易管理-交易预付金配置")] [HttpPost] public JsonResult batchSetInitialMargin(TradeMarginTemplateReq req) { @@ -8087,6 +8100,8 @@ namespace YLErp.Web.Controllers /// /// 获取交易预付金规则列表 /// + //BUG-05 修正:接口补权限注解(与同组 tradeMarginTemplateList/Edit 一致) + [MyAuthorize("交易管理-交易预付金配置")] [HttpPost] public JsonResult tradeMarginTemplateQuery(TradeMarginTemplateReq req) { diff --git a/YLErpWeb/Views/clientbalance/TradeMarketReport.cshtml b/YLErpWeb/Views/clientbalance/TradeMarketReport.cshtml index 1bde2638..3215540e 100644 --- a/YLErpWeb/Views/clientbalance/TradeMarketReport.cshtml +++ b/YLErpWeb/Views/clientbalance/TradeMarketReport.cshtml @@ -86,7 +86,7 @@ 期初结存 初始保证金金额 - + 已实现盈亏 @@ -103,16 +103,16 @@ 盯市金额 - - + 授信额度 + 追保账户 可用资金 - - + 已使用授信 + 其他收支 diff --git a/YLErpWeb/Views/clientbalance/TradeMarketReport_BalanceStatement.cshtml b/YLErpWeb/Views/clientbalance/TradeMarketReport_BalanceStatement.cshtml index f6dd7225..7bba1cf6 100644 --- a/YLErpWeb/Views/clientbalance/TradeMarketReport_BalanceStatement.cshtml +++ b/YLErpWeb/Views/clientbalance/TradeMarketReport_BalanceStatement.cshtml @@ -322,7 +322,7 @@ else if (cellValue == "出金" || cellValue == "其他支出") { return "支出"; } - else if (rowObject.Action == "系统操作-期权费" || rowObject.Action == "系统操作-平仓费" || rowObject.Action == "系统操作-行权费" || rowObject.Action == "系统操作-票息" || rowObject.Action == "系统操作-互换" || rowObject.Action == "系统操作-应付预付金" || rowObject.Action == "系统操作-预付金返息") { + else if (rowObject.Action == "系统操作-期权费" || rowObject.Action == "系统操作-平仓费" || rowObject.Action == "系统操作-行权费" || rowObject.Action == "系统操作-票息" || rowObject.Action == "系统操作-互换" || rowObject.Action == "系统操作-应付预付金" || rowObject.Action == "系统操作-预付金返息" || rowObject.Action == "系统操作-追加保证金") { if (rowObject.Money < 0) { return "支出"; } diff --git a/YLErpWeb/Views/entryexit/entryexitList.cshtml b/YLErpWeb/Views/entryexit/entryexitList.cshtml index aaafc651..f4ee4332 100644 --- a/YLErpWeb/Views/entryexit/entryexitList.cshtml +++ b/YLErpWeb/Views/entryexit/entryexitList.cshtml @@ -384,6 +384,10 @@ else if (rowObject.Action == "系统操作-预付金返息") { action = "预付金返息"; } + else if (rowObject.Action == "系统操作-追加保证金") { + //阶段四 §4.1 合约维度 EOD 结算产生的追保入金 + action = "追加保证金"; + } if (action) { return `${action}${parseFloat(rowObject.Money) > 0 ? "收入" : "支出"}`; } diff --git a/YLErpWeb/Views/margin_template_v2/margin_template_v2ClientEdit.cshtml b/YLErpWeb/Views/margin_template_v2/margin_template_v2ClientEdit.cshtml index f1ce1c96..815a2769 100644 --- a/YLErpWeb/Views/margin_template_v2/margin_template_v2ClientEdit.cshtml +++ b/YLErpWeb/Views/margin_template_v2/margin_template_v2ClientEdit.cshtml @@ -253,7 +253,7 @@ {{spanText(sec.detail).priceCurShort1}} ≤ × {{spanText(sec.detail).priceInit}} diff --git a/YLErpWeb/Views/margin_template_v2/margin_template_v2DefaultEdit.cshtml b/YLErpWeb/Views/margin_template_v2/margin_template_v2DefaultEdit.cshtml index 850c0fbd..3e4eca0f 100644 --- a/YLErpWeb/Views/margin_template_v2/margin_template_v2DefaultEdit.cshtml +++ b/YLErpWeb/Views/margin_template_v2/margin_template_v2DefaultEdit.cshtml @@ -254,7 +254,7 @@ {{spanText(sec.detail).priceCurShort1}} ≤ × {{spanText(sec.detail).priceInit}} diff --git a/YLErpWeb/Views/margin_template_v2/margin_template_v2Edit.cshtml b/YLErpWeb/Views/margin_template_v2/margin_template_v2Edit.cshtml index e4906c83..0be12633 100644 --- a/YLErpWeb/Views/margin_template_v2/margin_template_v2Edit.cshtml +++ b/YLErpWeb/Views/margin_template_v2/margin_template_v2Edit.cshtml @@ -230,7 +230,7 @@ {{spanText(sec.detail).priceCurShort1}} ≤ × {{spanText(sec.detail).priceInit}} diff --git a/YLErpWeb/wwwroot/Scripts/app/client/tradeMarketReport.js b/YLErpWeb/wwwroot/Scripts/app/client/tradeMarketReport.js index 0398c209..7e3f4cc2 100644 --- a/YLErpWeb/wwwroot/Scripts/app/client/tradeMarketReport.js +++ b/YLErpWeb/wwwroot/Scripts/app/client/tradeMarketReport.js @@ -290,11 +290,13 @@ function SearchClientBalance() { main.post("/trade_span/GetClientLatestBalance", { clientId: $("#ClientId").val(), ValueDateFrom: param.ValueDateStart, ValueDateTo: param.ValueDateEnd, IsClientBalanceGap: param.IsClientBalanceGap, IsGetOuterMarginGap: param.IsGetOuterMarginGap, ParentFlag: param.ParentFlag }).done(function (data) { $("#LastDayRemainFund").text(numFormart(data.LastDayRemainFund)); - $("#MySideMargin").text(numFormart(data.MySideMargin)); + //R2 阶段三 §3.2 口径(BUG-07 修正,与邮件/Excel 报告 SettlementReportService 同源): + //初始保证金=SwapInitMargin(应付预付金净额)、维持保证金=−MySideMargin(client_span 维持保证金反号聚合) + $("#SwapInitMargin").text(numFormart(data.SwapInitMargin)); $("#WinLoss").text(numFormart(data.WinLoss)); $("#CashInCashOutChange").text(numFormart(data.NetFundAll)); $("#PositionPnl").text(numFormart(isPvRounded ? data.RoundedPositionPnl : data.PositionPnl)); - $("#MaintenanceMargin").text(numFormart(data.MaintenanceMargin)); + $("#MaintenanceMargin").text(numFormart(-data.MySideMargin)); $("#NetFundAll").text(numFormart(data.NetFund)); $("#SwapMarketAmount").text(numFormart(data.SwapMarketAmount)); $("#VmFundSum").text(numFormart(data.VmFundSum)); @@ -303,6 +305,8 @@ function SearchClientBalance() { $("#MarginByPayableMargin").text(numFormart(data.MarginByPayableMarginTotal)); $("#ToDayRemainFund").text(numFormart(data.AmountFund)); $("#DesirableFund").text(numFormart(data.DesirableFundTotal)); + $("#TotalCredit").text(numFormart(data.TotalCredit)); + $("#UsedCredit").text(numFormart(data.UsedCredit)); }); var data = { clientId: $("#ClientId").val(), ValueDateFrom: $("#ValueDateFrom").val(), ValueDateTo: $("#ValueDate").val() }; diff --git a/YLErpWeb/wwwroot/Scripts/app/marginTemplate/marginTemplateV2DefaultEdit.js b/YLErpWeb/wwwroot/Scripts/app/marginTemplate/marginTemplateV2DefaultEdit.js index b9a92bed..8585ebda 100644 --- a/YLErpWeb/wwwroot/Scripts/app/marginTemplate/marginTemplateV2DefaultEdit.js +++ b/YLErpWeb/wwwroot/Scripts/app/marginTemplate/marginTemplateV2DefaultEdit.js @@ -462,7 +462,7 @@ const vue = new Vue({ }, //规则15录入区块文案:按 detail 资产类型切换计价口径。 //债券类(利率债16/信用债32/其它债券64)及其他类型(默认债券口径):期初净价/当前净价,金额=×期初全价×券面总额; - //基金及基金专户(32768)/债券指数(536870912):参考标的期初净价/参考标的当前净价(空头第1层用"参考标的当前收盘价"), + //基金及基金专户(32768)/债券指数(536870912):参考标的期初净价/参考标的当前收盘价(空头各层统一收盘价,BUG-11 修正), //金额=×参考标的期初价格×参考标的名义份额。 spanText(detail) { var ut = (detail && detail.UnderlyingType) || 0; @@ -471,6 +471,7 @@ const vue = new Vue({ priceInit: '参考标的期初净价', priceCur: '参考标的当前净价', priceCurShort1: '参考标的当前收盘价', + priceCurShort: '参考标的当前收盘价', amountBase: '参考标的期初价格 × 参考标的名义份额' }; } @@ -478,6 +479,7 @@ const vue = new Vue({ priceInit: '期初净价', priceCur: '当前净价', priceCurShort1: '当前净价', + priceCurShort: '当前净价', amountBase: '期初全价 × 券面总额' }; }, diff --git a/YLErpWeb/wwwroot/Scripts/app/marginTemplate/marginTemplateV2Edit.js b/YLErpWeb/wwwroot/Scripts/app/marginTemplate/marginTemplateV2Edit.js index 557d4c0b..0e09bc36 100644 --- a/YLErpWeb/wwwroot/Scripts/app/marginTemplate/marginTemplateV2Edit.js +++ b/YLErpWeb/wwwroot/Scripts/app/marginTemplate/marginTemplateV2Edit.js @@ -226,7 +226,7 @@ const vue = new Vue({ needDetailRules.push(MarginRuleTypeEnum.买入自动赎回规则1); needDetailRules.push(MarginRuleTypeEnum.卖出自动赎回规则1); needDetailRules.push(MarginRuleTypeEnum.区间追保结构); - needDetailRules.push(MarginRuleTypeEnum.单独计算规则1); + needDetailRules.push(MarginRuleTypeEnum.单独计算规则); if (needDetailRules.indexOf(that.marginTemplate.RuleType.toString()) >= 0) { new Promise((reslove) => { //规则15:新增的是"资产类型区块"(含区间录入结构与独立区块键);其他规则维持原样 @@ -473,7 +473,7 @@ const vue = new Vue({ }, //规则15录入区块文案:按 detail 资产类型切换计价口径。 //债券类(利率债16/信用债32/其它债券64)及其他类型(默认债券口径):期初净价/当前净价,金额=×期初全价×券面总额; - //基金及基金专户(32768)/债券指数(536870912):参考标的期初净价/参考标的当前净价(空头第1层用"参考标的当前收盘价"), + //基金及基金专户(32768)/债券指数(536870912):参考标的期初净价/参考标的当前收盘价(空头各层统一收盘价,BUG-11 修正), //金额=×参考标的期初价格×参考标的名义份额。 spanText(detail) { var ut = (detail && detail.UnderlyingType) || 0; @@ -482,6 +482,7 @@ const vue = new Vue({ priceInit: '参考标的期初净价', priceCur: '参考标的当前净价', priceCurShort1: '参考标的当前收盘价', + priceCurShort: '参考标的当前收盘价', amountBase: '参考标的期初价格 × 参考标的名义份额' }; } @@ -489,6 +490,7 @@ const vue = new Vue({ priceInit: '期初净价', priceCur: '当前净价', priceCurShort1: '当前净价', + priceCurShort: '当前净价', amountBase: '期初全价 × 券面总额' }; }, diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeList.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeList.js index 6925ac7e..b0e65a9c 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeList.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeList.js @@ -1,4 +1,4 @@ -var g_grid = {}; +var g_grid = {}; var query_data = {}; $(function () { @@ -337,13 +337,17 @@ function confirmAllSelect() { main.message("请选择需确认的交易"); return; } - var confirmFunc = function (additionalProcessing) { + var confirmFunc = function (additionalProcessing, id, useRecall) { var ggrid = jQuery('#listGrid'); var rowIds = ggrid.jqGrid('getGridParam', 'selarrrow'); var ors = []; - for (var i = 0; i < rowIds.length; i++) { - var or = ggrid.getRowData(rowIds[i]).id; - ors.push(or); + if (id) { + ors.push(id); + } else { + for (var i = 0; i < rowIds.length; i++) { + var or = ggrid.getRowData(rowIds[i]).id; + ors.push(or); + } } var pData = { tradeids: ors.join(",") }; // R4:不再硬编码 IsSkipCheck=true 静默跳过资金校验——普通确认走校验, @@ -357,6 +361,72 @@ function confirmAllSelect() { main.message(data.msg); return; } + //R4(BUG-04 修正):资金不足时后端返回 success=true + proccessType="AdditionalProcessing"(交易实际未确认), + //必须弹额度试算特批窗口并按弹窗结果重提(复用确认列表页 swapTradeConfirmList.js 逻辑),不能静默刷新 + if (data.obj && data.obj.proccessType === "AdditionalProcessing") { + if (data.obj.TrialDataId) { + var layerSetting = { + type: 2, + title: "提示", + shadeClose: false, + shade: 0.4, + area: ['800px', '500px'], + content: "/trade/showQuotaTrial?id=" + data.obj.TrialDataId, + yes: function (index) { + var obj = window["layui-layer-iframe" + index].page; + if (obj.IsTF) { + if (obj.Data.Remark && obj.Data.Remark.length > 0) { + main.post("/trade/SaveQuotaTrial", obj.Data, { async: false }).done(function (res) { + if (!res || !res.success) { + main.message(res.msg); + } + }); + confirmFunc("LackOfMoney", obj.Data.TradeId, true); + } else { + main.message("必须填写说明内容,才可以录入交易"); + return; + } + } else { + if (obj.Data.Remark && obj.Data.Remark.length > 0) { + main.post("/trade/SaveQuotaTrial", obj.Data, { async: false }).done(function (res) { + if (!res || !res.success) { + main.message(res.msg); + } + }); + } + confirmFunc("LackOfMoney", obj.Data.TradeId, true); + } + (parent || window).layer.closeAll(); + }, + cancel: function () { + if (window.parent && window.parent.reloadtrade) { + window.parent.reloadtrade(); + } + (parent || window).layer.closeAll(); + } + }; + if (data.obj.type === "LackOfMoney") { + layerSetting.btn = ["交易特批", '取消']; + } + // 只展示"资金状况"的情况 + if (data.obj.typecode === "FundStatus") { + layerSetting.btn = ["确认", '取消']; + } + layer.open(layerSetting); + } + else { + if (data.obj.message) { + main.message(data.obj.message); + } + } + return; + } + if (useRecall) { + //特批单笔已通过,重跑本批剩余交易 + confirmFunc(); + return; + } + main.message(data.msg); SearchClick(true); }); }; diff --git a/YLErpWeb/wwwroot/Scripts/app/trade/swapTradeList.js b/YLErpWeb/wwwroot/Scripts/app/trade/swapTradeList.js index 2e7987fa..effbe631 100644 --- a/YLErpWeb/wwwroot/Scripts/app/trade/swapTradeList.js +++ b/YLErpWeb/wwwroot/Scripts/app/trade/swapTradeList.js @@ -1,4 +1,4 @@ -var g_grid = {}; +var g_grid = {}; var query_data = {}; $(function () { @@ -1048,13 +1048,17 @@ function confirmAllSelect() { main.message("请选择需确认的交易"); return; } - var confirmFunc = function (additionalProcessing) { + var confirmFunc = function (additionalProcessing, id, useRecall) { var ggrid = jQuery('#listGrid'); var rowIds = ggrid.jqGrid('getGridParam', 'selarrrow'); var ors = []; - for (var i = 0; i < rowIds.length; i++) { - var or = ggrid.getRowData(rowIds[i]).id; - ors.push(or); + if (id) { + ors.push(id); + } else { + for (var i = 0; i < rowIds.length; i++) { + var or = ggrid.getRowData(rowIds[i]).id; + ors.push(or); + } } var pData = { tradeids: ors.join(",") }; // R4:不再硬编码 IsSkipCheck=true 静默跳过资金校验——普通确认走校验, @@ -1069,6 +1073,72 @@ function confirmAllSelect() { main.message(data.msg); return; } + //R4(BUG-04 修正):资金不足时后端返回 success=true + proccessType="AdditionalProcessing"(交易实际未确认), + //必须弹额度试算特批窗口并按弹窗结果重提(复用确认列表页 swapTradeConfirmList.js 逻辑),不能静默刷新 + if (data.obj && data.obj.proccessType === "AdditionalProcessing") { + if (data.obj.TrialDataId) { + var layerSetting = { + type: 2, + title: "提示", + shadeClose: false, + shade: 0.4, + area: ['800px', '500px'], + content: "/trade/showQuotaTrial?id=" + data.obj.TrialDataId, + yes: function (index) { + var obj = window["layui-layer-iframe" + index].page; + if (obj.IsTF) { + if (obj.Data.Remark && obj.Data.Remark.length > 0) { + main.post("/trade/SaveQuotaTrial", obj.Data, { async: false }).done(function (res) { + if (!res || !res.success) { + main.message(res.msg); + } + }); + confirmFunc("LackOfMoney", obj.Data.TradeId, true); + } else { + main.message("必须填写说明内容,才可以录入交易"); + return; + } + } else { + if (obj.Data.Remark && obj.Data.Remark.length > 0) { + main.post("/trade/SaveQuotaTrial", obj.Data, { async: false }).done(function (res) { + if (!res || !res.success) { + main.message(res.msg); + } + }); + } + confirmFunc("LackOfMoney", obj.Data.TradeId, true); + } + (parent || window).layer.closeAll(); + }, + cancel: function () { + if (window.parent && window.parent.reloadtrade) { + window.parent.reloadtrade(); + } + (parent || window).layer.closeAll(); + } + }; + if (data.obj.type === "LackOfMoney") { + layerSetting.btn = ["交易特批", '取消']; + } + // 只展示"资金状况"的情况 + if (data.obj.typecode === "FundStatus") { + layerSetting.btn = ["确认", '取消']; + } + layer.open(layerSetting); + } + else { + if (data.obj.message) { + main.message(data.obj.message); + } + } + return; + } + if (useRecall) { + //特批单笔已通过,重跑本批剩余交易 + confirmFunc(); + return; + } + main.message(data.msg); SearchClick(true); }); };