diff --git a/Framework/YLErp.Core/DBModels/SwapFlowEvent.cs b/Framework/YLErp.Core/DBModels/SwapFlowEvent.cs index b9079e27..632ed8c3 100644 --- a/Framework/YLErp.Core/DBModels/SwapFlowEvent.cs +++ b/Framework/YLErp.Core/DBModels/SwapFlowEvent.cs @@ -228,6 +228,16 @@ namespace YLErp.DBModels [NotMapped] public decimal BeforeCloseFee { get; set; } /// + /// 基础费率(仅前端展示,不存库) + /// + [NotMapped] + public decimal PosiTradingFeeUnit { get; set; } + /// + /// 基础费率模式 0=百分比 1=单位数量(仅前端展示,不存库) + /// + [NotMapped] + public int PosiFeeType { get; set; } + /// /// 持仓腿id /// [DisplayName("持仓腿id")] diff --git a/Framework/YLErp.Core/DBModels/SwapPosition.cs b/Framework/YLErp.Core/DBModels/SwapPosition.cs index bcc18c36..04a306f9 100644 --- a/Framework/YLErp.Core/DBModels/SwapPosition.cs +++ b/Framework/YLErp.Core/DBModels/SwapPosition.cs @@ -113,6 +113,11 @@ namespace YLErp.DBModels [DataChange] public decimal PosiTradingFeeUnit { get; set; } /// + /// 单位交易费用模式 0=百分比 1=单位数量 + /// + [DataChange] + public int PosiFeeType { get; set; } + /// /// 起始日 /// [DisplayName("起始日")] @@ -246,6 +251,10 @@ namespace YLErp.DBModels /// public int? interest_rule { get; set; } /// + /// 利息端类别 + /// + public string category_tag { get; set; } + /// /// 互换观察日集合 /// [NotMapped] diff --git a/Framework/YLErp.Resources/Dictionary/db_dictionaries.xml b/Framework/YLErp.Resources/Dictionary/db_dictionaries.xml index a0f9091e..97770296 100644 --- a/Framework/YLErp.Resources/Dictionary/db_dictionaries.xml +++ b/Framework/YLErp.Resources/Dictionary/db_dictionaries.xml @@ -30,6 +30,7 @@ + diff --git a/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看多】-【债券ETF】-清洁版.docx b/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看多】-【债券ETF】-清洁版.docx index 5347c974..a98c760b 100644 Binary files a/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看多】-【债券ETF】-清洁版.docx and b/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看多】-【债券ETF】-清洁版.docx differ diff --git a/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看空】-【债券ETF】-清洁版.docx b/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看空】-【债券ETF】-清洁版.docx index dc9710fa..465c9774 100644 Binary files a/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看空】-【债券ETF】-清洁版.docx and b/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看空】-【债券ETF】-清洁版.docx differ diff --git a/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs b/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs index 1aeed0a5..a926b273 100644 --- a/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs +++ b/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs @@ -241,11 +241,21 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator dic["参考标的期初全价%"] = ((double)swapPosition.PosiGrossPrice * 100).ToString("N4"); dic["参考标的期初净价%"] = ((double)(swapPosition.PosiNetNoFeePrice ?? 0m) * 100).ToString("N4"); - // 固定收益率(年化)- 债券期初到期收益率 - //dic["固定收益率(年化)"] = swapPosition.InitYtm.HasValue - // ? ((double)swapPosition.InitYtm.Value * 100).ToString("N4") - // : "0.0000"; - dic["固定收益率(年化)"] = "0.0000"; //需求说直接都是0 + // 固定收益率(年化)- ETF默认取"增强收益"腿的计息利率 + bool isEtf = IsBondEtf(underlying?.UnderlyingCode ?? string.Empty); + if (isEtf) + { + var enhancePosition = swapPositions + .Where(x => ConsTrade.InterestModels.Contains(x.InterestMode) && x.category_tag == "增强收益") + .FirstOrDefault(); + dic["固定收益率"] = enhancePosition != null + ? ((double)enhancePosition.InterestRateDefault * 100).ToString("N4") + : "0.0000"; + } + else + { + dic["固定收益率"] = "0.0000"; + } // 获取客户适用的保证金率 var clientMarginRate = UnderlyingHelper.GetApplicableMarginRate( @@ -405,9 +415,18 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator : "0.0000"; // 利率类型判断(固定/浮动) - var interestMargin = swapPositions - .Where(x => ConsTrade.InterestModels.Contains(x.InterestMode) && x.interest_rest_days != null) - .FirstOrDefault(); + swap_position interestMargin = null; + // ETF: 优先取"互换利率"腿 + if (isEtf) + { + interestMargin = swapPositions + .Where(x => ConsTrade.InterestModels.Contains(x.InterestMode) && x.category_tag == "互换利率") + .FirstOrDefault(); + } + if (interestMargin == null) + interestMargin = swapPositions + .Where(x => ConsTrade.InterestModels.Contains(x.InterestMode) && x.interest_rest_days != null) + .FirstOrDefault(); if (interestMargin == null) interestMargin = swapPositions .Where(x => ConsTrade.InterestModels.Contains(x.InterestMode) && string.IsNullOrWhiteSpace(x.FloatRateUnderlyingCode)) diff --git a/UnitTestProject/Modules/SwapModule/ClosePercentProductContractTest.cs b/UnitTestProject/Modules/SwapModule/ClosePercentProductContractTest.cs new file mode 100644 index 00000000..ad44bead --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/ClosePercentProductContractTest.cs @@ -0,0 +1,357 @@ +namespace YLErp.Modules.SwapModule +{ + /// + /// 平仓比例口径A(占期初名义本金)产品契约测试 + /// ================================================================================ + /// 产品需求(不可违背): + /// ClosePercent 永远是"占期初名义本金(NotionalValue)的比例",即口径A。 + /// 例:期初本金 5000 万,首次平 35%(ClosePercent=0.35),剩余 3250 万。 + /// 第二次想平掉剩余的 50%,ClosePercent 应 = 1625万/5000万 = 0.325(占期初), + /// 而非 0.50(占剩余)。 + /// + /// 前后端协作约定: + /// - 前端(unwindSwapTrade.js):ClosePercent 始终以 NotionalValue 为分母计算/显示 + /// - 前端传给后端:ClosePercent 为口径A + /// - 后端入口(SwapUnwind/ApplySwapTrade):ToRemainingClosePercent 将 A→B 供内部计算 + /// - 后端落库(SaveSwapDealInternal):ToOriginalClosePercent 将 B→A 还原存储 + /// - 后端 InitUnwind 默认值:CalcDefaultInitClosePercent = PosiNotionalValue/NotionalValue(口径A) + /// + /// 本测试守护的回归场景(c9071a4e 曾犯的错误): + /// 1. 前端把 oriClosePercent 硬编码为 1(应为 PosiNotionalValue/NotionalValue) + /// 2. 前端把 CloseNotionalValue 分母从 NotionalValue 改为 PosiNotionalValue + /// 3. 前端把 ClosePercent 分母从 NotionalValue 改为 PosiNotionalValue + /// 4. 前端把 getInterestList 的 notionalValue/posiNotionalValue 参数去掉 + /// 5. 前端把 calcCloseQtyByPercent 从 SwapCalc.calcCloseQtyByOriginalPercent 改为直接乘 + /// 6. 后端 InitUnwind 默认 ClosePercent 改为 1 而非剩余比例 + /// + /// 与既有测试的关系: + /// - ApplySwapTradeClosePercentBugTest:测 A→B 转换函数正确性(函数级) + /// - InitUnwindDefaultClosePercentTest:测默认值函数正确性(函数级) + /// - 本测试:测完整多步场景的口径A契约(场景级),补齐"装配测试"盲区 + /// ================================================================================ + [TestClass] + public class ClosePercentProductContractTest + { + // GLMS-20260701-0006 真实数据 + private const decimal OriginalNotional = 50_000_000m; // 期初名义本金(NotionalValue) + private const decimal FirstClosePercentA = 0.35m; // 第一次平仓35%(口径A) + private const decimal RemainingAfter1st = 32_500_000m; // 首次平35%后剩余(PosiNotionalValue) + // 第二次想平掉剩余的 50% → 平仓额=16,250,000 → ClosePercent(A)=1625万/5000万=0.325 + private const decimal SecondCloseNotional = 16_250_000m; + private const decimal SecondClosePercentA = 0.325m; // 口径A:占期初 + private const decimal SecondClosePercentB = 0.50m; // 口径B:占剩余 + + // ================================================================ + // 契约1:ClosePercent = CloseNotionalValue / NotionalValue(口径A) + // 如果有人把分母改成 PosiNotionalValue,此测试会红 + // ================================================================ + [TestMethod] + public void CPC_001_平仓比例必须用期初名义本金为分母_而非剩余名义本金() + { + // 正确:口径A = 平仓名义本金 / 期初名义本金 + decimal correctA = SecondCloseNotional / OriginalNotional; + SwapDealTestFactory.AssertDecimalEqual(SecondClosePercentA, correctA, 1e-10m, + "口径A:16,250,000 / 50,000,000 = 0.325"); + + // 错误:口径B = 平仓名义本金 / 剩余名义本金 + decimal buggyB = SecondCloseNotional / RemainingAfter1st; + SwapDealTestFactory.AssertDecimalEqual(SecondClosePercentB, buggyB, 1e-10m, + "口径B(错误):16,250,000 / 32,500,000 = 0.50"); + + // 两者必须不同——如果相同说明测试场景退化(首次平仓 remaining==original) + Assert.AreNotEqual(correctA, buggyB, + "口径A(0.325) 和 口径B(0.50) 在多次部分平仓后必须不同,否则测试场景退化"); + Console.WriteLine($"口径A={correctA}(正确),口径B={buggyB}(错误)"); + } + + // ================================================================ + // 契约2:CloseNotionalValue = ClosePercent × NotionalValue(口径A) + // 如果有人把乘数改成 PosiNotionalValue,此测试会红 + // ================================================================ + [TestMethod] + public void CPC_002_平仓名义本金必须用期初名义本金为乘数_而非剩余名义本金() + { + // 正确:口径A → CloseNotionalValue = ClosePercent(A) × NotionalValue + decimal correctNotional = SecondClosePercentA * OriginalNotional; + SwapDealTestFactory.AssertDecimalEqual(SecondCloseNotional, correctNotional, 1e-6m, + "口径A:0.325 × 50,000,000 = 16,250,000"); + + // 错误:口径B → CloseNotionalValue = ClosePercent(A) × PosiNotionalValue + // 如果前端传口径A的0.325但误用 PosiNotionalValue 做乘数 + decimal buggyNotional = SecondClosePercentA * RemainingAfter1st; + // 0.325 × 32,500,000 = 10,562,500 ≠ 16,250,000 + Assert.AreNotEqual(SecondCloseNotional, buggyNotional, + "口径A的0.325 × 剩余本金32,500,000 = 10,562,500 ≠ 16,250,000,乘数错了"); + Console.WriteLine($"正确={correctNotional},错误(用剩余)={buggyNotional}"); + } + + // ================================================================ + // 契约3:oriClosePercent = PosiNotionalValue / NotionalValue(不能硬编码为1) + // 如果有人把 oriClosePercent 改成 1,此测试会红 + // ================================================================ + [TestMethod] + public void CPC_003_最多可平比例必须为剩余除以期初_不能硬编码为1() + { + // 正确:oriClosePercent = PosiNotionalValue / NotionalValue + decimal correctOri = RemainingAfter1st / OriginalNotional; + SwapDealTestFactory.AssertDecimalEqual(0.65m, correctOri, 1e-10m, + "oriClosePercent = 32,500,000 / 50,000,000 = 0.65"); + + // 错误:硬编码为 1(c9071a4e 的错误) + decimal buggyOri = 1m; + Assert.AreNotEqual(correctOri, buggyOri, + "多次部分平仓后 oriClosePercent 必须小于 1,硬编码 1 会允许平超过剩余持仓"); + + // 首次平仓时 oriClosePercent 才等于 1(remaining == original) + decimal firstTimeOri = OriginalNotional / OriginalNotional; + SwapDealTestFactory.AssertDecimalEqual(1m, firstTimeOri, 1e-10m, + "首次平仓时 oriClosePercent = 1(remaining == original)"); + Console.WriteLine($"多次部分平仓后:oriClosePercent={correctOri}(≠1),首次:{firstTimeOri}(=1)"); + } + + // ================================================================ + // 契约4:CloseQty 必须经过 A→B 转换,不能直接 PositionQty × ClosePercent(A) + // 如果有人删除 calcCloseQtyByOriginalPercent 调用改为直接乘,此测试会红 + // ================================================================ + [TestMethod] + public void CPC_004_平仓数量必须经过口径A到B转换_不能直接乘() + { + // 场景:期初数量 32,500,000(=剩余数量),oriClosePercent=0.65 + // 用户输入 ClosePercent(A) = 0.325(想平剩余的 50%) + decimal positionQty = RemainingAfter1st; // 32,500,000 + decimal oriClosePercent = RemainingAfter1st / OriginalNotional; // 0.65 + + // 正确:CloseQty = PositionQty × (ClosePercent(A) / oriClosePercent) + // = 32,500,000 × (0.325 / 0.65) = 32,500,000 × 0.5 = 16,250,000 + decimal correctQty = positionQty * (SecondClosePercentA / oriClosePercent); + SwapDealTestFactory.AssertDecimalEqual(16_250_000m, correctQty, 1e-6m, + "正确:32,500,000 × (0.325/0.65) = 16,250,000"); + + // 错误:CloseQty = PositionQty × ClosePercent(A)(直接乘,不做转换) + // = 32,500,000 × 0.325 = 10,562,500 ❌ + decimal buggyQty = positionQty * SecondClosePercentA; + Assert.AreNotEqual(correctQty, buggyQty, + "直接乘会得到 10,562,500 而非 16,250,000,数量算少 35%"); + + // JS 浮点精度守卫:32500000×(0.5/0.65) 可能 = 24999999.999999996 + // SwapCalc.calcCloseQtyByOriginalPercent 用 roundHalfAwayFromZero 修复 + decimal jsFloatTrap = (decimal)((double)positionQty * ((double)SecondClosePercentA / (double)oriClosePercent)); + Console.WriteLine($"正确={correctQty},错误(直接乘)={buggyQty},JS浮点陷阱={jsFloatTrap}"); + } + + // ================================================================ + // 契约5:后端 A→B→A 往返转换必须还原(多次部分平仓场景) + // 守护 SwapUnwind/ApplySwapTrade 入口的 ToRemainingClosePercent + SaveSwapDealInternal 的 ToOriginalClosePercent + // ================================================================ + [TestMethod] + public void CPC_005_多次部分平仓_A到B到A往返转换必须还原原值() + { + // 第二次部分平仓:前端传 ClosePercent(A) = 0.325 + decimal closePercentA = SecondClosePercentA; + + // 后端入口:A → B + decimal closePercentB = SwapDealService.ToRemainingClosePercent( + closePercentA, OriginalNotional, RemainingAfter1st); + SwapDealTestFactory.AssertDecimalEqual(SecondClosePercentB, closePercentB, 1e-10m, + "A→B:0.325 × 50,000,000 / 32,500,000 = 0.50"); + + // 后端落库:B → A 还原 + decimal restoredA = SwapDealService.ToOriginalClosePercent( + closePercentB, OriginalNotional, RemainingAfter1st); + SwapDealTestFactory.AssertDecimalEqual(closePercentA, restoredA, 1e-10m, + "B→A 还原:0.50 × 32,500,000 / 50,000,000 = 0.325(必须等于原始A)"); + + Console.WriteLine($"A={closePercentA} → B={closePercentB} → A'={restoredA} ✅ 往返还原"); + } + + // ================================================================ + // 契约6:后端 InitUnwind 默认 ClosePercent = PosiNotionalValue / NotionalValue(口径A) + // 不能硬编码为 1(c9071a4e 前端硬编码1的错误在后端等价于此) + // ================================================================ + [TestMethod] + public void CPC_006_InitUnwind默认值必须为剩余除以期初_不能硬编码为1() + { + // 多次部分平仓后:期初 50M,剩余 32.5M + decimal defaultValue = SwapDealService.CalcDefaultInitClosePercent( + OriginalNotional, RemainingAfter1st); + + // 正确:0.65(占期初的"平剩余全部"比例) + SwapDealTestFactory.AssertDecimalEqual(0.65m, defaultValue, 1e-10m, + "CalcDefaultInitClosePercent(50M, 32.5M) = 0.65(口径A)"); + + // 不能是 1(硬编码错误) + Assert.AreNotEqual(1m, defaultValue, + "多次部分平仓后默认值不能为1,否则意味着'平掉原始全部'而非'平剩余全部'"); + + Console.WriteLine($"InitUnwind 默认 ClosePercent(A) = {defaultValue}(≠1)✅"); + } + + // ================================================================ + // 契约7:SwapUnwind 第二次部分平仓必须正确做 A→B 转换 + // 这是"装配测试"——验证后端入口确实执行了转换,而不只是函数本身正确 + // ================================================================ + [TestMethod] + public void CPC_007_SwapUnwind第二次部分平仓_入口必须将ClosePercent从A转为B() + { + // 模拟 GLMS-20260701-0006 第二次部分平仓 + var td = new trade + { + id = 2001, + TradeNumber = "CPC-TEST-007", + TradeType = "收益互换", + TradeStatus = "确认成交", + ValidState = "Valid", + StockEqvNotional = (double)RemainingAfter1st, // 32,500,000 + OriginalStockEqvNotional = (double)OriginalNotional, // 50,000,000 + Notional = (double)RemainingAfter1st, + TradeAmount = (double)RemainingAfter1st + }; + + var service = new TestableSwapDealService(td); + + // 前端传 ClosePercent = 0.325(口径A,占期初) + var unwindData = new UnwindData + { + SwapTradeId = td.id, + SwapRealizedPnL = 1000m, + SwapCloseAmount = 1000m, + CloseMethod = (int)CloseMethodEnum.部分平仓, + ClosePercent = SecondClosePercentA, // 0.325(口径A) + CloseQty = 16_250_000m, + CloseNotionalValue = SecondCloseNotional, // 16,250,000 + PositionQty = RemainingAfter1st, // 32,500,000 + NotionalValue = OriginalNotional, // 50,000,000(期初) + PosiNotionalValue = RemainingAfter1st, // 32,500,000(剩余) + ValueDate = new DateTime(2026, 7, 14), + UnwindDate = new DateTime(2026, 7, 15), + StartDate = new DateTime(2026, 7, 1) + }; + + service.SwapUnwind(unwindData); + + // 验证 SwapUnwind 被调用 + Assert.AreEqual(1, service.SaveSwapDealCalls.Count, "SwapUnwind 应调用 SaveSwapDeal"); + + // 验证传给 SaveSwapDeal 的 ClosePercent 已转为口径B + var savedData = service.SaveSwapDealCalls[0].data; + decimal expectedB = SwapDealService.ToRemainingClosePercent( + SecondClosePercentA, OriginalNotional, RemainingAfter1st); + + SwapDealTestFactory.AssertDecimalEqual(expectedB, savedData.ClosePercent, 1e-10m, + "SwapUnwind 应将 ClosePercent 从口径A(0.325)转为口径B(0.50)"); + + // 口径B 不等于口径A(验证转换确实发生了) + Assert.AreNotEqual(SecondClosePercentA, savedData.ClosePercent, + "口径B(0.50) 不应等于口径A(0.325),否则说明转换缺失"); + + Console.WriteLine($"SwapUnwind: 输入A={SecondClosePercentA} → 输出B={savedData.ClosePercent} ✅"); + } + + // ================================================================ + // 契约8:完整多步场景——3次平仓合计应等于期初本金 + // 首次35%(A) → 第二次平剩余50%(A=0.325) → 第三次全平剩余(A=0.325) + // 合计 CloseNotionalValue = 17.5M + 16.25M + 16.25M = 50M = 原始本金 + // ================================================================ + [TestMethod] + public void CPC_008_三次部分平仓合计本金必须等于期初名义本金() + { + decimal remaining = OriginalNotional; // 50,000,000 + decimal totalClosed = 0m; + + // Step1: 平 35%(首次,remaining == original, A = B = 0.35) + decimal step1A = 0.35m; + decimal step1Notional = step1A * OriginalNotional; // 17,500,000 + totalClosed += step1Notional; + remaining -= step1Notional; // 32,500,000 + + // Step2: 平剩余的 50% → A = 16,250,000 / 50,000,000 = 0.325 + decimal step2Notional = 16_250_000m; + decimal step2A = step2Notional / OriginalNotional; // 0.325 + // 后端 A→B 转换 + decimal step2B = SwapDealService.ToRemainingClosePercent( + step2A, OriginalNotional, remaining); + SwapDealTestFactory.AssertDecimalEqual(0.50m, step2B, 1e-10m, + "Step2: A=0.325 → B=0.50(平剩余50%)"); + totalClosed += step2Notional; + remaining -= step2Notional; // 16,250,000 + + // Step3: 全平剩余 → A = 16,250,000 / 50,000,000 = 0.325 + decimal step3Notional = remaining; + decimal step3A = step3Notional / OriginalNotional; // 0.325 + decimal step3B = SwapDealService.ToRemainingClosePercent( + step3A, OriginalNotional, remaining); + SwapDealTestFactory.AssertDecimalEqual(1.0m, step3B, 1e-10m, + "Step3: A=0.325 → B=1.0(全平剩余)"); + totalClosed += step3Notional; + remaining -= step3Notional; // 0 + + // 守恒:合计 = 期初 + SwapDealTestFactory.AssertDecimalEqual(OriginalNotional, totalClosed, 1e-6m, + "三次平仓合计必须 = 期初名义本金 50,000,000"); + SwapDealTestFactory.AssertDecimalEqual(0m, remaining, 1e-6m, + "三次平仓后剩余必须 = 0"); + + Console.WriteLine($"Step1: A=0.35, Notional=17,500,000"); + Console.WriteLine($"Step2: A=0.325→B=0.50, Notional=16,250,000"); + Console.WriteLine($"Step3: A=0.325→B=1.00, Notional=16,250,000"); + Console.WriteLine($"合计={totalClosed} = 期初{OriginalNotional} ✅"); + } + + // ================================================================ + // 契约9:GetUnwindInterestList 必须接收 notionalValue 和 posiNotionalValue + // 前端 getInterestList 传这两个参数给后端做 A→B 转换 + // 如果前端删掉这两个参数,后端 ToRemainingClosePercent 在 posiNotionalValue=0 时会跳过转换 + // ================================================================ + [TestMethod] + public void CPC_009_ToRemainingClosePercent_PosiNotionalValue为零时跳过转换_前端必须传值() + { + // 模拟前端不传 notionalValue/posiNotionalValue(默认0) + decimal result = SwapDealService.ToRemainingClosePercent( + SecondClosePercentA, notionalValue: 0, posiNotionalValue: 0); + + // posiNotionalValue <= 0 时直接返回原值(不转换) + SwapDealTestFactory.AssertDecimalEqual(SecondClosePercentA, result, 1e-10m, + "posiNotionalValue=0 时不做转换——所以前端必须传 notionalValue/posiNotionalValue"); + + // 正确:前端传值后转换正常 + decimal resultWithValues = SwapDealService.ToRemainingClosePercent( + SecondClosePercentA, OriginalNotional, RemainingAfter1st); + SwapDealTestFactory.AssertDecimalEqual(SecondClosePercentB, resultWithValues, 1e-10m, + "前端传值后:A=0.325 → B=0.50 ✅"); + + // 两者必须不同 + Assert.AreNotEqual(result, resultWithValues, + "传 vs 不传 notionalValue 结果不同——前端必须传,否则利息计算用错口径"); + + Console.WriteLine($"不传值(默认0):{result}(未转换,错误地用A算利息)"); + Console.WriteLine($"传值:{resultWithValues}(正确转换为B)"); + } + + // ================================================================ + // 契约10:首次平仓 A==B(退化场景,不应误报) + // 首次平仓时 NotionalValue == PosiNotionalValue,A=B,转换系数=1 + // 这是既有测试全绿的原因——必须用非退化场景才能捕获回归 + // ================================================================ + [TestMethod] + public void CPC_010_首次平仓A等于B_退化场景_不能作为唯一测试() + { + decimal closePercentA = 0.35m; + decimal firstRemaining = OriginalNotional; // 首次 remaining == original + + decimal convertedB = SwapDealService.ToRemainingClosePercent( + closePercentA, OriginalNotional, firstRemaining); + + // 首次平仓:A == B(转换系数 = 1) + SwapDealTestFactory.AssertDecimalEqual(closePercentA, convertedB, 1e-10m, + "首次平仓 remaining==original → A==B==0.35(退化场景)"); + + // 退化场景下即使不做转换结果也一样——这就是既有测试全绿的原因 + decimal noConversion = closePercentA; + Assert.AreEqual(noConversion, convertedB, + "退化场景:做不做转换结果一样 → 无法发现'转换缺失'的bug"); + + Console.WriteLine($"⚠ 退化场景:A={closePercentA} == B={convertedB}(首次平仓,无法暴露双重转换bug)"); + Console.WriteLine($"✅ 非退化场景见 CPC_005/007:A=0.325 ≠ B=0.50(多次部分平仓后才能暴露)"); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/InitUnwindTradingFeeTest.cs b/UnitTestProject/Modules/SwapModule/InitUnwindTradingFeeTest.cs new file mode 100644 index 00000000..8691bb11 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/InitUnwindTradingFeeTest.cs @@ -0,0 +1,65 @@ +using System.Reflection; +using YLErp.DBModels; + +namespace YLErp.Modules.SwapModule +{ + [TestClass] + public class InitUnwindTradingFeeTest + { + private static decimal InvokeCalcInitTradingFee(swap_position position, UnwindData unwindData) + { + var method = typeof(SwapDealService).GetMethod( + "CalcInitTradingFee", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.IsNotNull(method, "未找到 CalcInitTradingFee 私有静态方法"); + + return (decimal)method.Invoke(null, new object[] { position, unwindData }); + } + + [TestMethod] + public void 百分比模式_按平仓名义本金计算并四舍五入到两位() + { + var position = new swap_position + { + PosiFeeType = 0, + PosiTradingFeeUnit = 0.1234m + }; + var unwindData = new UnwindData + { + CloseNotionalValue = 1_000_000m, + CloseQty = 8888m + }; + + var fee = InvokeCalcInitTradingFee(position, unwindData); + + Assert.AreEqual(1234.00m, fee); + } + + [TestMethod] + public void 单位数量模式_按平仓数量计算并四舍五入到两位() + { + var position = new swap_position + { + PosiFeeType = 1, + PosiTradingFeeUnit = 1.235m + }; + var unwindData = new UnwindData + { + CloseNotionalValue = 1_000_000m, + CloseQty = 10m + }; + + var fee = InvokeCalcInitTradingFee(position, unwindData); + + Assert.AreEqual(12.35m, fee); + } + + [TestMethod] + public void 空入参_返回零() + { + Assert.AreEqual(0m, InvokeCalcInitTradingFee(null, new UnwindData())); + Assert.AreEqual(0m, InvokeCalcInitTradingFee(new swap_position(), null)); + } + } +} diff --git a/YLErpDAL/DataBase/YLContext.cs b/YLErpDAL/DataBase/YLContext.cs index 78936a0c..b9eb9d20 100644 --- a/YLErpDAL/DataBase/YLContext.cs +++ b/YLErpDAL/DataBase/YLContext.cs @@ -1,4 +1,7 @@ using BaseOUDAL; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using System.ComponentModel.DataAnnotations.Schema; +using System.Reflection; using YLErp.Core.DBModels; using YLErp.Model; @@ -14,6 +17,19 @@ namespace YLErp.BLL protected override void OnModelCreating(ModelBuilder modelBuilder) { + var clientBalanceMoneyConverter = new ValueConverter( + value => value.HasValue ? Convert.ToDecimal(value.Value) : null, + value => value.HasValue ? (double)value.Value : null); + var clientBalanceDaily = modelBuilder.Entity(); + foreach (var property in typeof(ClientBalanceDaily).GetProperties() + .Where(property => property.PropertyType == typeof(double?) + && property.GetCustomAttribute() == null)) + { + clientBalanceDaily.Property(property.Name) + .HasConversion(clientBalanceMoneyConverter) + .HasColumnType("decimal(20,6)"); + } + modelBuilder.Entity().HasKey(c => new { c.PGroup, c.PName }); modelBuilder.Entity().HasKey(c => new { c.ValueDate, c.OptionCode }); modelBuilder.Entity().HasKey(c => new { c.From, c.Key }); @@ -414,4 +430,4 @@ namespace YLErp.BLL public DbSet glms_risk_variable { get; set; } } -} \ No newline at end of file +} diff --git a/YLErpDAL/Model/SwapFlowEventExportModel.cs b/YLErpDAL/Model/SwapFlowEventExportModel.cs index d710bf22..d37cd6d0 100644 --- a/YLErpDAL/Model/SwapFlowEventExportModel.cs +++ b/YLErpDAL/Model/SwapFlowEventExportModel.cs @@ -99,6 +99,6 @@ namespace YLErp.Model public string OptLog { get; set; } - public decimal? InitYtm { get; set; } + public string InitYtm { get; set; } } } diff --git a/YLErpDAL/Modules/AppModule/AppUpgrader.cs b/YLErpDAL/Modules/AppModule/AppUpgrader.cs index 4a35b61f..ead11cb0 100644 --- a/YLErpDAL/Modules/AppModule/AppUpgrader.cs +++ b/YLErpDAL/Modules/AppModule/AppUpgrader.cs @@ -306,14 +306,13 @@ namespace YLErp.Modules.AppModule //----------------------------------------------- configService.AddDataIfNotExists("ProjectConfig", "Erp.IsAutoSealAfterGeneratedBook", "false", "bool", "确认书生成时是否自动用印(IsAutoSealAndUploadFiles勾选时生效)"); configService.AddDataIfNotExists("ProjectConfig", "Erp.ReportFileBeginNumber", "0", "int", "报送文件开始编号"); - //----------------------------------------------- - // 删除不再使用的 - //----------------------------------------------- RemoveUnUsed(configService); } private static void RemoveUnUsed(InnerAppConfigService configService) { + configService.RemoveData("ProjectConfig", "Trade.SwapMarginTemplateConfig"); + if (AppManager.Version.Major < 3) { configService.RemoveData("ProjectConfig", "Erp.TradeConfirmBookEmailTPL"); @@ -453,6 +452,38 @@ namespace YLErp.Modules.AppModule } adminDb.SaveChanges(); + + var marginTemplateDictionary = adminDb.Dictionaries.FirstOrDefault(item => item.Name == YLErp.Modules.SwapModule.SwapMarginTemplateConfigService.DictionaryName); + if (marginTemplateDictionary == null) + { + return; + } + + var existingNames = adminDb.DictionaryItems + .Where(item => item.DictId == marginTemplateDictionary.Id) + .Select(item => item.Name) + .ToHashSet(); + var nextIndex = adminDb.DictionaryItems + .Where(item => item.DictId == marginTemplateDictionary.Id) + .Select(item => item.IndexNum) + .DefaultIfEmpty(-1) + .Max(); + foreach (var templateName in YLErp.Modules.SwapModule.SwapMarginTemplateConfigService.InitialTemplateNames) + { + if (existingNames.Contains(templateName)) + { + continue; + } + + adminDb.DictionaryItems.Add(new BaseOUDAL.DictionaryItem + { + DictId = marginTemplateDictionary.Id, + Name = templateName, + ShortName = templateName, + IndexNum = ++nextIndex + }); + } + adminDb.SaveChanges(); } catch (Exception ex) { diff --git a/YLErpDAL/Modules/EodModule/SettlementModule/EodHandleSwapFlowService.cs b/YLErpDAL/Modules/EodModule/SettlementModule/EodHandleSwapFlowService.cs index 4fbe9235..44683a41 100644 --- a/YLErpDAL/Modules/EodModule/SettlementModule/EodHandleSwapFlowService.cs +++ b/YLErpDAL/Modules/EodModule/SettlementModule/EodHandleSwapFlowService.cs @@ -455,7 +455,7 @@ namespace YLErp.Modules.EodModule Lots = swapFlow.Lots, IsNight = swapFlow.IsNight, OpponentRole = "甲方", - MarginTemplateName = "系统默认", + MarginTemplateName = null, MarginType = MarginTypeEnum.DEFAULT, IsGroup = isSingleTrade ? 0 : 2 }; @@ -743,7 +743,7 @@ namespace YLErp.Modules.EodModule td.OriginalNotional = td.Notional; td.OriginalStockEqvNotional = td.StockEqvNotional; td.StockEqvNotionalReal = td.StockEqvNotionalReal; - td.MarginTemplateName = "系统默认"; + td.MarginTemplateName = null; td.MarginType = MarginTypeEnum.DEFAULT; td.IsTradePricePayType = true; td.TradeSource = TradeSourceEnum.导入交易.ToString(); diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 05182f5f..7bf7f637 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -291,6 +291,9 @@ namespace YLErp.Modules.SwapModule floatEvent.UnderlyingInstrumentType = position.UnderlyingInstrumentType; floatEvent.CloseFee = 0; floatEvent.BeforeCloseFee = oriPosition.PosiTradingFeePending; + floatEvent.TradingFee = CalcInitTradingFee(oriPosition, unwindData); + floatEvent.PosiTradingFeeUnit = oriPosition?.PosiTradingFeeUnit ?? 0; + floatEvent.PosiFeeType = oriPosition?.PosiFeeType ?? 0; floatEvent.MarkClosePnl = 0; floatEvent.PayDirection = position.PosiDirection; floatEvent.PosiGrossPrice = position.PosiGrossPrice; @@ -313,6 +316,20 @@ namespace YLErp.Modules.SwapModule } return unwindData; } + private static decimal CalcInitTradingFee(swap_position oriPosition, UnwindData unwindData) + { + if (oriPosition == null || unwindData == null) + { + return 0; + } + + if (oriPosition.PosiFeeType == 1) + { + return Math.Round(oriPosition.PosiTradingFeeUnit * unwindData.CloseQty, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + } + + return Math.Round(oriPosition.PosiTradingFeeUnit / 100m * unwindData.CloseNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + } /// /// 校验上日是否收盘 /// diff --git a/YLErpDAL/Modules/SwapModule/SwapFlowService.cs b/YLErpDAL/Modules/SwapModule/SwapFlowService.cs index 430a5e53..f31bec68 100644 --- a/YLErpDAL/Modules/SwapModule/SwapFlowService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapFlowService.cs @@ -372,18 +372,18 @@ namespace YLErp.Modules.SwapModule exportModel.PositionType = item.PositionType==1?"多头":"空头"; exportModel.UnderlyingCode = item.UnderlyingCode; exportModel.MatuirityDate = item.MatuirityDate.OtcFormatDate(); - exportModel.TradingAmountAvg = item.TradingAmountAvg.OtcFormat(OtcFormatFlag.umprice); - exportModel.TradingAmountFeeAvg = item.TradingAmountFeeAvg.OtcFormat(OtcFormatFlag.umprice); - exportModel.Quantity = item.Quantity.OtcFormatMoney(false, 4); - exportModel.TradingAmount = item.TradingAmount.OtcFormatMoney(false, 4); + exportModel.TradingAmountAvg = item.TradingAmountAvg.OtcFormatMoney(false, 2); + exportModel.TradingAmountFeeAvg = item.TradingAmountFeeAvg.OtcFormatMoney(false, 2); + exportModel.Quantity = item.Quantity.OtcFormatMoney(false, 2); + exportModel.TradingAmount = item.TradingAmount.OtcFormatMoney(false, 2); exportModel.ContractSize = item.ContractSize.ToString(); - exportModel.TradingFee = item.TradingFee.OtcFormatMoney(false, 4); - exportModel.TradingFeePending = item.TradingFeePending.OtcFormatMoney(false, 4); - exportModel.DividendPending = item.DividendPending.OtcFormatMoney(false, 4); - exportModel.MarkClosePnl = item.MarkClosePnl.OtcFormatMoney(false, 4); - exportModel.DividendIn = item.DividendIn.OtcFormatMoney(false, 4); + exportModel.TradingFee = item.TradingFee.OtcFormatMoney(false, 2); + exportModel.TradingFeePending = item.TradingFeePending.OtcFormatMoney(false, 2); + exportModel.DividendPending = item.DividendPending.OtcFormatMoney(false, 2); + exportModel.MarkClosePnl = item.MarkClosePnl.OtcFormatMoney(false, 2); + exportModel.DividendIn = item.DividendIn.OtcFormatMoney(false, 2); exportModel.OptLog = item.OptLog; - exportModel.InitYtm = item.InitYtm; + exportModel.InitYtm = item.InitYtm?.OtcFormatMoney(false, 9); list.Add(exportModel); } var tplFilePath = OtcAppContext.MapPath("/App_Docs"); diff --git a/YLErpDAL/Modules/SwapModule/SwapMarginTemplateConfigService.cs b/YLErpDAL/Modules/SwapModule/SwapMarginTemplateConfigService.cs new file mode 100644 index 00000000..667670dd --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/SwapMarginTemplateConfigService.cs @@ -0,0 +1,39 @@ +using BaseOUDAL; +using YLErp.Models; + +namespace YLErp.Modules.SwapModule +{ + public static class SwapMarginTemplateConfigService + { + public const string DictionaryName = "保证金模板名称"; + + public static readonly string[] InitialTemplateNames = { "现金保证金", "授信保证金" }; + + public static SwapMarginTemplateConfig GetConfig() + { + using var db = new ErpBaseContext(); + var dictionaryId = db.Dictionaries + .Where(item => item.Name == DictionaryName) + .Select(item => item.Id) + .FirstOrDefault(); + var items = db.DictionaryItems + .Where(item => item.DictId == dictionaryId && !string.IsNullOrWhiteSpace(item.Name)) + .OrderBy(item => item.IndexNum) + .Select(item => new SelectItem { Text = item.Name, Value = item.Name }) + .ToArray(); + + return new SwapMarginTemplateConfig + { + options = items, + defaultValue = items.FirstOrDefault()?.Value + }; + } + } + + public class SwapMarginTemplateConfig + { + public IEnumerable options { get; set; } + + public string defaultValue { get; set; } + } +} diff --git a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs index c23de7b8..f32f97fe 100644 --- a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs @@ -1,4 +1,4 @@ -using BaseOUDAL; +using BaseOUDAL; using ClosedXML.Report.Options; using Confluent.Kafka; using CsvHelper; @@ -341,7 +341,7 @@ namespace YLErp.Modules.SwapModule TradeDate = flowMerge.OccurTime, TraderId = asset.TraderIdsInt.FirstOrDefault(), TraderName = asset.TraderNamesList.FirstOrDefault(), - MarginTemplateName = "系统默认", + MarginTemplateName = null, OpponentRole = "乙方", StructureType = structureType, InitialMargin = 0, @@ -1375,6 +1375,7 @@ namespace YLErp.Modules.SwapModule position.PosiTradingFee = swap.PosiTradingFee; position.PosiTradingFee=Math.Round(position.PosiTradingFee, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); position.PosiTradingFeeUnit = swap.PosiTradingFeeUnit; + position.PosiFeeType = swap.PosiFeeType; position.PosiTradingFeePending = swap.PosiTradingFeePending; position.PosiTradingFeePending = Math.Round(position.PosiTradingFeePending, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); position.UnderlyingCode = swap.UnderlyingCode; @@ -1414,6 +1415,7 @@ namespace YLErp.Modules.SwapModule position.FloatRateUnderlyingCode = swap.FloatRateUnderlyingCode; position.interest_rest_days = swap.interest_rest_days; position.interest_rule = swap.interest_rule; + position.category_tag = string.IsNullOrEmpty(swap.category_tag) ? "互换利率" : swap.category_tag; position.InitYtm = RoundSwapBondNetPriceAndYtm(swap.InitYtm); if (position.InitYtm != null && position.InitYtm > 0) { diff --git a/YLErpDAL/Modules/TradeModule/SwapModule/SwapTradeFlowImportService.cs b/YLErpDAL/Modules/TradeModule/SwapModule/SwapTradeFlowImportService.cs index efac3554..fb8d835d 100644 --- a/YLErpDAL/Modules/TradeModule/SwapModule/SwapTradeFlowImportService.cs +++ b/YLErpDAL/Modules/TradeModule/SwapModule/SwapTradeFlowImportService.cs @@ -378,7 +378,7 @@ namespace YLErp.Modules.TradeModule.SwapModule importTrade.StockEqvNotionalReal = importTrade.StockEqvNotionalReal; importTrade.trade_swap.RateCalcMode = "01"; importTrade.IsUsePremiumRate = true; - importTrade.MarginTemplateName = "系统默认"; + importTrade.MarginTemplateName = null; importTrade.MarginType = MarginTypeEnum.DEFAULT; importTrade.IsTradePricePayType = true; importTrade.TradeSource = TradeSourceEnum.导入交易.ToString(); diff --git a/YLErpDAL/Modules/TradeModule/SwapModule/SwapTradeFlowMoreImportService.cs b/YLErpDAL/Modules/TradeModule/SwapModule/SwapTradeFlowMoreImportService.cs index 64c89cae..5e0f2ee2 100644 --- a/YLErpDAL/Modules/TradeModule/SwapModule/SwapTradeFlowMoreImportService.cs +++ b/YLErpDAL/Modules/TradeModule/SwapModule/SwapTradeFlowMoreImportService.cs @@ -519,7 +519,7 @@ namespace YLErp.Modules.TradeModule.SwapModule importTrade.ParticipationRate = 1; //预付金 - importTrade.MarginTemplateName = "系统默认"; + importTrade.MarginTemplateName = null; importTrade.MarginType = MarginTypeEnum.DEFAULT; SetDBModelCreator(importTrade); diff --git a/YLErpDAL/Modules/TradeModule/SwapModule/SwapTradeImportService.cs b/YLErpDAL/Modules/TradeModule/SwapModule/SwapTradeImportService.cs index 4ae8a21b..6f985cbf 100644 --- a/YLErpDAL/Modules/TradeModule/SwapModule/SwapTradeImportService.cs +++ b/YLErpDAL/Modules/TradeModule/SwapModule/SwapTradeImportService.cs @@ -726,7 +726,7 @@ namespace YLErp.Modules.TradeModule.SwapModule td.OriginalStockEqvNotional = td.StockEqvNotional; td.StockEqvNotionalReal = td.StockEqvNotionalReal; td.IsUsePremiumRate = true; - td.MarginTemplateName = "系统默认"; + td.MarginTemplateName = null; td.MarginType = MarginTypeEnum.DEFAULT; td.IsTradePricePayType = true; td.TradeSource = TradeSourceEnum.导入交易.ToString(); @@ -2815,7 +2815,7 @@ namespace YLErp.Modules.TradeModule.SwapModule td.SettlementDate = td.ExerciseDate; td.StockEqvNotional = reader.GetDouble("名义本金(人民币)", true) ?? 0; - td.MarginTemplateName = "系统默认"; + td.MarginTemplateName = null; td.UnWindDate = reader.GetDate("提前终止日/终止日", true); td.Comments = reader.GetString("备注", false); //td.UnderlyingName = reader.GetString("标的名称", true); diff --git a/YLErpWeb/Areas/Admin/Controllers/OtcConfigController.cs b/YLErpWeb/Areas/Admin/Controllers/OtcConfigController.cs index b186e765..dd95f9a2 100644 --- a/YLErpWeb/Areas/Admin/Controllers/OtcConfigController.cs +++ b/YLErpWeb/Areas/Admin/Controllers/OtcConfigController.cs @@ -282,4 +282,4 @@ namespace YLErp.Web.Areas.Admin.Controllers #endregion } -} \ No newline at end of file +} diff --git a/YLErpWeb/Controllers/SwapTrade2Controller.cs b/YLErpWeb/Controllers/SwapTrade2Controller.cs index db1089ab..00297abc 100644 --- a/YLErpWeb/Controllers/SwapTrade2Controller.cs +++ b/YLErpWeb/Controllers/SwapTrade2Controller.cs @@ -45,48 +45,26 @@ namespace YLErp.Web.Controllers ViewBag.observationDate = observationDate; return View(); } - public ActionResult TradeEdit(string enid, bool isUseApproval = false) + public ActionResult TradeEdit(string enid, string renewEnid = null, bool isUseApproval = false) { ViewBag.isUseApproval = isUseApproval; - var intid = DecryptInt(enid); + // The new/renew flow uses the literal "0" to indicate that no trade exists yet. + var intid = enid == "0" ? 0 : DecryptInt(enid); trade r = null; if (intid == 0) { - TradeExtendJson tradeExtendJson = new TradeExtendJson() + r = CreateNewTrade(); + var renewTradeId = DecryptInt(renewEnid); + if (renewTradeId > 0) { - FlowBookMode = (int)FlowBookModeEnum.否, - FloatingPnlAnnualized = false, - NeedOpenFee = false, - OpenFeeType = 0, - InterestCalcMode = "10", - SettlementRules=0, - DividendPayDate=0 - }; - var tradeDateCountry = GetBestCountry(valuedateBLL.ValueDate.Year); - r = new trade() - { - TradeType = "收益互换", - UnderlyingInstrumentType = "Stock", - StartDate = valuedateBLL.ValueDate, - TradeDate = QdpCalendarHelper.GetNonHolidayDefore(valuedateBLL.ValueDate.AddDays(-1), tradeDateCountry), - TraderId = CurUser.UserId, - TraderName = CurUser.UserName, - MarginTemplateName = "系统默认", - OpponentRole = "乙方", - OriginalStockEqvNotional = 0, - StructureType = "普通收益互换", - InitialMargin = 0 - }; - r.StructureType = "普通债券类收益互换"; - tradeExtendJson.FlowBookMode = (int)FlowBookModeEnum.先进先出; - r.trade_extend = new trade_extend() - { - ExtendJson = JsonHelper.Serialize(tradeExtendJson) - }; - r.MetaDic = new Dictionary - { - { "清算机构", "甲方" } - }; + var sourceTrade = new SwapTradeService(CurUser).GetSwapTrade(renewTradeId); + if (sourceTrade == null) + { + return ShowError("没有找到交易数据"); + } + + r = CreateRenewTrade(sourceTrade, r); + } return View(r); } SwapTradeService swapTradeService = new SwapTradeService(CurUser); @@ -98,6 +76,130 @@ namespace YLErp.Web.Controllers return View(r); } + + private trade CreateNewTrade() + { + var defaultMarginTemplateName = SwapMarginTemplateConfigService.GetConfig().defaultValue; + var tradeExtendJson = new TradeExtendJson() + { + FlowBookMode = (int)FlowBookModeEnum.先进先出, + FloatingPnlAnnualized = false, + NeedOpenFee = false, + OpenFeeType = 0, + InterestCalcMode = "10", + SettlementRules = 0, + DividendPayDate = 0 + }; + var tradeDateCountry = GetBestCountry(valuedateBLL.ValueDate.Year); + return new trade() + { + TradeType = "收益互换", + UnderlyingInstrumentType = "Stock", + StartDate = valuedateBLL.ValueDate, + TradeDate = QdpCalendarHelper.GetNonHolidayDefore(valuedateBLL.ValueDate.AddDays(-1), tradeDateCountry), + TraderId = CurUser.UserId, + TraderName = CurUser.UserName, + MarginTemplateName = defaultMarginTemplateName, + OpponentRole = "乙方", + OriginalStockEqvNotional = 0, + StructureType = "普通债券类收益互换", + InitialMargin = 0, + trade_extend = new trade_extend() + { + ExtendJson = JsonHelper.Serialize(tradeExtendJson) + }, + MetaDic = new Dictionary + { + { "清算机构", "甲方" } + } + }; + } + + private trade CreateRenewTrade(trade sourceTrade, trade defaultTrade) + { + var renewTrade = sourceTrade.Clone(); + renewTrade.id = 0; + renewTrade.TradeNumber = string.Empty; + renewTrade.ParentTradeId = 0; + renewTrade.TradeDate = defaultTrade.TradeDate; + renewTrade.StartDate = defaultTrade.StartDate; + renewTrade.ExerciseDate = null; + renewTrade.MaturityDate = null; + renewTrade.SettlementDate = null; + renewTrade.UnWindDate = null; + renewTrade.PremiumPayDate = null; + renewTrade.SettlementFlagDate = null; + renewTrade.HasPartialUnWind = null; + renewTrade.TradeStatus = null; + renewTrade.CheckStatus = null; + renewTrade.ProcessStatus = null; + renewTrade.ProcessOrderId = 0; + renewTrade.ProcessOrderBranch = 0; + renewTrade.ProcessOptDate = null; + renewTrade.ValidState = null; + renewTrade.CreateDate = null; + renewTrade.TradeSource = null; + // 恢复初始名义本金(源交易若有过部分平仓,StockEqvNotional/TradeAmount/Notional 已递减, + // 但 OriginalStockEqvNotional 和 OriginalNotional 始终保留原始值不被递减) + if (renewTrade.OriginalStockEqvNotional != null) + { + renewTrade.StockEqvNotional = (double)renewTrade.OriginalStockEqvNotional; + } + renewTrade.Notional = renewTrade.OriginalNotional ?? renewTrade.TradeAmount; + renewTrade.TradeAmount = renewTrade.OriginalNotional ?? renewTrade.TradeAmount; + // 结算标识 — 源交易可能为"延期结算",续做时重置为正常结算 + renewTrade.SettlementFlag = 0; + renewTrade.SettlementFlagOptId = null; + // trade_swap — 重置源交易遗留的 PK/FK 和运行时字段 + if (renewTrade.trade_swap != null) + { + renewTrade.trade_swap.id = 0; + renewTrade.trade_swap.TradeId = 0; + renewTrade.trade_swap.FlowId = null; + } + renewTrade.trade_extend = sourceTrade.trade_extend?.Clone() ?? defaultTrade.trade_extend; + renewTrade.trade_extend.TradeId = 0; + renewTrade.trade_Initial_Margin = sourceTrade.trade_Initial_Margin?.Clone() ?? new trade_initial_margin(); + renewTrade.trade_Initial_Margin.TradeId = 0; + renewTrade.MetaDic = sourceTrade.MetaDic == null + ? new Dictionary() + : new Dictionary(sourceTrade.MetaDic); + // 只克隆初始持仓(IsInitial=true),避免将部分平仓后的实时持仓(名义本金已递减)带入续做交易 + renewTrade.swap_positions = sourceTrade.swap_positions + ?.Where(p => p.IsInitial) + .Select(position => + { + var renewPosition = position.Clone(); + renewPosition.id = 0; + renewPosition.PositionId = 0; + renewPosition.SwapTradeId = 0; + renewPosition.PosiNumber = null; + renewPosition.PosiStartDate = defaultTrade.StartDate.Value; + renewPosition.PosiMatuirityDate = null; + // 预付金腿的 HappenDate 用于后续生成资金流水(ResetMarginAmount 按 HappenDate 过滤), + // 续做时设为新交易的起始日;非预付金腿的 HappenDate 无实际用途,置 null + renewPosition.HappenDate = + position.InterestMode == (int)YLErp.DBModels.InterestModeEnum.初始预付金 + ? defaultTrade.StartDate + : null; + // 清空运行时累计字段(这些字段在源交易存续期间可能被累计) + renewPosition.InterestAmount = 0; + renewPosition.InterestFeePending = 0; + renewPosition.PosiDividendIncome = 0; + renewPosition.PosiTradingFeePending = 0; + renewPosition.InterestSwapInterval = null; + renewPosition.Obervation = null; + return renewPosition; + }).ToList() ?? new List(); + // 清空源交易的事件/持仓快照等集合,避免与源交易共享引用 + renewTrade.swap_Events = new List(); + renewTrade.swap_Flow_Events = new List(); + renewTrade.eod_swaps = new List(); + renewTrade.inital_eod_swap_positions = new List(); + renewTrade.eod_swap_positions = new List(); + renewTrade.ClientCashInCashOutList = new List(); + return renewTrade; + } /// /// 详情 /// @@ -115,6 +217,12 @@ namespace YLErp.Web.Controllers { return ShowError("没有找到交易数据"); } + var marginTemplateConfig = SwapMarginTemplateConfigService.GetConfig(); + if (string.IsNullOrWhiteSpace(tradeObj.MarginTemplateName) + || !marginTemplateConfig.options.Any(item => item.Value == tradeObj.MarginTemplateName)) + { + tradeObj.MarginTemplateName = marginTemplateConfig.defaultValue; + } TradeViewModel model; model = new TradeViewModel(tradeObj) @@ -1111,4 +1219,4 @@ namespace YLErp.Web.Controllers } -} \ No newline at end of file +} diff --git a/YLErpWeb/Controllers/SwapTradeController.cs b/YLErpWeb/Controllers/SwapTradeController.cs index 8746b33b..ad82b7c7 100644 --- a/YLErpWeb/Controllers/SwapTradeController.cs +++ b/YLErpWeb/Controllers/SwapTradeController.cs @@ -303,7 +303,7 @@ namespace YLErp.Web.Controllers TradeDate = valuedateBLL.ValueDate, TraderId = CurUser.UserId, TraderName = CurUser.UserName, - MarginTemplateName = "系统默认", + MarginTemplateName = null, OpponentRole = "乙方", trade_swap = new trade_swap() { @@ -1196,4 +1196,4 @@ namespace YLErp.Web.Controllers return JsonSuccess("", 0); } } -} \ No newline at end of file +} diff --git a/YLErpWeb/Views/SwapTrade2/EventList.cshtml b/YLErpWeb/Views/SwapTrade2/EventList.cshtml index cfbe53fc..c3905e38 100644 --- a/YLErpWeb/Views/SwapTrade2/EventList.cshtml +++ b/YLErpWeb/Views/SwapTrade2/EventList.cshtml @@ -12,7 +12,7 @@ - + }
@@ -28,4 +28,4 @@
-
\ No newline at end of file + diff --git a/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml b/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml index 661828bd..2e707b9c 100644 --- a/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml +++ b/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml @@ -1,5 +1,6 @@ @using YLErp.Web.Models.JsModels; @using YLErp.Commons; +@using YLErp.Modules.SwapModule; @model trade @{ ViewBag.Title = "交易信息 | 编辑"; @@ -13,12 +14,14 @@ var jsClient = canChangeClient && Model.ClientId > 0 ? jsClients.FirstOrDefault(n => n.id == Model.ClientId) : null; var jsAssetUnits = JsDataModel.GetAssetUnits(CurUser); var tradeMarginTemplates = new tradeController().GetMarginTemplates(); + var swapMarginTemplateItems = SwapMarginTemplateConfigService.GetConfig().options; var tradeMarginTemplateItems = new tradeController().GetMarginTemplateItems(); var jsAssetUnit = Model.AssetId > 0 ? jsAssetUnits.FirstOrDefault(n => n.id == Model.AssetId) : null; var assetunits = JsDataModel.GetAssetUnits(CurUser); var jsTraders = canAddNewTrader ? JsDataModel.GetTraders(assetunits) : Enumerable.Empty(); var jsTrader = canAddNewTrader && Model.TraderId > 0 ? jsTraders.FirstOrDefault(n => n.id == Model.TraderId) : null; var currencys = CurrencyController.getList(); + var categoryTagOptions = DictionaryBLL.GetList("利息端类别", false, "互换利率"); List places = new List(); List agencys = new List(); var tradingPlaceMap = YLErp.DBModels.Consts.ConsReport.TradingPlaceMapDisplay; @@ -66,6 +69,7 @@ jsTrader, jsTraders, tradeMarginTemplates = tradeMarginTemplates, + swapMarginTemplateItems = swapMarginTemplateItems, tradeMarginTemplateItems = tradeMarginTemplateItems, needRemark = !isAdd && valuedateBLL.SystemDate.EditTradeNeedRemark, parentTradeId = ViewBag.ParentTradeId, @@ -266,6 +270,14 @@
+
+ + +
@@ -351,6 +363,7 @@ 计息方式 重置频率(天) 利率准则 + 类别 结算规则 @@ -401,6 +414,14 @@ + + + @@ -429,6 +450,7 @@ 期初标的成交收益率% 期初标的价格 数量 + 基础费率 交易费用后付 @@ -452,13 +474,13 @@ - + - + - + 重算 @@ -466,6 +488,16 @@ {{item.underlying!=null?item.underlying.QuoteUnitString:''}} + + + +
我方{{item.PosiDirection==1?"支付":"收取"}}交易费用
diff --git a/YLErpWeb/Views/SwapTrade2/TradeView.cshtml b/YLErpWeb/Views/SwapTrade2/TradeView.cshtml index 310b7897..4cdf1c8e 100644 --- a/YLErpWeb/Views/SwapTrade2/TradeView.cshtml +++ b/YLErpWeb/Views/SwapTrade2/TradeView.cshtml @@ -236,6 +236,10 @@ 派息金额支付日 @(trade.trade_extend.ExtendObj.DividendPayDate == 0 ? "到期结算日" : "派息日+" + (trade.trade_extend.ExtendObj.DividendPayDate - 1)) + + 保证金模板 + @trade.MarginTemplateName +
@@ -300,6 +304,7 @@ 计息方式 重置频率(天) 利率准则 + 类别 结算规则 @if (trade.swap_positions != null) @@ -344,6 +349,7 @@ @item.interest_rest_days @((item.interest_rule != null) ? (SwapInterestRule)item.interest_rule : "") + @(string.IsNullOrEmpty(item.category_tag) ? "互换利率" : item.category_tag) @@ -378,6 +384,7 @@ @initYtmTitle } 数量 + 基础费率 交易费用后付 @foreach (var item in paySwapPositions) @@ -403,6 +410,16 @@ @item.PosiQuantity.OtcFormat(OtcFormatFlag.StockEqvNotional) + + @if (item.PosiFeeType == 0) + { + @(item.PosiTradingFeeUnit.ToString("0.0000") + "%") + } + else + { + @item.PosiTradingFeeUnit.ToString("0.00") + } + @item.PosiTradingFeePending.OtcFormat(OtcFormatFlag.StockEqvNotional) @@ -504,6 +521,7 @@ 计息方式 重置频率(天) 利率准则 + 类别 结算规则 @{ @@ -545,6 +563,7 @@ @(item.InterestType == 0 ? "单利" : "复利") @item.interest_rest_days @((item.interest_rule != null) ? (SwapInterestRule)item.interest_rule : "") + @(string.IsNullOrEmpty(item.category_tag) ? "互换利率" : item.category_tag) } diff --git a/YLErpWeb/Views/SwapTrade2/header.cshtml b/YLErpWeb/Views/SwapTrade2/header.cshtml index 1e9a93e1..d6706aff 100644 --- a/YLErpWeb/Views/SwapTrade2/header.cshtml +++ b/YLErpWeb/Views/SwapTrade2/header.cshtml @@ -157,6 +157,7 @@ { @MyControls.Btn("收益结算", string.Format("unWindLongShortSwap('{0}')", tradeModel.EncryptId)) } + @MyControls.Btn("续做", string.Format("renewTrade('{0}')", tradeModel.EncryptId)) } diff --git a/YLErpWeb/Views/entryexit/entryexitList.cshtml b/YLErpWeb/Views/entryexit/entryexitList.cshtml index aaafc651..ca20dcc2 100644 --- a/YLErpWeb/Views/entryexit/entryexitList.cshtml +++ b/YLErpWeb/Views/entryexit/entryexitList.cshtml @@ -355,7 +355,7 @@ return numeral(-1 * cellValue).format("0,0.00") === 'NaN' ? "0" : numeral(-1 * cellValue).format("0,0.00"); } else { - return numeral(cellValue).format("0,0.000") === 'NaN' ? "0" : numeral(cellValue).format("0,0.000"); + return numeral(cellValue).format("0,0.00") === 'NaN' ? "0" : numeral(cellValue).format("0,0.00"); } } diff --git a/YLErpWeb/fe-tests/closePercentContract.test.js b/YLErpWeb/fe-tests/closePercentContract.test.js new file mode 100644 index 00000000..2addeb24 --- /dev/null +++ b/YLErpWeb/fe-tests/closePercentContract.test.js @@ -0,0 +1,168 @@ +/** + * closePercentContract.test.js — 平仓比例口径A(占期初名义本金)前端接线守卫 + * ============================================================================ + * 产品需求(不可违背):ClosePercent 永远是"占期初名义本金(NotionalValue)的比例",即口径A。 + * + * 本文件是"接线测试"(wiring test):读 unwindSwapTrade.js 源码文本,断言关键逻辑 + * 仍然使用口径A的公式。如果有人把公式改成口径B(如 c9071a4e 曾犯的错误), + * 对应断言会立即变红。 + * + * 与 swapCalc.test.js 的区别: + * swapCalc.test.js 测 SwapCalc 纯函数本身正确性(零件级) + * 本文件测 unwindSwapTrade.js 确实在调用这些函数/使用正确公式(装配级) + * ============================================================================ + */ +const fs = require('fs'); +const path = require('path'); + +const unwindSrc = fs.readFileSync( + path.join(__dirname, '..', 'wwwroot', 'Scripts', 'app', 'swaptrade', 'unwindSwapTrade.js'), + 'utf8' +); + +describe('口径A产品契约:unwindSwapTrade.js 接线守卫', () => { + + // ==================================================================== + // 契约1:oriClosePercent 必须用 PosiNotionalValue / NotionalValue 计算 + // 不能硬编码为 1(c9071a4e 的错误) + // ==================================================================== + describe('oriClosePercent 必须为剩余/期初', () => { + test('源码中 oriClosePercent 必须包含 PosiNotionalValue / NotionalValue 公式', () => { + // 正确代码:this.oriClosePercent = ... PosiNotionalValue / ... NotionalValue + expect(unwindSrc).toMatch(/oriClosePercent.*PosiNotionalValue.*\/.*NotionalValue/s); + }); + + test('源码中 oriClosePercent 不能被硬编码为 1', () => { + // c9071a4e 的错误:this.deal.ClosePercent = 1 (直接覆盖) + // 检查 initDeal 中不存在 oriClosePercent = 1 的硬编码 + const initDealSection = unwindSrc.match(/initDeal\(\)[\s\S]*?\},/); + expect(initDealSection).toBeTruthy(); + // 不应出现 oriClosePercent = 1 或 ClosePercent = 1 的硬编码 + //(CloseMethod===1 时设置 ClosePercent=1 是允许的,但 oriClosePercent 不应被设为1) + expect(initDealSection[0]).not.toMatch(/oriClosePercent\s*=\s*1\b/); + }); + }); + + // ==================================================================== + // 契约2:calcCloseQtyByPercent 必须调用 SwapCalc.calcCloseQtyByOriginalPercent + // 不能直接 PositionQty × ClosePercent(c9071a4e 的错误) + // ==================================================================== + describe('calcCloseQtyByPercent 必须调用 SwapCalc', () => { + test('源码中 calcCloseQtyByPercent 必须调用 SwapCalc.calcCloseQtyByOriginalPercent', () => { + expect(unwindSrc).toContain('SwapCalc.calcCloseQtyByOriginalPercent'); + }); + + test('changeCloseMethod 的部分平仓分支必须调用 calcCloseQtyByPercent', () => { + // 正确代码:this.deal.CloseQty = this.calcCloseQtyByPercent(this.deal.ClosePercent); + expect(unwindSrc).toMatch(/calcCloseQtyByPercent\s*\(this\.deal\.ClosePercent\)/); + }); + + test('changeClosePercent 必须调用 calcCloseQtyByPercent', () => { + expect(unwindSrc).toMatch(/this\.deal\.CloseQty\s*=\s*this\.calcCloseQtyByPercent/); + }); + + test('changeCloseNotionalValue 必须调用 calcCloseQtyByPercent', () => { + expect(unwindSrc).toMatch(/this\.deal\.CloseQty\s*=\s*this\.calcCloseQtyByPercent/); + }); + }); + + // ==================================================================== + // 契约3:CloseNotionalValue 必须用 NotionalValue 做乘数/分母(口径A) + // 不能用 PosiNotionalValue(c9071a4e 的错误) + // ==================================================================== + describe('CloseNotionalValue 必须基于 NotionalValue(口径A)', () => { + test('changeCloseQty: CloseNotionalValue = ClosePercent × NotionalValue', () => { + // 正确:ClosePercent × parseFloat(this.deal.NotionalValue) + // 错误:ClosePercent × parseFloat(this.deal.PosiNotionalValue) + const changeCloseQtySection = unwindSrc.match(/changeCloseQty\(\)[\s\S]*?\n\s*\},/); + expect(changeCloseQtySection).toBeTruthy(); + expect(changeCloseQtySection[0]).toMatch(/CloseNotionalValue.*NotionalValue/); + expect(changeCloseQtySection[0]).not.toMatch(/CloseNotionalValue.*PosiNotionalValue/); + }); + + test('changeClosePercent: CloseNotionalValue = ClosePercent × NotionalValue', () => { + const changeClosePercentSection = unwindSrc.match(/changeClosePercent\(\)[\s\S]*?\n\s*\},/); + expect(changeClosePercentSection).toBeTruthy(); + expect(changeClosePercentSection[0]).toMatch(/CloseNotionalValue.*NotionalValue/); + expect(changeClosePercentSection[0]).not.toMatch(/CloseNotionalValue.*PosiNotionalValue/); + }); + }); + + // ==================================================================== + // 契约4:changeCloseQty 的 ClosePercent 必须乘以 oriClosePercent(口径A→B→A 转换) + // 不能直接 CloseQty / PositionQty(c9071a4e 的错误) + // ==================================================================== + describe('changeCloseQty 的 ClosePercent 必须乘以 oriClosePercent', () => { + test('ClosePercent = (CloseQty/PositionQty) × oriClosePercent', () => { + // 正确:× ori(把占剩余比例转回占期初口径) + // 错误:不乘 ori(直接用占剩余比例作为 ClosePercent) + const changeCloseQtySection = unwindSrc.match(/changeCloseQty\(\)[\s\S]*?\n\s*\},/); + expect(changeCloseQtySection).toBeTruthy(); + expect(changeCloseQtySection[0]).toMatch(/oriClosePercent/); + expect(changeCloseQtySection[0]).toMatch(/\*\s*ori/); + }); + + test('changeCloseNotionalValue 的 ClosePercent 必须除以 NotionalValue', () => { + // 正确:ClosePercent = CloseNotionalValue / NotionalValue + // 错误:ClosePercent = CloseNotionalValue / PosiNotionalValue + const changeCloseNotionalSection = unwindSrc.match(/changeCloseNotionalValue\(\)[\s\S]*?\n\s*\},/); + expect(changeCloseNotionalSection).toBeTruthy(); + expect(changeCloseNotionalSection[0]).toMatch(/ClosePercent.*NotionalValue/); + expect(changeCloseNotionalSection[0]).not.toMatch(/ClosePercent.*PosiNotionalValue/); + }); + }); + + // ==================================================================== + // 契约5:getInterestList 必须传 notionalValue 和 posiNotionalValue 给后端 + // 后端 GetUnwindInterestList 需要这两个值做 A→B 转换 + // 如果删掉(c9071a4e 的错误),后端不转换,利息用错口径计算 + // ==================================================================== + describe('getInterestList 必须传 notionalValue/posiNotionalValue', () => { + test('postData 必须包含 notionalValue', () => { + expect(unwindSrc).toMatch(/notionalValue:\s*thisObj\.deal\.NotionalValue/); + }); + + test('postData 必须包含 posiNotionalValue', () => { + expect(unwindSrc).toMatch(/posiNotionalValue:\s*thisObj\.deal\.PosiNotionalValue/); + }); + + test('getInterestList 的 postData 不能只有 closePercent 而缺少 notionalValue', () => { + // 精确匹配 getInterestList 方法定义(以 getInterestList() { 开头,到 main.post 结束) + // 匹配模式:方法名+参数列表+花括号开始,一直到包含 main.post 的 postData 定义 + const methodMatch = unwindSrc.match( + /getInterestList\(\)\s*\{[\s\S]*?var\s+postData\s*=\s*\{[^}]*\}/ + ); + expect(methodMatch).toBeTruthy(); + const postData = methodMatch[0]; + expect(postData).toContain('notionalValue'); + expect(postData).toContain('posiNotionalValue'); + }); + }); + + // ==================================================================== + // 契约6:CloseMethod 赋值对象必须是 deal(不是 floatPosition) + // c9071a4e 在 changeClosePercent 中误赋值到 floatPosition.CloseMethod + // ==================================================================== + describe('CloseMethod 必须赋值给 deal', () => { + test('changeClosePercent 的 CloseMethod 必须赋值给 this.deal', () => { + const changeClosePercentSection = unwindSrc.match(/changeClosePercent\(\)[\s\S]*?\n\s*\},/); + expect(changeClosePercentSection).toBeTruthy(); + expect(changeClosePercentSection[0]).toMatch(/this\.deal\.CloseMethod\s*=/); + expect(changeClosePercentSection[0]).not.toMatch(/this\.floatPosition\.CloseMethod\s*=/); + }); + + test('changeCloseNotionalValue 的 CloseMethod 必须赋值给 this.deal', () => { + const changeCloseNotionalSection = unwindSrc.match(/changeCloseNotionalValue\(\)[\s\S]*?\n\s*\},/); + expect(changeCloseNotionalSection).toBeTruthy(); + expect(changeCloseNotionalSection[0]).toMatch(/this\.deal\.CloseMethod\s*=/); + expect(changeCloseNotionalSection[0]).not.toMatch(/this\.floatPosition\.CloseMethod\s*=/); + }); + + test('changeCloseNotionalValue 必须设置 CloseMethod(不能删除)', () => { + // c9071a4e 完全删除了 changeCloseNotionalValue 中的 CloseMethod 判断 + const changeCloseNotionalSection = unwindSrc.match(/changeCloseNotionalValue\(\)[\s\S]*?\n\s*\},/); + expect(changeCloseNotionalSection).toBeTruthy(); + expect(changeCloseNotionalSection[0]).toMatch(/CloseMethod/); + }); + }); +}); diff --git a/YLErpWeb/fe-tests/unwindSwapTrade.test.js b/YLErpWeb/fe-tests/unwindSwapTrade.test.js new file mode 100644 index 00000000..b977b41e --- /dev/null +++ b/YLErpWeb/fe-tests/unwindSwapTrade.test.js @@ -0,0 +1,92 @@ +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +function createNumberFormat(precision) { + const formatter = (value) => Number(Number(value || 0).toFixed(precision)); + formatter.precision = precision; + return formatter; +} + +function loadUnwindHelpers() { + const filePath = path.join(__dirname, '../wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js'); + const code = fs.readFileSync(filePath, 'utf8') + '\nmodule.exports = { swapPosiFeeCalc, consPosiFeeType };'; + + const stockEqvNotional = createNumberFormat(2); + const sandbox = { + module: { exports: {} }, + exports: {}, + console, + require, + window: { otcformat: { options: {} } }, + otcformat: { + options: {}, + trading: { + premiumRateP: { precision: 4 }, + tradePrice: { precision: 4 }, + notional: { precision: 6 }, + StockEqvNotional: stockEqvNotional, + marginRateP: { precision: 4 }, + umpriceP: { precision: 4 } + }, + fixed6: createNumberFormat(6) + }, + model: { + ValueDate: '2026-07-27', + FlowEvents: [], + StructureType: '', + TradeStartDate: '' + }, + isUseApproval: false, + Vue: function (options) { return options; }, + FastVue: { + vueDatePicker() { return {}; }, + vueNumberInput() { return {}; } + }, + tradeHelper: { IsBond() { return false; } }, + main: { + post() { + return { + done() { return this; } + }; + }, + message() { } + }, + SwapCalc: { + roundHalfAwayFromZero(value) { return value; }, + calcCloseQtyByOriginalPercent() { return 0; } + }, + _: { + round(value, precision) { + return Number(Number(value || 0).toFixed(precision || 0)); + } + } + }; + + sandbox.window.otcformat = sandbox.otcformat; + vm.runInNewContext(code, sandbox, { filename: filePath }); + return sandbox.module.exports; +} + +function expectClose(actual, expected, tolerance) { + expect(Math.abs(actual - expected)).toBeLessThanOrEqual(tolerance || 1e-6); +} + +describe('unwindSwapTrade 基础费率计算', () => { + const { swapPosiFeeCalc, consPosiFeeType } = loadUnwindHelpers(); + + test('百分比模式按平仓名义本金计算并保留两位', () => { + const result = swapPosiFeeCalc.calcTradingFee(consPosiFeeType.Percent, 0.1234, 1000000, 5000); + expectClose(result, 1234.00); + }); + + test('单位数量模式按平仓数量计算并保留两位', () => { + const result = swapPosiFeeCalc.calcTradingFee(consPosiFeeType.Unit, 1.235, 1000000, 10); + expectClose(result, 12.35); + }); + + test('未知模式默认按百分比模式处理', () => { + const result = swapPosiFeeCalc.calcTradingFee(99, 0.1, 200000, 10); + expectClose(result, 200.00); + }); +}); diff --git a/YLErpWeb/wwwroot/Scripts/app/risk/SubstandardTrade.js b/YLErpWeb/wwwroot/Scripts/app/risk/SubstandardTrade.js index c351e43c..02f7f83d 100644 --- a/YLErpWeb/wwwroot/Scripts/app/risk/SubstandardTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/risk/SubstandardTrade.js @@ -246,7 +246,7 @@ function getColModelDefault() { width: 85, align: 'center', sortable: false, - formatter: otcformat.trading.umprice + formatter: EventTwoDecimalFormat }, { name: 'TradeType2', @@ -374,7 +374,7 @@ function getColModelDefault() { width: 65, align: 'center', sortable: false, - formatter: otcformat.trading.umprice + formatter: EventTwoDecimalFormat }); return tradeHelper.getAmountToNotional(colModelGrid); @@ -388,6 +388,14 @@ function ShowStructFormater(cellValue, options, rowObject) { return cellValue || ''; } +function EventTwoDecimalFormat(cellValue) { + if (cellValue === null || cellValue === undefined || cellValue === '') { + return ''; + } + var price = Number(cellValue); + return isFinite(price) ? price.toFixed(2) : cellValue; +} + function StrikeFormatter(cellValue, options, rowObject) { if (rowObject.TradeType === "自定义交易") { return ""; @@ -396,20 +404,12 @@ function StrikeFormatter(cellValue, options, rowObject) { return "--"; } - if (rowObject.Strike) { - if (rowObject.IsMoneynessOption === "是") { - return otcformat.trading.premiumRateP(rowObject.Strike); - } else { - return otcformat.trading.umprice(rowObject.Strike); - } - } else { - if (rowObject.Strike === 0) { - return otcformat.trading.umprice(0); - } - else { - return ""; - } - } + if (cellValue === null || cellValue === undefined || cellValue === '' || cellValue === 'NaN') return ''; + var strike = Number(cellValue); + if (!isFinite(strike)) return ''; + return rowObject.IsMoneynessOption === "是" + ? (strike * 100).toFixed(2) + '%' + : EventTwoDecimalFormat(strike); } function CommissionFormatter(cellValue, options, rowObject) { @@ -651,4 +651,4 @@ function showcolumnChooser() { function getColModel() { return getColModelDefault(); -} \ No newline at end of file +} diff --git a/YLErpWeb/wwwroot/Scripts/app/risk/quotaMonitor.js b/YLErpWeb/wwwroot/Scripts/app/risk/quotaMonitor.js index 4c159554..4d6328a9 100644 --- a/YLErpWeb/wwwroot/Scripts/app/risk/quotaMonitor.js +++ b/YLErpWeb/wwwroot/Scripts/app/risk/quotaMonitor.js @@ -361,6 +361,16 @@ function formatQuotaAbs(obj, fieldName) { return html; } +function formatFixedTwoDecimals(cellvalue) { + if (cellvalue === null || cellvalue === undefined || cellvalue === '' || cellvalue === 'NaN') { + return ''; + } + var numberValue = Number(cellvalue); + return isFinite(numberValue) + ? numberValue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + : ''; +} + function formatQuotaRef(obj, fieldName) { var html = ""; var upperValue = obj["Quota_" + fieldName + "_Upper"]; @@ -544,8 +554,21 @@ function quotaMonitorUploadForm(url) { } function Output() { - var fileName = new moment().format("YYYYMMDD") + $('#myTab .active a').text(); - main.toExcel("listGrid", fileName, "xls", null, [" ", "操作"]); + var summaryType = $('#myTab .active a').text(); + var fileName = new moment().format("YYYYMMDD") + summaryType; + var formatters = null; + if (summaryType === '整体业务汇总' || summaryType === '标的汇总' || summaryType === '客户汇总') { + formatters = [{ + colName: '名义本金', + formatter: function (cellvalue, options, rowObject) { + if (summaryType === '整体业务汇总' && rowObject.BusinessType === '场内业务') { + return ''; + } + return formatFixedTwoDecimals(cellvalue); + } + }]; + } + main.toExcel("listGrid", fileName, "xls", null, [" ", "操作"], formatters); } function confirmAllSelect() { @@ -1090,7 +1113,7 @@ var colModel_undelrying = [ align: 'right', sortable: false, formatter: function (cellvalue, options, rowObject) { - return !cellvalue || cellvalue == "NaN" ? "" : cellvalue.toLocaleString(); + return formatFixedTwoDecimals(cellvalue); }, cellattr: function (cellvalue, options, rowObject) { var style = checkQuota(rowObject, 'StockEqvNotional'); @@ -1507,7 +1530,7 @@ var colModel_client = [ align: 'right', sortable: false, formatter: function (cellvalue, options, rowObject) { - return !cellvalue || cellvalue == "NaN" ? "" : cellvalue.toLocaleString(); + return formatFixedTwoDecimals(cellvalue); }, cellattr: function (cellvalue, options, rowObject) { var style = "style='" + checkQuota(rowObject, 'StockEqvNotional') + "'"; @@ -1658,10 +1681,7 @@ var colModel_global = [ if (rowObject.BusinessType == "场内业务") { return '
'; } - if (page.IsGuoXin && cellvalue != null) { - return cellvalue == "NaN" ? "" : cellvalue.toLocaleString(); - } - return !cellvalue || cellvalue == "NaN" ? "" : cellvalue.toLocaleString(); + return formatFixedTwoDecimals(cellvalue); }, cellattr: function (cellvalue, options, rowObject) { var style = checkQuota(rowObject, 'StockEqvNotional'); @@ -2132,4 +2152,4 @@ var colModel_Log = [ align: 'center', sortable: false } -]; \ No newline at end of file +]; diff --git a/YLErpWeb/wwwroot/Scripts/app/scenario/tradeScenario.js b/YLErpWeb/wwwroot/Scripts/app/scenario/tradeScenario.js index ccdd4c86..c30a7782 100644 --- a/YLErpWeb/wwwroot/Scripts/app/scenario/tradeScenario.js +++ b/YLErpWeb/wwwroot/Scripts/app/scenario/tradeScenario.js @@ -13,20 +13,12 @@ const colModelGrid = (function () { } function StrikeFormatter(cellValue, options, rowObject) { - if (rowObject.Strike) { - if (rowObject.IsMoneynessOption === "是") { - return otcformat.trading.premiumRateP(rowObject.Strike); - } else { - return otcformat.trading.umprice(rowObject.Strike); - } - } else { - if (rowObject.Strike === 0) { - return 0; - } - else { - return ""; - } - } + if (cellValue === null || cellValue === undefined || cellValue === '' || cellValue === 'NaN') return ''; + var strike = Number(cellValue); + if (!isFinite(strike)) return ''; + return rowObject.IsMoneynessOption === "是" + ? (strike * 100).toFixed(2) + '%' + : strike.toFixed(2); } var col = [ @@ -273,4 +265,4 @@ function setTestValue(list) { testStstus = false; main.alert("计算完成"); } -} \ No newline at end of file +} diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/EodPositionRisks.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/EodPositionRisks.js index 99067de4..ca12a59f 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/EodPositionRisks.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/EodPositionRisks.js @@ -168,7 +168,9 @@ function colModelGridEodPosition() { index: 'eodPosition.PosiNetPrice', width: 90, align: 'center', - formatter: PriceFormat + formatter: PriceFormat, + exportFormatter: ExportPriceNineDecimalFormat, + exportNumberFormat: '0.000000000' }, { name: 'eodPosition.PosiGrossPrice', label: '期初价格-不含费', @@ -176,6 +178,8 @@ function colModelGridEodPosition() { width: 90, align: 'center', formatter: PriceFormat, + exportFormatter: ExportPriceNineDecimalFormat, + exportNumberFormat: '0.000000000' }, { name: 'eodPosition.PosiQuantity', label: '名义数量', @@ -743,7 +747,7 @@ function exportVisibleColumns() { var tabName = page.tabIndex == 2 ? '框架合约' : '日终持仓'; var fileName = '日终持仓风险_互换_' + tabName + (dateStr ? '_' + dateStr : ''); if (page.tabIndex != 2) { - main.exportVisibleColumnsToExcel(jgrid, fileName, null); + exportEodPositionRows(jgrid, fileName); return; } @@ -770,6 +774,26 @@ function exportVisibleColumns() { }); } +function exportEodPositionRows(jgrid, fileName) { + var exportPostData = $.extend({}, GetPostData(), { + page: jgrid.jqGrid('getGridParam', 'page'), + rows: jgrid.jqGrid('getGridParam', 'rowNum'), + sidx: jgrid.jqGrid('getGridParam', 'sortname'), + sord: jgrid.jqGrid('getGridParam', 'sortorder') + }); + $.ajax({ + url: queryurl, + type: 'POST', + dataType: 'json', + traditional: true, + data: exportPostData + }).done(function (result) { + main.exportVisibleColumnsToExcel(jgrid, fileName, null, result && result.rows ? result.rows : []); + }).fail(function () { + main.message && main.message('导出失败,无法获取日终持仓数据'); + }); +} + function getVisibleEodSwapBusinessColumnNames(jgrid) { var colModel = jgrid.jqGrid('getGridParam', 'colModel') || []; return colModel.filter(function (col) { @@ -803,6 +827,14 @@ function PriceFormat(cellValue, options, rowObject) { return otcformat.trading.umprice(cellValue); } +function ExportPriceNineDecimalFormat(cellValue) { + if (cellValue === null || cellValue === undefined || cellValue === '') { + return ''; + } + var price = Number(cellValue); + return isFinite(price) ? price.toFixed(9) : cellValue; +} + function RealizedPnlFormat(cellValue, options, rowObject) { return otcformat.trading.tradePrice(cellValue); } diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/eventlist.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/eventlist.js index e9f8f6b7..c8c32767 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/eventlist.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/eventlist.js @@ -176,36 +176,31 @@ var getColModelGrid = function () { label: '成交全价', width: 90, align: 'center', - formatter: otcformat.trading.umprice + formatter: EventTwoDecimalFormat }, { name: 'TradingAmountFeeAvg', label: '成交全价(含费)', width: 90, align: 'center', - formatter: otcformat.trading.umprice + formatter: EventTwoDecimalFormat }, { name: 'TradingAmountNetAvg', label: '成交净价', width: 90, align: 'center', - formatter: otcformat.trading.umprice + formatter: EventTwoDecimalFormat }, { name: 'InitYtm', label: '成交收益率', width: 90, align: 'center', - formatter: function (cellValue, options, rowObject) { - if (cellValue == null) { - return ""; - } - return otcformat.trading.premiumRateP(cellValue); - } + formatter: EventNineDecimalFormat }, { name: 'TradingAmountNetFeeAvg', label: '成交净价(含费)', width: 90, align: 'center', - formatter: otcformat.trading.umprice + formatter: EventTwoDecimalFormat }, { name: 'Quantity', label: '成交数量/张数', @@ -217,7 +212,7 @@ var getColModelGrid = function () { label: '成交金额(元)', width: 90, align: 'center', - formatter: otcformat.trading.umprice + formatter: EventTwoDecimalFormat }, { name: 'ContractSize', label: '乘数', @@ -230,31 +225,31 @@ var getColModelGrid = function () { label: '交易费用佣金', width: 90, align: 'center', - formatter: otcformat.trading.umprice + formatter: EventTwoDecimalFormat }, { name: 'TradingFeePending', label: '待结算交易费用佣金', width: 90, align: 'center', - formatter: otcformat.trading.umprice + formatter: EventTwoDecimalFormat }, { name: 'DividendPending', label: '待结算分红收益', width: 90, align: 'center', - formatter: otcformat.trading.umprice + formatter: EventTwoDecimalFormat }, { name: 'MarkClosePnl', label: '浮动端平仓盈亏·浮动', width: 160, align: 'center', - formatter: otcformat.trading.umprice + formatter: EventTwoDecimalFormat }, { name: 'DividendIn', label: '浮动端平仓盈亏·分红', width: 160, align: 'center', - formatter: otcformat.trading.umprice + formatter: EventTwoDecimalFormat } ]; return col; @@ -262,6 +257,23 @@ var getColModelGrid = function () { var colModelGrid = getColModelGrid(); +function EventTwoDecimalFormat(cellValue) { + if (cellValue === null || cellValue === undefined || cellValue === '') { + return ''; + } + var price = Number(cellValue); + return isFinite(price) ? price.toFixed(2) : cellValue; +} + +function EventNineDecimalFormat(cellValue) { + if (cellValue === null || cellValue === undefined || cellValue === '') { + return ''; + } + var price = Number(cellValue); + return isFinite(price) ? price.toFixed(9) : cellValue; +} + + function gridComplete() { $('.ui-jqgrid-bdiv', '#gbox_listGrid').floatingScroll(); } diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js index 91b2e26a..c3eecede 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js @@ -12,11 +12,39 @@ const inputFormatTradeAmount = Object.freeze({ precision: otcformat.trading.noti const inputFormatSwapRate = Object.freeze({ precision: otcformat.trading.premiumRateP.precision, negative: true, append: '%' }); const inputFormatTradePrice = Object.freeze({ precision: otcformat.trading.tradePrice.precision, negative: true, append: '' }); const inputFormatTradeSinglePrice = Object.freeze({ precision: otcformat.trading.tradeSinglePrice.precision, negative: true, append: '', percent: false }); +const inputFormatPosiFeePercent = Object.freeze({ precision: 4, negative: true, append: '%' }); +const inputFormatPosiFeeUnit = Object.freeze({ precision: 2, negative: true, append: '' }); const inputFormatMarginRate = Object.freeze({ precision: otcformat.trading.marginRateP.precision, append: '%' }); const inputFormatSwapDeliveryPrice = Object.freeze({ precision: 9, negative: true, append: '', percent: false }); const inputFormatSwapBondDeliveryPrice = Object.freeze({ precision: 9, negative: true, append: '', percent: true }); const inputFormatSwapBondNetPriceAndYtm = Object.freeze({ precision: 9, negative: true, append: '', percent: true }); const swapBondStoragePricePrecision = inputFormatSwapBondDeliveryPrice.precision + 2; +const consPosiFeeType = Object.freeze({ Percent: 0, Unit: 1 }); +const swapPosiFeeCalc = Object.freeze({ + normalizeFeeType(feeType) { + return Number(feeType) === consPosiFeeType.Unit ? consPosiFeeType.Unit : consPosiFeeType.Percent; + }, + calcPending(feeType, feeUnit, stockEqvNotional, quantity) { + const normalizedFeeType = this.normalizeFeeType(feeType); + const normalizedFeeUnit = Number(feeUnit) || 0; + const normalizedNotional = Number(stockEqvNotional) || 0; + const normalizedQuantity = Number(quantity) || 0; + const tradingFeePending = normalizedFeeType === consPosiFeeType.Percent + ? normalizedFeeUnit / 100 * normalizedNotional + : normalizedFeeUnit * normalizedQuantity; + return otcformat.trading.tradeSinglePrice(tradingFeePending); + }, + calcFeeUnit(feeType, tradingFeePending, stockEqvNotional, quantity) { + const normalizedFeeType = this.normalizeFeeType(feeType); + const normalizedTradingFeePending = Number(tradingFeePending) || 0; + const normalizedNotional = Number(stockEqvNotional) || 0; + const normalizedQuantity = Number(quantity) || 0; + if (normalizedFeeType === consPosiFeeType.Percent) { + return normalizedNotional === 0 ? 0 : _.round(normalizedTradingFeePending / normalizedNotional * 100, inputFormatPosiFeePercent.precision); + } + return normalizedQuantity === 0 ? 0 : _.round(normalizedTradingFeePending / normalizedQuantity, inputFormatPosiFeeUnit.precision); + } +}); const consUnderlyingFlagBase = (function () { let unSelFlag = tradeHelper.UnderlyingSelectFlag; @@ -153,6 +181,7 @@ const vue = new Vue({ currencys: page.currencys, getNotionalSingleFee: 0, isSingleFee: page.Trade.trade_extend.ExtendObj.OpenFeeType == 0, + posiFeeModePercent: true, observation: {//互换观察日 ObservationInterval: "", IntervalList: [], @@ -236,6 +265,41 @@ const vue = new Vue({ const isBond = tradeHelper.IsBond(item && item.UnderlyingInstrumentType); return `${index}-${field}-${isBond ? 'bond' : 'other'}`; }, + getCurrentPosiFeeType() { + return this.posiFeeModePercent ? consPosiFeeType.Percent : consPosiFeeType.Unit; + }, + normalizePosiFeeType(feeType) { + return swapPosiFeeCalc.normalizeFeeType(feeType); + }, + syncPosiFeeModeByItem(item) { + this.posiFeeModePercent = this.normalizePosiFeeType(item && item.PosiFeeType) !== consPosiFeeType.Unit; + }, + syncPayItemFeeType(item) { + item.PosiFeeType = this.getCurrentPosiFeeType(); + }, + refreshTradingFeePendingByUnit(item) { + this.syncPayItemFeeType(item); + item.PosiTradingFeePending = swapPosiFeeCalc.calcPending( + item.PosiFeeType, + item.PosiTradingFeeUnit, + this.trade.StockEqvNotional, + item.PosiQuantity + ); + }, + refreshTradingFeeUnitByPending(item) { + this.syncPayItemFeeType(item); + item.PosiTradingFeeUnit = swapPosiFeeCalc.calcFeeUnit( + item.PosiFeeType, + item.PosiTradingFeePending, + this.trade.StockEqvNotional, + item.PosiQuantity + ); + }, + refreshPayTradingFeesByUnit() { + this.paySwapList.forEach(item => { + this.refreshTradingFeePendingByUnit(item); + }); + }, changeStructureType() { this.trade.StockEqvNotional = 0; let direction = this.trade.trade_extend.ExtendObj.Direction; @@ -382,17 +446,18 @@ const vue = new Vue({ changeContractSize(item) { this.calcNotional(); }, - //变更名义本金 + //变更名义本金(仅格式化,不反算数量) changeStockEqvNotional() { this.trade.StockEqvNotional = otcformat.trading.StockEqvNotional(this.trade.StockEqvNotional); + this.refreshPayTradingFeesByUnit(); //计算数量 - if (this.paySwapList.length > 0) { - var item = this.paySwapList[0]; - var deliveryPrice = this.roundStorageDeliveryPrice(item, item.PosiGrossPrice); - var notional = deliveryPrice * item.ContractSize; - item.PosiQuantity = notional == 0 ? 0 : _.round(this.trade.StockEqvNotional / notional, page.otcFormatConfig.StockEqvNotional.precision); - this.calcNotional(); - } + // if (this.paySwapList.length > 0) { + // var item = this.paySwapList[0]; + // var deliveryPrice = this.roundStorageDeliveryPrice(item, item.PosiGrossPrice); + // var notional = deliveryPrice * item.ContractSize; + // item.PosiQuantity = notional == 0 ? 0 : _.round(this.trade.StockEqvNotional / notional, page.otcFormatConfig.StockEqvNotional.precision); + // this.calcNotional(); + // } }, //变更初始预付金 为¥ @@ -474,6 +539,7 @@ const vue = new Vue({ var stockEqvNotional = SwapCalc.calcStockEqvNotional(deliveryPrice, national);//名义本金=期初价格*数量*乘数 this.trade.StockEqvNotional = otcformat.trading.stockEqvNotional(stockEqvNotional); payItem.PosiNotionalValue = this.trade.StockEqvNotional; + this.refreshPayTradingFeesByUnit(); } }, //变更到期日 @@ -509,26 +575,27 @@ const vue = new Vue({ }, //变更单位交易费用 changeTradingFeeUnit(item) { - //计算交易费用 - //if (this.trade.trade_extend.ExtendObj.OpenFeeType == 0) {//按手数收费 - // item.PosiTradingFee = item.ContractSize == 0 ? 0 : otcformat.trading.tradeSinglePrice(item.PosiQuantity * item.PosiTradingFeeUnit / item.ContractSize); - //} else { - // item.PosiTradingFee = otcformat.trading.tradeSinglePrice(item.PosiQuantity * item.PosiTradingFeeUnit); - //} - + this.refreshTradingFeePendingByUnit(item); }, //变更交易费用 changeTradingFee(item) { - //计算单位交易费用 - //if (item.PosiQuantity == 0) { - // item.PosiTradingFeeUnit = 0; - // return - //} - //if (this.trade.trade_extend.ExtendObj.OpenFeeType == 0) {//按手数收费 - // item.PosiTradingFeeUnit = item.ContractSize == 0 ? 0 : otcformat.trading.tradeSinglePrice(item.PosiTradingFee * item.ContractSize / item.PosiQuantity); - //} else { - // item.PosiTradingFeeUnit = otcformat.trading.tradeSinglePrice(item.PosiTradingFee / item.PosiQuantity); - //} + this.refreshTradingFeeUnitByPending(item); + }, + showPayAbsPrice() { + this.posiFeeModePercent = false; + this.paySwapList.forEach(item => { + item.PosiFeeType = consPosiFeeType.Unit; + item.PosiTradingFeeUnit = 0; + item.PosiTradingFeePending = 0; + }); + }, + showPayPercentPrice() { + this.posiFeeModePercent = true; + this.paySwapList.forEach(item => { + item.PosiFeeType = consPosiFeeType.Percent; + item.PosiTradingFeeUnit = 0; + item.PosiTradingFeePending = 0; + }); }, savetrade() { if (!this.checkSubmitData()) { @@ -618,6 +685,7 @@ const vue = new Vue({ x.PosiGrossPrice = thisObj.roundStorageDeliveryPrice(x, x.PosiGrossPrice); x.PosiNetNoFeePrice = thisObj.roundStorageBondNetPriceAndYtm(x.PosiNetNoFeePrice); x.InitYtm = x.InitYtm == null ? null : thisObj.roundStorageBondNetPriceAndYtm(x.InitYtm); + x.PosiFeeType = thisObj.normalizePosiFeeType(x.PosiFeeType); thisObj.trade.swap_positions.push(x); }); } else { @@ -1414,6 +1482,7 @@ const vue = new Vue({ thisObj.getSwapList = thisObj.trade.swap_positions.filter(x => { if ((x.UnderlyingCode == null || x.UnderlyingCode.length == 0) && x.IsInitial && (x.InterestMode == 1 || x.InterestMode == 2 || x.InterestMode == 7 || x.InterestMode == 8 || x.InterestMode == 9)) return x; }); thisObj.getSwapList.forEach((val, num, arr) => { arr[num].index = num; + arr[num].category_tag = arr[num].category_tag || '互换利率'; // 解析 InterestSwapInterval 为 SwapIntervalList if (arr[num].InterestSwapInterval && !arr[num].SwapIntervalList) { try { @@ -1438,6 +1507,7 @@ const vue = new Vue({ thisObj.paySwapList = thisObj.trade.swap_positions.filter(x => { if (x.UnderlyingCode != null && x.UnderlyingCode.length != 0 && x.IsInitial) return x; }); thisObj.paySwapList.forEach((val, num, arr) => { arr[num].index = num; + arr[num].PosiFeeType = thisObj.normalizePosiFeeType(arr[num].PosiFeeType); this.StockEqvNotional = val.ContractSize * val.PosiQuantity * val.PosiGrossPrice; // D2 修复:重开(审批重开/刷新)已保存的债券成交单时,三字段互算的手动标志随页面重置而丢失; // 若不锁,用户一旦编辑任一价格字段就会以它为源重新反算、覆盖当初保存的其他两格。 @@ -1449,6 +1519,9 @@ const vue = new Vue({ thisObj.$set(arr[num], 'bondManual', { CP: true, DP: true, YD: true }); } }); + if (thisObj.paySwapList.length > 0) { + thisObj.syncPosiFeeModeByItem(thisObj.paySwapList[0]); + } } if (thisObj.paySwapList.length == 0) { @@ -1502,7 +1575,8 @@ const vue = new Vue({ HappenDate: null,//发生日期, Currency: 'CNY',//币种 interest_rest_days: 7,//重置频率 - interest_rule: null//利率准则 + interest_rule: null,//利率准则 + category_tag: '互换利率'//类别 } thisObj.getSwapList.push(getSwap); }, @@ -1574,6 +1648,7 @@ const vue = new Vue({ PosiTradingFee: 0,//交易费用 PosiTradingFeePending: 0,//交易费用后付 PosiTradingFeeUnit: 0,//单位交易费用 + PosiFeeType: thisObj.getCurrentPosiFeeType(),//单位交易费用模式 InterestDirection: 0,//利息收支方式 InterestRateDefault: 0,//计息利率 InterestMode: 0,//计息基本类型 diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js index d0759efc..585a37cb 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js @@ -26,6 +26,10 @@ function editTrade(enid) { window.location.href = `/swapTrade2/tradeEdit/?enid=${enid}`; } +function renewTrade(enid) { + window.location.href = `/swapTrade2/tradeEdit/?enid=0&renewEnid=${encodeURIComponent(enid)}`; +} + function editTradeRemarkInfo(enid) { main.open("修改备注", "/trade/EditRemarkInfo?enid=" + enid, { area: ["700px", "500px"] }); } @@ -586,4 +590,4 @@ function SubmissionFields(enid) { function SubmissionFieldsHistory(enid) { main.open("报送相关字段填写", "/trade/submissionFieldsHistory?encryptId=" + enid); -} \ No newline at end of file +} diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/tradeEndConfirmList.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/tradeEndConfirmList.js index 6f82f664..f6e515c6 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/tradeEndConfirmList.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/tradeEndConfirmList.js @@ -423,9 +423,9 @@ const colModelGrid = (new function () { }, { name: 'trade.ClientName', label: '客户名称', index: 'trade.ClientName', width: 180, align: 'left' }, { - name: 'swap_flow_event.Quantity', label: '份额', index: 'swap_flow_event.Quantity', width: 150, align: 'left', formatter: ShowNotionalFormater + name: 'swap_flow_event.Quantity', label: '份额', index: 'swap_flow_event.Quantity', width: 150, align: 'left', formatter: otcformat.fixed2 }, { - name: 'swap_flow_event.TradingAmountAvg', label: '结算价', index: 'swap_flow_event.TradingAmountAvg', width: 150, align: 'left', formatter: otcformat.trading.umprice + name: 'swap_flow_event.TradingAmountAvg', label: '结算价', index: 'swap_flow_event.TradingAmountAvg', width: 150, align: 'left', formatter: otcformat.fixed2 }, { name: 'swap_event.unwindData.SwapCloseAmount', label: '了结总额', index: 'swap_event.unwindData.SwapCloseAmount', width: 150, align: 'left', formatter: otcformat.trading.StockEqvNotional, sortable: false, }, { diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js index 3838b7ea..554329ac 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js @@ -8,6 +8,22 @@ const inputFormatEqvNotional = Object.freeze({ precision: otcformat.trading.Stoc const inputFormatMarginRate = Object.freeze({ precision: otcformat.trading.marginRateP.precision, append: '%' }); const inputFormatMarginRateNoPercent = Object.freeze({ precision: otcformat.trading.umpriceP.precision, append: '', percent: true }); const inputFormatSwapDeliveryPrice = Object.freeze({ precision: 9, append: '', negative: true }); +const consPosiFeeType = Object.freeze({ Percent: 0, Unit: 1 }); +const swapPosiFeeCalc = { + normalizeFeeType(feeType) { + return Number(feeType) === consPosiFeeType.Unit ? consPosiFeeType.Unit : consPosiFeeType.Percent; + }, + calcTradingFee(feeType, feeUnit, closeNotionalValue, closeQty) { + const normalizedFeeType = this.normalizeFeeType(feeType); + const normalizedFeeUnit = Number(feeUnit) || 0; + const normalizedCloseNotionalValue = Number(closeNotionalValue) || 0; + const normalizedCloseQty = Number(closeQty) || 0; + const tradingFee = normalizedFeeType === consPosiFeeType.Unit + ? normalizedFeeUnit * normalizedCloseQty + : normalizedFeeUnit / 100 * normalizedCloseNotionalValue; + return otcformat.trading.StockEqvNotional(_.round(tradingFee, 2)); + } +}; let ValueDate = model.ValueDate; const vue = new Vue({ el: '#vueDiv', @@ -140,6 +156,7 @@ const vue = new Vue({ this.deal.CloseQty = this.calcCloseQtyByPercent(this.deal.ClosePercent); } this.calcTradingFeePending(); + this.refreshTradingFeeByUnit(); this.getInterestList(); this.calcFloatClosePnl(); }, @@ -153,6 +170,13 @@ const vue = new Vue({ calcTradingFeePending() { this.floatPosition.TradingFeePending = this.floatPosition.BeforeCloseFee * parseFloat(this.deal.ClosePercent); }, + refreshTradingFeeByUnit() { + this.floatPosition.TradingFee = swapPosiFeeCalc.calcTradingFee( + this.floatPosition.PosiFeeType, + this.floatPosition.PosiTradingFeeUnit, + this.deal.CloseNotionalValue, + this.deal.CloseQty); + }, changeCloseQty() {//修改平仓数量 if (parseFloat(this.deal.CloseQty) > parseFloat(this.deal.PositionQty)) { main.message("平仓数量不能超过持仓数量"); @@ -169,6 +193,7 @@ const vue = new Vue({ // 占期初口径:平仓名义本金 = 平仓比例 × 期初名义本金(NotionalValue) this.deal.CloseNotionalValue = otcformat.trading.StockEqvNotional(parseFloat(this.deal.ClosePercent) * parseFloat(this.deal.NotionalValue)); this.calcTradingFeePending(); + this.refreshTradingFeeByUnit(); this.getInterestList(); this.calcFloatClosePnl(); }, @@ -187,6 +212,7 @@ const vue = new Vue({ this.deal.CloseMethod = 2; } this.calcTradingFeePending(); + this.refreshTradingFeeByUnit(); this.getInterestList(); this.calcFloatClosePnl(); }, @@ -205,6 +231,7 @@ const vue = new Vue({ this.deal.CloseMethod = 2; } this.calcTradingFeePending(); + this.refreshTradingFeeByUnit(); this.getInterestList(); this.calcFloatClosePnl(); }, diff --git a/YLErpWeb/wwwroot/Scripts/app/trade/swapTradeEdit.js b/YLErpWeb/wwwroot/Scripts/app/trade/swapTradeEdit.js index 95b22a66..0ad5dc9e 100644 --- a/YLErpWeb/wwwroot/Scripts/app/trade/swapTradeEdit.js +++ b/YLErpWeb/wwwroot/Scripts/app/trade/swapTradeEdit.js @@ -263,18 +263,18 @@ const vue = new Vue({ this.CnStockEqvNotional(); this.changeStockEqvNotional(); }, - //变更名义本金 + //变更名义本金(仅格式化,不反算数量) changeStockEqvNotional() { this.trade.StockEqvNotional = otcformat.trading.StockEqvNotional(this.trade.StockEqvNotional); if (this.trade.trade_swap.IsPayFloatingProfit) { - var spotPrice = isPaySyntheticUnderlying ? payMaxPrice : $("#trade_swap\\.PaySpotPrice").val(); - var notional = spotPrice != 0 ? this.trade.StockEqvNotional / spotPrice : 0; - this.trade.trade_swap.PayNotional = otcformat.trading.notional(Math.abs(notional)); - var tradeAmount = notional / payCountRatio; - this.trade.trade_swap.PayTradeAmount = otcformat.trading.notional(Math.abs(tradeAmount)); - var lots = notional / payContractSize; - this.trade.trade_swap.PayLot = otcformat.trading.notional(Math.abs(lots)); + // var spotPrice = isPaySyntheticUnderlying ? payMaxPrice : $("#trade_swap\\.PaySpotPrice").val(); + // var notional = spotPrice != 0 ? this.trade.StockEqvNotional / spotPrice : 0; + // this.trade.trade_swap.PayNotional = otcformat.trading.notional(Math.abs(notional)); + // var tradeAmount = notional / payCountRatio; + // this.trade.trade_swap.PayTradeAmount = otcformat.trading.notional(Math.abs(tradeAmount)); + // var lots = notional / payContractSize; + // this.trade.trade_swap.PayLot = otcformat.trading.notional(Math.abs(lots)); if (this.isSingleFee) { this.changeGetSingleFee(); } @@ -284,13 +284,6 @@ const vue = new Vue({ } if (this.trade.trade_swap.IsGetFloatingProfit) { - var spotPrice = isGetSyntheticUnderlying ? getMaxPrice : $("#trade_swap\\.GetSpotPrice").val(); - var notional = spotPrice != 0 ? this.trade.StockEqvNotional / spotPrice : 0; - this.trade.trade_swap.GetNotional = otcformat.trading.notional(Math.abs(notional)); - var tradeAmount = notional / getCountRatio; - this.trade.trade_swap.GetTradeAmount = otcformat.trading.notional(Math.abs(tradeAmount)); - var lots = notional / getContractSize; - this.trade.trade_swap.GetLot = otcformat.trading.notional(Math.abs(lots)); if (this.isSingleFee) { this.changePaySingleFee(); } diff --git a/YLErpWeb/wwwroot/Scripts/utils.js b/YLErpWeb/wwwroot/Scripts/utils.js index a615eda2..95c78d12 100644 --- a/YLErpWeb/wwwroot/Scripts/utils.js +++ b/YLErpWeb/wwwroot/Scripts/utils.js @@ -1236,7 +1236,9 @@ main.exportVisibleColumnsToExcel = function (jgrid, fileName, groupConfig, expor exportCols.forEach(function (col) { var colIndex = colModel.indexOf(col); var rawValue = $.jgrid.getAccessor(row, col.name); - var formattedValue = gridElement && gridElement.formatter + var formattedValue = typeof col.exportFormatter === 'function' + ? col.exportFormatter(rawValue, row) + : gridElement && gridElement.formatter ? gridElement.formatter(rowIndex + 1, rawValue, colIndex, row, 'add') : rawValue; formattedRow[col.name] = $('
').html(formattedValue == null ? '' : String(formattedValue)).text().replace(/\u00a0/g, ''); @@ -1263,7 +1265,9 @@ main.exportVisibleColumnsToExcel = function (jgrid, fileName, groupConfig, expor for (var c = 0; c < exportCols.length; c++) { var val = rows[i][exportCols[c].name]; if (val === undefined || val === null) val = ''; - html += '' + escapeXml(String(val)) + ''; + var numberFormat = exportCols[c].exportNumberFormat; + var style = numberFormat ? ' style="mso-number-format:\'' + escapeXml(String(numberFormat)) + '\';"' : ''; + html += '' + escapeXml(String(val)) + ''; } html += ''; }