diff --git a/UnitTestProject/Modules/EodModule/EodCheckSettlePriceClientScopeTest.cs b/UnitTestProject/Modules/EodModule/EodCheckSettlePriceClientScopeTest.cs new file mode 100644 index 00000000..f53b268a --- /dev/null +++ b/UnitTestProject/Modules/EodModule/EodCheckSettlePriceClientScopeTest.cs @@ -0,0 +1,116 @@ +using YLErp.Modules.EodModule.SettlementModule; + +namespace YLErp.Modules.EodModule +{ + /// + /// 验证 EodCheckSettlePrice 的持仓分支按 ClientIds 收敛: + /// 给定收盘客户时,不应再把“仅属于其他客户”的上一交易日持仓标的纳入结算价缺失检查。 + /// + /// 采用确定性夹具:插入两条合成持仓(客户A持标的A、客户B持标的B),直接调用抽出的 + /// static 查询方法断言过滤语义,finally 中清理,避免依赖测试库现有数据形状。 + /// 若 underlying_manager 无足够的对冲类型标的,则 Assert.Inconclusive 跳过。 + /// + [TestClass] + public class EodCheckSettlePriceClientScopeTest : UnitTestBase + { + [TestMethod] + public void PositionUnderlyingQuery_ExcludesOtherClients_WhenClientIdsGiven() + { + using var db = DbContextFactory.GetYLDbContext(); + + // 选两个存在的、非期货的标的(避开 GetFutureTypes,保证通过方法内部的期货到期过滤); + // 持仓 TradeType 固定为"股票"(属于 TradeTypesForHedge),才能进入结算价检查。 + var futureTypes = ConsGlobal.InstrumentType.GetFutureTypes(); + var underlyings = db.underlying_manager + .Where(u => u.UnderlyingCode != null && !futureTypes.Contains(u.UnderlyingInstrumentType)) + .Take(5) + .ToList(); + if (underlyings.Count < 2) + { + Assert.Inconclusive("underlying_manager 无足够的非期货标的,跳过"); + return; + } + var uA = underlyings[0]; + var uB = underlyings[1]; + + // 复用一条现有持仓的 BookId/TradeId,确保外键合法(若存在) + var sample = db.eod_trade_position.FirstOrDefault(p => p.BookId != 0); + int bookId = sample?.BookId ?? 1; + int tradeId = sample?.TradeId ?? 0; + + // 合成日期与客户,避免与测试库真实数据冲突 + var preSettleDate = new DateTime(2026, 5, 1); + var settleDate = new DateTime(2026, 5, 2); + int clientA = 900001; + int clientB = 900002; + + var rows = new List + { + new eod_trade_position + { + ValueDate = preSettleDate, + ClientId = clientA, + UnderlyingCode = uA.UnderlyingCode, + UnderlyingId = uA.id, + TradeType = "股票", + BookId = bookId, + TradeId = tradeId, + Amount = 1, + HedgeUniqueCode = "UT_CLIENTSCOPE_A" + }, + new eod_trade_position + { + ValueDate = preSettleDate, + ClientId = clientB, + UnderlyingCode = uB.UnderlyingCode, + UnderlyingId = uB.id, + TradeType = "股票", + BookId = bookId, + TradeId = tradeId, + Amount = 1, + HedgeUniqueCode = "UT_CLIENTSCOPE_B" + } + }; + + foreach (var r in rows) + { + r.OptId = 0; + r.OptName = "UT_CLIENTSCOPE"; + r.OptDate = DateTime.Now; + } + + try + { + db.eod_trade_position.AddRange(rows); + db.SaveChanges(); + + var fullSet = EodCheckSettlePrice.QueryPositionUnderlyingCodes(db, preSettleDate, settleDate, null) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var filteredA = EodCheckSettlePrice.QueryPositionUnderlyingCodes(db, preSettleDate, settleDate, new List { clientA }) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + Assert.IsTrue(fullSet.Contains(uA.UnderlyingCode), "全客户结果应包含客户A的标的"); + Assert.IsTrue(fullSet.Contains(uB.UnderlyingCode), "全客户结果应包含客户B的标的"); + Assert.IsTrue(filteredA.Contains(uA.UnderlyingCode), "按客户A收敛后仍应包含客户A的标的"); + // 关键断言:修复点——按客户A收敛后不应再包含“仅属客户B”的标的 + Assert.IsFalse(filteredA.Contains(uB.UnderlyingCode), + "修复验证失败:按客户A收敛后仍包含仅属客户B的持仓标的(ClientId 过滤未生效)"); + } + finally + { + // 清理合成数据,使测试库状态不变 + foreach (var r in rows) + { + var exist = db.eod_trade_position.FirstOrDefault(x => + x.ValueDate == preSettleDate && x.ClientId == r.ClientId && + x.UnderlyingCode == r.UnderlyingCode && x.HedgeUniqueCode == r.HedgeUniqueCode); + if (exist != null) + { + db.eod_trade_position.Remove(exist); + } + } + db.SaveChanges(); + } + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs b/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs index ba6f8790..25d3d10f 100644 --- a/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs @@ -713,7 +713,7 @@ namespace YLErp.Modules.SwapModule "部分平仓计算必须带入自动互换遗留的待实现尾差"); Assert.AreEqual(position.id, service.LastInterestCalculationEodPosition.PositionId, "部分平仓计息必须按腿标识匹配上一日日终"); - AssertDecimal(0.006383561644m, firstCloseResult.InterestIncomeSum, + AssertDecimal(-0.010438356164m, firstCloseResult.InterestIncomeSum, "部分平仓后待实现应延续历史尾差"); AssertDecimal(0.02m, firstCloseResult.RealizedInterest, "部分平仓后累计已实现应包含此前自动互换和本次平仓"); @@ -744,7 +744,7 @@ namespace YLErp.Modules.SwapModule const decimal rate = 0.0299m; const decimal pendingInterest = 0.820379534246m; const decimal settledInterest = 0.82m; - const decimal expectedPendingInterest = 0.820569301369m; + const decimal expectedPendingInterest = 0.410474008219m; var service = new StubEodPositionService(); var td = CreateTrade(); td.trade_extend.ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson @@ -782,6 +782,31 @@ namespace YLErp.Modules.SwapModule "The previous EOD identity must not be reset to a new position"); } + [TestMethod] + public void DI_MANUAL_PREPAY_PARTIAL_CLOSE_UsesHistoryPlusRemainingDailyInterest() + { + var service = new StubEodPositionService(); + var td = CreateTrade(); + var position = CreateInterestPosition(); + position.InterestMode = (int)InterestModeEnum.初始预付金; + position.InterestPrincipalFix = Principal; + var previousEod = CreatePreEod(StartDate.AddDays(2), 100m); + previousEod.InterestMode = position.InterestMode; + previousEod.TdInterestPrincipal = Principal; + var closeFlow = CreateSwapFlowEvent(StartDate.AddDays(3), 50m); + closeFlow.EventType = (int)SwapFlowEventTypeEnum.平仓; + closeFlow.InterestPrincipal = 500m; + + var result = service.ExecuteSaveAutoEodWithCloseInterestPosition( + previousEod, position, td, StartDate.AddDays(3), null, + 500m, 0m, new List { closeFlow }, 500m, false); + + var expected = previousEod.InterestIncomeSum + + result.TdInterestIncome - result.TdCloseInterest; + AssertDecimal(expected, result.InterestIncomeSum, + "预付金部分平仓待实现收益应为历史待实现+平仓后当日新增-平仓实现"); + } + [TestMethod] public void DI_AUTO_SETTLEMENT_005_AutoSettlementKeepsRemainingPrincipal() { diff --git a/UnitTestProject/Modules/SwapModule/GLMS20260105PartialCloseDividendBugTest.cs b/UnitTestProject/Modules/SwapModule/GLMS20260105PartialCloseDividendBugTest.cs new file mode 100644 index 00000000..d3ee8e5f --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/GLMS20260105PartialCloseDividendBugTest.cs @@ -0,0 +1,219 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using YLErp.DBModels; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 【绿灯验收】国联民生-期间结算事件验证-分红前部分平仓过的交易,平仓与分红结束并收盘后, + /// 继续平仓 / 互换时"浮动端平仓盈亏·分红(DividendIn)"应为 0(修复方案C:后端读 EOD 单一可信源)。 + /// ============================================================================ + /// 参考交易:GLMS-20260105-0004(测试库 id=1921,标的 230004.IB) + /// 场景时间线(库内真实数据): + /// 2026-01-05 开仓,期初持仓 50,000,000(支付方向 Direction=2,多头 PositionType=1) + /// 2026-02-28 部分平仓 40%(20,000,000),剩余 30,000,000;该日早于 3/2 登记日, + /// 已平仓的 40% 不享有 3/2 付息 → 平仓事件 DividendIn=0(正确) + /// 2026-03-02 债券期间付息登记日;当日自动互换(EventType=4),按【剩余持仓 30,000,000】 + /// 实现分红 DividendIn=-54,240(=30,000,000 × 0.001808);EOD PosiDividendSum=0(全实现) + /// 2026-03-03 收盘后对该交易继续平仓/互换 + /// + /// 修复前(Bug):前端 getDivindIn 用期初全额持仓(50M)×totalInterest 算出 -90,400, + /// 扣除 consumedDividend(-54,240) 得 remainDividend=-36,160 展示 → 错误。 + /// -36,160 恰=已平仓40%(20M)×单位付息(0.001808)×支付方向(-1),即把"登记日前已平仓、 + /// 不享有该笔分红"的部分重复计入。 + /// + /// 修复后(方案C):后端 InitUnwind/InitIncome 经 GetPreEodDividendSum 读上一收盘日 + /// eod_swap_position.PosiDividendSum。3/2 互换后该值=0 → DividendIn=0(正确)。 + /// 前端 getDivindIn 不再自算,直接用后端值。 + /// ============================================================================ + /// 本测试连真实测试库,调用真实 SwapDealService.GetPreEodDividendSum 验证修复后 DividendIn=0。 + /// 连不上库时 Inconclusive 跳过(CI 无 DB 环境不挡)。 + /// + [TestClass] + public class GLMS20260105PartialCloseDividendBugTest + { + private const string TradeNumber = "GLMS-20260105-0004"; + // 3/3 继续平仓/互换的业务日期;上一收盘日为 3/2(EOD 已生成,PosiDividendSum=0) + private static readonly DateTime DealDate0303 = new(2026, 3, 3); + + /// + /// 连真实库的 SwapDealService 子类,仅用于暴露 protected GetPreEodDividendSum 供单测调用。 + /// 不 override 任何 seam → DbContext 走真实 YLContext(与 DbContextFactory.GetYLDbContext() 同库)。 + /// + private sealed class RealDbSwapDealService : SwapDealService + { + public RealDbSwapDealService() : base(OptUserInfo.UnitTestUser) { } + + public decimal ExposeGetPreEodDividendSum(int tradeId, long positionId, DateTime dealDate) + => GetPreEodDividendSum(tradeId, positionId, dealDate); + } + + [TestMethod] + [TestCategory("DBRecording")] + public void Green_3_3_Unwind_DividendIn_FromEOD_Equals_Zero() + { + YLContext db; + try { db = DbContextFactory.GetYLDbContext(); } + catch (Exception ex) { Assert.Inconclusive($"无法连接测试库(CI/无DB环境正常跳过):{ex.Message}"); return; } + + try + { + var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber); + if (td == null) { Assert.Inconclusive($"测试库不存在交易 {TradeNumber}"); return; } + + // 浮动腿持仓:非初始、UnderlyingCode 非空(当前剩余 30,000,000) + var positions = db.swap_position.Where(x => x.SwapTradeId == td.id && !x.Invalid).ToList(); + var floatLeg = positions.FirstOrDefault(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial); + Assert.IsNotNull(floatLeg, "未找到浮动腿持仓(非初始、UnderlyingCode 非空)"); + + // ---- 调用真实 GetPreEodDividendSum(方案C 修复核心)---- + var service = new RealDbSwapDealService(); + decimal dividendIn = service.ExposeGetPreEodDividendSum(td.id, floatLeg.PositionId, DealDate0303); + + // ---- 绿灯断言:3/2 互换后 EOD PosiDividendSum=0 → DividendIn=0 ---- + Assert.AreEqual(0m, dividendIn, 0.01m, + $"[绿灯·方案C验收] 3/3平仓/互换 DividendIn 期望=0(3/2互换后剩余持仓30M的分红已全部实现," + + $"EOD PosiDividendSum=0)。实际={dividendIn}。修复前该值为 -36,160(期初持仓×totalInterest 重算误计入已平仓40%)。"); + + // ---- 健全性:上一 EOD 确为 3/2,且 PosiDividendSum=0 ---- + var lastEod = db.eod_swap_position + .Where(x => x.SwapTradeId == td.id && !x.Invalid && x.ValueDate < DealDate0303 && x.PositionId == floatLeg.PositionId) + .OrderByDescending(x => x.ValueDate).FirstOrDefault(); + Assert.IsNotNull(lastEod, "应存在 3/2 的 EOD 持仓记录"); + Assert.AreEqual(new DateTime(2026, 3, 2), lastEod.ValueDate, "上一收盘日应为 3/2"); + Assert.AreEqual(0m, lastEod.PosiDividendSum, 0.01m, + $"3/2 EOD PosiDividendSum 应=0(当日 TdPosiDividend={lastEod.TdPosiDividend} 全额由互换 TdCloseDividend={lastEod.TdCloseDividend} 实现)"); + Assert.AreEqual(30_000_000m, lastEod.PosiQuantity, "3/2 剩余持仓应为 30,000,000(2/28已平仓40%)"); + } + finally { db?.Dispose(); } + } + + /// + /// 互换页(InitIncome)与平仓页(InitUnwind)读同一 EOD:同 position、同 dealDate, + /// GetPreEodDividendSum 返回值必然一致。本测试明确覆盖"点击收益互换同理"路径 + /// (用户反馈:平仓页 -36,160,点击收益互换同样错误)。 + /// + [TestMethod] + [TestCategory("DBRecording")] + public void Green_3_3_Income_DividendIn_FromEOD_Equals_Zero() + { + YLContext db; + try { db = DbContextFactory.GetYLDbContext(); } + catch (Exception ex) { Assert.Inconclusive($"无法连接测试库(CI/无DB环境正常跳过):{ex.Message}"); return; } + + try + { + var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber); + if (td == null) { Assert.Inconclusive($"测试库不存在交易 {TradeNumber}"); return; } + + var floatLeg = db.swap_position + .Where(x => x.SwapTradeId == td.id && !x.Invalid && !x.IsInitial && x.UnderlyingCode != null && x.UnderlyingCode != "") + .FirstOrDefault(); + Assert.IsNotNull(floatLeg, "未找到浮动腿持仓"); + + // InitIncome 与 InitUnwind 调用 GetPreEodDividendSum 的入参一致 → 结果一致 + var service = new RealDbSwapDealService(); + decimal incomeDividendIn = service.ExposeGetPreEodDividendSum(td.id, floatLeg.PositionId, DealDate0303); + + Assert.AreEqual(0m, incomeDividendIn, 0.01m, + $"[互换页·方案C验收] 3/3收益互换 DividendIn 期望=0,实际={incomeDividendIn}。" + + $"InitIncome 与 InitUnwind 共用 GetPreEodDividendSum,应返回同一 EOD 值。"); + } + finally { db?.Dispose(); } + } + + /// + /// 防御性:dealDate 早于任何 EOD(如交易首日尚未收盘)时,GetPreEodDividendSum 应返回 0, + /// 不抛异常。覆盖"无 EOD 记录"边界——fallback 到 dealDate.AddDays(-1) 后查无数据 → 0。 + /// + [TestMethod] + [TestCategory("DBRecording")] + public void Green_NoEodBeforeDealDate_Returns_Zero_WithoutThrowing() + { + YLContext db; + try { db = DbContextFactory.GetYLDbContext(); } + catch (Exception ex) { Assert.Inconclusive($"无法连接测试库(CI/无DB环境正常跳过):{ex.Message}"); return; } + + try + { + var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber); + if (td == null) { Assert.Inconclusive($"测试库不存在交易 {TradeNumber}"); return; } + + var floatLeg = db.swap_position + .Where(x => x.SwapTradeId == td.id && !x.Invalid && !x.IsInitial && x.UnderlyingCode != null && x.UnderlyingCode != "") + .FirstOrDefault(); + Assert.IsNotNull(floatLeg, "未找到浮动腿持仓"); + + var service = new RealDbSwapDealService(); + // dealDate = 交易开始日前一天,确保无任何 EOD 满足 ValueDate < dealDate + var preStartDate = td.StartDate.Value.AddDays(-1); + decimal dividendIn = service.ExposeGetPreEodDividendSum(td.id, floatLeg.PositionId, preStartDate); + + Assert.AreEqual(0m, dividendIn, 0.01m, + $"[防御] dealDate({preStartDate:yyyy-MM-dd})早于所有 EOD 时,GetPreEodDividendSum 应返回 0,实际={dividendIn}。" + + $"与历史 InitUnwind/InitIncome 中 DividendIn=0 行为一致,不抛异常。"); + } + finally { db?.Dispose(); } + } + + // ============================================================================ + // 非0场景验收(关键防回归) + // ---------------------------------------------------------------------------- + // GLMS-20260105-0004 的 PosiDividendSum 恰好=0(3/2 已全额互换),无法暴露 + // "DividendPending 硬编码 0" 的回归。本测试用一笔 PosiDividendSum≠0 的活交易 + // GLMS-20260706-0004(标的 230004.IB,持仓 1亿,待实现分红 -150,000,多日稳定) + // 验证:GetPreEodDividendSum 返回非0的 -150,000 → 修复后 DividendIn/DividendPending + // 均应为此值(而非历史硬0回归)。 + // 若本测试因硬0回归而失败(实际=0),即说明有人把 DividendPending 改回了硬0, + // 或 GetPreEodDividendSum 传导链路被破坏。 + // ============================================================================ + + /// + /// 非0场景:PosiDividendSum≠0 时,GetPreEodDividendSum 返回真实非0值, + /// 该值应同时成为 DividendIn 与 DividendPending(全量口径)。 + /// 这是针对"硬0被0掩盖"盲区的核心防回归测试。 + /// + [TestMethod] + [TestCategory("DBRecording")] + public void Green_NonZeroPosiDividendSum_Returns_RealValue_NotHardZero() + { + const string nonZeroTrade = "GLMS-20260706-0004"; // PosiDividendSum=-150,000,确认成交活交易 + YLContext db; + try { db = DbContextFactory.GetYLDbContext(); } + catch (Exception ex) { Assert.Inconclusive($"无法连接测试库(CI/无DB环境正常跳过):{ex.Message}"); return; } + + try + { + var td = db.trade.FirstOrDefault(t => t.TradeNumber == nonZeroTrade); + if (td == null) { Assert.Inconclusive($"测试库不存在交易 {nonZeroTrade}"); return; } + + var floatLeg = db.swap_position + .Where(x => x.SwapTradeId == td.id && !x.Invalid && !x.IsInitial && x.UnderlyingCode != null && x.UnderlyingCode != "") + .FirstOrDefault(); + Assert.IsNotNull(floatLeg, $"未找到 {nonZeroTrade} 的浮动腿持仓"); + + // 取该交易最近一次 EOD 的 PosiDividendSum 作为期望值(多日稳定 -150,000) + var lastEod = db.eod_swap_position + .Where(x => x.SwapTradeId == td.id && !x.Invalid && x.PositionId == floatLeg.PositionId && x.PosiQuantity > 0) + .OrderByDescending(x => x.ValueDate).FirstOrDefault(); + Assert.IsNotNull(lastEod, $"未找到 {nonZeroTrade} 的有效 EOD"); + Assert.AreNotEqual(0m, lastEod.PosiDividendSum, 0.01m, + $"前置:{nonZeroTrade} 的 PosiDividendSum 应≠0(本测试专为非0场景设计),实际={lastEod.PosiDividendSum}。" + + $"若该交易已互换/平仓致归0,请换另一笔 PosiDividendSum≠0 的活交易。"); + + // dealDate 取 lastEod 次日,确保 GetPreEodDividendSum 读到这笔非0 EOD + var dealDate = lastEod.ValueDate.AddDays(1); + var service = new RealDbSwapDealService(); + decimal dividend = service.ExposeGetPreEodDividendSum(td.id, floatLeg.PositionId, dealDate); + + // ---- 核心:返回值=真实非0的 PosiDividendSum,不是硬0 ---- + Assert.AreEqual(lastEod.PosiDividendSum, dividend, 0.01m, + $"[非0场景·方案C验收] {nonZeroTrade} GetPreEodDividendSum 应返回 EOD PosiDividendSum={lastEod.PosiDividendSum}," + + $"实际={dividend}。该值将同时成为 DividendPending(待结算分红收益,全量口径)。" + + $"若实际=0,说明 DividendPending 硬0回归未修复,或传导链路被破坏(参见 SwapDealService.GetPreEodDividendSum 注释的口径论证)。"); + + Console.WriteLine($"[非0场景验证通过] {nonZeroTrade}: PosiDividendSum={dividend}(非0)→ DividendIn/DividendPending 均为此值,非硬0。"); + } + finally { db?.Dispose(); } + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/InitUnwindTradingFeeTest.cs b/UnitTestProject/Modules/SwapModule/InitUnwindTradingFeeTest.cs index 0b9f7b37..8ebfb199 100644 --- a/UnitTestProject/Modules/SwapModule/InitUnwindTradingFeeTest.cs +++ b/UnitTestProject/Modules/SwapModule/InitUnwindTradingFeeTest.cs @@ -111,27 +111,25 @@ namespace YLErp.Modules.SwapModule } [TestMethod] - public void PartialCloseTradingFeeAndPendingFeeUseTheSameRoundedOriginalFeeAllocation() + public void ManuallyAdjustedPendingFeeDoesNotOverrideBaseRateCloseFee() { var oriPosition = new swap_position { - PosiFeeType = 0, - PosiTradingFeeUnit = 1.1234m, - PosiTradingFeePending = 113.46m + PosiFeeType = 1, + PosiTradingFeeUnit = 0.123456m, + PosiTradingFeePending = 1235.56m }; var unwindData = new UnwindData { - NotionalValue = 10098m, - CloseNotionalValue = 4039.2m, NotionalQty = 10000m, - CloseQty = 4000m + CloseQty = 10000m }; var tradingFee = InvokeCalcInitTradingFee(oriPosition, unwindData); var pendingFee = InvokeCalcInitTradingFeePending(oriPosition, new swap_position(), unwindData); - Assert.AreEqual(45.38m, tradingFee); - Assert.AreEqual(45.38m, pendingFee); + Assert.AreEqual(1234.56m, tradingFee); + Assert.AreEqual(1235.56m, pendingFee); } [TestMethod] diff --git a/UnitTestProject/Modules/SwapModule/SwapIncomeScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapIncomeScenarioTest.cs index 5a1d4308..02aaf36d 100644 --- a/UnitTestProject/Modules/SwapModule/SwapIncomeScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapIncomeScenarioTest.cs @@ -63,5 +63,35 @@ namespace YLErp.Modules.SwapModule Assert.AreEqual(ClientCashInCashOut.系统操作_预付金返息, service.ClientCashCalls[1].action); Console.WriteLine($"SI_002: 互换={service.ClientCashCalls[0].amount}, 预付金返息={service.ClientCashCalls[1].amount} ✅"); } + + [TestMethod] + public void SI_003_SwapIncome_含预付金腿_不返还预付金本金() + { + var td = SwapDealTestFactory.CreateTrade(); + td.ExerciseDate = new DateTime(2026, 12, 31); + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 0m); + unwindData.FlowEvents.Add(new swap_flow_event + { + UnderlyingCode = "UT-FLOAT", + MarkClosePnl = 100m, + PayDirection = 1 + }); + unwindData.FlowEvents.Add(new swap_flow_event + { + InterestMode = (int)InterestModeEnum.初始预付金, + InterestDirection = 1, + InterestPrincipal = 10000m, + InterestClosePnL = 2m + }); + + service.SwapIncome(unwindData); + + Assert.AreEqual(0m, unwindData.SwapMarginAmount, "手动互换不应返还预付金本金"); + Assert.IsFalse(service.ClientCashCalls.Any(x => x.action == ClientCashInCashOut.系统操作_应付预付金), + "手动互换不应生成应付预付金流水"); + Assert.IsTrue(service.ClientCashCalls.Any(x => x.action == ClientCashInCashOut.系统操作_预付金返息), + "手动互换仍应结算预付金返息"); + } } } diff --git a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs index 8b69eea1..e4dccf28 100644 --- a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs @@ -36,6 +36,7 @@ namespace YLErp.Modules.SwapModule // 输出别名(转发到基类捕获属性) public List CreatedEodPositions => PersistedPositions; + public List LastInterestCalculationPositions { get; private set; } public TestableSwapEodService( List trades, List positions, @@ -55,6 +56,7 @@ namespace YLErp.Modules.SwapModule protected override List FindTradeExtends(List tradeIds) => _extends; protected override List FindEodSwapsByDate(DateTime valueDate) => _eodSwaps; protected override List FindFlowEvents(int swapTradeId, DateTime settleDate) => _flowEvents; + protected override List FindCompletedFlowEvents(List tradeIds) => _flowEvents; protected override List FindEodSwapPositions(int swapTradeId, DateTime preSettleDate) => _eodPositions.Where(x => x.SwapTradeId == swapTradeId && x.ValueDate >= preSettleDate).ToList(); protected override List FindSwapPositions(int swapTradeId) @@ -80,7 +82,17 @@ namespace YLErp.Modules.SwapModule decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue, decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, bool needPrice, decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, - List closeList = null) => new List(); + List closeList = null) + { + LastInterestCalculationPositions = positions; + return positions.Select(position => new swap_flow_event + { + PositionId = position.id, + InterestPrincipal = 1000m, + InterestRate = 0.01m, + FloatRate = 0.01m + }).ToList(); + } public void ExecuteSwapPositionCompose(DateTime settleDate, DateTime preSettleDate) => SwapPositionCompose(settleDate, preSettleDate, null); @@ -250,5 +262,173 @@ namespace YLErp.Modules.SwapModule Assert.IsTrue(ex.Message.Contains("未收盘"), $"异常消息应含'未收盘',实际:{ex.Message}"); Console.WriteLine($"SPC_004: 抛异常'{ex.Message}' ✅"); } + + [TestMethod] + public void SPC_005_部分平仓后_预付金日终按实时剩余本金计息() + { + const long initialPrepayId = 2; + var td = CreateTrade(); + var initialPrepay = new swap_position + { + id = initialPrepayId, SwapTradeId = SwapTradeId, PosiDirection = 0, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.初始预付金, + InterestPrincipalFix = 1000m, IsInitial = true, Invalid = false, + PosiStartDate = SettleDate.AddDays(-1), PosiMatuirityDate = td.ExerciseDate.Value, + InterestSwapInterval = "[]" + }; + var realPrepay = new swap_position + { + id = 3, PositionId = initialPrepayId, SwapTradeId = SwapTradeId, PosiDirection = 0, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.初始预付金, + InterestPrincipalFix = 700m, IsInitial = false, Invalid = false + }; + var prepayEod = new eod_swap_position + { + id = 200, SwapTradeId = SwapTradeId, PositionId = initialPrepayId, + ValueDate = PreSettleDate, PosiDirection = 0, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.初始预付金, + InterestPrincipalFix = 700m, TdInterestPrincipal = 700m + }; + var service = new TestableSwapEodService( + new List { td }, + new List { CreateFloatPosition(1, 1000), initialPrepay, realPrepay }, + new List { CreateFloatEodPosition(1, 1000, 1.0020m), prepayEod }, + new List { new eod_swap { SwapTradeId = SwapTradeId, ValueDate = PreSettleDate } }, + new List { CreateExtend() }, + new List + { + CreateCloseFlowEvent(1, 300), + new swap_flow_event + { + SwapTradeId = SwapTradeId, PositionId = initialPrepayId, + EventType = (int)SwapEventTypeEnum.平仓, + EventDate = SettleDate, + DataState = (int)SwapFlowDateStateEnum.完成 + } + }); + + service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate); + + var calculatedPrepay = service.LastInterestCalculationPositions + .Single(x => x.id == initialPrepayId); + Assert.AreEqual(700m, calculatedPrepay.InterestPrincipalFix); + Assert.AreEqual(initialPrepayId, calculatedPrepay.id); + } + + [TestMethod] + public void SPC_006_平仓日_预付金日终不得重复扣减实时剩余本金() + { + const long initialPrepayId = 2; + var td = CreateTrade(); + var initialPrepay = new swap_position + { + id = initialPrepayId, SwapTradeId = SwapTradeId, PosiDirection = 0, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.初始预付金, + InterestPrincipalFix = 1000m, IsInitial = true, Invalid = false, + IsAnnualized = true, + PosiStartDate = SettleDate.AddDays(-1), PosiMatuirityDate = td.ExerciseDate.Value, + InterestSwapInterval = "[]" + }; + var realPrepay = new swap_position + { + id = 3, PositionId = initialPrepayId, SwapTradeId = SwapTradeId, PosiDirection = 0, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.初始预付金, + InterestPrincipalFix = 700m, IsInitial = false, Invalid = false + }; + var prepayEod = new eod_swap_position + { + id = 200, SwapTradeId = SwapTradeId, PositionId = initialPrepayId, + ValueDate = PreSettleDate, PosiDirection = 0, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.初始预付金, + InterestPrincipalFix = 1000m, TdInterestPrincipal = 1000m + }; + var closeFlow = CreateCloseFlowEvent(1, 300); + closeFlow.InterestRate = 0.01m; + var prepayCloseFlow = new swap_flow_event + { + SwapTradeId = SwapTradeId, PositionId = initialPrepayId, + EventType = (int)SwapEventTypeEnum.平仓, + EventDate = SettleDate, DataState = (int)SwapFlowDateStateEnum.完成, + InterestMode = (int)InterestModeEnum.初始预付金, + InterestPrincipal = 300m + }; + var service = new TestableSwapEodService( + new List { td }, + new List { CreateFloatPosition(1, 1000), initialPrepay, realPrepay }, + new List { CreateFloatEodPosition(1, 1000, 1.0020m), prepayEod }, + new List { new eod_swap { SwapTradeId = SwapTradeId, ValueDate = PreSettleDate } }, + new List { CreateExtend() }, + new List { closeFlow, prepayCloseFlow }); + + service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate); + + var persistedPrepay = service.CreatedEodPositions + .Single(x => x.PositionId == initialPrepayId); + Assert.AreEqual(700m, persistedPrepay.InterestPrincipalFix, + "实时腿已经扣减到700,日终不得再次按平仓比例扣减"); + Assert.AreEqual(700m, persistedPrepay.TdInterestPrincipal, + "平仓日预付金计息本金应立即切换为实时剩余本金"); + Assert.AreEqual(700m * 0.01m / 365m, persistedPrepay.TdInterestIncome, + "平仓日新增利息应按实时剩余本金计算"); + } + + [TestMethod] + public void SPC_007_HistoricalReplayUsesAsOfPrincipal() + { + const long originalPositionId = 2; + var original = new swap_position + { + id = originalPositionId, PosiDirection = 0, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.初始预付金, + InterestPrincipalFix = 10000m + }; + var realtime = new swap_position + { + PositionId = originalPositionId, + InterestMode = (int)InterestModeEnum.初始预付金, + InterestPrincipalFix = 7000m + }; + var close = new swap_flow_event + { + PositionId = originalPositionId, + PositionType = 0, + EventType = (int)SwapEventTypeEnum.平仓, + EventDate = new DateTime(2026, 7, 9), + InterestMode = (int)InterestModeEnum.初始预付金, + InterestPrincipal = 3000m + }; + var floatClose = new swap_flow_event + { + PositionId = 1, + PositionType = 1, + EventType = (int)SwapEventTypeEnum.平仓, + EventDate = new DateTime(2026, 7, 9), + TradingAmount = 3000000m + }; + var originalWithFloat = new List + { + original, + new swap_position { id = 1, PosiDirection = 1, PosiNotionalValue = 10000000m } + }; + + var beforeClose = SwapDealService.ResolveInterestLegPositionsAsOf( + originalWithFloat, new List { realtime }, + new[] { close, floatClose }, new DateTime(2026, 7, 8)) + .Single(x => x.id == originalPositionId); + var onCloseDate = SwapDealService.ResolveInterestLegPositionsAsOf( + originalWithFloat, new List { realtime }, + new[] { close, floatClose }, new DateTime(2026, 7, 9)) + .Single(x => x.id == originalPositionId); + + Assert.AreEqual(10000m, beforeClose.InterestPrincipalFix); + Assert.AreEqual(7000m, onCloseDate.InterestPrincipalFix); + } } } diff --git a/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs index ea9bf5c0..e156f2b9 100644 --- a/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs @@ -166,6 +166,35 @@ namespace YLErp.Modules.SwapModule Console.WriteLine($"UW_005: 反序列化SwapRealizedPnL=8000, 资金流水={service.ClientCashCalls[0].amount}, TradeStatus={td.TradeStatus} ✅"); } + [TestMethod] + public void UW_005A_ApproveSwapTrade_互换审核_不返还预付金本金() + { + var td = SwapDealTestFactory.CreateTrade(); + td.ExerciseDate = new DateTime(2026, 12, 31); + var unwindData = SwapDealTestFactory.CreateUnwindData( + swapRealizedPnL: 100m, swapMarginAmount: -10000m); + var swapEvent = new swap_event + { + id = 2, + SwapTradeId = SwapDealTestFactory.SwapTradeId, + EventType = (int)SwapEventTypeEnum.互换, + Invalid = false, + EventData = JsonConvert.SerializeObject(unwindData) + }; + var service = new TestableSwapDealService(td, + swapEvents: new Dictionary + { + [(int)SwapEventTypeEnum.互换] = swapEvent + }); + + service.ApproveSwapTrade(td, (int)SwapEventTypeEnum.互换); + + Assert.AreEqual(1, service.ClientCashCalls.Count, "互换审批只应生成互换结算流水"); + Assert.AreEqual(ClientCashInCashOut.系统操作_互换, service.ClientCashCalls[0].action); + Assert.IsFalse(service.ClientCashCalls.Any(x => x.action == ClientCashInCashOut.系统操作_应付预付金), + "互换审批不应生成应付预付金流水"); + } + // ================================================================ // 场景6:ApplySwapTrade 提交审核 —— 前置校验与保存事件 // ================================================================ @@ -197,6 +226,8 @@ namespace YLErp.Modules.SwapModule public void UW_007_SwapUnwind_占期初A转占剩余B_全平判定正确() { var td = SwapDealTestFactory.CreateTrade(); + td.StockEqvNotional = 600000; + td.TradeAmount = 600000; var service = new TestableSwapDealService(td); var unwindData = SwapDealTestFactory.CreateUnwindData( swapRealizedPnL: 0m, closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 0.6m, @@ -259,6 +290,33 @@ namespace YLErp.Modules.SwapModule Assert.AreEqual(500000.01, td.StockEqvNotional, 0.000001, "trade 剩余名义本金应在扣减后舍入两位小数"); } + [TestMethod] + public void UW_013_SwapUnwind_合法零点零一剩余不应判定全平() + { + var td = SwapDealTestFactory.CreateTrade(); + td.StockEqvNotional = 1000000.01; + td.TradeAmount = 10000.01; + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.CreateUnwindData( + swapRealizedPnL: 0m, + closeMethod: (int)CloseMethodEnum.部分平仓, + closePercent: 1000000m / 1000000.01m, + closeQty: 10000m, + closeNotionalValue: 1000000m, + positionQty: 10000.01m); + unwindData.NotionalValue = 1000000.01m; + unwindData.PosiNotionalValue = 1000000.01m; + + service.SwapUnwind(unwindData); + + Assert.AreEqual("确认成交", td.TradeStatus, + "剩余名义本金和数量均为0.01时仍应保持部分平仓状态"); + Assert.AreEqual(1, td.HasPartialUnWind, + "合法的0.01尾差不应被清零"); + Assert.AreEqual(0.01, td.StockEqvNotional, 0.000001); + Assert.AreEqual(0.01, td.TradeAmount, 0.000001); + } + [TestMethod] public void UW_010_SwapUnwind_现金与两位利息事件保持一致() { @@ -289,5 +347,183 @@ namespace YLErp.Modules.SwapModule Assert.AreEqual(10m, unwindData.SwapRealizedPnL); Assert.AreEqual(-10d, service.ClientCashCalls[0].amount, 0.001d); } + + [TestMethod] + public void UW_011_trade2308_full_close_uses_remaining_values_and_zeroes_tail() + { + var td = SwapDealTestFactory.CreateTrade(); + td.StockEqvNotional = 4906156.15; + td.TradeAmount = 5000000; + td.Notional = 5000000; + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.CreateUnwindData( + swapRealizedPnL: 0m, + closeMethod: (int)CloseMethodEnum.部分平仓, + closePercent: 0.5m, + closeQty: 5000000.01m, + closeNotionalValue: 4906156.15m, + positionQty: 5000000m); + unwindData.NotionalValue = 9812312.31m; + unwindData.PosiNotionalValue = 4906156.15m; + + service.SwapUnwind(unwindData); + + var saved = service.SaveSwapDealCalls[0].data; + Assert.AreEqual((int)CloseMethodEnum.部分平仓, saved.CloseMethod, + "CloseMethod 保留本次部分平仓意图,终态由扣减后的持仓事实决定"); + Assert.AreEqual(5000000m, saved.CloseQty); + Assert.AreEqual(4906156.15m, saved.CloseNotionalValue); + Assert.AreEqual(0d, td.StockEqvNotional, 0.000001); + Assert.AreEqual(0d, td.TradeAmount, 0.000001); + Assert.AreEqual(0d, td.Notional, 0.000001); + Assert.AreEqual("已平仓", td.TradeStatus); + } + + [TestMethod] + public void UW_014_full_close_quantity_normalization_recalculates_pnl_and_cash() + { + var td = SwapDealTestFactory.CreateTrade(); + td.StockEqvNotional = 4906156.15; + td.TradeAmount = 5000000; + td.Notional = 5000000; + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.CreateUnwindData( + swapRealizedPnL: 50000000.10m, + closeMethod: (int)CloseMethodEnum.部分平仓, + closePercent: 0.5m, + closeQty: 5000000.01m, + closeNotionalValue: 4906156.15m, + positionQty: 5000000m); + unwindData.NotionalValue = 9812312.31m; + unwindData.PosiNotionalValue = 4906156.15m; + unwindData.SwapCloseAmount = 50000000.10m; + var floatEvent = new swap_flow_event + { + UnderlyingCode = "UT-FLOAT", + PositionType = (int)PositionTypeFlag.Long, + EventType = (int)SwapEventTypeEnum.平仓, + PayDirection = 1, + PosiGrossPrice = 1m, + TradingAmountAvg = 11m, + MarkClosePnl = 50000000.10m + }; + unwindData.FlowEvents.Add(floatEvent); + + service.SwapUnwind(unwindData); + + Assert.AreEqual(5000000m, unwindData.CloseQty); + Assert.AreEqual(50000000m, floatEvent.MarkClosePnl); + Assert.AreEqual(50000000m, unwindData.SwapRealizedPnL); + Assert.AreEqual(50000000m, unwindData.SwapCloseAmount); + Assert.AreEqual(-50000000d, service.ClientCashCalls.Single().amount, 0.001d); + } + + [TestMethod] + public void UW_012_approve_restores_A_to_B_and_normalizes_flow_for_full_close() + { + var td = SwapDealTestFactory.CreateTrade(); + td.StockEqvNotional = 4906156.15; + td.TradeAmount = 5000000; + var unwindData = SwapDealTestFactory.CreateUnwindData( + swapRealizedPnL: 0m, + closeMethod: (int)CloseMethodEnum.部分平仓, + closePercent: 4906156.15m / 9812312.31m, + closeQty: 5000000.01m, + closeNotionalValue: 4906156.15m, + positionQty: 5000000m); + unwindData.NotionalValue = 9812312.31m; + unwindData.PosiNotionalValue = 4906156.15m; + var floatEvent = new swap_flow_event + { + EventId = 1, + UnderlyingCode = "261031.IB", + PositionType = (int)PositionTypeFlag.Long, + Quantity = 5000000.01m, + PositionQty = -0.01m + }; + var swapEvent = new swap_event + { + id = 1, + SwapTradeId = SwapDealTestFactory.SwapTradeId, + EventType = (int)SwapEventTypeEnum.平仓, + Invalid = false, + EventData = JsonConvert.SerializeObject(unwindData) + }; + var service = new TestableSwapDealService(td, + swapEvents: new Dictionary + { + [(int)SwapEventTypeEnum.平仓] = swapEvent + }, + flowEventsByEventId: new Dictionary> + { + [1] = new List { floatEvent } + }); + + service.ApproveSwapTrade(td, (int)SwapEventTypeEnum.平仓); + + Assert.AreEqual(1m, swapEvent.unwindData.ClosePercent); + Assert.AreEqual((int)CloseMethodEnum.部分平仓, swapEvent.unwindData.CloseMethod, + "审批不应把部分平仓事件改写为全平意图"); + Assert.AreEqual(5000000m, swapEvent.unwindData.CloseQty); + Assert.AreEqual(4906156.15m, swapEvent.unwindData.CloseNotionalValue); + Assert.AreEqual(5000000m, floatEvent.Quantity); + Assert.AreEqual(0m, floatEvent.PositionQty); + Assert.AreEqual("已平仓", td.TradeStatus); + Assert.AreEqual(0d, td.StockEqvNotional, 0.000001); + Assert.AreEqual(0d, td.TradeAmount, 0.000001); + } + + [TestMethod] + public void UW_015_approve_full_close_recalculates_normalized_pnl_before_cash() + { + var td = SwapDealTestFactory.CreateTrade(); + td.StockEqvNotional = 4906156.15; + td.TradeAmount = 5000000; + var unwindData = SwapDealTestFactory.CreateUnwindData( + swapRealizedPnL: 50000000.10m, + closeMethod: (int)CloseMethodEnum.部分平仓, + closePercent: 4906156.15m / 9812312.31m, + closeQty: 5000000.01m, + closeNotionalValue: 4906156.15m, + positionQty: 5000000m); + unwindData.NotionalValue = 9812312.31m; + unwindData.PosiNotionalValue = 4906156.15m; + unwindData.SwapCloseAmount = 50000000.10m; + var floatEvent = new swap_flow_event + { + EventId = 1, + UnderlyingCode = "261031.IB", + PositionType = (int)PositionTypeFlag.Long, + PayDirection = 1, + PosiGrossPrice = 1m, + TradingAmountAvg = 11m, + MarkClosePnl = 50000000.10m, + Quantity = 5000000.01m, + PositionQty = -0.01m + }; + var swapEvent = new swap_event + { + id = 1, + SwapTradeId = SwapDealTestFactory.SwapTradeId, + EventType = (int)SwapEventTypeEnum.平仓, + Invalid = false, + EventData = JsonConvert.SerializeObject(unwindData) + }; + var service = new TestableSwapDealService(td, + swapEvents: new Dictionary + { + [(int)SwapEventTypeEnum.平仓] = swapEvent + }, + flowEventsByEventId: new Dictionary> + { + [1] = new List { floatEvent } + }); + + service.ApproveSwapTrade(td, (int)SwapEventTypeEnum.平仓); + + Assert.AreEqual(5000000m, swapEvent.unwindData.CloseQty); + Assert.AreEqual(50000000m, swapEvent.unwindData.SwapRealizedPnL); + Assert.AreEqual(-50000000d, service.ClientCashCalls.Single().amount, 0.001d); + } } } diff --git a/YLErpDAL/Model/KafkaConfig.cs b/YLErpDAL/Model/KafkaConfig.cs index 5d144e1e..341ba482 100644 --- a/YLErpDAL/Model/KafkaConfig.cs +++ b/YLErpDAL/Model/KafkaConfig.cs @@ -57,6 +57,10 @@ namespace YLErp.Model public string AccountCapitalTopicGroupId { get; set; } public int AutoOffsetReset { get; set; } + /// + /// TRS合约数据推送topic(对外,如onebp等) + /// + public string ContractTopic { get; set; } = "onederi.trs.onebp.contract.v1"; } } diff --git a/YLErpDAL/Modules/EodModule/GLMSGreeksHandleService.cs b/YLErpDAL/Modules/EodModule/GLMSGreeksHandleService.cs index 8193aaf8..5ccfdf94 100644 --- a/YLErpDAL/Modules/EodModule/GLMSGreeksHandleService.cs +++ b/YLErpDAL/Modules/EodModule/GLMSGreeksHandleService.cs @@ -188,7 +188,7 @@ namespace YLErp.Modules.EodModule private Dictionary GetTFeatureBondInnerCode(List underlyingCodes,DateTime valueDate,DbConnection conn) { - var sql = "SELECT contractcode,deliverableinnercode,spread FROM fut_cgbderiv WHERE TradingDay = @ValueDate AND pricetype = 3 AND (contractcode,irr) IN (SELECT contractcode,MAX(irr) FROM fut_cgbderiv WHERE TradingDay = @ValueDate AND contractcode IN (@UmCodes) AND pricetype = 3 GROUP BY contractcode);"; + var sql = "SELECT contractcode,deliverableinnercode,spread FROM fut_cgbderiv WHERE tradingday = @ValueDate AND pricetype = 3 AND (contractcode,irr) IN (SELECT contractcode,MAX(irr) FROM fut_cgbderiv WHERE tradingday = @ValueDate AND contractcode IN (@UmCodes) AND pricetype = 3 GROUP BY contractcode);"; var datas = conn.Query(sql, new { ValueDate = valueDate, diff --git a/YLErpDAL/Modules/EodModule/SettlementModule/EodCheckSettlePrice.cs b/YLErpDAL/Modules/EodModule/SettlementModule/EodCheckSettlePrice.cs index 87af919a..45539739 100644 --- a/YLErpDAL/Modules/EodModule/SettlementModule/EodCheckSettlePrice.cs +++ b/YLErpDAL/Modules/EodModule/SettlementModule/EodCheckSettlePrice.cs @@ -1,5 +1,5 @@ -using System.Linq; -using System.Linq.Dynamic.Core; +using YLErp.BLL; +using YLErp.Configuration.Enums; using YLErp.Modules.DataProviderModule; namespace YLErp.Modules.EodModule.SettlementModule @@ -7,7 +7,7 @@ namespace YLErp.Modules.EodModule.SettlementModule /// /// 检查当日结算价,是否全部进系统。 /// - class EodCheckSettlePrice : EodSettleServiceBaseV2 + public class EodCheckSettlePrice : EodSettleServiceBaseV2 { public const string Step = "检查标的结算价格缺失"; @@ -58,7 +58,7 @@ namespace YLErp.Modules.EodModule.SettlementModule allQuery = DbContext.trade.Where(tradePredicate).Select(n => n.UnderlyingCode).Distinct(); - if (PS.Config.ErpElement.ForwardTradePriceModel == Configuration.Enums.ForwardTradePriceModel.STANDARD) + if (PS.Config.ErpElement.ForwardTradePriceModel == ForwardTradePriceModel.STANDARD) { allQuery = allQuery.Union(DbContext.trade.Where(tradePredicate).Where(x => x.BasisUnderlyingCode != null && x.BasisUnderlyingCode != "").Select(n => n.BasisUnderlyingCode).Distinct()); } @@ -88,14 +88,8 @@ namespace YLErp.Modules.EodModule.SettlementModule allQuery = allQuery == null ? exTradeQuery.Distinct() : allQuery.Union(exTradeQuery.Distinct()); var preSettleDate = _context.PreSettleDate; - //最后一个交易日持仓信息 - var futureTypes = ConsGlobal.InstrumentType.GetFutureTypes(); - var positionQuery = from t in DbContext.eod_trade_position - join um in DbContext.underlying_manager on t.UnderlyingCode equals um.UnderlyingCode - where t.ValueDate == preSettleDate && t.Amount != 0 - && ConsTrade.TradeTypesForHedge.Contains(t.TradeType) - && (!futureTypes.Contains(um.UnderlyingInstrumentType) || um.MaturityDate >= settleDate) - select t.UnderlyingCode; + // 持仓分支传入 clienIds:使上一交易日持仓标的按当前收盘客户作用域收敛(防御点详见 QueryPositionUnderlyingCodes)。 + var positionQuery = QueryPositionUnderlyingCodes(DbContext, preSettleDate, settleDate, clienIds); allQuery = allQuery.Union(positionQuery.Distinct()); } @@ -130,6 +124,41 @@ namespace YLErp.Modules.EodModule.SettlementModule _context.RaiseError(Step, "标的代码:" + codes); } } + + /// + /// 取上一交易日持仓中需结算价的标的(持仓分支)。 + /// 抽成 static 以便单测直接覆盖 ClientId 收敛语义。 + /// + /// ── 防御行为(EQD-6967)───────────────────────────────────────────── + /// 收盘缺失价检查在“按客户作用域结算(ClientIds 非 null)”时,本应只校验 + /// 当前收盘客户自己的标的,而不应把“其他客户”或“系统级(ClientId=0)”的 + /// 上一交易日持仓标的误报为缺失价。历史上持仓分支完全未引用 ClientId, + /// 导致客户 A 收盘时被其他客户/系统级持仓的标的噪声干扰(见缺陷现象)。 + /// + /// 本方法通过 clienIds 过滤做收敛,与 OTC 分支、客户产品分支已有的 + /// ClientId 过滤语义保持一致: + /// · clienIds == null → 系统级全量结算,短路为 true(不收窄,零回归); + /// · clienIds != null → 仅返回属于指定客户的持仓标的(Contains(t.ClientId)), + /// 过滤掉其他客户及系统级(ClientId=0)持仓噪声。 + /// 注意:场外交易结算模式(IsSettleExchangeTrades=false)下持仓分支整块跳过, + /// 此时本方法不会被调用,属操作层面的规避而非修复。 + /// ─────────────────────────────────────────────────────────────────── + /// + public static IQueryable QueryPositionUnderlyingCodes(YLContext db, DateTime preSettleDate, DateTime settleDate, IEnumerable clienIds) + { + var futureTypes = ConsGlobal.InstrumentType.GetFutureTypes(); + return from t in db.eod_trade_position + join um in db.underlying_manager on t.UnderlyingCode equals um.UnderlyingCode + where t.ValueDate == preSettleDate && t.Amount != 0 + && ConsTrade.TradeTypesForHedge.Contains(t.TradeType) + && (!futureTypes.Contains(um.UnderlyingInstrumentType) || um.MaturityDate >= settleDate) + // 【防御点·EQD-6967】按客户作用域收敛持仓标的: + // clienIds==null → 系统级全量结算,不收窄(零回归); + // clienIds!=null → 仅保留指定客户持仓,过滤掉其他客户/系统级(ClientId=0)持仓噪声。 + && (clienIds == null || clienIds.Contains(t.ClientId)) + select t.UnderlyingCode; + } + /// /// 获取互换标的 /// diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 05d0a372..2c0e6310 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -47,6 +47,65 @@ namespace YLErp.Modules.SwapModule unwindData.CloseNotionalValue = Math.Round(unwindData.CloseNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); } + private static bool NormalizeFullCloseRequest(UnwindData unwindData) + { + if (unwindData.CloseMethod != (int)CloseMethodEnum.全部平仓 + && unwindData.ClosePercent < 1 + && !(unwindData.PositionQty > 0 && unwindData.CloseQty >= unwindData.PositionQty) + && !(unwindData.PosiNotionalValue > 0 && unwindData.CloseNotionalValue >= unwindData.PosiNotionalValue)) + { + return false; + } + + var closeQty = unwindData.CloseQty; + var closeNotionalValue = unwindData.CloseNotionalValue; + unwindData.ClosePercent = 1; + if (unwindData.PositionQty > 0) unwindData.CloseQty = unwindData.PositionQty; + if (unwindData.PosiNotionalValue > 0) unwindData.CloseNotionalValue = unwindData.PosiNotionalValue; + return closeQty != unwindData.CloseQty || closeNotionalValue != unwindData.CloseNotionalValue; + } + + private static void RecalculateNormalizedUnwindAmounts(UnwindData unwindData) + { + var floatLeg = unwindData.FlowEvents.FirstOrDefault(x => !string.IsNullOrEmpty(x.UnderlyingCode)); + if (floatLeg == null || floatLeg.PosiGrossPrice == 0) return; + + var input = new UnwindInput + { + Multiplier = ConsGlobal.InstrumentType.IsBond(floatLeg.UnderlyingInstrumentType) ? 100 : 1, + PosiGrossPrice = floatLeg.PosiGrossPrice, + TradingAmountAvg = floatLeg.TradingAmountAvg, + CloseQty = unwindData.CloseQty, + PositionQty = unwindData.PositionQty, + ContractSize = floatLeg.ContractSize, + CloseNotionalValue = unwindData.CloseNotionalValue, + PayDirection = floatLeg.PayDirection, + PositionType = floatLeg.PositionType, + TradingFee = floatLeg.TradingFee.ToString(), + TradingFeePending = floatLeg.TradingFeePending.ToString(), + DividendIn = floatLeg.DividendIn.ToString() + }; + foreach (var leg in unwindData.FlowEvents.Where(x => string.IsNullOrEmpty(x.UnderlyingCode))) + { + var target = leg.InterestMode == (int)InterestModeEnum.初始预付金 + || leg.InterestMode == (int)InterestModeEnum.追加预付金 + ? input.MarginLegs + : input.InterestLegs; + target.Add(new LegInput { InterestClosePnL = leg.InterestClosePnL }); + } + + var result = FrontendCalcReference.CalcUnwind(input); + floatLeg.MarkClosePnl = result.MarkClosePnl; + unwindData.SwapCloseAmount = result.SwapCloseAmount; + unwindData.SwapRealizedPnL = result.SwapRealizedPnL; + unwindData.SwapMarginRebatePnl = result.SwapMarginRebatePnl; + } + + private static bool IsFullCloseAfterDeduction(UnwindData unwindData, double remainingNotional, double remainingQuantity) + { + return unwindData.ClosePercent == 1 || (remainingNotional == 0 && remainingQuantity == 0); + } + // 待实现利息会进入 decimal(30,12) 日终快照 private const int InterestCalculationPrecision = 12; @@ -85,6 +144,10 @@ namespace YLErp.Modules.SwapModule if (unwindData.FlowEvents.Any(x => !string.IsNullOrEmpty(x.UnderlyingCode))) { CalcCloseAmount(unwindData); + if (eventType == (int)SwapEventTypeEnum.互换) + { + unwindData.SwapMarginAmount = 0; + } return; } @@ -287,7 +350,15 @@ namespace YLErp.Modules.SwapModule floatEvent.PositionId = position.PositionId; floatEvent.EventType = (int)SwapEventTypeEnum.平仓; floatEvent.EventReason = "交易"; - floatEvent.DividendIn = 0; + // 方案C:分红收益改由上一收盘日 EOD PosiDividendSum 提供(单一可信源), + // 前端 getDivindIn 不再覆盖;消除"期初持仓×totalInterest"对已平仓部分的重复计入。 + // 同一 EOD 值取一次喂两栏: + // DividendIn = "浮动端平仓盈亏·分红"(本次动作要落袋的,落库后被前端按需展示) + // DividendPending = "待结算分红收益"(仍挂在账上、未来才结的存量 = PosiDividendSum 全量口径, + // 见 GetPreEodDividendSum 注释的口径论证;切勿改回硬0或分摊,会落库回归) + decimal preEodDividendSum = GetPreEodDividendSum(tradeId, position.PositionId, dealDate); + floatEvent.DividendIn = preEodDividendSum; + floatEvent.DividendPending = preEodDividendSum; floatEvent.UnderlyingCode = position.UnderlyingCode; floatEvent.UnderlyingInstrumentType = position.UnderlyingInstrumentType; floatEvent.CloseFee = 0; @@ -324,19 +395,12 @@ namespace YLErp.Modules.SwapModule return 0; } - if (oriPosition.PosiTradingFeeUnit == 0) + if (oriPosition.PosiFeeType == 1) { - return 0; + return Math.Round(oriPosition.PosiTradingFeeUnit * unwindData.CloseQty, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); } - var closeBase = oriPosition.PosiFeeType == 1 ? unwindData.CloseQty : unwindData.CloseNotionalValue; - var originalBase = oriPosition.PosiFeeType == 1 ? unwindData.NotionalQty : unwindData.NotionalValue; - if (originalBase <= 0) - { - return 0; - } - - return Math.Round(oriPosition.PosiTradingFeePending * closeBase / originalBase, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + return Math.Round(oriPosition.PosiTradingFeeUnit / 100m * unwindData.CloseNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); } private static decimal CalcInitTradingFeePending(swap_position oriPosition, swap_position position, UnwindData unwindData) @@ -346,7 +410,14 @@ namespace YLErp.Modules.SwapModule return position?.PosiTradingFeePending ?? 0; } - return CalcInitTradingFee(oriPosition, unwindData); + var closeBase = oriPosition.PosiFeeType == 1 ? unwindData.CloseQty : unwindData.CloseNotionalValue; + var originalBase = oriPosition.PosiFeeType == 1 ? unwindData.NotionalQty : unwindData.NotionalValue; + if (originalBase <= 0) + { + return position?.PosiTradingFeePending ?? 0; + } + + return Math.Round(oriPosition.PosiTradingFeePending * closeBase / originalBase, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); } /// /// 校验上日是否收盘 @@ -498,7 +569,9 @@ namespace YLErp.Modules.SwapModule if (position != null) { floatEvent.PositionId = position.PositionId; - floatEvent.DividendIn = 0; + // 方案C:分红收益改由上一收盘日 EOD PosiDividendSum 提供(单一可信源), + // 前端 getDivindIn 不再覆盖;消除"期初持仓×totalInterest"对已平仓部分的重复计入。 + floatEvent.DividendIn = GetPreEodDividendSum(tradeId, position.PositionId, dealDate); floatEvent.UnderlyingCode = position.UnderlyingCode; floatEvent.UnderlyingInstrumentType = position.UnderlyingInstrumentType; floatEvent.CloseFee = 0; @@ -606,6 +679,65 @@ namespace YLErp.Modules.SwapModule return p; }).ToList(); } + + /// + /// 计算预付金腿当前真实持仓 (当前持仓+未来持仓) + /// + /// + /// + /// + /// + /// + public static List ResolveInterestLegPositionsAsOf( + List origPositions, List realPositions, + IEnumerable completedFlowEvents, DateTime settleDate) + { + realPositions ??= new List(); + var futureFlows = (completedFlowEvents ?? Enumerable.Empty()) + .Where(x => x.EventType == (int)SwapEventTypeEnum.平仓 && x.EventDate > settleDate) + .ToList(); + var originalNotional = origPositions.Where(x => x.PosiDirection > 0) + .Sum(x => x.PosiNotionalValue); + var futureCloseNotional = futureFlows.Where(x => x.PositionType > 0) + .Sum(x => x.TradingAmount); + var hasNotionalFlows = futureCloseNotional > 0 && originalNotional > 0; + var futureClosePrincipal = futureFlows + .Where(x => x.InterestMode == (int)InterestModeEnum.初始预付金 + || x.InterestMode == (int)InterestModeEnum.追加预付金) + .GroupBy(x => x.PositionId) + .ToDictionary(x => x.Key, x => x.Sum(v => v.InterestPrincipal)); + var priorClosePositionIds = new HashSet((completedFlowEvents ?? Enumerable.Empty()) + .Where(x => x.EventType == (int)SwapEventTypeEnum.平仓 && x.EventDate <= settleDate) + .Select(x => x.PositionId)); + + return origPositions.Where(x => x.PosiDirection == 0).Select(p => + { + if (p.InterestMode == (int)InterestModeEnum.初始预付金 + || p.InterestMode == (int)InterestModeEnum.追加预付金) + { + var realLeg = realPositions.FirstOrDefault(r => r.PositionId == p.id); + if (realLeg != null) + { + if (!priorClosePositionIds.Contains(p.id)) + { + return p; + } + var futurePrincipal = hasNotionalFlows + ? p.InterestPrincipalFix * futureCloseNotional / originalNotional + : futureClosePrincipal.TryGetValue(p.id, out var flowPrincipal) ? flowPrincipal : 0m; + var asOfPrincipal = realLeg.InterestPrincipalFix + futurePrincipal; + asOfPrincipal = Math.Min(p.InterestPrincipalFix, Math.Max(0m, asOfPrincipal)); + if (asOfPrincipal != p.InterestPrincipalFix) + { + var clone = p.Clone(); + clone.InterestPrincipalFix = asOfPrincipal; + return clone; + } + } + } + return p; + }).ToList(); + } public static decimal ResolveUnwindPreviousNotional( eod_swap lastEod, @@ -834,6 +966,47 @@ namespace YLErp.Modules.SwapModule return notionalValue > 0 ? posiNotionalValue / notionalValue : 1; } + /// + /// 读取"上一收盘日"浮动腿的待实现分红(eod_swap_position.PosiDividendSum), + /// 用于平仓/互换预览页展示"浮动端平仓盈亏·分红(DividendIn)" 与 "待结算分红收益(DividendPending)"。 + /// 方案C:替代前端 totalInterest × 期初持仓 的重算——后者会把登记日前已平仓、 + /// 不享有该笔分红的部分重复计入(GLMS-20260105-0004 误显 -36,160)。 + /// EOD 的 PosiDividendSum 已按"实际持仓递推 + 当日实现扣除"算出待实现分红, + /// 是单一可信源。 + /// 复用 GetUnwindInterests(cs:624-626) 的"上一 EOD 日期"推导:取 eod_swap 中 + /// ValueDate < dealDate 的最大日期,无则 dealDate.AddDays(-1);再经 + /// SwapEodPositionService.GetPreEodPositions 取该日持仓,匹配 PositionId。 + /// 抽为 protected virtual:与 GetMaxIncomeValueDate 一致,便于测试替身覆写、 + /// 也兼容无 EOD 的边界(返回 0,与历史 DividendIn=0 行为一致)。 + /// + /// 上一收盘日该浮动腿的待实现分红;无 EOD 记录返回 0 + /// + /// 【口径论证·勿改】为什么 DividendPending 也用本方法的全量值(非分摊、非硬0): + /// 1. 字段语义直接对应:EOD PosiDividendSum 的 DisplayName="浮动端平仓盈亏·分红未实现" + /// (EodSwapPosition.cs:186),递推式 PosiDividendSum=前日+当日新计-当日实现 + /// (SwapEodPositionService.cs:1825),即"扣过当日实现后、还挂在账上未来才结的存量"。 + /// 前端列"待结算分红收益"(SwapflowList.js:561) 字面就是同一回事 → 直接取 PosiDividendSum。 + /// 2. 是"存量"非"流量":DividendPending 描述的是"账上还欠多少"(与本次平仓比例无关的总额), + /// 而 DividendIn 才是"本次动作落袋多少"。两者口径本就不同,各自正确。若把 DividendPending 改成 + /// 按本次平仓比例分摊,会把"存量"误当"流量",与列名"待结算"矛盾。 + /// 3. 历史教训:方案C 初版曾把前端 DividendPending 硬编码 0(commit e3c473ba),因测试交易 + /// PosiDividendSum 恰好=0(3/2 已全额互换)而测试通过、掩盖问题。但对 PosiDividendSum≠0 的部分 + /// 平仓交易,硬0 会落库(SwapFlowEventService.cs:588 冲账取负写入 swap_flow_event.DividendPending) + /// 并在事件列表"待结算分红收益"列显示错误的 0 —— 这是确定的回归。故本方法返回值同时喂两栏, + /// 前端不得再覆盖。例外:互换页 DividendPending 保持 0(互换语义=全量结清,结清后待结算归0)。 + /// + protected virtual decimal GetPreEodDividendSum(int tradeId, long positionId, DateTime dealDate) + { + var lastEod = DbContext.eod_swap + .Where(x => x.ValueDate < dealDate && x.SwapTradeId == tradeId) + .OrderByDescending(o => o.ValueDate).FirstOrDefault(); + var preEodDate = lastEod == null ? dealDate.AddDays(-1) : lastEod.ValueDate; + var preEod = new SwapEodPositionService(this) + .GetPreEodPositions(tradeId, preEodDate) + .FirstOrDefault(x => x.PositionId == positionId); + return preEod == null ? 0m : preEod.PosiDividendSum; + } + /// /// 获取固定利率 /// @@ -1335,10 +1508,14 @@ namespace YLErp.Modules.SwapModule NormalizeNotionalValues(unwindData); NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum.平仓, "系统操作_平仓"); //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); + if (NormalizeFullCloseRequest(unwindData)) + { + RecalculateNormalizedUnwindAmounts(unwindData); + } + ValidateFrontendPnL(unwindData, isIncome: false); // 只读校验告警,不阻断交易 bool cofirm = false; ExecuteInTransaction(() => { @@ -1354,18 +1531,24 @@ namespace YLErp.Modules.SwapModule DealFloatPosition(unwindData); var flowList = new List(unwindData.FlowEvents); var eventId = SaveSwapDeal(unwindData, (int)SwapEventTypeEnum.平仓, clientCashId, "系统操作_平仓"); - if (unwindData.CloseMethod == (int)CloseMethodEnum.全部平仓 || unwindData.ClosePercent == 1) + var remainingStockEqvNotional = Math.Round(td.StockEqvNotional - Convert.ToDouble(unwindData.CloseNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + var remainingTradeAmount = td.TradeAmount - Convert.ToDouble(unwindData.CloseQty); + var isFullClose = IsFullCloseAfterDeduction(unwindData, remainingStockEqvNotional, remainingTradeAmount); + if (isFullClose) { td.TradeStatus = "已平仓"; + td.StockEqvNotional = 0; + td.TradeAmount = 0; CallSaveSwapTradeClientCash(td, unwindData.ValueDate); } else { td.HasPartialUnWind = 1; + td.StockEqvNotional = remainingStockEqvNotional; + td.TradeAmount = remainingTradeAmount; } + td.Notional = td.TradeAmount; td.UnWindDate = unwindData.UnwindDate; - td.StockEqvNotional = Math.Round(td.StockEqvNotional - Convert.ToDouble(unwindData.CloseNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - td.TradeAmount -= Convert.ToDouble(unwindData.CloseQty); SaveAllChanges(); cofirm = true; }); @@ -1860,20 +2043,65 @@ namespace YLErp.Modules.SwapModule throw new Exception("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效"); } swapEvent.unwindData = JsonConvert.DeserializeObject(swapEvent.EventData); + NormalizeNotionalValues(swapEvent.unwindData); + // Stored events keep display ratio A; approval calculations consume remaining ratio B. + swapEvent.unwindData.ClosePercent = ToRemainingClosePercent( + swapEvent.unwindData.ClosePercent, + swapEvent.unwindData.NotionalValue, + swapEvent.unwindData.PosiNotionalValue); + var flowList = FindFlowEventsByEventId(swapEvent.id); + swapEvent.unwindData.FlowEvents = flowList; + if (eventType == (int)SwapEventTypeEnum.平仓) + { + if (NormalizeFullCloseRequest(swapEvent.unwindData)) + { + RecalculateNormalizedUnwindAmounts(swapEvent.unwindData); + } + } if (eventType == (int)SwapEventTypeEnum.互换) { NormalizeIncomeUnwindDate(swapEvent.unwindData); ValidateIncomeValueDate(swapEvent.unwindData, td); } - var flowList = FindFlowEventsByEventId(swapEvent.id); + if (eventType == (int)SwapEventTypeEnum.平仓) + { + foreach (var item in flowList.Where(x => x.PositionType > 0)) + { + item.Quantity = swapEvent.unwindData.CloseQty; + item.PositionQty = swapEvent.unwindData.ClosePercent == 1 + ? 0 + : swapEvent.unwindData.PositionQty - swapEvent.unwindData.CloseQty; + } + } string action = eventType == (int)SwapEventTypeEnum.互换 ? ClientCashInCashOut.系统操作_互换 : ClientCashInCashOut.系统操作_平仓费; int clientCashId = AddClientCash(td, Convert.ToDouble(-swapEvent.unwindData.SwapRealizedPnL), action, swapEvent.unwindData.ValueDate); - if (swapEvent.unwindData.SwapMarginAmount != 0) + if (eventType == (int)SwapEventTypeEnum.平仓 && swapEvent.unwindData.SwapMarginAmount != 0) { AddClientCash(td, Convert.ToDouble(swapEvent.unwindData.SwapMarginAmount), ClientCashInCashOut.系统操作_应付预付金, swapEvent.unwindData.ValueDate); } swapEvent.ClientCashId = clientCashId; - if (swapEvent.unwindData.CloseMethod == (int)CloseMethodEnum.全部平仓) + td.UnWindDate = swapEvent.unwindData.UnwindDate; + if (eventType != (int)SwapEventTypeEnum.互换) + { + var remainingStockEqvNotional = Math.Round(td.StockEqvNotional - Convert.ToDouble(swapEvent.unwindData.CloseNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + var remainingTradeAmount = td.TradeAmount - Convert.ToDouble(swapEvent.unwindData.CloseQty); + var isFullClose = IsFullCloseAfterDeduction(swapEvent.unwindData, remainingStockEqvNotional, remainingTradeAmount); + if (isFullClose) + { + td.TradeStatus = "已平仓"; + td.StockEqvNotional = 0; + td.TradeAmount = 0; + CallSaveSwapTradeClientCash(td, swapEvent.unwindData.ValueDate); + } + else + { + td.TradeStatus = ConsTrade.确认成交; + td.HasPartialUnWind = 1; + td.StockEqvNotional = remainingStockEqvNotional; + td.TradeAmount = remainingTradeAmount; + } + } + else if (swapEvent.unwindData.CloseMethod == (int)CloseMethodEnum.全部平仓) { td.TradeStatus = "已平仓"; CallSaveSwapTradeClientCash(td, swapEvent.unwindData.ValueDate); @@ -1883,12 +2111,6 @@ namespace YLErp.Modules.SwapModule td.TradeStatus = ConsTrade.确认成交; td.HasPartialUnWind = 1; } - td.UnWindDate = swapEvent.unwindData.UnwindDate; - if (eventType != (int)SwapEventTypeEnum.互换) - { - td.StockEqvNotional = Math.Round(td.StockEqvNotional - Convert.ToDouble(swapEvent.unwindData.CloseNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - td.TradeAmount -= Convert.ToDouble(swapEvent.unwindData.CloseQty); - } td.Notional = td.TradeAmount; UpdateInitalPosition(flowList, swapEvent.unwindData, eventType); @@ -1920,6 +2142,13 @@ namespace YLErp.Modules.SwapModule // 与 SwapUnwind(L1270) 保持一致——缺少此转换会导致 SaveSwapDealInternal 的 B→A 还原出错 // (例如第二次部分平仓 50%(A) → 错误还原为 0.325 而非 0.50)。 unwindData.ClosePercent = ToRemainingClosePercent(unwindData.ClosePercent, unwindData.NotionalValue, unwindData.PosiNotionalValue); + if (eventType == (int)SwapEventTypeEnum.平仓) + { + if (NormalizeFullCloseRequest(unwindData)) + { + RecalculateNormalizedUnwindAmounts(unwindData); + } + } string action = eventType == (int)SwapEventTypeEnum.互换 ? ClientCashInCashOut.系统操作_互换 : ClientCashInCashOut.系统操作_平仓费; ExecuteInTransaction(() => { @@ -2053,8 +2282,17 @@ namespace YLErp.Modules.SwapModule else { // 平仓时才扣减持仓 - position.PosiQuantity -= unwindData.CloseQty; - position.PosiNotionalValue = Math.Round(position.PosiNotionalValue - unwindData.CloseNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + var remainingPositionQty = position.PosiQuantity - unwindData.CloseQty; + var remainingPositionNotional = Math.Round( + position.PosiNotionalValue - unwindData.CloseNotionalValue, + ConsGlobal.MoneyRound, + MidpointRounding.AwayFromZero); + position.PosiQuantity = unwindData.ClosePercent == 1 + ? 0 + : remainingPositionQty; + position.PosiNotionalValue = unwindData.ClosePercent == 1 + ? 0 + : remainingPositionNotional; position.PosiTradingFee -= position.PosiTradingFee * unwindData.ClosePercent; position.PosiTradingFeePending -= position.PosiTradingFeePending * unwindData.ClosePercent; } @@ -2068,10 +2306,13 @@ namespace YLErp.Modules.SwapModule position.InterestFeePending += interest.InterestFee; if ((interest.InterestMode == (int)InterestModeEnum.追加预付金 || interest.InterestMode == (int)InterestModeEnum.初始预付金) && eventType == (int)SwapEventTypeEnum.平仓) { - position.InterestPrincipalFix = Math.Round( + var remainingInterestPrincipal = Math.Round( position.InterestPrincipalFix - interest.InterestPrincipal, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + position.InterestPrincipalFix = unwindData.ClosePercent == 1 + ? 0 + : remainingInterestPrincipal; } } } diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index cb0458b7..35214d22 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -307,6 +307,14 @@ namespace YLErp.Modules.SwapModule return DbContext.swap_flow_event.Where(eventExpression).ToList(); } + protected virtual List FindCompletedFlowEvents(List tradeIds) + { + return DbContext.swap_flow_event + .Where(x => tradeIds.Contains(x.SwapTradeId) + && x.DataState == (int)SwapFlowDateStateEnum.完成) + .ToList(); + } + #endregion /// @@ -352,6 +360,7 @@ namespace YLErp.Modules.SwapModule var tradeRealPositionList = allTradePositionList.Where(t => !t.IsInitial).ToList(); var tradeExtendList = FindTradeExtends(tradeIds); var eodSwapList = FindEodSwapsByDate(preSettleDate); + var completedFlowEvents = FindCompletedFlowEvents(tradeIds); List eventTyps = new List() { (int)SwapEventTypeEnum.平仓, (int)SwapEventTypeEnum.互换, (int)SwapEventTypeEnum.自动互换 }; foreach (var td in tradeQueryList) { @@ -364,7 +373,10 @@ namespace YLErp.Modules.SwapModule var realPositions = tradeRealPositionList.Where(s => s.SwapTradeId == td.id); var posiList = positions.Where(x => x.PosiQuantity > 0).ToList(); var realPosiList = realPositions.ToList(); - var interestList = positions.Where(x => x.InterestDirection > 0).ToList(); + var tradeCompletedFlowEvents = completedFlowEvents.Where(x => x.SwapTradeId == td.id).ToList(); + var interestList = SwapDealService.ResolveInterestLegPositionsAsOf( + positions.ToList(), realPosiList, tradeCompletedFlowEvents, settleDate) + .Where(x => x.InterestDirection > 0).ToList(); DateTime posiDate = td.TradeDate.Value;//交易日期 var lastEodSwap = eodSwapList.FirstOrDefault(x => x.SwapTradeId == td.id); //上一交易日无日终归档,且不是交易日期,且当前收盘日期不是交易日期,报错 @@ -1351,8 +1363,9 @@ namespace YLErp.Modules.SwapModule //持仓内容-利息腿 newEodPayPosition.InterestDirection = position.InterestDirection; newEodPayPosition.InterestMode = position.InterestMode; + // ResolveInterestLegPositions 已提供平仓后的实时剩余本金,日终不再重复扣减。 newEodPayPosition.InterestPrincipalFix = position.InterestPrincipalFix; - newEodPayPosition.InterestPrincipalFix *= (1 - closePercent); + // newEodPayPosition.InterestPrincipalFix *= (1 - closePercent); newEodPayPosition.InterestRateDefault = position.InterestRateDefault; newEodPayPosition.InterestSwapInterval = position.InterestSwapInterval; newEodPayPosition.IsAnnualized = position.IsAnnualized; @@ -1364,9 +1377,11 @@ namespace YLErp.Modules.SwapModule newEodPayPosition.interest_rest_days = position.interest_rest_days; newEodPayPosition.interest_rule = position.interest_rule; //利息端估值用信息 - newEodPayPosition.TdInterestPrincipal = position.InterestMode == (int)InterestModeEnum.标的期初全价 - ? posiNotionalValue - : interests.Count > 0 ? interests.First().InterestPrincipal : 0; + newEodPayPosition.TdInterestPrincipal = interestModes.Contains(position.InterestMode) + ? position.InterestPrincipalFix + : position.InterestMode == (int)InterestModeEnum.标的期初全价 + ? posiNotionalValue + : interests.Count > 0 ? interests.First().InterestPrincipal : 0; if (interval != null) { newEodPayPosition.TdInterestRate = interval.Rate; @@ -1397,8 +1412,11 @@ namespace YLErp.Modules.SwapModule } else { + var pendingInterestBeforeSettlement = autoSwap + ? interestAmountBeforeSettlement + : lastInterestIncomeSum + newEodPayPosition.TdInterestIncome; newEodPayPosition.InterestIncomeSum = RoundEodInterest( - interestAmountBeforeSettlement - newEodPayPosition.TdCloseInterest); + pendingInterestBeforeSettlement - newEodPayPosition.TdCloseInterest); newEodPayPosition.InterestFeeSum = eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee - newEodPayPosition.TdCloseInterestFee; } //持仓内容-利息腿-损益统计(本方视角) diff --git a/YLErpDAL/Modules/SwapModule/SwapEventService.cs b/YLErpDAL/Modules/SwapModule/SwapEventService.cs index 844d30b5..782b83e1 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEventService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEventService.cs @@ -94,28 +94,34 @@ namespace YLErp.Modules.SwapModule { var obj = DbContext.swap_event.Where(O => O.id == swap_Event.id).FirstOrDefault(); var trade = DbContext.trade.Where(O => O.id == swap_Event.SwapTradeId).FirstOrDefault(); + if (trade == null) + { + throw new ServiceException("未找到交易,请刷新页面后再次尝试!"); + } + var oldMaturityDate = trade.ExerciseDate.Value; + if (obj != null && !string.IsNullOrEmpty(obj.EventData)) + { + oldMaturityDate = JsonConvert.DeserializeObject(obj.EventData).OldMaturityDate; + } + if (swap_Event.extenstionData.NewMaturityDate <= oldMaturityDate) + { + throw new ServiceException("新到期日应晚于原到期日!"); + } + if (obj != null && obj.ValueDate >= swap_Event.ValueDate) + { + throw new ServiceException("新展期日应晚于原展期日!"); + } + if (swap_Event.extenstionData.NewMaturityDate< trade.StartDate) + { + throw new ServiceException("新到期日不应早于交易开始日期!"); + } + swap_Event.extenstionData.OldMaturityDate = oldMaturityDate; string data = JsonConvert.SerializeObject(swap_Event.extenstionData); if (obj != null) { obj.Invalid = true; } swap_Event.EventReason = "交易展期"; - if (swap_Event.extenstionData.OldMaturityDate == swap_Event.extenstionData.NewMaturityDate) - { - throw new ServiceException("新到期日不应和原到期日一致!"); - } - if (obj!=null&&obj.ValueDate >= swap_Event.ValueDate) - { - throw new ServiceException("新展期日应晚于原展期日!"); - } - if (trade == null) - { - throw new ServiceException("未找到交易,请刷新页面后再次尝试!"); - } - if (swap_Event.extenstionData.NewMaturityDate< trade.StartDate) - { - throw new ServiceException("新到期日不应早于交易开始日期!"); - } trade.ExerciseDate = swap_Event.extenstionData.NewMaturityDate; //修改互换观察日到期日 new SwapTradeService(this).UpdateObservationDay(trade.id, swap_Event.extenstionData.OldMaturityDate, trade.ExerciseDate.Value); diff --git a/YLErpDAL/Modules/TradeModule/TradeBLL.cs b/YLErpDAL/Modules/TradeModule/TradeBLL.cs index 2efe1960..62e42170 100644 --- a/YLErpDAL/Modules/TradeModule/TradeBLL.cs +++ b/YLErpDAL/Modules/TradeModule/TradeBLL.cs @@ -276,10 +276,10 @@ namespace YLErp.BLL } var swapPositions = db.Set() .Where(sp => swapTradeIds.Contains(sp.SwapTradeId) && sp.IsInitial && sp.UnderlyingCode != null) - .Select(sp => new { sp.SwapTradeId, sp.PosiNetPrice, sp.UnderlyingCode }) + .Select(sp => new { sp.SwapTradeId, sp.PosiGrossPrice, sp.UnderlyingCode }) .ToList(); var logger = LogFactory.GetLogger(); - var posDict = swapPositions.GroupBy(sp => sp.SwapTradeId).ToDictionary(g => g.Key, g => g.First().PosiNetPrice); + var posDict = swapPositions.GroupBy(sp => sp.SwapTradeId).ToDictionary(g => g.Key, g => g.First().PosiGrossPrice); var umProvider = DataCacheProvider.GetUnderlyingDataSource(); // 需求②:平仓/行权/互换交易,审批角色应取 CloseProcess 流程的节点角色,而非 TradeProcess var closeProcessRoles = db.approvalprocess @@ -312,14 +312,14 @@ namespace YLErp.BLL tradeLinq.TradeSinglePrice = option.OpenCommission; } - // --- 新增逻辑:针对收益互换类型,用 swap_position.PosiNetPrice 覆盖展示用的期初标的价格 --- + // --- 针对收益互换类型,用 swap_position.PosiGrossPrice 覆盖展示用的期初标的价格 --- try { - if (tradeLinq.TradeType == "收益互换" && posDict.TryGetValue(tradeLinq.id, out var netPrice) && netPrice > 0) + if (tradeLinq.TradeType == "收益互换" && posDict.TryGetValue(tradeLinq.id, out var grossPrice) && grossPrice > 0) { - // 将期初价格覆盖为互换持仓的 PosiNetPrice(仅使用 PosiDirection != 0 的期初持仓) - logger.Info($"tradeOpeningProcessQuery.DAL override: tradeId={tradeLinq.id} beforeInitialSpot={tradeLinq.InitialSpotPrice} dbPosi={netPrice}"); - tradeLinq.InitialSpotPrice = Convert.ToDouble(netPrice); + // 将期初价格覆盖为互换持仓的 PosiGrossPrice(仅使用 PosiDirection != 0 的期初持仓) + logger.Info($"tradeOpeningProcessQuery.DAL override: tradeId={tradeLinq.id} beforeInitialSpot={tradeLinq.InitialSpotPrice} dbPosi={grossPrice}"); + tradeLinq.InitialSpotPrice = Convert.ToDouble(grossPrice); } } catch (Exception ex) diff --git a/YLErpWeb/App_Data/Config/swappriceprecision.js b/YLErpWeb/App_Data/Config/swappriceprecision.js index ca3cca8a..70ddad58 100644 --- a/YLErpWeb/App_Data/Config/swappriceprecision.js +++ b/YLErpWeb/App_Data/Config/swappriceprecision.js @@ -1,48 +1,56 @@ // 通过 /front/swappriceprecision 加载。可按 UnderlyingInstrumentType 修改;缺项或非法值由页面精度组件回退内置默认规则。 window.main = window.main || {}; window.main.swapPricePrecision = { - Stock: { integerDigits: 7, precision: 2 }, - StockIndex: { integerDigits: 7, precision: 2 }, - StockIF: { integerDigits: 7, precision: 4 }, - CommodityFutures: { integerDigits: 7, precision: 4 }, - CommoditySpot: { integerDigits: 7, precision: 4 }, - NewOtcStock: { integerDigits: 7, precision: 4 }, - HKStock: { integerDigits: 7, precision: 4 }, - HKStockIndex: { integerDigits: 7, precision: 4 }, - Fund: { integerDigits: 7, precision: 4 }, + common: { + amount: { precision: 2, grouping: true }, + quantity: { integerDigits: 16, precision: 2, grouping: true }, + rate: { precision: 4 } + }, + Stock: { integerDigits: 7, precision: 2, quantityPrecision: 2, quantityIntegerDigits: 12 }, + StockIndex: { integerDigits: 7, precision: 2, quantityPrecision: 2, quantityIntegerDigits: 12 }, + StockIF: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 }, + CommodityFutures: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 }, + CommoditySpot: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 }, + NewOtcStock: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 }, + HKStock: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 }, + HKStockIndex: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 }, + Fund: { integerDigits: 7, precision: 4, quantityPrecision: 4, quantityIntegerDigits: 12 }, Bond: { + quantityPrecision: 0, quantityIntegerDigits: 16, grossPrice: { integerDigits: 6, precision: 9 }, netPrice: { integerDigits: 6, precision: 9 }, yield: { integerDigits: 2, precision: 4 } }, TBonds: { + quantityPrecision: 0, quantityIntegerDigits: 16, grossPrice: { integerDigits: 6, precision: 9 }, netPrice: { integerDigits: 6, precision: 9 }, yield: { integerDigits: 2, precision: 4 } }, CreditBonds: { + quantityPrecision: 0, quantityIntegerDigits: 16, grossPrice: { integerDigits: 6, precision: 9 }, netPrice: { integerDigits: 6, precision: 9 }, yield: { integerDigits: 2, precision: 4 } }, OtherBonds: { + quantityPrecision: 0, quantityIntegerDigits: 16, grossPrice: { integerDigits: 6, precision: 9 }, netPrice: { integerDigits: 6, precision: 9 }, yield: { integerDigits: 2, precision: 4 } }, - TBFutures: { integerDigits: 8, precision: 4 }, - OtherFutures: { integerDigits: 8, precision: 4 }, - GoldSpot: { integerDigits: 8, precision: 4 }, - OtherSpot: { integerDigits: 8, precision: 4 }, - AbroadFutures: { integerDigits: 8, precision: 4 }, - AbroadSpot: { integerDigits: 8, precision: 4 }, - AbroadStock: { integerDigits: 8, precision: 2 }, - AbroadStockIndex: { integerDigits: 8, precision: 4 }, - ExRate: { integerDigits: 2, precision: 8 }, - Shibor: { integerDigits: 2, precision: 4 }, - FixingRepoRate: { integerDigits: 2, precision: 4 }, - RateYield: {integerDigits: 6, precision: 8}, - BondIndex: {integerDigits: 6, precision: 4}, - - // TODO: 利率收益率(6+8)、债券指数(6+4)、黄金期货(6+4)待对应的 UnderlyingInstrumentType 枚举确认后启用。 + TBFutures: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 }, + OtherFutures: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 }, + GoldFutures: { quantityIntegerDigits: 12 }, + GoldSpot: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 }, + OtherSpot: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 }, + AbroadFutures: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 }, + AbroadSpot: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 }, + AbroadStock: { integerDigits: 8, precision: 2, quantityPrecision: 2, quantityIntegerDigits: 12 }, + AbroadStockIndex: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 }, + ExRate: { integerDigits: 2, precision: 8, quantityPrecision: 8, quantityIntegerDigits: 16 }, + Shibor: { integerDigits: 2, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 }, + FixingRepoRate: { integerDigits: 2, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12 }, + RateYield: {integerDigits: 6, precision: 8, quantityPrecision: 2, quantityIntegerDigits: 12}, + BondIndex: {integerDigits: 6, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 12}, }; diff --git a/YLErpWeb/Controllers/BondPaymentController.cs b/YLErpWeb/Controllers/BondPaymentController.cs index fa4f2ef2..f7368838 100644 --- a/YLErpWeb/Controllers/BondPaymentController.cs +++ b/YLErpWeb/Controllers/BondPaymentController.cs @@ -79,6 +79,10 @@ namespace YLErp.Web.Controllers } /// /// 获取某债券期间付息,扣除当天已消费部分 + /// 【已废弃·不再用于平仓/互换预览】分红展示改由后端 SwapDealService.GetPreEodDividendSum + /// 读 EOD PosiDividendSum 提供(方案C,单一可信源)。前端 unwindSwapTrade.js / incomeSwapTrade.js + /// 的 getDivindIn 不再调用本接口。本接口仅保留供历史调用方,consumedDividend 查询无日期过滤的 + /// 隐患随废弃自然消解,不再单独修复。 /// /// /// diff --git a/YLErpWeb/Views/SwapTrade2/ExtensionTime.cshtml b/YLErpWeb/Views/SwapTrade2/ExtensionTime.cshtml index 0c7748b9..3b541234 100644 --- a/YLErpWeb/Views/SwapTrade2/ExtensionTime.cshtml +++ b/YLErpWeb/Views/SwapTrade2/ExtensionTime.cshtml @@ -55,7 +55,7 @@ {{dateFormat(extenstion.extenstionData.OldMaturityDate)}} - + {{extenstion.OptName}} {{dateFormat(extenstion.OptTime,'YYYY-MM-DD HH:mm:ss')}} diff --git a/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml b/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml index ea00430b..91d9d26f 100644 --- a/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml +++ b/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml @@ -42,15 +42,15 @@ 成交名义本金 - {{deal.NotionalValue}} + {{formatAmount(deal.NotionalValue)}} 持仓名义本金 - {{deal.PosiNotionalValue}} + {{formatAmount(deal.PosiNotionalValue)}} 成交数量 - {{deal.NotionalQty}} + {{formatQuantity(deal.NotionalQty)}} 持仓数量 - {{deal.PositionQty2}} + {{formatQuantity(deal.PositionQty2)}} 起始日期 @@ -66,7 +66,7 @@ 平仓总额 - {{deal.SwapCloseAmount}} + {{formatAmount(deal.SwapCloseAmount)}} @@ -98,7 +98,7 @@ - {{item.InterestClosePnL}} + {{formatAmount(item.InterestClosePnL)}} @@ -126,7 +126,7 @@ - {{item.InterestClosePnL}} + {{formatAmount(item.InterestClosePnL)}} @@ -177,13 +177,13 @@ - {{floatPosition.Quantity}} + {{formatQuantity(floatPosition.Quantity)}}
我方{{floatPosition.PayDirection==1?"支付":"收取"}}交易费用
- {{floatPosition.FloatPnlSum}} + {{formatAmount(floatPosition.FloatPnlSum)}} diff --git a/YLErpWeb/Views/SwapTrade2/SwapLongShortSwap.cshtml b/YLErpWeb/Views/SwapTrade2/SwapLongShortSwap.cshtml index f004920a..4df23cf7 100644 --- a/YLErpWeb/Views/SwapTrade2/SwapLongShortSwap.cshtml +++ b/YLErpWeb/Views/SwapTrade2/SwapLongShortSwap.cshtml @@ -19,6 +19,8 @@ + + }
@@ -26,11 +28,11 @@
- +
- +
@@ -46,7 +48,7 @@
- +
@@ -73,7 +75,7 @@ - {{item.InterestClosePnL}} + {{formatAmount(item.InterestClosePnL)}} @@ -101,7 +103,7 @@ - {{item.InterestClosePnL}} + {{formatAmount(item.InterestClosePnL)}} diff --git a/YLErpWeb/Views/SwapTrade2/SwapLongShortUnwind.cshtml b/YLErpWeb/Views/SwapTrade2/SwapLongShortUnwind.cshtml index 5257e478..bdbc1601 100644 --- a/YLErpWeb/Views/SwapTrade2/SwapLongShortUnwind.cshtml +++ b/YLErpWeb/Views/SwapTrade2/SwapLongShortUnwind.cshtml @@ -20,6 +20,8 @@ + + }
@@ -27,20 +29,20 @@
- +
- +
- +
- +
@@ -60,7 +62,7 @@
- +
@@ -89,7 +91,7 @@ - {{item.InterestClosePnL}} + {{formatAmount(item.InterestClosePnL)}} @@ -113,7 +115,7 @@ - {{item.InterestClosePnL}} + {{formatAmount(item.InterestClosePnL)}} diff --git a/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml b/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml index 7f9e2ea1..4d7c979a 100644 --- a/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml +++ b/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml @@ -33,20 +33,20 @@
- +
- +
- +
- +
@@ -64,7 +64,7 @@
- +
@@ -96,7 +96,7 @@
- +
@@ -125,7 +125,7 @@ - {{item.InterestClosePnL}} + {{formatAmount(item.InterestClosePnL)}} @@ -165,7 +165,7 @@ - {{item.InterestClosePnL}} + {{formatAmount(item.InterestClosePnL)}} @@ -214,7 +214,7 @@ - {{deal.CloseQty}} + {{formatQuantity(deal.CloseQty)}}
我方{{floatPosition.PayDirection==1?"支付":"收取"}}交易费用
@@ -222,8 +222,8 @@ - {{floatPosition.DividendIn}} - {{floatPosition.FloatPnlSum}} + {{formatAmount(floatPosition.DividendIn)}} + {{formatAmount(floatPosition.FloatPnlSum)}} diff --git a/YLErpWeb/Views/SwapTrade2/SwapflowList.cshtml b/YLErpWeb/Views/SwapTrade2/SwapflowList.cshtml index eb805b1c..863283eb 100644 --- a/YLErpWeb/Views/SwapTrade2/SwapflowList.cshtml +++ b/YLErpWeb/Views/SwapTrade2/SwapflowList.cshtml @@ -102,6 +102,8 @@ + + @@ -231,15 +233,15 @@
- +
- +
- +
diff --git a/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml b/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml index eead5b88..6561cbfe 100644 --- a/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml +++ b/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml @@ -493,7 +493,7 @@ - {{item.underlying!=null?item.underlying.QuoteUnitString:''}} + {{item.underlying!=null?item.underlying.QuoteUnitString:''}}