From a17cdc2b742dfb51ee955668001f49600fe2c7b5 Mon Sep 17 00:00:00 2001 From: hjhan Date: Thu, 16 Jul 2026 14:41:06 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix(=E6=9C=9F=E6=9D=83=E5=88=B0=E6=9C=9F):?= =?UTF-8?q?=20=E5=80=BA=E5=88=B8=E6=A0=87=E7=9A=84=E5=88=B0=E6=9C=9F?= =?UTF-8?q?=E5=8F=96=E4=BB=B7=E8=A1=A5=E6=9F=A5=E4=B8=AD=E5=80=BA=E4=BC=B0?= =?UTF-8?q?=E5=80=BC=E8=A1=A8=EF=BC=8C=E5=B9=B6=E6=A0=87=E6=B3=A8=E5=80=BA?= =?UTF-8?q?=E5=88=B8=E5=87=80/=E5=85=A8=E4=BB=B7=E6=98=A0=E5=B0=84?= =?UTF-8?q?=E4=B8=8D=E4=B8=80=E8=87=B4(Layer2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EodPriceQueryService 新增 TryGetSettlementEodPrice:债券走中债估值、期货/股票走原路径 - 单笔 tradeExpireInner + 批量 MultipleTradeExpireConfirm 改用统一方法,修 GLMS-20260715-0002 债券期权到期报'结算价未找到' - 移除批量路径未初始化的 EodPriceProvider(对债券无效且有误导性的 footgun) - Layer2:标注 EodPriceProvider.Initialize 与 GetBondPrice 债券 ClosePrice/SettlePrice 净全价定义相反,待统一(不改逻辑) - 新增白盒单测覆盖债券标的到期取价(3用例 DB驱动,均通过) --- .../EodPriceQueryServiceSettlementTest.cs | 72 +++++++++++++++++++ .../DataProviderModule/EodPriceProvider.cs | 3 + .../EodPriceQueryService.cs | 20 ++++++ .../DealModule/TradeExpireConfirmService.cs | 10 +-- 4 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 UnitTestProject/Modules/DataProviderModule/EodPriceQueryServiceSettlementTest.cs diff --git a/UnitTestProject/Modules/DataProviderModule/EodPriceQueryServiceSettlementTest.cs b/UnitTestProject/Modules/DataProviderModule/EodPriceQueryServiceSettlementTest.cs new file mode 100644 index 00000000..ec615f82 --- /dev/null +++ b/UnitTestProject/Modules/DataProviderModule/EodPriceQueryServiceSettlementTest.cs @@ -0,0 +1,72 @@ +namespace YLErp.Modules.DataProviderModule +{ + /// + /// TryGetSettlementEodPrice(债券感知统一取价)的白盒测试。 + /// 覆盖期权/交易到期结算场景:债券标的应走中债估值表取到价(修复"结算价未找到"), + /// 非债券标的行为应与原 TryGetEodPrice 完全一致(不影响期货/股票)。 + /// 注:DB 驱动,需连测试库;无数据时 Assert.Inconclusive 跳过。 + /// + [TestClass] + public class EodPriceQueryServiceSettlementTest : YLUnitTestBase + { + [TestMethod] + public void BondUnderlying_RoutesToChinaBondValuation() + { + using var db = DbContextFactory.GetYLDbContext(); + var bond = (from b in db.china_bond_valuation + join u in db.underlying_manager on b.bond_id equals u.UnderlyingCode + where b.dirty_price_close > 0 + orderby b.valuation_date descending + select new { b.bond_id, vd = b.valuation_date }).FirstOrDefault(); + if (bond == null) Assert.Inconclusive("测试库无债券估值数据,跳过"); + + var ok = EodPriceQueryService.TryGetSettlementEodPrice(bond.vd, bond.bond_id, out var ep); + Assert.IsTrue(ok, "债券标的应走中债估值表取到价(修复点)"); + Assert.IsNotNull(ep); + // 债券 ClosePrice=全价(dirty_price_close),应与 GetBondPrice().ClosePrice 一致 + var bondPrice = EodPriceQueryService.GetBondPrice(bond.vd, bond.bond_id); + Assert.IsNotNull(bondPrice); + Assert.AreEqual(bondPrice.ClosePrice, ep.ClosePrice, 1e-6); + } + + [TestMethod] + public void NonBondUnderlying_RoutesToStockOrFuturePath() + { + using var db = DbContextFactory.GetYLDbContext(); + var stock = (from s in db.eod_stock_price + join u in db.underlying_manager on s.UnderlyingCode equals u.UnderlyingCode + where s.ClosePrice > 0 && u.UnderlyingInstrumentType == "Stock" + select new { s.UnderlyingCode, s.ValueDate }).FirstOrDefault(); + if (stock == null) Assert.Inconclusive("测试库无(股票类型)价格数据,跳过"); + + var ok = EodPriceQueryService.TryGetSettlementEodPrice(stock.ValueDate, stock.UnderlyingCode, out var ep); + var okOld = EodPriceQueryService.TryGetEodPrice(stock.ValueDate, stock.UnderlyingCode, out var epOld); + Assert.AreEqual(okOld, ok, "非债券标的行为应与原 TryGetEodPrice 一致"); + if (ok) + { + Assert.IsNotNull(ep); + Assert.AreEqual(epOld.ClosePrice, ep.ClosePrice, 1e-6, "非债券标的取到的收盘价应与原路径相同"); + } + } + + [TestMethod] + public void BondOptionExpiry_Regression_OldPathFailsNewPathSucceeds() + { + using var db = DbContextFactory.GetYLDbContext(); + var bond = (from b in db.china_bond_valuation + join u in db.underlying_manager on b.bond_id equals u.UnderlyingCode + where b.dirty_price_close > 0 + orderby b.valuation_date descending + select new { b.bond_id, vd = b.valuation_date }).FirstOrDefault(); + if (bond == null) Assert.Inconclusive("测试库无债券估值数据,跳过"); + + // 旧路径:TryGetEodPrice 只 join 期货/股票两表,债券取不到价 + var oldOk = EodPriceQueryService.TryGetEodPrice(bond.vd, bond.bond_id, out _); + // 新路径:债券感知统一取价,应能取到 + var newOk = EodPriceQueryService.TryGetSettlementEodPrice(bond.vd, bond.bond_id, out var ep); + Assert.IsFalse(oldOk, "回归基线:旧路径对债券标的应取不到价(这正是期权到期报'结算价未找到'的根因)"); + Assert.IsTrue(newOk && ep != null && ep.ClosePrice > 0, + "修复验证:统一取价应能为债券标的取到结算价,期权到期不再报'结算价未找到'"); + } + } +} diff --git a/YLErpDAL/Modules/DataProviderModule/EodPriceProvider.cs b/YLErpDAL/Modules/DataProviderModule/EodPriceProvider.cs index 18b31de5..c59eca99 100644 --- a/YLErpDAL/Modules/DataProviderModule/EodPriceProvider.cs +++ b/YLErpDAL/Modules/DataProviderModule/EodPriceProvider.cs @@ -140,6 +140,9 @@ namespace YLErp.Modules.DataProviderModule { if (item.UnderlyingInstrumentType == "Bonds") { + // [Layer2-待统一] 债券映射口径:SettlePrice=全价(dirty_price_close),ClosePrice=净价(net_price)。 + // 注意:这与 EodPriceQueryService.GetBondPrice 的映射【完全相反】(GetBondPrice: ClosePrice=全价,SettlePrice=净价)。 + // 两处对"债券收盘价/结算价"的净全价定义不一致属历史遗留,请勿随意改动单侧,需业务先定调后统一(见 TryGetSettlementEodPrice 注释)。 item.SettlePrice = Convert.ToDouble(BondPriceConverter.ToStorage(item.DeciSettlePrice)); item.ClosePrice = Convert.ToDouble(BondPriceConverter.ToStorage(item.DeciClosePrice)); item.ReferencePrice = Convert.ToDouble(BondPriceConverter.ToStorage(item.DeciReferencePrice)); diff --git a/YLErpDAL/Modules/DataProviderModule/EodPriceQueryService.cs b/YLErpDAL/Modules/DataProviderModule/EodPriceQueryService.cs index c660b0c6..7792e5e1 100644 --- a/YLErpDAL/Modules/DataProviderModule/EodPriceQueryService.cs +++ b/YLErpDAL/Modules/DataProviderModule/EodPriceQueryService.cs @@ -114,6 +114,23 @@ namespace YLErp.Modules.DataProviderModule return (eodPrice = GetBondPrice(valueDate, underlyingCode)) != null; } /// + /// 统一日终结算取价(债券感知)。 + /// 用于交易/期权到期结算:债券标的走中债估值表(TryGetBondEodPrice),期货/股票走原 InnerGetEodPrice。 + /// 解决到期路径(tradeExpireInner / MultipleTradeExpireConfirm)漏查债券表导致"结算价未找到"的问题。 + /// 注:债券 ClosePrice/SettlePrice 映射沿用 GetBondPrice 口径(ClosePrice=全价 dirty_price_close,SettlePrice=净价 net_price), + /// 与 EodPriceProvider 的映射(ClosePrice=净价,SettlePrice=全价)相反——属历史不一致(见 EodPriceProvider.Initialize 与 GetBondPrice 的注释), + /// 本方法保持与系统既有"债券现价"约定(UnderlyingCodePrice)一致,不引入新口径。 + /// + public static bool TryGetSettlementEodPrice(DateTime valueDate, string underlyingCode, out EodPrice eodPrice) + { + var um = DataCacheProvider.GetUnderlyingDataSource().GetData(underlyingCode); + if (um != null && ConsGlobal.InstrumentType.IsBond(um.UnderlyingInstrumentType)) + { + return TryGetBondEodPrice(valueDate, underlyingCode, out eodPrice); + } + return TryGetEodPrice(valueDate, underlyingCode, out eodPrice); + } + /// /// 尝试获取标的某日的日终价 /// public static bool TryGetEodPrice(DateTime valueDate, int underlyingId, out EodPrice eodPrice) @@ -231,6 +248,9 @@ namespace YLErp.Modules.DataProviderModule Vobp = bondPrice.vobp, ValueDate = valueDate, UnderlyingCode = underlyingCode, + // [Layer2-待统一] 债券映射口径:ClosePrice=全价(dirty_price_close),SettlePrice=净价(net_price)。 + // 注意:这与 EodPriceProvider.Initialize 的映射【完全相反】(EodPriceProvider: ClosePrice=净价,SettlePrice=全价)。 + // 两处对"债券收盘价/结算价"的净全价定义不一致属历史遗留,请勿随意改动单侧,需业务先定调后统一(见 TryGetSettlementEodPrice 注释)。 ClosePrice = Convert.ToDouble(BondPriceConverter.ToStorage(bondPrice.dirty_price_close)), SettlePrice = Convert.ToDouble(BondPriceConverter.ToStorage(bondPrice.net_price)), ReferencePrice = Convert.ToDouble(BondPriceConverter.ToStorage(bondPrice.yield)) diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeExpireConfirmService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeExpireConfirmService.cs index a523d7ca..ab9624f9 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeExpireConfirmService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeExpireConfirmService.cs @@ -140,7 +140,9 @@ namespace YLErp.Modules.TradeModule.DealModule #region 设置期末价格和执行价格 - var finalPrice = EodPriceQueryService.TryGetEodPrice(exerciseDate, td.UnderlyingCode, out var eodPrice) + // 债券标的需走中债估值表取价,原 TryGetEodPrice 只查期货/股票两表会漏掉债券,导致"结算价未找到"。 + // 统一改用债券感知的 TryGetSettlementEodPrice(见 EodPriceQueryService)。 + var finalPrice = EodPriceQueryService.TryGetSettlementEodPrice(exerciseDate, td.UnderlyingCode, out var eodPrice) ? eodPrice.GetPrice(td.SettlementType) : 0; if (finalPrice <= 0) @@ -280,8 +282,6 @@ namespace YLErp.Modules.TradeModule.DealModule trade_cash tradeCash = null; //日终价格 - var underlyingIds = tradeUnwindTrades.Select(t => t.UnderlyingId).ToList(); - var EodPriceProvider = new EodPriceProvider(valueDate); //批量结算的全是现金流交易就不用结算价 if (!EodPriceQueryService.CheckDbExists(valueDate) && tradeQuery.Any(t => t.TradeType != "现金流交易")) { @@ -308,8 +308,8 @@ namespace YLErp.Modules.TradeModule.DealModule var CountRatio = 1; if (t.TradeType != "现金流交易") { - //结算价 - if (EodPriceProvider.TryGetEodPrice(t.UnderlyingCode, out var eodPrice)) + //结算价(债券感知统一取价:债券走中债估值,期货/股票走原路径,见 EodPriceQueryService.TryGetSettlementEodPrice) + if (EodPriceQueryService.TryGetSettlementEodPrice(valueDate, t.UnderlyingCode, out var eodPrice)) { settlePrice = eodPrice.GetPrice(t.SettlementType); } From 6424b380014e02ff71d2039c39a215974a7750fd Mon Sep 17 00:00:00 2001 From: hjhan Date: Thu, 16 Jul 2026 14:56:28 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix(=E6=9C=9F=E6=9D=83=E5=88=B0=E6=9C=9F):?= =?UTF-8?q?=20checkEodPrice=20=E5=89=8D=E7=BD=AE=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E5=80=BA=E5=88=B8=E6=84=9F=E7=9F=A5=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E5=8F=96=E4=BB=B7=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - trade_cashController.checkEodPrice 是前端'执行到期'按钮的前置 gate, 原用 EodPriceQueryService.TryGetEodPrice(仅查期货+股票两表), 债券标的(如 GLMS-20260715-0002 / 180011.IB)必然取不到价而报 '交易日{date}的结算价或收盘价未找到',挡在 tradeExpireConfirm 之前, 导致层1 对 tradeExpireInner/MultipleTradeExpireConfirm 的修复被绕过。 - 改为与层1 一致的 EodPriceQueryService.TryGetSettlementEodPrice (债券走中债估值、期货/股票走原路径),三处到期取价口径统一。 --- YLErpWeb/Controllers/trade_cashController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/YLErpWeb/Controllers/trade_cashController.cs b/YLErpWeb/Controllers/trade_cashController.cs index b2730b8c..44106885 100644 --- a/YLErpWeb/Controllers/trade_cashController.cs +++ b/YLErpWeb/Controllers/trade_cashController.cs @@ -148,7 +148,7 @@ namespace YLErp.Web.Controllers { valueDate = QdpCalendarHelper.GetNonHolidayDefore(valueDate); } - if (!EodPriceQueryService.TryGetEodPrice(valueDate, td.UnderlyingCode, out _)) + if (!EodPriceQueryService.TryGetSettlementEodPrice(valueDate, td.UnderlyingCode, out _)) { return JsonError($"交易日{valueDate:yyyy-MM-dd}的结算价或收盘价未找到!"); } From 5a6d928b89635b782da7ec60bc725c8adf1a9d6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=94=A6=E9=BA=9F=20=E7=8E=8B?= Date: Thu, 16 Jul 2026 16:54:44 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E5=9C=A8=E4=BF=AE=E6=94=B9=E5=AE=A1?= =?UTF-8?q?=E6=89=B9=E6=B5=81=E7=A8=8B=E6=97=B6=EF=BC=8C=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E4=BA=86=E5=AF=B9=E6=9C=AA=E5=AE=A1=E6=89=B9=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E4=BA=A4=E6=98=93=E7=9A=84=E6=A0=A1=E9=AA=8C=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs b/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs index ae140dd0..05bd4f0f 100644 --- a/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs +++ b/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs @@ -172,6 +172,11 @@ namespace YLErp.Modules.SystemModule private void ChangeTradeProcess(List data, approvalprocess[] delList) { var trades = DbContext.trade.Where(x => x.ValidState == "Valid" && (x.TradeStatus == ConsTrade.审批中 || x.TradeStatus == ConsTrade.平仓待复核 || x.TradeStatus == ConsTrade.行权待复核 || x.TradeStatus == ConsTrade.互换待复核)).ToList(); + // 任何修改,只要存在审批中的交易,都提示 + if (trades != null && trades.Count > 0) + { + throw new ServiceException("审批页面存在未审批完的交易,不能修改审批流程!"); + } var noGroupData = data.Where(x => x.approvalGroupId == 0); var branch = data.Where(x => x.approvalGroupId != 0);//修改有分支 var groupId = UserBLL.GetApprovalProcessGroup(UserInfo.UserId); From d5b43e3769ae6573c585a24ef867e0bfcaf2b9e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=94=A6=E9=BA=9F=20=E7=8E=8B?= Date: Thu, 16 Jul 2026 17:17:21 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E5=8F=AA=E6=8F=90=E7=A4=BA=E4=B8=8D?= =?UTF-8?q?=E9=98=BB=E5=A1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs | 5 ----- YLErpWeb/wwwroot/Scripts/app/system/Approvalprocess.js | 4 ++-- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs b/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs index 05bd4f0f..ae140dd0 100644 --- a/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs +++ b/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs @@ -172,11 +172,6 @@ namespace YLErp.Modules.SystemModule private void ChangeTradeProcess(List data, approvalprocess[] delList) { var trades = DbContext.trade.Where(x => x.ValidState == "Valid" && (x.TradeStatus == ConsTrade.审批中 || x.TradeStatus == ConsTrade.平仓待复核 || x.TradeStatus == ConsTrade.行权待复核 || x.TradeStatus == ConsTrade.互换待复核)).ToList(); - // 任何修改,只要存在审批中的交易,都提示 - if (trades != null && trades.Count > 0) - { - throw new ServiceException("审批页面存在未审批完的交易,不能修改审批流程!"); - } var noGroupData = data.Where(x => x.approvalGroupId == 0); var branch = data.Where(x => x.approvalGroupId != 0);//修改有分支 var groupId = UserBLL.GetApprovalProcessGroup(UserInfo.UserId); diff --git a/YLErpWeb/wwwroot/Scripts/app/system/Approvalprocess.js b/YLErpWeb/wwwroot/Scripts/app/system/Approvalprocess.js index 50dde0e8..cff6d111 100644 --- a/YLErpWeb/wwwroot/Scripts/app/system/Approvalprocess.js +++ b/YLErpWeb/wwwroot/Scripts/app/system/Approvalprocess.js @@ -669,7 +669,7 @@ var app = new Vue({ return; } if (thisObj.tradeItems != null && thisObj.tradeItems.length > 0) { - main.confirm("确认修改交易审批流程?", function () { + main.confirm("审批页面存在未审批完的交易,修改审批流程后这些交易需要重新审批,确认修改?", function () { main.post("/AccountOpeningProcess/AddProcess", { type: thisObj.tradeItems[0].Type, data: thisObj.tradeItems }, { async: false }).done( @@ -1391,7 +1391,7 @@ var app = new Vue({ } thisObj.stringifyAllTrigger(thisObj.closeItems); // 需求①:序列化触发条件 if (thisObj.closeItems != null && thisObj.closeItems.length > 0) { - main.confirm("确认修改交易了结审批流程?", function () { + main.confirm("审批页面存在未审批完的交易,修改审批流程后这些交易需要重新审批,确认修改?", function () { main.post("/AccountOpeningProcess/AddProcess", { type: "CloseProcess", data: thisObj.closeItems }, { async: false }).done( From 238824f4f49b386d09f7fa60e04489b161d9d051 Mon Sep 17 00:00:00 2001 From: hjhan Date: Thu, 16 Jul 2026 19:54:26 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(=E9=A2=84=E4=BB=98=E9=87=91=E8=BF=94?= =?UTF-8?q?=E5=9B=9E/=E5=B9=B3=E4=BB=93=E6=AF=94=E4=BE=8B):=20=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E5=8D=A0=E6=9C=9F=E5=88=9D=E8=AF=AD=E4=B9=89=E5=B9=B6?= =?UTF-8?q?=E5=9C=A8=E5=90=8E=E7=AB=AF=E5=85=A5=E5=8F=A3=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E8=BD=AC=E5=8D=A0=E5=89=A9=E4=BD=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前提交 4f46e263 误将平仓比例改为占剩余持仓(Definition B)语义,与需求不符。 正确需求:每次打开平仓/提前终止均基于最开始原始名义本金(Definition A), 但每次返回的预付金金额需正确(后端按占剩余计算)。 - SwapDealService: 新增 ToRemainingClosePercent/ToOriginalClosePercent 双语义转换; SwapUnwind 入口把前端传来的占期初(A)转为占剩余(B),全计算链公式不动; SaveSwapDealInternal 序列化前还原为 A 落库,事件列表读取即显示占期初比例。 - SwapTrade2Controller.GetUnwindInterestList: 接收期初/剩余名义本金,预览利息按 B 计算。 - unwindSwapTrade.js: 回退为占期初语义(ClosePercent/平仓名义本金用 NotionalValue, oriClosePercent=剩余/期初),提前终止同理。 - 单测 UW_007/008 覆盖 A→B 全平/部分平仓判定。 不影响收盘后盯市/估值(仅平仓入口与展示口径)。 --- .../SwapModule/SwapUnwindScenarioTest.cs | 50 +++++++++++++++++++ .../Modules/SwapModule/SwapDealService.cs | 33 ++++++++++++ YLErpWeb/Controllers/SwapTrade2Controller.cs | 7 ++- .../Scripts/app/swaptrade/unwindSwapTrade.js | 28 +++++------ 4 files changed, 101 insertions(+), 17 deletions(-) diff --git a/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs index 103b65f5..7d1dfc27 100644 --- a/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs @@ -187,5 +187,55 @@ namespace YLErp.Modules.SwapModule "SwapRealizedPnL 应=SwapCloseAmount(6000)"); Console.WriteLine($"UW_006: CloseReCheck={service.CloseReCheckCallCount}次, SwapRealizedPnL={service.SaveSwapDealCalls[0].data.SwapRealizedPnL} ✅"); } + + // ================================================================ + // 场景7:前端传"占期初(A)"语义,后端入口转"占剩余(B)" —— 全平判定 + // 原始名义本金 100M / 剩余 60M,前端传 A=0.6(平掉原始 60M = 剩余全部) + // B = A × Notional/Posi = 0.6 × 100/60 = 1.0 → 触发全平 + // ================================================================ + [TestMethod] + public void UW_007_SwapUnwind_占期初A转占剩余B_全平判定正确() + { + var td = SwapDealTestFactory.CreateTrade(); + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.CreateUnwindData( + swapRealizedPnL: 0m, closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 0.6m, + closeQty: 600000m, closeNotionalValue: 600000m, positionQty: 600000m); + unwindData.NotionalValue = 1000000m; // 期初名义本金 + unwindData.PosiNotionalValue = 600000m; // 剩余名义本金 + + service.SwapUnwind(unwindData); + + // 桩 SaveSwapDeal 收集的是转换后的 B(落库 A 还原在生产 SaveSwapDealInternal 中,桩跳过) + Assert.AreEqual(1.0m, service.SaveSwapDealCalls[0].data.ClosePercent, 0.0001m, + "入口 A=0.6 应转为 B=1.0(占剩余全平)"); + Assert.AreEqual("已平仓", td.TradeStatus, "B==1 触发全平 TradeStatus=已平仓"); + Console.WriteLine($"UW_007: A=0.6→B={service.SaveSwapDealCalls[0].data.ClosePercent}, TradeStatus={td.TradeStatus} ✅"); + } + + // ================================================================ + // 场景8:占期初(A)转占剩余(B) —— 部分平仓 + // 原始 100M / 剩余 60M,前端传 A=0.3(平掉原始 30M = 剩余的 50%) + // B = A × Notional/Posi = 0.3 × 100/60 = 0.5 → 部分平仓 + // ================================================================ + [TestMethod] + public void UW_008_SwapUnwind_占期初A转占剩余B_部分平仓正确() + { + var td = SwapDealTestFactory.CreateTrade(); + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.CreateUnwindData( + swapRealizedPnL: 0m, closeMethod: (int)CloseMethodEnum.部分平仓, closePercent: 0.3m, + closeQty: 300000m, closeNotionalValue: 300000m, positionQty: 600000m); + unwindData.NotionalValue = 1000000m; // 期初名义本金 + unwindData.PosiNotionalValue = 600000m; // 剩余名义本金 + + service.SwapUnwind(unwindData); + + Assert.AreEqual(0.5m, service.SaveSwapDealCalls[0].data.ClosePercent, 0.0001m, + "入口 A=0.3 应转为 B=0.5(占剩余 50%)"); + Assert.AreEqual(1, td.HasPartialUnWind, "B≠1 应为部分平仓,设 HasPartialUnWind=1"); + Assert.AreEqual("确认成交", td.TradeStatus, "部分平仓 TradeStatus 保持不变"); + Console.WriteLine($"UW_008: A=0.3→B={service.SaveSwapDealCalls[0].data.ClosePercent}, HasPartialUnWind={td.HasPartialUnWind} ✅"); + } } } diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 26285156..246142bd 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -726,6 +726,30 @@ namespace YLErp.Modules.SwapModule return (closePrincipal, posiPrincipal, newClosePercent); } + /// + /// 平仓比例口径转换(解决"显示占期初 / 计算占剩余"双语义问题)。 + /// 前端与事件列表展示用"占期初(original)"语义(A);后端 CalcNotionalByMode / 费用递减 / + /// 全平判定均按"占剩余(remaining)"语义(B)消费。 + /// A → B:B = A × 期初名义本金(NotionalValue) / 剩余名义本金(PosiNotionalValue),并 cap 到 1。 + /// B → A:A = B × 剩余名义本金 / 期初名义本金。 + /// 分母为 0(无持仓等异常场景)时原样返回,避免除零。 + /// + public static decimal ToRemainingClosePercent(decimal originalClosePercent, decimal notionalValue, decimal posiNotionalValue) + { + if (posiNotionalValue <= 0) return originalClosePercent; + var remaining = originalClosePercent * notionalValue / posiNotionalValue; + return remaining > 1 ? 1 : remaining; + } + + /// + /// B(占剩余) → A(占期初),用于落库 / 事件列表展示还原。见 ToRemainingClosePercent。 + /// + public static decimal ToOriginalClosePercent(decimal remainingClosePercent, decimal notionalValue, decimal posiNotionalValue) + { + if (notionalValue <= 0) return remainingClosePercent; + return remainingClosePercent * posiNotionalValue / notionalValue; + } + /// /// 获取固定利率 /// @@ -1226,6 +1250,9 @@ namespace YLErp.Modules.SwapModule } //CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制 ValidateFrontendPnL(unwindData, isIncome: false); // 只读校验告警,不阻断交易 + // 前端按"占期初(original)"语义传 ClosePercent(A);后端全链路按"占剩余(remaining)"语义(B)消费。 + // 入口统一转换为 B,落库展示用的 A 由 SaveSwapDealInternal 还原。 + unwindData.ClosePercent = ToRemainingClosePercent(unwindData.ClosePercent, unwindData.NotionalValue, unwindData.PosiNotionalValue); bool cofirm = false; ExecuteInTransaction(() => { @@ -1843,7 +1870,13 @@ namespace YLErp.Modules.SwapModule } var flowList = new List(unwindData.FlowEvents); unwindData.FlowEvents.Clear(); + // 落库展示用"占期初(original)"语义(A);计算链(费用递减/全平判定)用"占剩余(remaining)"语义(B)。 + // 序列化前把 ClosePercent 还原为 A,序列化后立即还原回 B 供后续使用。 + var storedClosePercent = ToOriginalClosePercent(unwindData.ClosePercent, unwindData.NotionalValue, unwindData.PosiNotionalValue); + var incomingClosePercent = unwindData.ClosePercent; + unwindData.ClosePercent = storedClosePercent; string data = JsonConvert.SerializeObject(unwindData); + unwindData.ClosePercent = incomingClosePercent; var swapEvent = new SwapEventService(this).AddSwapEventDate(unwindData.ValueDate, unwindData.SwapTradeId, eventType, data, clientCashId, true, eventResason);//将平仓、互换总额存入事件 foreach (var item in flowList) { diff --git a/YLErpWeb/Controllers/SwapTrade2Controller.cs b/YLErpWeb/Controllers/SwapTrade2Controller.cs index 52d4ae46..717ad13f 100644 --- a/YLErpWeb/Controllers/SwapTrade2Controller.cs +++ b/YLErpWeb/Controllers/SwapTrade2Controller.cs @@ -283,9 +283,12 @@ namespace YLErp.Web.Controllers /// /// /// - public JsonResult GetUnwindInterestList(DateTime valueDate,DateTime unwindDate, int tradeId, decimal closePercent, int eventType) + public JsonResult GetUnwindInterestList(DateTime valueDate,DateTime unwindDate, int tradeId, decimal closePercent, int eventType, decimal notionalValue = 0, decimal posiNotionalValue = 0) { - var interests = new SwapDealService(CurUser).GetUnwindInterests(valueDate, unwindDate, tradeId, closePercent, eventType); + // 前端按"占期初(original)"语义传 closePercent(A);后端 GetUnwindInterests 按"占剩余(remaining)"语义(B)计算。 + // 多空互换前端不传 notionalValue/posiNotionalValue(默认 0),则跳过转换保持原行为。 + var convertedClosePercent = SwapDealService.ToRemainingClosePercent(closePercent, notionalValue, posiNotionalValue); + var interests = new SwapDealService(CurUser).GetUnwindInterests(valueDate, unwindDate, tradeId, convertedClosePercent, eventType); foreach (var interest in interests) { interest.TdInterestAmount=Math.Round(interest.TdInterestAmount, ConsGlobal.MoneyRound,MidpointRounding.AwayFromZero); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js index e1fce1dc..03f90bc7 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js @@ -17,8 +17,8 @@ const vue = new Vue({ marginList: [], initPosiNetPrice: 0, multiplier: 1, - // 多次部分平仓后,ClosePercent 语义为“占剩余持仓的比例”(后端 GetUnwindInterests 用 remainingBase×closePercent 计算预付金返还), - // 故最多可平 100% 剩余,oriClosePercent 恒为 1;不能用 model.ClosePercent(=剩余/原始,Definition A 旧口径),否则全部平仓/按比例会少返预付金 + // 平仓比例展示/输入均为"占期初(original)"语义(A):默认与每次重开都基于原始名义本金。 + // oriClosePercent = 剩余名义本金/期初名义本金 = 最多可平比例(不能平超过剩余持仓)。 oriClosePercent: 1, ratio: 1, shortRatio: 1, @@ -55,12 +55,9 @@ const vue = new Vue({ this.ratio = this.floatPosition.PayDirection == 1 ? -1 : 1; this.shortRatio = this.floatPosition.PositionType == 1 ? 1 : -1; this.TradeStartDate = model.TradeStartDate; - // 多次部分平仓后 ClosePercent 语义为"占剩余持仓比例"。 - // 仅当"全部平仓"(CloseMethod==1) 时修正旧口径(model.ClosePercent 可能=剩余/原始<1)为 1; - // "部分平仓"(CloseMethod==2) 时保留已提交比例(平仓待复核场景),避免覆盖用户已提交的 closePercent - if (this.deal.CloseMethod === 1) { - this.deal.ClosePercent = 1; - } + // 最多可平比例(占期初口径) = 剩余名义本金 / 期初名义本金;分母为 0 时兜底为 1 + this.oriClosePercent = (this.deal.NotionalValue && this.deal.PosiNotionalValue) + ? this.deal.PosiNotionalValue / this.deal.NotionalValue : 1; // 转换期末标的价格为百分比形式 if (this.floatPosition.TradingAmountAvg) { this.floatPosition.TradingAmountAvg = this.floatPosition.TradingAmountAvg * this.multiplier; @@ -154,8 +151,8 @@ const vue = new Vue({ } else { this.deal.CloseMethod = 2; } - // 多次部分平仓后 PosiNotionalValue 才是剩余本金,不能用原始 NotionalValue,否则平仓名义本金偏大 - this.deal.CloseNotionalValue = otcformat.trading.StockEqvNotional(parseFloat(this.deal.ClosePercent) * parseFloat(this.deal.PosiNotionalValue)); + // 占期初口径:平仓名义本金 = 平仓比例 × 期初名义本金(NotionalValue) + this.deal.CloseNotionalValue = otcformat.trading.StockEqvNotional(parseFloat(this.deal.ClosePercent) * parseFloat(this.deal.NotionalValue)); this.calcTradingFeePending(); this.getInterestList(); this.calcFloatClosePnl(); @@ -167,8 +164,8 @@ const vue = new Vue({ return; } this.deal.CloseQty = otcformat.trading.notional(parseFloat(this.deal.PositionQty) * parseFloat(this.deal.ClosePercent)); - // 多次部分平仓后 PosiNotionalValue 才是剩余本金,不能用原始 NotionalValue,否则平仓名义本金偏大 - this.deal.CloseNotionalValue = otcformat.trading.StockEqvNotional(parseFloat(this.deal.ClosePercent) * parseFloat(this.deal.PosiNotionalValue)); + // 占期初口径:平仓名义本金 = 平仓比例 × 期初名义本金(NotionalValue) + this.deal.CloseNotionalValue = otcformat.trading.StockEqvNotional(parseFloat(this.deal.ClosePercent) * parseFloat(this.deal.NotionalValue)); if (parseFloat(this.deal.CloseNotionalValue) == parseFloat(this.deal.PosiNotionalValue)) { this.floatPosition.CloseMethod = 1; } else { @@ -184,8 +181,8 @@ const vue = new Vue({ this.deal.CloseNotionalValue = this.deal.PosiNotionalValue; return; } - // 多次部分平仓后应以 PosiNotionalValue(剩余) 为分母,否则 ClosePercent 偏小,导致后端预付金返还本金计算错误 - this.deal.ClosePercent = otcformat.fixed6(parseFloat(this.deal.CloseNotionalValue) / parseFloat(this.deal.PosiNotionalValue)); + // 占期初口径:平仓比例 = 平仓名义本金 / 期初名义本金(NotionalValue) + this.deal.ClosePercent = otcformat.fixed6(parseFloat(this.deal.CloseNotionalValue) / parseFloat(this.deal.NotionalValue)); this.deal.CloseQty = otcformat.trading.notional(parseFloat(this.deal.PositionQty) * parseFloat(this.deal.ClosePercent)); this.calcTradingFeePending(); this.getInterestList(); @@ -272,7 +269,8 @@ const vue = new Vue({ }, getInterestList() {//根据平仓日期获取利息腿信息 var thisObj = this; - var postData = { valueDate: thisObj.deal.ValueDate, unwindDate: thisObj.deal.UnwindDate, tradeId: thisObj.deal.SwapTradeId, closePercent: thisObj.deal.ClosePercent, eventType: 2 } + // closePercent 按"占期初(original)"语义(A)传给后端,由 GetUnwindInterestList 转为"占剩余(B)"计算 + var postData = { valueDate: thisObj.deal.ValueDate, unwindDate: thisObj.deal.UnwindDate, tradeId: thisObj.deal.SwapTradeId, closePercent: thisObj.deal.ClosePercent, eventType: 2, notionalValue: thisObj.deal.NotionalValue, posiNotionalValue: thisObj.deal.PosiNotionalValue } main.post("/swaptrade2/GetUnwindInterestList", postData, { async: true }).done(function (resp) { thisObj.interestList = resp.obj.filter((item) => { return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9;