From 7d0eb16f64353ebbe3cc72dd1916259cf5928334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=90=8D=E9=94=90?= <1565842059@qq.com> Date: Tue, 25 Aug 2026 13:49:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(eodswapposition):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=A1=86=E6=9E=B6=E5=90=88=E7=BA=A6=E6=96=B0=E5=8F=A3=E5=BE=84?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在EodPnlCalculator中添加CalculateEodSwapRiskNewFields方法,实现新口径计算逻辑 - 创建EodSwapRiskNewFields和EodSwapRiskNewResponse模型类,支持新的展示字段 - 实现SearchEodSwapNewList查询方法,复用旧查询权限控制并计算新字段 - 添加index=3的新框架合约Tab页面,使用独立查询接口和列配置 - 前端JavaScript中实现colModelGridEodSwapNew列模型,替换旧字段并新增9个字段 - 完善导出功能支持新Tab的标准格式导出,保持与旧口径分离 - 更新测试用例验证新框架合约前端接线正确性 --- Framework/YLErp.Core/DBModels/EodSwap.cs | 82 ++++++++ .../SwapEodPositionRiskNewTabTest.cs | 178 ++++++++++++++++++ .../Modules/SwapModule/EodPnlCalculator.cs | 98 ++++++++++ .../SwapModule/SwapEodPositionService.cs | 108 +++++++++++ YLErpWeb/Controllers/SwapTrade2Controller.cs | 17 ++ .../Views/SwapTrade2/EodPositionRisks.cshtml | 7 +- YLErpWeb/fe-tests/eodPositionRisks.test.js | 69 ++++++- .../Scripts/app/swaptrade/EodPositionRisks.js | 106 ++++++++++- 8 files changed, 655 insertions(+), 10 deletions(-) create mode 100644 UnitTestProject/Modules/SwapModule/SwapEodPositionRiskNewTabTest.cs diff --git a/Framework/YLErp.Core/DBModels/EodSwap.cs b/Framework/YLErp.Core/DBModels/EodSwap.cs index d268cd50..80f8fd30 100644 --- a/Framework/YLErp.Core/DBModels/EodSwap.cs +++ b/Framework/YLErp.Core/DBModels/EodSwap.cs @@ -220,4 +220,86 @@ namespace YLErp.DBModels /// public decimal MarginInterestLoss { get; set; } } + + /// + /// EQD-7084 新“框架合约”Tab 的新增字段及拆分后的估值口径。 + /// 该模型不映射数据库,仅由新查询接口计算返回。 + /// + public class EodSwapRiskNewFields + { + /// + /// 浮动收益端标的类型,仅供前端按债券/非债券选择期初价格精度使用, + /// 不参与任何收益或估值计算。 + /// + public string UnderlyingInstrumentType { get; set; } + + /// 浮动收益端多空方向。 + public string UnderlyingDirection { get; set; } + + /// 浮动收益端标的代码。 + public string UnderlyingCode { get; set; } + + /// 期初标的价格;债券按百分价格展示。 + public decimal? InitialPrice { get; set; } + + /// 名义数量,取合约名义本金。 + public decimal NotionalQuantity { get; set; } + + /// 合约起始日。 + public DateTime? ContractStartDate { get; set; } + + /// 合约到期日。 + public DateTime? ContractMaturityDate { get; set; } + + /// 利息端基准:FR007 或固定利率。 + public string InterestBenchmark { get; set; } + + /// 普通利息腿当前交易日适用利率合计。 + public decimal InterestRatePrice { get; set; } + + /// + /// 开平仓费用。日终腿已按我方收益方向归一:我方支付为负、我方收取为正; + /// 新 Tab 单独展示该金额,但估值中仍须计入一次。 + /// + public decimal OpeningClosingFee { get; set; } + + /// + /// 不含开平仓费用的浮动端待实现收益,来源为日终浮动腿的 PosiMtmPnL; + /// 不可再由旧口径的 PosiProfitSum 反推,避免把费用重新混入本列。 + /// + public decimal FloatingUnrealizedPnl { get; set; } + + /// + /// 排除初始/维持保证金腿后的普通利息端待实现收益。保证金利息保留在其独立两列, + /// 且只通过 MarginInterestAmount 参与估值,以满足“利息端仅展示利息端盈亏”的新口径。 + /// + public decimal OrdinaryInterestPnl { get; set; } + + /// + /// 保证金利息净额,仅供两种合约估值维持旧总额;前端不直接绑定该字段, + /// 以防它再次落入“合约利息端待实现收益”。 + /// + public decimal MarginInterestAmount { get; set; } + + /// 收取对手方保证金利息。 + public decimal MarginInterestGain { get; set; } + + /// 支付对手方保证金利息。 + public decimal MarginInterestLoss { get; set; } + + /// 到期轧差口径估值;仅 DividendPayDate=0 时有值,且包含期间付息/分红。 + public decimal? MaturityNettingValuation { get; set; } + + /// 期间支付派息口径估值;仅 DividendPayDate 非 0 时有值,不重复计入期间付息/分红。 + public decimal? PeriodPaymentValuation { get; set; } + } + + /// + /// EQD-7084 新“框架合约”Tab 响应。继承旧响应以保持原有列字段完全一致, + /// 新接口只额外序列化新增字段。 + /// + public class EodSwapRiskNewResponse : EodSwapResponse + { + public EodSwapRiskNewFields NewFields { get; set; } + } } diff --git a/UnitTestProject/Modules/SwapModule/SwapEodPositionRiskNewTabTest.cs b/UnitTestProject/Modules/SwapModule/SwapEodPositionRiskNewTabTest.cs new file mode 100644 index 00000000..97a1527d --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapEodPositionRiskNewTabTest.cs @@ -0,0 +1,178 @@ +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule; + +/// +/// EQD-7084 新“框架合约”Tab 的口径测试。 +/// 纯计算测试不依赖数据库,直接锁定 EodPnlCalculator 的新口径。 +/// +[TestClass] +public class SwapEodPositionRiskNewTabTest +{ + [TestMethod] + public void 新口径_普通利息排除保证金_浮动收益剔除费用并保留估值总额() + { + var floating = new[] + { + // EOD 归一后,费用已经按我方收益视角落库;支付费用为负数。 + FloatingLeg("600000", 1, 100m, 0m, -12m, "普通收益互换") + }; + var interests = new[] + { + InterestLeg(1, (int)InterestModeEnum.固定值, 80m, 0.02m, 0.02m), + InterestLeg(1, (int)InterestModeEnum.初始预付金, 20m, 0.01m, 0.01m) + }; + + var fields = InvokeCalculation( + floating, + interests, + structureType: "普通收益互换", + notionalValue: 1_000m, + startDate: new DateTime(2026, 1, 1), + maturityDate: new DateTime(2026, 12, 31), + periodAmount: 5m, + dividendPayDate: 0); + + Assert.AreEqual(100m, GetDecimal(fields, "FloatingUnrealizedPnl"), 0.0001m, + "新浮动端待实现收益应排除 PosiFeePending:PosiProfitSum(88) - PosiFeePending(-12) = 100"); + Assert.AreEqual(-12m, GetDecimal(fields, "OpeningClosingFee"), 0.0001m, + "开平仓费用直接使用 EOD 已归一的 PosiFeePending"); + Assert.AreEqual(80m, GetDecimal(fields, "OrdinaryInterestPnl"), 0.0001m, + "利息端待实现收益应排除初始/维持保证金腿"); + Assert.AreEqual(-20m, GetDecimal(fields, "MarginInterestAmount"), 0.0001m, + "保证金利息仍应按保证金腿方向计入估值"); + Assert.AreEqual(153m, GetDecimal(fields, "MaturityNettingValuation"), 0.0001m, + "估值应保持旧口径:100 - 12 + 80 - 20 + 5 = 153;费用只计一次"); + } + + [TestMethod] + public void 新口径_当前利率合计使用普通利息腿TdInterestRate_并识别FR007() + { + var fr007Leg = InterestLeg(2, (int)InterestModeEnum.合约名义本金规模, 20m, 0.03m, 0.018m); + fr007Leg.FloatRateUnderlyingCode = "FR007"; + fr007Leg.FloatRate = 0.015m; + var fields = InvokeCalculation( + new[] { FloatingLeg("600001", 2, 100m, 0m, 0m, "普通收益互换") }, + new[] + { + InterestLeg(1, (int)InterestModeEnum.固定值, 10m, 0.02m, 0.0125m), + fr007Leg + }, + structureType: "普通收益互换", + notionalValue: 100m, + startDate: new DateTime(2026, 2, 1), + maturityDate: new DateTime(2026, 8, 1), + periodAmount: 0m, + dividendPayDate: 1); + + Assert.AreEqual(0.0305m, GetDecimal(fields, "InterestRatePrice"), 0.0000001m, + "利率端价格应为普通利息腿当前 TdInterestRate 合计,而非默认利差合计"); + Assert.AreEqual("FR007", GetString(fields, "InterestBenchmark")); + } + + [TestMethod] + public void 新口径_普通利息腿无FR007时基准为固定利率() + { + var fields = InvokeCalculation( + new[] { FloatingLeg("600002", 1, 100m, 0m, 0m, "普通收益互换") }, + new[] { InterestLeg(1, (int)InterestModeEnum.固定值, 10m, 0.02m, 0.0125m) }, + structureType: "普通收益互换", + notionalValue: 100m, + startDate: new DateTime(2026, 2, 1), + maturityDate: new DateTime(2026, 8, 1), + periodAmount: 0m, + dividendPayDate: 1); + + Assert.AreEqual("固定利率", GetString(fields, "InterestBenchmark")); + } + + [TestMethod] + public void 新口径_债券期初价格按风险页约定放大百分价格_并保留合同字段() + { + var fields = InvokeCalculation( + new[] { FloatingLeg("110000", 1, 99.12m, 0m, 0m, "普通债券类收益互换", "Bond") }, + new[] { InterestLeg(1, (int)InterestModeEnum.固定值, 1m, 0.01m, 0.01m) }, + structureType: "普通债券类收益互换", + notionalValue: 9_900m, + startDate: new DateTime(2026, 3, 1), + maturityDate: new DateTime(2027, 3, 1), + periodAmount: 0m, + dividendPayDate: 1); + + Assert.AreEqual(99.12m, GetDecimal(fields, "InitialPrice"), 0.0001m, + "债券日终 PosiGrossPrice 已由 SetPosiPrice 按风险页口径缩放,新接口不能再次乘 100"); + Assert.AreEqual(9_900m, GetDecimal(fields, "NotionalQuantity"), 0.0001m); + Assert.AreEqual("多头", GetString(fields, "UnderlyingDirection")); + Assert.AreEqual("110000", GetString(fields, "UnderlyingCode")); + Assert.AreEqual("Bond", GetString(fields, "UnderlyingInstrumentType")); + Assert.AreEqual(new DateTime(2026, 3, 1), GetDate(fields, "ContractStartDate")); + Assert.AreEqual(new DateTime(2027, 3, 1), GetDate(fields, "ContractMaturityDate")); + } + + private static object InvokeCalculation( + IEnumerable floating, + IEnumerable interests, + string structureType, + decimal notionalValue, + DateTime startDate, + DateTime maturityDate, + decimal periodAmount, + int dividendPayDate) + { + return EodPnlCalculator.CalculateEodSwapRiskNewFields( + floating, + interests, + structureType, + notionalValue, + startDate, + maturityDate, + periodAmount, + dividendPayDate); + } + + private static decimal GetDecimal(object fields, string name) + => Convert.ToDecimal(fields.GetType().GetProperty(name)!.GetValue(fields)); + + private static string GetString(object fields, string name) + => (string)fields.GetType().GetProperty(name)!.GetValue(fields)!; + + private static DateTime GetDate(object fields, string name) + => (DateTime)fields.GetType().GetProperty(name)!.GetValue(fields)!; + + private static eod_swap_position FloatingLeg( + string code, + int positionType, + decimal mtm, + decimal dividend, + decimal fee, + string structureType, + string instrumentType = null) + => new() + { + UnderlyingCode = code, + UnderlyingInstrumentType = instrumentType ?? structureType, + PositionType = positionType, + PosiGrossPrice = mtm, + PosiMtmPnL = mtm, + PosiDividendSum = dividend, + PosiFeePending = fee, + PosiProfitSum = mtm + dividend + fee, + PosiNotionalValue = 100m + }; + + private static eod_swap_position InterestLeg( + int direction, + int mode, + decimal profit, + decimal defaultRate, + decimal currentRate) + => new() + { + InterestDirection = direction, + InterestMode = mode, + InterestProfitSum = profit, + InterestRateDefault = defaultRate, + TdInterestRate = currentRate + }; +} diff --git a/YLErpDAL/Modules/SwapModule/EodPnlCalculator.cs b/YLErpDAL/Modules/SwapModule/EodPnlCalculator.cs index 3532be45..cbb90e4d 100644 --- a/YLErpDAL/Modules/SwapModule/EodPnlCalculator.cs +++ b/YLErpDAL/Modules/SwapModule/EodPnlCalculator.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; +using System.Linq; using YLErp; using YLErp.DBModels; using YLErp.DBModels.Enums; +using YLErp.Modules.SwapModule.Margin; using YLErp.Modules.SwapModule.ReturnLegs; namespace YLErp.Modules.SwapModule @@ -179,5 +181,101 @@ namespace YLErp.Modules.SwapModule { position.RealizedPnl = position.RealizedInterest + position.RealizedInterestFee; } + + /// + /// 计算 EQD-7084 新“框架合约”Tab 的纯展示口径。 + /// 浮动腿盯市收益、开平仓费用和普通利息分别计算;保证金腿的利息 + /// 仅作为估值组成项保留一次,不混入新 Tab 的普通利息列。 + /// + public static EodSwapRiskNewFields CalculateEodSwapRiskNewFields( + IEnumerable floatingLegs, + IEnumerable interestLegs, + string structureType, + decimal notionalValue, + DateTime? startDate, + DateTime? maturityDate, + decimal periodAmount, + int dividendPayDate) + { + // 日终明细以 UnderlyingCode 是否存在区分浮动腿和利息腿;调用方即使传入混合集合, + // 这里也会重新过滤,避免保证金/利息数据被带入浮动端新口径。 + var floating = (floatingLegs ?? Enumerable.Empty()) + .Where(x => x != null && !string.IsNullOrEmpty(x.UnderlyingCode)) + .ToList(); + var interests = (interestLegs ?? Enumerable.Empty()) + .Where(x => x != null && string.IsNullOrEmpty(x.UnderlyingCode)) + .ToList(); + // MarginModes 覆盖初始/维持保证金相关腿。它们的利息不属于需求中的“利息端待实现收益”, + // 但必须单独保留,以使两个合约估值与旧口径总额保持一致。 + var ordinaryInterests = interests.Where(x => !MarginModes.Contains(x.InterestMode)).ToList(); + var marginInterests = interests.Where(x => MarginModes.Contains(x.InterestMode)).ToList(); + var firstFloating = floating.FirstOrDefault(); + + // PosiGrossPrice 已是 EOD 归档口径的期初全价;债券价格不可在报表接口再次乘 100。 + var initialPrice = firstFloating?.PosiGrossPrice; + // PosiFeePending 是日终归一后的我方损益方向:支付费用为负、收取费用为正。 + // 本列独立展示它,下面的 valuation 再加回一次,不能因展示拆列而改变合约估值。 + var openingClosingFee = floating.Sum(x => x.PosiFeePending); + // PosiMtmPnL 已排除分红和费用,避免从 PosiProfitSum 重复拆分历史费用。 + var floatingUnrealizedPnl = floating.Sum(x => x.PosiMtmPnL); + var ordinaryInterestPnl = ordinaryInterests.Sum(x => + x.InterestProfitSum * DirectionRatio.InterestLegPnl(x.InterestDirection, x.InterestMode)); + var marginInterestAmount = marginInterests.Sum(x => + x.InterestProfitSum * DirectionRatio.InterestLegPnl(x.InterestDirection, x.InterestMode)); + + // 新口径估值 = 去费用浮动收益 + 开平仓费用 + 普通利息 + 保证金利息。 + // “浮动端待实现收益”列不包含费用,而合约估值仍沿用旧总额,故费用只能在此加一次。 + var valuation = floatingUnrealizedPnl + + openingClosingFee + + ordinaryInterestPnl + + marginInterestAmount; + var result = new EodSwapRiskNewFields + { + UnderlyingInstrumentType = firstFloating?.UnderlyingInstrumentType, + UnderlyingDirection = string.Join(",", floating + .Select(x => x.PositionType == (int)PositionTypeFlag.Long ? "多头" + : x.PositionType == (int)PositionTypeFlag.Short ? "空头" : "") + .Where(x => !string.IsNullOrEmpty(x)) + .Distinct()), + UnderlyingCode = string.Join(",", floating + .Select(x => x.UnderlyingCode) + .Where(x => !string.IsNullOrEmpty(x)) + .Distinct()), + InitialPrice = initialPrice, + NotionalQuantity = notionalValue, + ContractStartDate = startDate, + ContractMaturityDate = maturityDate, + // 只要普通利息腿存在 FR007,即按需求显示 FR007;保证金腿不影响该展示基准。 + InterestBenchmark = ordinaryInterests.Any(x => + !string.IsNullOrWhiteSpace(x.FloatRateUnderlyingCode) + && x.FloatRateUnderlyingCode.IndexOf("FR007", StringComparison.OrdinalIgnoreCase) >= 0) + ? "FR007" : "固定利率", + // 使用日终当日实际适用的 TdInterestRate 合计,而非合同初始利率或利差字段。 + InterestRatePrice = ordinaryInterests.Sum(x => x.TdInterestRate), + OpeningClosingFee = openingClosingFee, + FloatingUnrealizedPnl = floatingUnrealizedPnl, + OrdinaryInterestPnl = ordinaryInterestPnl, + MarginInterestAmount = marginInterestAmount, + MarginInterestGain = marginInterests + .Where(x => x.InterestDirection == (int)SwapDirectionEnum.支付) + .Sum(x => Math.Abs(x.InterestIncomeSum)), + MarginInterestLoss = marginInterests + .Where(x => x.InterestDirection == (int)SwapDirectionEnum.收取) + .Sum(x => -Math.Abs(x.InterestIncomeSum)) + }; + + // DividendPayDate=0 表示到期才与本金轧差,期间付息/分红需要加进该口径; + // 其余支付方式则由现金支付承担期间金额,估值字段不再包含 periodAmount。 + if (dividendPayDate == 0) + { + result.MaturityNettingValuation = valuation + periodAmount; + } + else + { + result.PeriodPaymentValuation = valuation; + } + + return result; + } } } diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index d13a96ec..67313730 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -3370,6 +3370,91 @@ namespace YLErp.Modules.SwapModule return retListResult; } + + /// + /// 查询 EQD-7084 新“框架合约”字段。 + /// 旧查询负责筛选、排序、分页及旧字段计算;新字段只基于当前页对应的日终腿补充计算, + /// 避免改变旧接口的返回口径。 + /// + public SearchListResult SearchEodSwapNewList(EodSwapQueryRequest req) + { + // 新 Tab 与旧 Tab 共享同一套权限、筛选、排序和分页边界;先复用旧查询, + // 再只替换需求明确调整的展示字段,避免新接口悄然改变旧口径或查询范围。 + var oldResult = SearchEodSwapList(req); + var oldRows = oldResult.rows?.ToList() ?? new List(); + var tradeIds = oldRows.Select(x => x.position.SwapTradeId).Distinct().ToList(); + var valueDates = oldRows.Select(x => x.position.ValueDate).Distinct().ToList(); + + if (tradeIds.Count == 0) + { + return new SearchListResult(oldResult, + Enumerable.Empty()); + } + + // 当前页的交易、日终明细和扩展信息各批量读取一次,随后在内存按“交易 + 日终日”配对。 + // 不在 rows.Select 内查询数据库,避免分页结果产生 N+1 查询。 + var trades = DbContext.trade + .Where(x => tradeIds.Contains(x.id)) + .Select(x => new { x.id, x.StartDate, x.ExerciseDate }) + .ToDictionary(x => x.id); + var eodPositionDetails = DbContext.eod_swap_position + .Where(x => tradeIds.Contains(x.SwapTradeId) + && valueDates.Contains(x.ValueDate) + && !x.Invalid) + .ToList(); + var tradeExtends = DbContext.trade_extend + .Where(x => tradeIds.Contains(x.TradeId)) + .ToList(); + + var rows = oldRows.Select(item => + { + // 同一交易可出现在多个日终日;必须同时匹配 ValueDate,不能把其他日期的腿混入本行。 + var details = eodPositionDetails + .Where(x => x.SwapTradeId == item.position.SwapTradeId + && x.ValueDate == item.position.ValueDate) + .ToList(); + var floatingLegs = details.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode)).ToList(); + var interestLegs = details.Where(x => string.IsNullOrEmpty(x.UnderlyingCode)).ToList(); + var tradeExtend = tradeExtends.FirstOrDefault(x => x.TradeId == item.position.SwapTradeId); + // 缺少扩展信息时按“期间支付”处理,和旧接口的默认值保持一致。 + var dividendPayDate = tradeExtend?.ExtendObj?.DividendPayDate ?? 1; + trades.TryGetValue(item.position.SwapTradeId, out var tradeInfo); + + return new EodSwapRiskNewResponse + { + position = item.position, + TradeDate = item.TradeDate, + SwapTradeNo = item.SwapTradeNo, + ClientName = item.ClientName, + StructureType = item.StructureType, + AssetBookName = item.AssetBookName, + ClientId = item.ClientId, + SwapTradeTypeStr = item.SwapTradeTypeStr, + UnderlyingType = item.UnderlyingType, + PeriodAmount = item.PeriodAmount, + FloatingUnrealizedPnl = item.FloatingUnrealizedPnl, + InterestPaymentMethod = item.InterestPaymentMethod, + MaturityNettingValuation = item.MaturityNettingValuation, + PeriodPaymentValuation = item.PeriodPaymentValuation, + MarginInterestGain = item.MarginInterestGain, + MarginInterestLoss = item.MarginInterestLoss, + // 所有 EQD-7084 差异集中在 NewFields;上方复制的旧字段用于保留原报表的 + // 基本信息、DV、期间金额及已实现收益,前端再将六个差异列绑定到 NewFields。 + NewFields = CalculateEodSwapRiskNewFields( + floatingLegs, + interestLegs, + item.StructureType, + item.position.NotionalValue, + tradeInfo?.StartDate, + tradeInfo?.ExerciseDate, + item.PeriodAmount, + dividendPayDate) + }; + }).ToList(); + + return new SearchListResult(oldResult, rows); + } + /// /// 获取互换交易日终持仓数据 /// @@ -3448,6 +3533,29 @@ namespace YLErp.Modules.SwapModule return retListResult; } + /// + /// 计算 EQD-7084 新“框架合约”Tab 的字段口径。 + /// 纯函数只依赖日终浮动腿、利息腿和交易级展示参数,供查询接口及无库单测共用。 + /// + public static EodSwapRiskNewFields CalculateEodSwapRiskNewFields( + IEnumerable floatingLegs, + IEnumerable interestLegs, + string structureType, + decimal notionalValue, + DateTime? startDate, + DateTime? ExerciseDate, + decimal periodAmount, + int dividendPayDate) + => EodPnlCalculator.CalculateEodSwapRiskNewFields( + floatingLegs, + interestLegs, + structureType, + notionalValue, + startDate, + ExerciseDate, + periodAmount, + dividendPayDate); + /// /// 互换持仓明细查询 /// diff --git a/YLErpWeb/Controllers/SwapTrade2Controller.cs b/YLErpWeb/Controllers/SwapTrade2Controller.cs index 7dbb0d69..e63a45ca 100644 --- a/YLErpWeb/Controllers/SwapTrade2Controller.cs +++ b/YLErpWeb/Controllers/SwapTrade2Controller.cs @@ -965,6 +965,23 @@ namespace YLErp.Web.Controllers var retListResult = service.SearchEodSwapList(req); return Json(retListResult); } + + /// + /// 日终持仓-互换新框架合约查询。 + /// 先与旧框架合约接口执行相同的账簿、资产单元和客户权限收敛, + /// 再返回 EQD-7084 拆分后的展示字段;不能直接绕过这些条件调用服务层。 + /// + /// + /// + public JsonResult EodSwapRiskNewQuery(EodSwapQueryRequest req) + { + req.BookIds = AssetUnitModel.IntersectAssetUnits(req.AssetIdGroupList, req.BookIds).ToList(); + req.UserAssets = CurUser.GetAssetUnitIds(); + req.UserClients = CurUser.GetClientIdsByCurUser(); + var service = new SwapEodPositionService(CurUser); + var retListResult = service.SearchEodSwapNewList(req); + return Json(retListResult); + } #endregion #region 结算报告 /// diff --git a/YLErpWeb/Views/SwapTrade2/EodPositionRisks.cshtml b/YLErpWeb/Views/SwapTrade2/EodPositionRisks.cshtml index aded5bf8..3a8a7e9f 100644 --- a/YLErpWeb/Views/SwapTrade2/EodPositionRisks.cshtml +++ b/YLErpWeb/Views/SwapTrade2/EodPositionRisks.cshtml @@ -46,7 +46,12 @@ 日终持仓
  • - 框架合约 + @* index=2 固定保留历史报表与导出配置,供新旧口径并行核对。 *@ + 框架合约(旧口径) +
  • +
  • + @* index=3 才使用 EQD-7084 新查询与拆分字段,不能复用旧 Tab 的列配置。 *@ + 框架合约
  • diff --git a/YLErpWeb/fe-tests/eodPositionRisks.test.js b/YLErpWeb/fe-tests/eodPositionRisks.test.js index 908528da..c26ee1b9 100644 --- a/YLErpWeb/fe-tests/eodPositionRisks.test.js +++ b/YLErpWeb/fe-tests/eodPositionRisks.test.js @@ -4,7 +4,8 @@ const vm = require('vm'); function loadEodPositionRiskHelpers() { const filePath = path.join(__dirname, '../wwwroot/Scripts/app/swaptrade/EodPositionRisks.js'); - const code = fs.readFileSync(filePath, 'utf8') + '\nmodule.exports = { TradeDirectionFormat };'; + const source = fs.readFileSync(filePath, 'utf8'); + const code = source + '\nmodule.exports = { TradeDirectionFormat, colModelGridEodSwap, colModelGridEodSwapNew, eodSwapGroupConfig, eodSwapRiskNewGroupConfig };'; const sandbox = { module: { exports: {} }, exports: {}, @@ -13,14 +14,25 @@ function loadEodPositionRiskHelpers() { numberFormat() { return function () { }; } + }, + otcformat: { + trading: { + notional() { return ''; }, + StockEqvNotional() { return ''; }, + tradePrice() { return ''; } + } + }, + swapPricePrecision: { + format() { return ''; } } }; vm.runInNewContext(code, sandbox, { filename: filePath }); - return sandbox.module.exports; + return { helpers: sandbox.module.exports, source }; } -const { TradeDirectionFormat } = loadEodPositionRiskHelpers(); +const loaded = loadEodPositionRiskHelpers(); +const { TradeDirectionFormat, colModelGridEodSwap, colModelGridEodSwapNew, eodSwapGroupConfig, eodSwapRiskNewGroupConfig } = loaded.helpers; describe('互换日终持仓交易方向', () => { test.each([ @@ -37,3 +49,54 @@ describe('互换日终持仓交易方向', () => { expect(TradeDirectionFormat(1, {}, { eodPosition: { PosiDirection: 0, PositionType: 1 } })).toBe(''); }); }); + +describe('EQD-7084 新框架合约前端接线', () => { + test('新列模型保留旧列并追加九个字段,六个展示列绑定 NewFields', () => { + const oldColumns = colModelGridEodSwap(); + const newColumns = colModelGridEodSwapNew(); + const oldNames = oldColumns.map(column => column.name); + const newNames = newColumns.map(column => column.name); + const replacements = { + FloatingUnrealizedPnl: 'NewFields.FloatingUnrealizedPnl', + 'position.InterestPnL': 'NewFields.OrdinaryInterestPnl', + MarginInterestGain: 'NewFields.MarginInterestGain', + MarginInterestLoss: 'NewFields.MarginInterestLoss', + MaturityNettingValuation: 'NewFields.MaturityNettingValuation', + PeriodPaymentValuation: 'NewFields.PeriodPaymentValuation' + }; + const newFields = [ + 'NewFields.UnderlyingDirection', + 'NewFields.UnderlyingCode', + 'NewFields.InitialPrice', + 'NewFields.NotionalQuantity', + 'NewFields.ContractStartDate', + 'NewFields.ContractMaturityDate', + 'NewFields.InterestBenchmark', + 'NewFields.InterestRatePrice', + 'NewFields.OpeningClosingFee' + ]; + + expect(newColumns).toHaveLength(oldColumns.length + 9); + Object.entries(replacements).forEach(([oldName, newName]) => { + expect(newNames).toContain(newName); + expect(newNames).not.toContain(oldName); + expect(newColumns.find(column => column.name === newName).label) + .toBe(oldColumns.find(column => column.name === oldName).label); + }); + oldNames + .filter(oldName => !Object.prototype.hasOwnProperty.call(replacements, oldName)) + .forEach(oldName => expect(newNames).toContain(oldName)); + newFields.forEach(field => expect(newNames).toContain(field)); + }); + + test('index=2 保留旧 endpoint/config,index=3 使用独立 endpoint/config 且界面不启用分组', () => { + expect(loaded.source).toContain("queryurl = '/swaptrade2/EodSwapRiskQuery';"); + expect(loaded.source).toContain("cloumnTargetName = \"eodSwapList\";"); + expect(loaded.source).toContain("queryurl = '/swaptrade2/EodSwapRiskNewQuery';"); + expect(loaded.source).toContain("cloumnTargetName = \"eodSwapRiskNewList\";"); + expect(loaded.source).toContain('eodSwapRiskNewExportColumnNames'); + expect(loaded.source).not.toMatch(/main\.initCollapsibleGroupHeaders\s*\(/); + expect(eodSwapGroupConfig).not.toBe(eodSwapRiskNewGroupConfig); + expect(eodSwapRiskNewGroupConfig.some(group => group.columns.includes('NewFields.InitialPrice'))).toBe(true); + }); +}); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/EodPositionRisks.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/EodPositionRisks.js index 8d799ab1..3afcd0aa 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/EodPositionRisks.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/EodPositionRisks.js @@ -1,6 +1,7 @@ var queryurl = '/swaptrade2/EodPositionRiskQuery'; var cloumnTargetName = "eodSwapPositionList"; var eodSwapExportColumnNames = []; +var eodSwapRiskNewExportColumnNames = []; $(function () { var PostData = {}; $("#DateValueDate").datepicker({ @@ -24,6 +25,18 @@ $(function () { }).map(function (col) { return col.name; }); + } else if (page.tabIndex == 3) { + // 新旧口径并行:独立 endpoint、列设置 key 与标准导出列,避免用户在新 Tab 调列后影响旧报表。 + queryurl = '/swaptrade2/EodSwapRiskNewQuery'; + $("#myTab li:first").removeClass("active"); + $("#myTab li:eq(2)").addClass("active"); + cloumnTargetName = "eodSwapRiskNewList"; + colModelGrid = colModelGridEodSwapNew(); + eodSwapRiskNewExportColumnNames = colModelGrid.filter(function (col) { + return !col.optionHide; + }).map(function (col) { + return col.name; + }); } PostData.ValueDate = $("#DateValueDate").val(); var grid = jQuery('#listGrid').jqGrid({ @@ -44,7 +57,8 @@ $(function () { pagerpos: 'left', rowNum: 25, rowList: [25, 50, 100, 200, 10000], - footerrow: page.tabIndex == 2, + // 两个框架合约 Tab 都需要承载后端返回的 DV 汇总;普通日终持仓维持原行为。 + footerrow: page.tabIndex == 2 || page.tabIndex == 3, loadComplete: gridComplete, onPaging: onJqgridPaging, grouping: true @@ -687,6 +701,58 @@ function colModelGridEodSwap() { return colModelGrid; } +// EQD-7084 新框架合约:复用旧列定义,只替换新口径字段并追加新增列。 +function colModelGridEodSwapNew() { + var colModelGrid = colModelGridEodSwap().map(function (col) { + return Object.assign({}, col); + }); + + // 替换后仍保留旧字段 index:后端沿用旧查询处理排序,NewFields 只是显示用的计算字段。 + function replaceColumn(oldName, newName) { + var column = colModelGrid.find(function (col) { return col.name === oldName; }); + if (column) { + column.name = newName; + // 新字段在服务端计算,沿用旧列的数据库排序字段,保持分页/排序请求有效。 + column.index = oldName; + } + } + + function newColumn(name, label, formatter, index) { + return { + name: name, + label: label, + index: index || name, + width: 150, + align: 'center', + formatter: formatter, + sortable: false + }; + } + + // 插入点必须在原“名义本金”前,使新需求字段与旧字段的业务阅读顺序、标准导出顺序一致。 + var contractInfoIndex = colModelGrid.findIndex(function (col) { + return col.name === 'position.NotionalValue'; + }); + colModelGrid.splice(contractInfoIndex, 0, + newColumn('NewFields.UnderlyingDirection', '标的多空(浮动端)'), + newColumn('NewFields.UnderlyingCode', '标的代码'), + newColumn('NewFields.InitialPrice', '期初价格', InitialPriceFormat), + newColumn('NewFields.NotionalQuantity', '名义数量', otcformat.trading.notional), + newColumn('NewFields.ContractStartDate', '合约起始日', 'date'), + newColumn('NewFields.ContractMaturityDate', '合约到期日', 'date'), + newColumn('NewFields.InterestBenchmark', '利息端基准'), + newColumn('NewFields.InterestRatePrice', '利率端价格', PercentFormat), + newColumn('NewFields.OpeningClosingFee', '开平仓费用', StockEqvNotionalFormat)); + + replaceColumn('FloatingUnrealizedPnl', 'NewFields.FloatingUnrealizedPnl'); + replaceColumn('position.InterestPnL', 'NewFields.OrdinaryInterestPnl'); + replaceColumn('MarginInterestGain', 'NewFields.MarginInterestGain'); + replaceColumn('MarginInterestLoss', 'NewFields.MarginInterestLoss'); + replaceColumn('MaturityNettingValuation', 'NewFields.MaturityNettingValuation'); + replaceColumn('PeriodPaymentValuation', 'NewFields.PeriodPaymentValuation'); + return colModelGrid; +} + //框架合约分组配置(对应需求《估值模块V1》2.2 字段定义) //columns 使用 colModel.name;组内列在 colModel 中必须连续 var eodSwapGroupConfig = [ @@ -699,14 +765,27 @@ var eodSwapGroupConfig = [ { title: '估值与实现收益', columns: ['position.dv01', 'InterestPaymentMethod', 'MaturityNettingValuation', 'PeriodPaymentValuation', 'position.RealizedPnL'] } ]; +// 新 Tab 页面不渲染可折叠分组表头(产品已要求取消界面分组); +// 此配置只服务“导出标准格式”,因此必须与旧 Tab 分开维护而不能删除。 +var eodSwapRiskNewGroupConfig = [ + { title: '基本信息', columns: ['position.ValueDate', 'AssetBookName', 'ClientName', 'SwapTradeNo', 'StructureType', 'SwapTradeTypeStr', 'UnderlyingType'] }, + { title: '新增字段', columns: ['NewFields.UnderlyingDirection', 'NewFields.UnderlyingCode', 'NewFields.InitialPrice', 'NewFields.NotionalQuantity', 'NewFields.ContractStartDate', 'NewFields.ContractMaturityDate', 'NewFields.InterestBenchmark', 'NewFields.InterestRatePrice', 'NewFields.OpeningClosingFee'] }, + { title: '名义本金', columns: ['position.NotionalValue', 'position.NotionalValueLong', 'position.NotionalValueShort'] }, + { title: '标的市值', columns: ['position.MarketValueLong', 'position.MarketValueShort'] }, + { title: '浮动端', columns: ['NewFields.FloatingUnrealizedPnl', 'PeriodAmount'] }, + { title: '利息端', columns: ['NewFields.OrdinaryInterestPnl'] }, + { title: '保证金', columns: ['position.InitMarginGain', 'position.PostionMarginGain', 'position.InitMarginLoss', 'position.PostionMarginLoss', 'NewFields.MarginInterestGain', 'NewFields.MarginInterestLoss'] }, + { title: '估值与实现收益', columns: ['position.dv01', 'InterestPaymentMethod', 'NewFields.MaturityNettingValuation', 'NewFields.PeriodPaymentValuation', 'position.RealizedPnL'] } +]; + function gridComplete() { var jgrid = $(this); if (arguments[0].Sum) { jgrid.footerData("set", { 'position.dv01': arguments[0].Sum["DV"] }); } - //框架合约Tab:列设置应用完成后补充期间付息提示。 - if (page.tabIndex == 2) { + // 两个框架合约 Tab 均保留 DV footer 与列设置;界面使用普通单层表头。 + if (page.tabIndex == 2 || page.tabIndex == 3) { var defer = main.setcolumnChooser(jgrid, cloumnTargetName); $.when(defer).done(function () { jgrid.jqGrid('setLabel', 'PeriodAmount', null, null, { @@ -734,13 +813,21 @@ function starttradeView(id) { function exportVisibleColumns() { var jgrid = jQuery('#listGrid'); var dateStr = $("#DateValueDate").val() || ''; - var tabName = page.tabIndex == 2 ? '框架合约' : '日终持仓'; + var tabName = page.tabIndex == 2 + ? '框架合约(旧口径)' + : page.tabIndex == 3 ? '框架合约' : '日终持仓'; var fileName = '日终持仓风险_互换_' + tabName + (dateStr ? '_' + dateStr : ''); - if (page.tabIndex != 2) { + if (page.tabIndex != 2 && page.tabIndex != 3) { main.exportVisibleColumnsToExcel(jgrid, fileName, null); return; } + // 虽然新 Tab 不展示分组表头,标准格式导出仍按需求输出分组标题和固定列顺序。 + var groupConfig = page.tabIndex == 3 ? eodSwapRiskNewGroupConfig : eodSwapGroupConfig; + var standardColumnNames = page.tabIndex == 3 + ? eodSwapRiskNewExportColumnNames + : eodSwapExportColumnNames; + layer.open({ type: 1, title: '选择导出方式', @@ -753,7 +840,7 @@ function exportVisibleColumns() { '', success: function (layero, index) { layero.find('.js-export-eod-swap-standard').on('click', function () { - exportEodSwapRows(jgrid, fileName, eodSwapGroupConfig, eodSwapExportColumnNames); + exportEodSwapRows(jgrid, fileName, groupConfig, standardColumnNames); layer.close(index); }); layero.find('.js-export-eod-swap-visible').on('click', function () { @@ -810,6 +897,13 @@ function RealizedPnlFormat(cellValue, options, rowObject) { function StockEqvNotionalFormat(cellValue, options, rowObject) { return otcformat.trading.StockEqvNotional(cellValue); } +function InitialPriceFormat(cellValue, options, rowObject) { + // 类型来自 NewFields(不再是旧 eodPosition 嵌套对象),以便债券按全价精度、非债券按普通价格精度展示。 + var instrumentType = rowObject + && rowObject.NewFields + && rowObject.NewFields.UnderlyingInstrumentType; + return swapPricePrecision.format(cellValue, instrumentType, 'grossPrice'); +} function NullableStockEqvNotionalFormat(cellValue, options, rowObject) { if (cellValue === null || cellValue === undefined || cellValue === '') { return '';