diff --git a/Framework/YLErp.Core/DBModels/ClientCashInCashOut.cs b/Framework/YLErp.Core/DBModels/ClientCashInCashOut.cs index 9122a710..f27ca12f 100644 --- a/Framework/YLErp.Core/DBModels/ClientCashInCashOut.cs +++ b/Framework/YLErp.Core/DBModels/ClientCashInCashOut.cs @@ -260,8 +260,9 @@ namespace YLErp.DBModels public const string 系统操作_应付预付金 = "系统操作-应付预付金"; public const string 系统操作_预付金返息 = "系统操作-预付金返息"; /// - /// 阶段四 §4.1 合约维度(MarginWatchRule==0)EOD 结算产生的追加保证金资金记录: - /// 交易级单条累计值(TradeId+Action+Deal=0 幂等 upsert),负数=客户应付追加;写入见 SwapAdditionalMarginService。 + /// 阶段四 §4.1 合约维度(MarginWatchRule==0)EOD 结算产生的追加保证金资金记录,负数=客户应付追加; + /// 腿化改造后由追保腿的现金部分簿记产生(Deal=现金腿 id,键 TradeId+Action+Deal 幂等 upsert; + /// EOD 重跑先删旧腿连带流水后重建,见 SwapAdditionalMarginService)。 /// public const string 系统操作_追加保证金 = "系统操作-追加保证金"; public const string 人工操作_其他 = "人工操作-其他"; diff --git a/Framework/YLErp.Core/Models/ClientSettleBalance.cs b/Framework/YLErp.Core/Models/ClientSettleBalance.cs index 087ea1c1..e9d9dc6d 100644 --- a/Framework/YLErp.Core/Models/ClientSettleBalance.cs +++ b/Framework/YLErp.Core/Models/ClientSettleBalance.cs @@ -1,4 +1,4 @@ -using Qdp.Foundation.Utilities; +using Qdp.Foundation.Utilities; using System.ComponentModel.DataAnnotations.Schema; using YLErp.DBModels; @@ -253,6 +253,12 @@ namespace YLErp.Models /// public double TotalCredit { get; set; } + /// + /// 原始授信额度(展示用:credit 表 Σ(OriginalCredit ?? Credit),未经 MaxCreditUseRatio 折算; + /// TotalCredit 仍是折算后值,供可用资金/追保公式使用) + /// + public double OriginalTotalCredit { get; set; } + /// /// 用户可用的名义本金规模 /// diff --git a/UnitTestProject/Modules/SwapModule/FundTagCalcTest.cs b/UnitTestProject/Modules/SwapModule/FundTagCalcTest.cs index 41df8245..460c1963 100644 --- a/UnitTestProject/Modules/SwapModule/FundTagCalcTest.cs +++ b/UnitTestProject/Modules/SwapModule/FundTagCalcTest.cs @@ -11,7 +11,7 @@ namespace YLErp.Modules.SwapModule [TestClass] public class FundTagCalcTest { - private static LegAmount Leg(long id, double amount, bool preferCredit) + private static LegAmount Leg(long id, decimal amount, bool preferCredit) => new() { Leg = new swap_position { id = id }, Amount = amount, PreferCredit = preferCredit }; // ================================================================ @@ -273,7 +273,7 @@ namespace YLErp.Modules.SwapModule InterestPrincipalFix = fix, FundTag = preferCredit ? ConsFundTag.Credit : ConsFundTag.Cash }, - Amount = (double)fix, + Amount = fix, PreferCredit = preferCredit }; diff --git a/UnitTestProject/Modules/SwapModule/Margin/SwapAdditionalMarginLegTest.cs b/UnitTestProject/Modules/SwapModule/Margin/SwapAdditionalMarginLegTest.cs new file mode 100644 index 00000000..0c091bd1 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/Margin/SwapAdditionalMarginLegTest.cs @@ -0,0 +1,225 @@ +using YLErp.DBModels; +using YLErp.Modules.SwapModule.Margin; + +namespace YLErp.Modules.SwapModule +{ + /// + /// EOD 追保腿化改造测试(纯函数层:腿构造、资金来源分配、幂等识别、跨日水位)。 + /// 服务层(SettleAdditionalMargin 的 DB 写入编排)依赖真实库,无内存测试基建—— + /// 此处按服务内实际调用顺序组合 SwapAdditionalMarginCalc / ConsFundTag / FundTagCalc 纯函数验证等价语义: + /// 增量>0 → BuildEodMarginLeg 建腿 → PreferCredit(腿标签, 交易级资金来源) 回退 → AllocateByLegPreference 分配 + /// (= ApplyMarginFundTags 内部分配),拆单守恒走 ApplySaveTimeSplit(与 SplitLeg 同口径倒挤)。 + /// + [TestClass] + public class SwapAdditionalMarginLegTest + { + private static readonly DateTime SettleDate = new(2026, 8, 27); + + private static trade NewTrade(string fundSource = ConsFundTag.Credit) + => new() + { + id = 2543, + TradeNumber = "TRS2026-2543", + StartDate = new DateTime(2026, 8, 20), + ExerciseDate = new DateTime(2027, 8, 20), + SettlementCurrency = "CNY", + MarginFundSource = fundSource + }; + + /// + /// 目标>累计 → 生成 1 条 mode6 追保腿:与手工追加预付金同形态 + /// (资金腿、IsInitial、收取方向、fix=增量两位 AwayFromZero 舍入、HappenDate=结算日、FundTag=NULL 回退、OptName 打标)。 + /// + [TestMethod] + public void AM_LEG_001_目标大于累计_生成追保腿() + { + var td = NewTrade(); + var target = SwapAdditionalMarginCalc.CalcTarget(2_808_000, 2_000_000); + Assert.AreEqual(808_000, target, 1e-6); + + var leg = SwapAdditionalMarginService.BuildEodMarginLeg(td, target, SettleDate, optId: 0); + + Assert.AreEqual(2543, leg.SwapTradeId); + Assert.AreEqual(0, leg.PositionType); + Assert.AreEqual(0, leg.PosiDirection); + Assert.IsTrue(leg.IsInitial); + Assert.AreEqual((int)InterestModeEnum.追加预付金, leg.InterestMode); + Assert.AreEqual((int)SwapDirectionEnum.收取, leg.InterestDirection); + Assert.AreEqual(808_000m, leg.InterestPrincipalFix); + Assert.AreEqual(SettleDate, leg.HappenDate); + Assert.IsNull(leg.FundTag); + Assert.AreEqual(SwapAdditionalMarginService.EodOptName, leg.OptName); + Assert.AreEqual("EOD追保", SwapAdditionalMarginService.EodOptName); + + //两位 AwayFromZero 舍入 + var rounded = SwapAdditionalMarginService.BuildEodMarginLeg(td, 1000.005, SettleDate, optId: 0); + Assert.AreEqual(1000.01m, rounded.InterestPrincipalFix); + + //币种:取交易结算币种;交易未设置时回退系统默认 CNY(与存量手工腿口径一致,2026-08-27 修复空币种) + Assert.AreEqual("CNY", leg.Currency); + td.SettlementCurrency = null; + Assert.AreEqual(ConsGlobal.Currency.CNY, + SwapAdditionalMarginService.BuildEodMarginLeg(td, 100, SettleDate, optId: 0).Currency); + } + + /// + /// 授信充足(腿未选 → 回退交易级 Credit):整腿定稿授信占用,现金部分 0(不产生现金流水)。 + /// 占用 remark 必须带"追加保证金"前缀(累计口径与清理链路的硬性识别点)。 + /// + [TestMethod] + public void AM_LEG_002_授信充足_全额授信零现金流水() + { + var td = NewTrade(ConsFundTag.Credit); + var leg = SwapAdditionalMarginService.BuildEodMarginLeg(td, 1000, SettleDate, optId: 0); + Assert.IsTrue(ConsFundTag.PreferCredit(leg.FundTag, td.MarginFundSource)); + + var plans = FundTagCalc.AllocateByLegPreference( + new List { new() { Leg = leg, Amount = 1000, PreferCredit = true } }, + creditAvailable: 5_000, ignoreMoneyCheck: false); + + Assert.AreEqual(1000, plans[0].CreditAmount); + Assert.AreEqual(0, plans[0].CashAmount); + Assert.IsFalse(plans[0].NeedSplit); + //CashAmount==0 → ApplyMarginFundTags 不写现金流水;占用 remark 前缀断言 + Assert.IsTrue((ClientCreditInoutService.AdditionalMarginRemark + "占用") + .StartsWith(ClientCreditInoutService.AdditionalMarginRemark)); + Assert.IsTrue((ClientCreditInoutService.AdditionalMarginRemark + "拆单授信部分") + .StartsWith(ClientCreditInoutService.AdditionalMarginRemark)); + } + + /// + /// 额度不足 → 跨界拆单:原腿保留授信部分标 Credit,克隆现金差额腿标 Cash, + /// 两腿 fix 倒挤守恒(现金流水绑现金腿 id,Deal=现金腿id 由 ApplyMarginFundTags 保证)。 + /// + [TestMethod] + public void AM_LEG_003_额度不足_拆腿加现金流水() + { + var td = NewTrade(ConsFundTag.Credit); + var leg = SwapAdditionalMarginService.BuildEodMarginLeg(td, 1000, SettleDate, optId: 0); + + var legs = new List { new() { Leg = leg, Amount = 1000, PreferCredit = true } }; + var plans = FundTagCalc.AllocateByLegPreference(legs, creditAvailable: 300, ignoreMoneyCheck: false); + Assert.IsTrue(plans[0].NeedSplit); + Assert.AreEqual(300, plans[0].CreditAmount); + Assert.AreEqual(700, plans[0].CashAmount); + + //ApplySaveTimeSplit 与 SplitLeg 同口径(原腿=授信部分、克隆现金腿倒挤守恒) + var newLegs = FundTagCalc.ApplySaveTimeSplit(legs, plans); + Assert.AreEqual(1, newLegs.Count); + Assert.AreEqual(300m, leg.InterestPrincipalFix); + Assert.AreEqual(ConsFundTag.Credit, leg.FundTag); + var cashLeg = newLegs[0]; + Assert.AreEqual(700m, cashLeg.InterestPrincipalFix); + Assert.AreEqual(ConsFundTag.Cash, cashLeg.FundTag); + Assert.AreEqual(1000m, leg.InterestPrincipalFix + cashLeg.InterestPrincipalFix); + //拆单两腿合计=增量,且现金腿为独立期初腿(流水 Deal 绑它) + Assert.AreEqual(0, cashLeg.id); + Assert.AreEqual(0, cashLeg.PositionId); + Assert.IsTrue(cashLeg.IsInitial); + } + + /// + /// margin_fund_source=Cash:腿未选回退交易级现金 → 整腿定稿现金、全额现金流水、零授信占用。 + /// + [TestMethod] + public void AM_LEG_004_交易级现金来源_全额现金零占用() + { + var td = NewTrade(ConsFundTag.Cash); + var leg = SwapAdditionalMarginService.BuildEodMarginLeg(td, 1000, SettleDate, optId: 0); + Assert.IsFalse(ConsFundTag.PreferCredit(leg.FundTag, td.MarginFundSource)); + + var plans = FundTagCalc.AllocateByLegPreference( + new List { new() { Leg = leg, Amount = 1000, PreferCredit = false } }, + creditAvailable: 5_000, ignoreMoneyCheck: false); + + Assert.AreEqual(0, plans[0].CreditAmount); + Assert.AreEqual(1000, plans[0].CashAmount); + Assert.IsFalse(plans[0].NeedSplit); + } + + /// + /// 增量≤0(追保回落/已补足)→ 不产生新腿:目标<已补足 与 目标=0 两种情形均跳过。 + /// + [TestMethod] + public void AM_LEG_005_增量非正_不产生新腿() + { + //已补足:目标 50,已补足 80 → 增量 -30 → 跳过 + var target = SwapAdditionalMarginCalc.CalcTarget(150, 100); + var increment = Math.Round(target - 80, 2, MidpointRounding.AwayFromZero); + Assert.IsTrue(increment <= 0); + + //追保回落到应付之下:目标 0 → 增量 ≤0 → 跳过(超付不返还,负缺口走可用资金公式) + var fallen = SwapAdditionalMarginCalc.CalcTarget(50, 100); + Assert.AreEqual(0, fallen, 1e-6); + Assert.IsTrue(Math.Round(fallen - 80, 2, MidpointRounding.AwayFromZero) <= 0); + } + + /// + /// 幂等:EOD 腿识别(OptName 打标 + mode6 + 重跑窗口)——手工腿(OptName=操作员)与 + /// 窗口外更早历史腿不受影响;重跑时旧腿金额已计入已补足 → 增量 0 不翻倍。 + /// + [TestMethod] + public void AM_LEG_006_幂等识别与重跑不翻倍() + { + var td = NewTrade(); + var eodLeg = SwapAdditionalMarginService.BuildEodMarginLeg(td, 80, SettleDate, optId: 0); + var manualLeg = new swap_position + { + InterestMode = (int)InterestModeEnum.追加预付金, + OptName = "张三", + HappenDate = SettleDate + }; + var initLeg = new swap_position + { + InterestMode = (int)InterestModeEnum.初始预付金, + OptName = SwapAdditionalMarginService.EodOptName, + HappenDate = SettleDate + }; + var earlierEodLeg = SwapAdditionalMarginService.BuildEodMarginLeg(td, 50, SettleDate.AddDays(-2), optId: 0); + + Assert.IsTrue(SwapAdditionalMarginService.IsEodMarginLeg(eodLeg, SettleDate)); + Assert.IsFalse(SwapAdditionalMarginService.IsEodMarginLeg(manualLeg, SettleDate)); + Assert.IsFalse(SwapAdditionalMarginService.IsEodMarginLeg(initLeg, SettleDate)); + //重跑窗口起点防误删更早历史日已归属腿 + Assert.IsFalse(SwapAdditionalMarginService.IsEodMarginLeg(earlierEodLeg, SettleDate)); + Assert.IsTrue(SwapAdditionalMarginService.IsEodMarginLeg(earlierEodLeg, SettleDate.AddDays(-2))); + + //重跑:旧腿簿记已计入已补足(现金 30 + 授信 50 = 80)→ 目标不变、增量 0 + var target = SwapAdditionalMarginCalc.CalcTarget(180, 100); + var rerunIncrement = Math.Round(target - 30 - 50, 2, MidpointRounding.AwayFromZero); + Assert.AreEqual(0, rerunIncrement, 1e-6); + } + + /// + /// 跨日序列(映射交易2538实例):首日补足水位后,维保回落不返还、回升只补差额,累计已补足不越目标水位。 + /// + [TestMethod] + public void AM_LEG_007_跨日序列_不越水位() + { + const double payableNet = 2_000_000; //初始预付金全走授信(占用净额) + var fundedLegs = new List(); + + //D1 维持 2,808,000 → 目标 808,000 → 腿1 + var day1Target = SwapAdditionalMarginCalc.CalcTarget(2_808_000, payableNet); + var day1Increment = Math.Round(day1Target - fundedLegs.Sum(), 2, MidpointRounding.AwayFromZero); + Assert.AreEqual(808_000, day1Increment, 1e-6); + fundedLegs.Add(day1Increment); + + //D2 维保回落至 2,500,000 → 目标 500,000 < 已补足 → 无新腿(不返还) + var day2Target = SwapAdditionalMarginCalc.CalcTarget(2_500_000, payableNet); + var day2Increment = Math.Round(day2Target - fundedLegs.Sum(), 2, MidpointRounding.AwayFromZero); + Assert.IsTrue(day2Increment <= 0); + + //D3 维保回升至 3,000,000 → 目标 1,000,000 − 已补足 808,000 = 增量 192,000 → 腿2 + var day3Target = SwapAdditionalMarginCalc.CalcTarget(3_000_000, payableNet); + var day3Increment = Math.Round(day3Target - fundedLegs.Sum(), 2, MidpointRounding.AwayFromZero); + Assert.AreEqual(192_000, day3Increment, 1e-6); + fundedLegs.Add(day3Increment); + + //D3 重跑:已补足=目标 → 增量 0,总量恒等不越水位 + var day3Rerun = Math.Round(day3Target - fundedLegs.Sum(), 2, MidpointRounding.AwayFromZero); + Assert.AreEqual(0, day3Rerun, 1e-6); + Assert.AreEqual(day3Target, fundedLegs.Sum(), 1e-6); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/SwapSpanBalanceCalcTest.cs b/UnitTestProject/Modules/SwapModule/SwapSpanBalanceCalcTest.cs index 06535673..fda8849a 100644 --- a/UnitTestProject/Modules/SwapModule/SwapSpanBalanceCalcTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapSpanBalanceCalcTest.cs @@ -110,7 +110,7 @@ namespace YLErp.Modules.SwapModule } /// - /// 合约维度追保金额(需求原文 现金+授信−已使用 的应追加方向取值):账户透支为正=应补足,盈余为负。 + /// 合约维度追保金额(Max((现金+授信−已使用)×−1, 0)):账户透支为正=应补足,盈余截断为 0。 /// [TestMethod] public void SB_013_合约维度追保金额_透支为正() @@ -121,10 +121,10 @@ namespace YLErp.Modules.SwapModule } [TestMethod] - public void SB_014_合约维度追保金额_盈余为负() + public void SB_014_合约维度追保金额_盈余截断为0() { - //现金50 + 授信100 − 已使用20 = 130 → 追保 = −130(盈余可返还方向) - Assert.AreEqual(-130, SwapSpanBalanceCalc.CalcContractDimensionCallMargin( + //现金50 + 授信100 − 已使用20 = 130 → 盈余,Max(...,0) 截断 → 追保 = 0 + Assert.AreEqual(0, SwapSpanBalanceCalc.CalcContractDimensionCallMargin( cashBalance: 50, totalCredit: 100, usedCredit: 20), 1e-6); } } diff --git a/UnitTestProject/Modules/SwapModule/SwapSpanPriceSourceTest.cs b/UnitTestProject/Modules/SwapModule/SwapSpanPriceSourceTest.cs index 17cd942e..94faf26b 100644 --- a/UnitTestProject/Modules/SwapModule/SwapSpanPriceSourceTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapSpanPriceSourceTest.cs @@ -1,5 +1,6 @@ using YLErp.DBModels; using YLErp.Modules.DataProviderModule; +using YLErp.QdpModule; namespace YLErp.Modules.SwapModule { @@ -40,17 +41,22 @@ namespace YLErp.Modules.SwapModule public void SP_002_ETF收盘价源_可取() { using var db = DbContextFactory.GetYLDbContext(); - var latest = db.eod_stock_price - .Where(x => x.ClosePrice > 0) - .OrderByDescending(x => x.ValueDate) - .Select(x => new { x.ValueDate, x.UnderlyingCode }) - .FirstOrDefault(); + //join underlying_manager:取数链路 InnerGetEodPrice 从标的表出发 join 价格表, + //价格表里未登记为标的的代码(同步进来的非管理标的)取数必返回 null,冒烟样本须限定在已登记标的上 + var latest = (from ep in db.eod_stock_price + where ep.ClosePrice > 0 + join um in db.underlying_manager on ep.UnderlyingCode equals um.UnderlyingCode + orderby ep.ValueDate descending + select new { ep.ValueDate, ep.UnderlyingCode }).FirstOrDefault(); if (latest == null) { Assert.Inconclusive("dev 库无股票/ETF日终价格数据,跳过"); } - Assert.IsTrue(EodPriceQueryService.TryGetEodPrice(latest.ValueDate, latest.UnderlyingCode, out var price)); + //价格表可能混有非交易日/未来日期的脏行(如 2026-08-30 周日),而取数链路会按交易日历调整日期 + //(非国君环境向后滚到下一交易日,脏行日期之后无数据 → 取不到);查询日期回退到最近交易日再验 + var queryDate = QdpCalendarHelper.GetNonHolidayDefore(latest.ValueDate.Date); + Assert.IsTrue(EodPriceQueryService.TryGetEodPrice(queryDate, latest.UnderlyingCode, out var price)); Assert.IsTrue(price.GetPrice(SettlementTypeEnum.ClosePrice) > 0); } } diff --git a/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs b/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs index c028b515..3fbd301d 100644 --- a/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs +++ b/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs @@ -141,10 +141,10 @@ namespace YLErp.Modules.SwapModule return ReleaseMarginByFundTagResult ?? new UnwindTagSplit { - CashMargin = Convert.ToDouble(marginAmount), - CashRebate = Convert.ToDouble(marginLegs.Any() + CashMargin = marginAmount, + CashRebate = marginLegs.Any() ? marginLegs.Sum(x => x.InterestClosePnL) - : marginRebate) + : marginRebate }; } diff --git a/YLErpDAL/BLL/EodSettlement/ClientBalanceUtility.cs b/YLErpDAL/BLL/EodSettlement/ClientBalanceUtility.cs index a4cc847b..a65ba73f 100644 --- a/YLErpDAL/BLL/EodSettlement/ClientBalanceUtility.cs +++ b/YLErpDAL/BLL/EodSettlement/ClientBalanceUtility.cs @@ -1,4 +1,4 @@ -using BaseOUDAL; +using BaseOUDAL; using DocumentFormat.OpenXml.Bibliography; using DocumentFormat.OpenXml.Spreadsheet; using MathNet.Numerics; @@ -303,6 +303,16 @@ namespace YLErp.BLL.EodSettlement var usedCreditDic = ClientCreditInoutService.GetUsedCreditByClients(clientIdS, db); var swapInitMarginDic = SwapSpanBalanceQueryService.GetSwapInitMarginByClients(clientIdS, lastDate, db); var swapAdditionalDic = SwapSpanBalanceQueryService.GetTradeAdditionalMarginByClients(clientIdS, lastDate, db); + //原始授信额度(展示用):与 EOD 写入 clientbalancedaily.Credit 同批过滤条件(EodClientBalanceCalc :68), + //取 Σ(OriginalCredit ?? Credit) 不经比例折算;TotalCredit 仍为折算后值供公式使用 + var originalCreditDic = db.credit.AsNoTracking() + .Where(t => t.ClientId != null && clientIdS.Contains(t.ClientId.Value) + && t.ProcessStatus == "已审批" + && (!t.CreditDeadLine.HasValue || t.CreditDeadLine >= lastDate) + && (!t.CreditStartDate.HasValue || t.CreditStartDate <= lastDate)) + .GroupBy(t => t.ClientId.Value) + .Select(g => new { ClientId = g.Key, Sum = g.Sum(t => (t.OriginalCredit ?? t.Credit) ?? 0d) }) + .ToDictionary(x => x.ClientId, x => x.Sum); foreach (var data in endDatas) { @@ -319,6 +329,7 @@ namespace YLErp.BLL.EodSettlement balance.TotalCredit = data.TotalCredit; + balance.OriginalTotalCredit = originalCreditDic.TryGetValue(data.ClientId, out var originalCredit) ? originalCredit : 0; balance.PayableMargin = data.PayableMargin; balance.GuaranteesTotalAmount = data.GuaranteesTotalAmount; balance.FrozenMarginMoney = data.FrozenMarginMoney; @@ -392,7 +403,7 @@ namespace YLErp.BLL.EodSettlement } // 是否追保/追保金额(阶段四 §4.2 按维度分流,允许负值=双向,不以 0 截断): // 客户维度(==1)= (维持−初始) − (现金+授信−已使用),负=可返还; - // 合约维度(==0)= −(现金+授信−已使用)(需求原文公式的应追加方向取值,开放问题2,两值均有产出); + // 合约维度(==0)= Max(−(现金+授信−已使用), 0)(需求原文公式,盈余截断为 0); // 未配置(NULL 存量)维持旧口径:盯市低于维持时 = 初始保证金金额−盯市金额,否则 0 if (client?.MarginWatchRule == 1) { @@ -671,6 +682,7 @@ namespace YLErp.BLL.EodSettlement DicTotal.AvailableAmount += dc.Value.AvailableAmount; DicTotal.TotalMarginTotal += dc.Value.TotalMargin; DicTotal.TotalCredit += dc.Value.TotalCredit; + DicTotal.OriginalTotalCredit += dc.Value.OriginalTotalCredit; DicTotal.WinLoss += dc.Value.WinLoss; DicTotal.TdWinLoss += dc.Value.TdWinLoss; DicTotal.PositionPremiumNetCash += dc.Value.PositionPremiumNetCash; diff --git a/YLErpDAL/BLL/EodSettlement/RealTimeClientBanlanceService.cs b/YLErpDAL/BLL/EodSettlement/RealTimeClientBanlanceService.cs index 804d3c98..f1740039 100644 --- a/YLErpDAL/BLL/EodSettlement/RealTimeClientBanlanceService.cs +++ b/YLErpDAL/BLL/EodSettlement/RealTimeClientBanlanceService.cs @@ -1,4 +1,4 @@ -using BaseOUDAL; +using BaseOUDAL; using Confluent.Kafka; using DocumentFormat.OpenXml.Drawing.Charts; using DocumentFormat.OpenXml.Office2010.Excel; @@ -300,9 +300,9 @@ namespace YLErp.BLL.Eod { item.AvailableAmount = item.MarginBalance - item.FrozenMarginMoney; } - // 是否追保/追保金额(阶段四 §4.2 按维度分流,与 ClientBalanceUtility 报告口径一致): + // 是否追保/追保金额(按维度分流,与 ClientBalanceUtility 报告口径一致): // 客户维度(==1)双向追保 = (维持−初始) − (现金+授信−已使用),允许负值(负=可返还); - // 合约维度(==0)= −(现金+授信−已使用);NULL 存量维持旧口径 + // 合约维度(==0)= Max(−(现金+授信−已使用), 0),盈余截断为 0;NULL 存量维持旧口径 if (ruleClient?.MarginWatchRule == 1) { item.MarginByPayableMarginTotal = SwapSpanBalanceCalc.CalcClientDimensionCallMargin( diff --git a/YLErpDAL/Modules/SwapModule/ClientCreditInoutService.cs b/YLErpDAL/Modules/SwapModule/ClientCreditInoutService.cs index 4af6dfa0..74c38c50 100644 --- a/YLErpDAL/Modules/SwapModule/ClientCreditInoutService.cs +++ b/YLErpDAL/Modules/SwapModule/ClientCreditInoutService.cs @@ -1,5 +1,6 @@ using YLErp.BLL; using YLErp.DBModels; +using YLErp.Modules.SwapModule.Margin; namespace YLErp.Modules.SwapModule { @@ -114,6 +115,8 @@ namespace YLErp.Modules.SwapModule /// 重新确认/重补时按最新标签重写,避免占用悬挂。 /// keepAdditionalMargin=true 时保留追加保证金部分(阶段四落地):重确认自愈/修改清除只重写 应付预付金 相关占用, /// 追加部分由 EOD 幂等维护(该两处不删除追加资金记录,授信占用须同步保留,否则已使用授信被低估); + /// 保护条件 = remark 前缀识别 或 绑定的 position_id 属于本交易现存 EOD 追保腿集合(腿化改造后 EOD 占用挂腿, + /// 删除范围跟腿走:腿还在则占用保留,腿消失由 EOD 幂等清理连带删除); /// 删除交易(资金记录全删)传 false 全清。 /// public void RemoveByTrade(int tradeId, bool keepAdditionalMargin = false) @@ -121,7 +124,15 @@ namespace YLErp.Modules.SwapModule var records = DbContext.client_credit_inout.Where(x => x.trade_id == tradeId).ToList(); if (keepAdditionalMargin) { - records = records.Where(x => !IsAdditionalMarginRecord(x)).ToList(); + var eodLegIds = DbContext.swap_position + .Where(x => x.SwapTradeId == tradeId && x.IsInitial + && x.InterestMode == (int)InterestModeEnum.追加预付金 + && x.OptName == SwapAdditionalMarginService.EodOptName) + .Select(x => x.id) + .ToList(); + records = records.Where(x => !IsAdditionalMarginRecord(x) + && !(x.position_id != null && eodLegIds.Contains(x.position_id.Value))) + .ToList(); } DbContext.client_credit_inout.RemoveRange(records); } diff --git a/YLErpDAL/Modules/SwapModule/Margin/FundTagCalc.cs b/YLErpDAL/Modules/SwapModule/Margin/FundTagCalc.cs index 61f1c6d0..58af47d9 100644 --- a/YLErpDAL/Modules/SwapModule/Margin/FundTagCalc.cs +++ b/YLErpDAL/Modules/SwapModule/Margin/FundTagCalc.cs @@ -16,14 +16,14 @@ public static class FundTagCalc /// 客户净收取(负金额)的腿不参与授信分配。 /// legs 需按预期占用顺序传入(HappenDate、id)。 /// - public static List AllocateByLegPreference(List legs, double creditAvailable, bool ignoreMoneyCheck) + public static List AllocateByLegPreference(List legs, decimal creditAvailable, bool ignoreMoneyCheck) { var plans = new List(); - var remaining = Math.Round(Math.Max(creditAvailable, 0), 2, MidpointRounding.AwayFromZero); + var remaining = Math.Round(Math.Max(creditAvailable, 0m), 2, MidpointRounding.AwayFromZero); foreach (var leg in legs) { var amount = Math.Round(leg.Amount, 2, MidpointRounding.AwayFromZero); - var credit = 0.0; + var credit = 0m; if (!ignoreMoneyCheck && leg.PreferCredit && amount > 0) { credit = Math.Min(amount, remaining); @@ -64,7 +64,7 @@ public static class FundTagCalc //应付额 = fix × (dir==1 ? 1 : -1),反推 fix 用同一比例(±1 自反) var payableRatio = position.InterestDirection == 1 ? 1 : -1; var originalFix = position.InterestPrincipalFix; - position.InterestPrincipalFix = Math.Round(Convert.ToDecimal(plan.CreditAmount) * payableRatio, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + position.InterestPrincipalFix = Math.Round(plan.CreditAmount * payableRatio, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); position.FundTag = ConsFundTag.Credit; var cashLeg = position.Clone(); cashLeg.id = 0; @@ -93,8 +93,8 @@ public static class FundTagCalc var result = new UnwindTagSplit(); foreach (var s in settlements) { - var margin = Convert.ToDouble(s.MarginAmount); - var rebate = Convert.ToDouble(s.RebateAmount); + var margin = s.MarginAmount; + var rebate = s.RebateAmount; if (s.Tag == ConsFundTag.Credit) { result.CreditMargin += margin; @@ -128,7 +128,7 @@ public static class FundTagCalc public class LegAmount { public swap_position Leg { get; set; } - public double Amount { get; set; } + public decimal Amount { get; set; } /// 腿上是否选了授信(swap_position.fund_tag=='Credit') public bool PreferCredit { get; set; } } @@ -140,11 +140,11 @@ public class LegFundPlan { public swap_position Leg { get; set; } /// 腿原簿记金额 - public double Amount { get; set; } + public decimal Amount { get; set; } /// 授信部分金额 - public double CreditAmount { get; set; } + public decimal CreditAmount { get; set; } /// 现金部分金额 - public double CashAmount { get; set; } + public decimal CashAmount { get; set; } /// 拆单时新拆出的现金腿(授信不足的差额;占用记录绑原腿、现金流水绑它) public swap_position CashLeg { get; set; } public bool NeedSplit => CreditAmount > 0 && CashAmount > 0; @@ -198,7 +198,7 @@ public static class MarginSettlementBuilder public class TagRelease { public long PositionId { get; set; } - public double Amount { get; set; } + public decimal Amount { get; set; } } /// @@ -207,13 +207,13 @@ public class TagRelease public class UnwindTagSplit { /// 现金部分返还本金(产生 应付预付金 资金流水) - public double CashMargin { get; set; } + public decimal CashMargin { get; set; } /// 授信部分返还本金(写授信出入表"释放",不产生资金流水) - public double CreditMargin { get; set; } + public decimal CreditMargin { get; set; } /// 现金部分预付金返息(产生 预付金返息 资金流水) - public double CashRebate { get; set; } + public decimal CashRebate { get; set; } /// 授信部分预付金返息(授信不进资金,不产生资金流水) - public double CreditRebate { get; set; } + public decimal CreditRebate { get; set; } /// 按腿的授信释放明细(position_id 匹配原占用记录) public List Releases { get; set; } = new List(); } diff --git a/YLErpDAL/Modules/SwapModule/Margin/SwapAdditionalMarginService.cs b/YLErpDAL/Modules/SwapModule/Margin/SwapAdditionalMarginService.cs index f6540009..02b31082 100644 --- a/YLErpDAL/Modules/SwapModule/Margin/SwapAdditionalMarginService.cs +++ b/YLErpDAL/Modules/SwapModule/Margin/SwapAdditionalMarginService.cs @@ -7,17 +7,29 @@ using YLErp.Modules.TradeModule; namespace YLErp.Modules.SwapModule.Margin { /// - /// R3 阶段四 §4.1:合约维度(MarginWatchRule==0)规则15 交易日终结算产生"追加保证金"资金记录。 + /// R3 阶段四 §4.1:合约维度(MarginWatchRule==0)规则15 交易日终结算产生"追加保证金"。 /// 交易维度追加保证金 = 维持保证金(阶段三引擎 trade_span 产出)− 已缴保证金净额 - /// (应付预付金现金净收额 + 初始授信占用净额 + 追加保证金现金累计 + 追加授信占用累计—— + /// (应付预付金现金净收额 + 初始授信占用净额—— /// 2026-08-27 修正:授信垫付的初始预付金不产生应付预付金流水,此前未计入已缴导致每个结算日按维持全额重复开追加); - /// 现金部分为逐结算日增量记录(BUG-03 修正:每结算日一条、Money=−increment,键 TradeId+Action+Deal+HappenDate 幂等), - /// 需求上升只增不减;授信优先(阶段二规则):授信部分只写授信出入表(remark 前缀=追加保证金,position_id 空、冗余 trade_id)。 - /// 由 EOD 在客户资金计算之前调用:当日新记录计入当日出入金窗口并翻"已结算",重跑时 目标/已补足 不变 → 新增为 0 不重复写。 + /// 需求上升只增不减、回落不返还。 + /// 腿化改造(2026-08-27):增量>0 时生成一条与手工追加预付金同形态的交易腿(InterestMode=追加预付金、 + /// OptName="EOD追保" 打标),并复用手工确认链路的 SwapFundTagService.ApplyMarginFundTags 完成授信/现金簿记 + /// (资金来源走腿 fund_tag→交易级 margin_fund_source 回退链;授信部分写授信出入表占用绑腿、不产生流水; + /// 现金部分出 Action=系统操作-追加保证金 流水、Deal=现金腿 id——Action 与手工腿簿记的"应付预付金"区隔, + /// 保住 EQD-6952 资金通知书聚合口径);平仓返还(ReleaseMarginByTag)与交易回退清理由既有链路自动获得。 + /// 幂等:重跑先清(本结算日起 EOD 旧追保腿及其簿记)后建;eod_swap_position 当日行显式补写(方案A—— + /// 腿生成晚于当日 SwapPositionCompose,快照须补齐,见 SwapEodPositionService.SaveEodAdditionalMarginPosition)。 + /// 由 EOD 在客户资金计算之前调用:当日新记录计入当日出入金窗口并翻"已结算"。 /// 客户维度(MarginWatchRule=1/NULL)不产生资金记录(§0 占用口径),不在本服务范围。 /// public class SwapAdditionalMarginService : YLBaseService { + /// + /// EOD 追保腿打标(OptName):与手工追加预付金腿(OptName=操作员实名)区分, + /// 幂等清理、RemoveByTrade 保护与时间轴回退清理均以此识别。拆单现金腿落库时被打服务身份,簿记后回打本标识。 + /// + public const string EodOptName = "EOD追保"; + public SwapAdditionalMarginService(OptUserInfo userInfo) : base(userInfo) { } @@ -26,6 +38,45 @@ namespace YLErp.Modules.SwapModule.Margin { } + /// + /// EOD 追保腿识别(InterestMode=追加预付金 + OptName 打标); + /// happenDateFrom 限定重跑/回退窗口起点,防误删更早历史日已归属腿。 + /// + public static bool IsEodMarginLeg(swap_position leg, DateTime? happenDateFrom = null) + { + return leg != null + && leg.InterestMode == (int)InterestModeEnum.追加预付金 + && leg.OptName == EodOptName + && (happenDateFrom == null || leg.HappenDate >= happenDateFrom); + } + + /// + /// 构造 EOD 追保腿(纯函数,便于单测;落库与 PosiNumber 回填由调用方完成): + /// 与手工追加预付金腿同形态(PositionType=0 资金腿、PosiDirection=0、IsInitial、InterestMode=追加预付金、 + /// 收取方向、fix=增量两位舍入),FundTag=null 回退交易级资金来源,OptName 打标 EOD 追保。 + /// + public static swap_position BuildEodMarginLeg(trade td, double increment, DateTime settleDate, int optId) + { + return new swap_position + { + SwapTradeId = td.id, + PositionType = 0, + PosiDirection = 0, + IsInitial = true, + InterestMode = (int)InterestModeEnum.追加预付金, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestPrincipalFix = Math.Round(Convert.ToDecimal(increment), 2, MidpointRounding.AwayFromZero), + HappenDate = settleDate, + PosiStartDate = td.StartDate ?? settleDate, + PosiMatuirityDate = td.ExerciseDate, + Currency = td.SettlementCurrency ?? ConsGlobal.Currency.CNY, + FundTag = null, + OptId = optId, + OptName = EodOptName, + OptTime = DateTime.Now + }; + } + /// /// 结算日逐客户逐交易产生追加保证金(clientFilter 为部分结算的客户过滤,与 EOD 请求一致)。 /// @@ -89,13 +140,13 @@ namespace YLErp.Modules.SwapModule.Margin .Select(g => new { TradeId = g.Key ?? 0, Sum = -g.Sum(x => x.Money ?? 0d) }) .ToDictionary(x => x.TradeId, x => x.Sum); - //追加保证金资金记录累计值(逐日增量记录求和即累计,BUG-03;Deal=0 交易级,口径与 EOD canonical 一致:非作废+已确认/已结算) + //追加保证金资金记录累计值(逐日增量记录求和即累计,BUG-03;口径与 EOD canonical 一致:非作废+已确认/已结算。 + //腿化改造后现金流水 Deal=现金腿 id(原 Deal==0 条件去除):该 Action 只有 EOD 会写,手工链路写应付预付金,语义天然隔离) var addRecordByTrade = DbContext.ClientCashInCashOut.AsNoTracking() .Where(x => x.TradeId != null && tradeIds.Contains(x.TradeId ?? 0) && x.Action == ClientCashInCashOut.系统操作_追加保证金 && x.ValidState != ConsGlobal.InValid && (x.State == ClientCashInCashOut.已确认 || x.State == ClientCashInCashOut.已结算) - && x.Deal == 0 && x.Money != null) .GroupBy(x => x.TradeId) .Select(g => new { TradeId = g.Key ?? 0, Funded = -g.Sum(x => x.Money ?? 0d) }) @@ -121,9 +172,8 @@ namespace YLErp.Modules.SwapModule.Margin var fundTagService = new SwapFundTagService(this); var cashService = new ClientCashInCashOutService(this); - var creditService = new ClientCreditInoutService(this); - //客户剩余可用授信逐笔扣减缓存(同一次结算内多笔追加按顺序消耗额度,与阶段二逐腿分配同语义) - var creditRemaining = new Dictionary(); + var eodPositionService = new SwapEodPositionService(this); + var flowEventService = new SwapFlowEventService(this); foreach (var clientGroup in trades.GroupBy(t => t.ClientId).OrderBy(g => g.Key)) { @@ -151,28 +201,97 @@ namespace YLErp.Modules.SwapModule.Margin continue; } - if (!creditRemaining.TryGetValue(td.ClientId, out var remain)) + //幂等清理:先删本结算日起 EOD 旧追保腿及其簿记(腿/流水/占用/快照同生共死),再按最新增量重建; + //手工追加预付金腿(OptName≠EOD追保)不受影响 + CleanupEodMarginLegs(td, settleDate); + + //建腿:与手工追加预付金同形态,FundTag=null 回退交易级资金来源 + var legIdsBefore = DbContext.swap_position.Where(x => x.SwapTradeId == td.id).Select(x => x.id).ToList(); + var leg = BuildEodMarginLeg(td, increment, settleDate, UserId); + DbContext.swap_position.Add(leg); + DbContext.SaveChanges(); + leg.PosiNumber = $"{td.TradeNumber}-{leg.id}"; + + //簿记:复用手工确认链路的标签分配(额度内授信占账、跨界拆单、现金出流水), + //Action 独立为 追加保证金(EQD-6952 口径)、占用 remark 带"追加保证金"前缀(累计/清理链路硬性识别点); + //resetExistingBookings=false——本交易初始预付金占用不在此重写(EOD 只簿记本次新腿,旧追保簿记上方已清) + fundTagService.ApplyMarginFundTags(td, new List { leg }, cashService, ignoreMoneyCheck: false, + cashAction: ClientCashInCashOut.系统操作_追加保证金, + occupyRemark: ClientCreditInoutService.AdditionalMarginRemark + "占用", + splitOccupyRemark: ClientCreditInoutService.AdditionalMarginRemark + "拆单授信部分", + bookingDate: settleDate, + resetExistingBookings: false); + + //本次新腿 = 追保腿 + 可能的拆单现金腿(SplitLeg 落库时打了服务身份 OptName,回打 EOD 标识保证幂等清理覆盖完整) + var newLegs = DbContext.swap_position.Where(x => x.SwapTradeId == td.id && x.IsInitial + && x.InterestMode == (int)InterestModeEnum.追加预付金 && !legIdsBefore.Contains(x.id)) + .ToList(); + newLegs.ForEach(x => x.OptName = EodOptName); + DbContext.SaveChanges(); + + //实时持仓克隆 + 开仓事件(参照 TradeConfirmService 簿记后动作,但只针对本次新腿—— + //整交易 InitialPosition 会把浮动腿实时持仓重置回开仓态、AddPositionEvent 会为全部腿重复建开仓事件,EOD 场景不可用); + //克隆继承定稿标签,供平仓返还分流与后续快照链使用 + foreach (var newLeg in newLegs) { - remain = fundTagService.GetAvailableCredit(td.ClientId, settleDate); - creditRemaining[td.ClientId] = remain; + var clone = newLeg.Clone(); + clone.id = 0; + clone.PositionId = newLeg.id; + clone.IsInitial = false; + DbContext.swap_position.Add(clone); } - var (creditPart, cashPart) = SwapAdditionalMarginCalc.Allocate(increment, remain); - if (creditPart > 0) + DbContext.SaveChanges(); + flowEventService.InitEvent(newLegs, td, EodOptName); + DbContext.SaveChanges(); + + //方案A:腿生成晚于当日 SwapPositionCompose(当日 trade_span 结算后才可算增量),显式补写当日 eod_swap_position 行, + //使当日报表明细(PostionMarginGain 等)不漏计;下一结算日快照由 Compose 先清后建正常接管 + foreach (var newLeg in newLegs) { - //授信部分不产生资金流水,只写授信出入表占用(占用记正数,BUG-01 修正口径) - creditService.Occupy(td.ClientId, null, td.id, creditPart, settleDate, - ClientCreditInoutService.AdditionalMarginRemark + "占用"); - creditRemaining[td.ClientId] = Math.Round(remain - creditPart, 2, MidpointRounding.AwayFromZero); - } - if (cashPart > 0) - { - //现金部分按结算日逐笔增量记录(BUG-03 修正:每结算日一条、Money=−increment、键含日期幂等), - //避免单条累计值覆盖 + HappenDate 前移使 EOD 差分窗口跨日全额重复计入;负数=客户应付追加 - cashService.SaveSwapTradeClientCash(td, -cashPart, settleDate, 0, - ClientCashInCashOut.系统操作_追加保证金, matchDate: true); + eodPositionService.SaveEodAdditionalMarginPosition(td, newLeg, settleDate); } } } } + + /// + /// 幂等清理:删除本交易 本结算日起 EOD 生成的旧追保腿(含其实时克隆)及其簿记—— + /// 现金流水(Action=追加保证金,Deal=腿id,按现行 DeleteTradeCashInCashOut 习惯硬删)、 + /// 授信占用(position_id=腿id)、开仓事件、方案A 补写的当日及以后 eod_swap_position 行。 + /// + private void CleanupEodMarginLegs(trade td, DateTime settleDate) + { + var oldLegs = DbContext.swap_position.Where(x => x.SwapTradeId == td.id + && x.InterestMode == (int)InterestModeEnum.追加预付金 + && x.OptName == EodOptName + && x.HappenDate >= settleDate) + .ToList(); + if (oldLegs.Count == 0) + { + return; + } + var oldLegIds = oldLegs.Select(x => x.id).ToList(); + //簿记(流水 Deal/占用 position_id/快照 PositionId/事件 PositionId)均绑期初腿(拆单现金腿也是 IsInitial 的独立腿) + var oldInitialIds = oldLegs.Where(x => x.IsInitial).Select(x => x.id).ToList(); + var oldCashRecords = DbContext.ClientCashInCashOut.Where(x => x.TradeId == td.id + && x.Action == ClientCashInCashOut.系统操作_追加保证金 + && oldInitialIds.Contains(x.Deal)) + .ToList(); + DbContext.ClientCashInCashOut.RemoveRange(oldCashRecords); + var oldCreditRecords = DbContext.client_credit_inout.Where(x => x.trade_id == td.id + && x.position_id != null && oldInitialIds.Contains(x.position_id.Value)) + .ToList(); + DbContext.client_credit_inout.RemoveRange(oldCreditRecords); + var oldEvents = DbContext.swap_flow_event.Where(x => x.SwapTradeId == td.id + && x.EventType == (int)SwapFlowEventTypeEnum.开仓 && oldInitialIds.Contains(x.PositionId)) + .ToList(); + DbContext.swap_flow_event.RemoveRange(oldEvents); + var oldEodRows = DbContext.eod_swap_position.Where(x => x.SwapTradeId == td.id + && x.ValueDate >= settleDate && oldInitialIds.Contains(x.PositionId)) + .ToList(); + DbContext.eod_swap_position.RemoveRange(oldEodRows); + DbContext.swap_position.RemoveRange(oldLegs); + DbContext.SaveChanges(); + } } } diff --git a/YLErpDAL/Modules/SwapModule/Margin/SwapSpanBalanceCalc.cs b/YLErpDAL/Modules/SwapModule/Margin/SwapSpanBalanceCalc.cs index c42a12da..3450b796 100644 --- a/YLErpDAL/Modules/SwapModule/Margin/SwapSpanBalanceCalc.cs +++ b/YLErpDAL/Modules/SwapModule/Margin/SwapSpanBalanceCalc.cs @@ -46,13 +46,12 @@ namespace YLErp.Modules.SwapModule.Margin } /// - /// 追保金额(合约维度,MarginWatchRule=0,阶段四 §4.2):−(现金结存 + 授信额度 − 已使用授信)。 - /// 需求原文公式为 现金结存+授信额度−已使用授信(与"交易维度追加保证金"的关系为开放问题2,两值均有产出), - /// 此处取负对齐字段"正数=应追加"口径:账户透支(现金+授信不足)为正=应补足,盈余为负。 + /// 追保金额(合约维度,MarginWatchRule=0):Max(−(现金结存 + 授信额度 − 已使用授信), 0)。 + /// 即需求原文公式 Max((现金结存+授信额度−已使用授信)×−1, 0):账户透支(现金+授信不足)为正=应补足,盈余截断为 0(不展示负数)。 /// public static double CalcContractDimensionCallMargin(double cashBalance, double totalCredit, double usedCredit) { - return Math.Round(-(cashBalance + totalCredit - usedCredit), 2, MidpointRounding.AwayFromZero); + return Math.Max(Math.Round(-(cashBalance + totalCredit - usedCredit), 2, MidpointRounding.AwayFromZero), 0); } } } diff --git a/YLErpDAL/Modules/SwapModule/Margin/SwapSpanBalanceQueryService.cs b/YLErpDAL/Modules/SwapModule/Margin/SwapSpanBalanceQueryService.cs index a599469b..ec26f6cf 100644 --- a/YLErpDAL/Modules/SwapModule/Margin/SwapSpanBalanceQueryService.cs +++ b/YLErpDAL/Modules/SwapModule/Margin/SwapSpanBalanceQueryService.cs @@ -8,16 +8,20 @@ namespace YLErp.Modules.SwapModule.Margin /// /// R2 阶段三 §3.2 估值报告/可用资金查询输入(静态查询,供 ClientBalanceUtility 与 RealTimeClientBanlanceService 共用,保证三处口径一致)。 /// 口径: - /// - 互换初始保证金(净收取为正)= 客户 应付预付金 流水收付净额取反(客户应付入金记负、平仓返还为正,取负号后净收取为正); + /// - 互换初始保证金(净收取为正)= 客户 应付预付金 流水收付净额取反 + 初始预付金授信占用净额 + /// (授信垫付的初始预付金无资金流水,2026-08-27 补入口径,否则授信垫付客户初始保证金展示为 0); /// - 交易维度追加保证金(合约维度)= Σ(维持保证金 − 累计保证金): /// 维持保证金取当日 trade_span.Spv(区间追保结构引擎产出,我方净收取为正); - /// 累计保证金 = 该交易 应付预付金+追加保证金 流水收付净额 + 追加保证金授信占用净额(§0 口径,阶段四 §4.1 起); + /// 累计保证金 = 该交易 应付预付金+追加保证金 流水收付净额 + 追加保证金授信占用净额(§0 口径,阶段四 §4.1 起) + /// + 初始预付金授信占用净额(非"追加保证金"前缀,授信垫付的初始预付金无现金流水,2026-08-27 与 EOD 追保侧同步修正); /// 仅统计规则15(区间追保结构,R1 三层级解析,与引擎同口径)且有当日 trade_span 的交易。 /// public static class SwapSpanBalanceQueryService { /// /// 客户维度输入:互换初始保证金(净收取为正,按客户汇总)。 + /// 初始保证金 = 应付预付金流水收付净额 + 初始预付金授信占用净额(非"追加保证金"前缀、关联交易)—— + /// 授信垫付的初始预付金不产生资金流水,只算流水会把授信垫付部分漏掉(展示为 0)。 /// public static Dictionary GetSwapInitMarginByClients(List clientIds, DateTime valueDate, YLContext db) { @@ -36,8 +40,26 @@ namespace YLErp.Modules.SwapModule.Margin .Select(x => new { ClientId = x.ClientId ?? 0, Money = x.Money ?? 0d }) .ToList(); - return flows.GroupBy(x => x.ClientId) + var result = flows.GroupBy(x => x.ClientId) .ToDictionary(g => g.Key, g => -g.Sum(x => x.Money)); + + //初始预付金的授信占用净额(占用记正/释放记负,Σ(amount) 即净已缴;trade_id != null 排除人工调整类记录, + //"追加保证金"前缀为 EOD 追保占用,不计入初始保证金) + var initCredit = db.client_credit_inout.AsNoTracking() + .Where(x => clientIds.Contains(x.client_id) + && x.trade_id != null + && x.happen_date <= valueDate + && (x.remark == null || !x.remark.StartsWith(ClientCreditInoutService.AdditionalMarginRemark))) + .GroupBy(x => x.client_id) + .Select(g => new { ClientId = g.Key, Sum = g.Sum(x => x.amount) }) + .ToList(); + + foreach (var item in initCredit) + { + result[item.ClientId] = (result.TryGetValue(item.ClientId, out var cash) ? cash : 0d) + item.Sum; + } + + return result; } /// @@ -101,11 +123,22 @@ namespace YLErp.Modules.SwapModule.Margin .Select(g => new { TradeId = g.Key ?? 0, Sum = g.Sum(x => x.amount) }) .ToDictionary(x => x.TradeId, x => x.Sum); + //初始预付金的授信占用净额(非"追加保证金"前缀:簿记初始占用 + 平仓释放取负,Σ(amount) 即净已缴)—— + //授信垫付的初始预付金不产生应付预付金流水,不计入会把授信初始占用当作未缴缺口多扣可用资金 + //(与 SwapAdditionalMarginService EOD 追保侧同笔修正,2026-08-27 交易2538实证:初始授信200万未扣) + var initCreditOccupied = db.client_credit_inout.AsNoTracking() + .Where(x => x.trade_id != null && spanTradeIds.Contains(x.trade_id ?? 0) + && (x.remark == null || !x.remark.StartsWith(ClientCreditInoutService.AdditionalMarginRemark))) + .GroupBy(x => x.trade_id) + .Select(g => new { TradeId = g.Key ?? 0, Sum = g.Sum(x => x.amount) }) + .ToDictionary(x => x.TradeId, x => x.Sum); + foreach (var group in maintenance.GroupBy(x => x.ClientId ?? 0)) { var total = group.Sum(x => x.Spv - (accumulated.TryGetValue(x.TradeId, out var acc) ? acc : 0d) - - (addCreditOccupied.TryGetValue(x.TradeId, out var occupied) ? occupied : 0d)); + - (addCreditOccupied.TryGetValue(x.TradeId, out var occupied) ? occupied : 0d) + - (initCreditOccupied.TryGetValue(x.TradeId, out var initOccupied) ? initOccupied : 0d)); result[group.Key] = total; } diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 66578dc1..0961dff0 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -1541,11 +1541,11 @@ namespace YLErp.Modules.SwapModule var split = interestEvents != null && interestEvents.Any(x => string.IsNullOrEmpty(x.UnderlyingCode)) ? ReleaseMarginByFundTag(td, valueDate, interestEvents, marginAmount, marginRebate) //无预付金腿结算事件(如金额手工归一化/无腿场景)——退化原逻辑,全额现金 - : new UnwindTagSplit { CashMargin = Convert.ToDouble(marginAmount), CashRebate = Convert.ToDouble(marginRebate) }; + : new UnwindTagSplit { CashMargin = marginAmount, CashRebate = marginRebate }; if (split.CashMargin != 0) - writeCash(td, split.CashMargin, ClientCashInCashOut.系统操作_应付预付金, valueDate); + writeCash(td, (double)split.CashMargin, ClientCashInCashOut.系统操作_应付预付金, valueDate); if (split.CashRebate != 0) - writeCash(td, -split.CashRebate, ClientCashInCashOut.系统操作_预付金返息, valueDate); + writeCash(td, (double)(-split.CashRebate), ClientCashInCashOut.系统操作_预付金返息, valueDate); } /// @@ -1561,7 +1561,7 @@ namespace YLErp.Modules.SwapModule //结算事件缺失(异常数据)时保底按传入总额走现金,不丢资金记录 if (settlements.Count == 0) { - return new UnwindTagSplit { CashMargin = Convert.ToDouble(marginAmount), CashRebate = Convert.ToDouble(marginRebate) }; + return new UnwindTagSplit { CashMargin = marginAmount, CashRebate = marginRebate }; } return split; } diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index ee537768..2ce8322a 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -1731,7 +1731,7 @@ namespace YLErp.Modules.SwapModule settlements[i].RebateAmount = legs[i].InterestClosePnL * -DirectionRatio.ReceivePay(legs[i].InterestDirection); } var split = FundTagCalc.SplitUnwindByTag(settlements); - return Math.Round(totalRebate - Convert.ToDecimal(split.CreditRebate), 2, MidpointRounding.AwayFromZero); + return Math.Round(totalRebate - split.CreditRebate, 2, MidpointRounding.AwayFromZero); } /// /// 互换更新实时持仓信息 @@ -2046,6 +2046,44 @@ namespace YLErp.Modules.SwapModule FinalizeInterestEodRoll(eodPayPosition, newEodPayPosition, ratio, td, valueDate, position.InterestDirection); PersistEodSwapPosition(newEodPayPosition); } + + /// + /// EOD 追保腿化·方案A:SettleAdditionalMargin 在当日 SwapPositionCompose/SwapEodCompose 之后生成 mode6 追保腿 + /// (增量依赖当日 trade_span,无法前移),当日快照已落表,显式补写当日 eod_swap_position 行, + /// 使当日报表明细(风险页 PostionMarginGain 等按快照腿汇总的列)不漏计。 + /// 字段填充参照 SaveEodInterestPosition 新腿形态(无当日流水、无上日归档:利息/损益字段为 0,TdCurrency 取当日汇率)。 + /// 幂等:先清(PositionId+ValueDate)后建;下一结算日 SwapPositionCompose 先清(ClearSwapPositions ValueDate>=当日) + /// 再从实时腿重建,补写行不会跨日残留。 + /// + public void SaveEodAdditionalMarginPosition(trade td, swap_position leg, DateTime valueDate) + { + var existing = DbContext.eod_swap_position + .Where(x => x.SwapTradeId == td.id && x.PositionId == leg.id && x.ValueDate == valueDate) + .ToList(); + DbContext.eod_swap_position.RemoveRange(existing); + var row = new eod_swap_position + { + ClientId = td.ClientId, + SwapTradeId = td.id, + PosiStartDate = leg.PosiStartDate, + PosiMatuirityDate = td.ExerciseDate, + ValueDate = valueDate, + PositionId = leg.id, + PosiStatus = 0, + Invalid = false + }; + UpdateDbOption(row); + //持仓内容-利息腿(无当日流水,FloatRate 取 0) + CopyInterestLegFields(row, leg, 0); + row.InterestFeePending = 0; + var ratio = DirectionRatio.InterestLegPnl(leg.InterestDirection, leg.InterestMode); + row.SwapPositionValue = PositionValueCalc.Calc(row.InterestProfitSum, row.PosiProfitSum, ratio); + row.RealizedPnl = row.RealizedInterest + row.RealizedInterestFee; + row.TdCurrency = Convert.ToDecimal(GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, valueDate, true, + DirectionRatio.RateType(leg.InterestDirection))); + PersistEodSwapPosition(row); + SaveAllChanges(); + } /// /// 自动互换用,当日无互换,当日无平仓 /// diff --git a/YLErpDAL/Modules/SwapModule/SwapFundTagService.cs b/YLErpDAL/Modules/SwapModule/SwapFundTagService.cs index f967f723..68570f69 100644 --- a/YLErpDAL/Modules/SwapModule/SwapFundTagService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapFundTagService.cs @@ -78,7 +78,7 @@ namespace YLErp.Modules.SwapModule .Select(x => new LegAmount { Leg = x, - Amount = Convert.ToDouble(x.InterestPrincipalFix * (x.InterestDirection == 1 ? 1 : -1)), + Amount = x.InterestPrincipalFix * (x.InterestDirection == 1 ? 1 : -1), PreferCredit = true }) .Where(x => x.Amount > 0) @@ -89,7 +89,7 @@ namespace YLErp.Modules.SwapModule { return; } - var plans = FundTagCalc.AllocateByLegPreference(allocateLegs, creditAvailable, ignoreMoneyCheck: false); + var plans = FundTagCalc.AllocateByLegPreference(allocateLegs, (decimal)creditAvailable, ignoreMoneyCheck: false); //授信不足的腿 = 偏好授信但授信没覆盖全额(含额度为0/被前腿耗尽的整体转现金) var shortPlans = plans.Where(p => p.CreditAmount < p.Amount).ToList(); if (shortPlans.Count == 0) @@ -127,17 +127,29 @@ namespace YLErp.Modules.SwapModule /// 特批全现金;按授信分配(腿选授信,或腿未选回退交易级 margin_fund_source=授信)的腿按剩余额度占用, /// 跨界腿拆单为 授信+现金 两条(原腿保留授信部分、差额拆出新现金腿);现金直接现金。 /// 授信腿只写授信出入表占用(占用记正数,绑定腿 position_id,冗余 trade_id),不产生资金流水; - /// 现金腿走 SaveSwapTradeClientCash 幂等 upsert 产生 应付预付金 记录。 + /// 现金腿走 SaveSwapTradeClientCash 幂等 upsert 产生资金记录(默认 Action=应付预付金;EOD 追保腿化传入 追加保证金)。 /// marginLegs 需为已过滤(IsDeductPrincipal 等)的预付金腿(InterestMode=5/6)。 + /// cashAction/occupyRemark/splitOccupyRemark:EOD 追保场景参数化(现金流水 Action 独立、占用 remark 带"追加保证金"前缀—— + /// 前缀是累计口径与清理链路的硬性识别点);bookingDate:授信可用额度与簿记的取值日(默认交易起始日,EOD 传结算日—— + /// 按结算日过滤授信有效期,防止已到期授信被占用);resetExistingBookings=false 时跳过"重确认自愈"的全交易占用重写 + /// (EOD 追保只簿记本次新腿,旧追保簿记由调用方幂等清理,初始预付金占用不在此重写范围、不能清)。 /// - public void ApplyMarginFundTags(trade td, List marginLegs, ClientCashInCashOutService cashService, bool ignoreMoneyCheck) + public void ApplyMarginFundTags(trade td, List marginLegs, ClientCashInCashOutService cashService, bool ignoreMoneyCheck, + string cashAction = ClientCashInCashOut.系统操作_应付预付金, + string occupyRemark = "簿记授信占用", + string splitOccupyRemark = "簿记拆单授信部分", + DateTime? bookingDate = null, + bool resetExistingBookings = true) { var creditService = new ClientCreditInoutService(this); //重确认/重补场景自愈:清掉本交易旧占用记录后按腿上当前选择与最新额度重写; //追加保证金占用(阶段四 EOD 写入)不随重写清除——其资金记录不在本方法删除范围,由 EOD 幂等维护 - creditService.RemoveByTrade(td.id, keepAdditionalMargin: true); + if (resetExistingBookings) + { + creditService.RemoveByTrade(td.id, keepAdditionalMargin: true); + } - var valueDate = td.TradeDate ?? DateTime.Now; + var valueDate = bookingDate ?? td.TradeDate ?? DateTime.Now; var creditAvailable = GetAvailableCredit(td.ClientId, valueDate); //客户应付为正:资金记录符号口径为 Money<0=客户付钱,即 收取方向(dir=1)腿 fix 为正应付额—— //正是占用授信的场景;支付方向(dir=2)为客户收钱,不占用授信,直接现金。 @@ -145,7 +157,7 @@ namespace YLErp.Modules.SwapModule .Select(x => new LegAmount { Leg = x, - Amount = Convert.ToDouble(x.InterestPrincipalFix * (x.InterestDirection == 1 ? 1 : -1)), + Amount = x.InterestPrincipalFix * (x.InterestDirection == 1 ? 1 : -1), //优先级:腿上显式选择 > 交易级 margin_fund_source 回退(§2.3 情形1)> 默认现金, //与 TradeCanBeConfirm 校验分流共用 ConsFundTag.PreferCredit 保证口径一致 PreferCredit = ConsFundTag.PreferCredit(x.FundTag, td.MarginFundSource) @@ -154,7 +166,7 @@ namespace YLErp.Modules.SwapModule .OrderBy(x => x.Leg.HappenDate ?? DateTime.MaxValue) .ThenBy(x => x.Leg.id) .ToList(); - var plans = FundTagCalc.AllocateByLegPreference(allocateLegs, creditAvailable, ignoreMoneyCheck); + var plans = FundTagCalc.AllocateByLegPreference(allocateLegs, (decimal)creditAvailable, ignoreMoneyCheck); //先落库拆分的新现金腿(需要 id 才能绑定现金流水) foreach (var plan in plans.Where(p => p.NeedSplit)) @@ -186,17 +198,17 @@ namespace YLErp.Modules.SwapModule { //授信部分(整腿授信 或 拆单后保留在原腿的可用额度部分):不产生资金流水,只写占用(占用绑原腿)。 //占用记正数(BUG-01 修正:已使用授信=Σ(amount) 占用上升;2026-08-20"与资金流水同号入金负"口径已废弃) - creditService.Occupy(td.ClientId, leg.id, td.id, plan.CreditAmount, happenDate, - plan.NeedSplit ? "簿记拆单授信部分" : "簿记授信占用"); + creditService.Occupy(td.ClientId, leg.id, td.id, (double)plan.CreditAmount, happenDate, + plan.NeedSplit ? splitOccupyRemark : occupyRemark); } //资金记录沿用既有符号口径(客户付钱为负 = -应付额):授信部分不产生流水, //现金部分按差额产生——拆单腿的流水绑新拆出的现金腿,整腿现金/负应付腿绑原腿 var recordAmount = plan != null - ? -plan.CashAmount + ? (double)(-plan.CashAmount) : Convert.ToDouble(leg.InterestPrincipalFix * (leg.InterestDirection == 1 ? -1 : 1)); if (recordAmount != 0) { - cashService.SaveSwapTradeClientCash(td, recordAmount, happenDate, plan?.CashLeg?.id ?? leg.id, ClientCashInCashOut.系统操作_应付预付金); + cashService.SaveSwapTradeClientCash(td, recordAmount, happenDate, plan?.CashLeg?.id ?? leg.id, cashAction); } } } @@ -244,7 +256,7 @@ namespace YLErp.Modules.SwapModule var creditService = new ClientCreditInoutService(this); foreach (var release in split.Releases) { - creditService.Release(td.ClientId, release.PositionId, td.id, release.Amount, valueDate, "平仓/到期释放"); + creditService.Release(td.ClientId, release.PositionId, td.id, (double)release.Amount, valueDate, "平仓/到期释放"); } } return split; diff --git a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs index 62beaf46..c38e770f 100644 --- a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs @@ -1,4 +1,4 @@ -using BaseOUDAL; +using BaseOUDAL; using ClosedXML.Report.Options; using Confluent.Kafka; using CsvHelper; @@ -31,6 +31,7 @@ using YLErp.Modules.DataProviderModule; using YLErp.Modules.EodModule; using YLErp.Modules.RiskModule; using YLErp.Modules.SalesModule; +using YLErp.Modules.SwapModule.Margin; using YLErp.Modules.TradeModule; using YLErp.Modules.TradeModule.DealModule; using YLErp.Modules.TradeModule.DocGenerateModule; @@ -1828,6 +1829,26 @@ namespace YLErp.Modules.SwapModule DbContext.swap_flow_event.RemoveRange(swapFlowEvents); var clientCashs = DbContext.ClientCashInCashOut.Where(x => x.TradeId == td.id && x.HappenDate >= valueDate).ToList(); DbContext.ClientCashInCashOut.RemoveRange(clientCashs); + //EOD 追保腿(腿化改造)同生共死:流水已在上方按 HappenDate>=valueDate 全删(不分 Action), + //此处同步删除本交易 valueDate 起的 EOD 追保腿(含实时克隆)及其授信占用、开仓事件、eod 快照行, + //回退后由下一次 EOD 幂等重建;手工追加预付金腿(OptName≠EOD追保)不受影响 + var eodMarginLegs = DbContext.swap_position.Where(x => x.SwapTradeId == td.id + && x.InterestMode == (int)InterestModeEnum.追加预付金 + && x.OptName == SwapAdditionalMarginService.EodOptName + && x.HappenDate >= valueDate) + .ToList(); + if (eodMarginLegs.Count > 0) + { + var eodMarginLegIds = eodMarginLegs.Where(x => x.IsInitial).Select(x => x.id).ToList(); + DbContext.client_credit_inout.RemoveRange(DbContext.client_credit_inout + .Where(x => x.trade_id == td.id && x.position_id != null && eodMarginLegIds.Contains(x.position_id.Value))); + DbContext.eod_swap_position.RemoveRange(DbContext.eod_swap_position + .Where(x => x.SwapTradeId == td.id && eodMarginLegIds.Contains(x.PositionId))); + DbContext.swap_flow_event.RemoveRange(DbContext.swap_flow_event + .Where(x => x.SwapTradeId == td.id && x.EventType == (int)SwapFlowEventTypeEnum.开仓 + && eodMarginLegIds.Contains(x.PositionId))); + DbContext.swap_position.RemoveRange(eodMarginLegs); + } DbContext.SaveChanges(); } /// diff --git a/YLErpDAL/Modules/TradeModule/TradeApprovalOAService.cs b/YLErpDAL/Modules/TradeModule/TradeApprovalOAService.cs index c9342f95..63baf9a4 100644 --- a/YLErpDAL/Modules/TradeModule/TradeApprovalOAService.cs +++ b/YLErpDAL/Modules/TradeModule/TradeApprovalOAService.cs @@ -239,9 +239,17 @@ namespace YLErp.Modules.TradeModule : null; var flowStatusType = (string)flow?["flowStatusType"]; var flowNode = (string)flow?["flowNode"]; + // OA 状态口径(以查询结果中的 flowStatusType + flowNode 为准): + // 0 + 退回:审批人退回,本地按拒绝处理; + // 3 + 结束:审批人批准,本地按通过处理; + // 3 + 强制归档:OA 流程被强制关闭。正常情况下,本地发起 + // forceEndOaFlow 后记录会先变为“归档中”、成功后变为“已归档”, + // 不会进入本轮待查询集合;若仍被查询到,则按客户确认的口径视为同意。 + // 0(非退回)及 1 + 审批人:草稿/待办,继续等待。 + // 其他组合也必须继续等待,不能仅凭 flowStatusType=1 或 3 推进本地流程。 var isReturned = flowStatusType == "0" && flowNode == "退回"; - // 1=批准;3=OA 批准后的自然归档。TRS 主动归档的记录不会进入本次查询。 - var isApproved = flowStatusType == "1" || flowStatusType == "3"; + var isApproved = flowStatusType == "3" + && (flowNode == "结束" || flowNode == "强制归档"); _logger.Info($"OA 移动审批查询结果,交易:{record.trade_id},节点:{record.approval_process_id},requestId:{record.oa_fileid},flowStatusType:{flowStatusType},flowNode:{flowNode},判定:{(isReturned ? "退回" : isApproved ? "通过" : "等待")}"); if (!isApproved && !isReturned) { diff --git a/YLErpWeb/wwwroot/Scripts/app/client/tradeMarketReport.js b/YLErpWeb/wwwroot/Scripts/app/client/tradeMarketReport.js index 7e3f4cc2..74969fe8 100644 --- a/YLErpWeb/wwwroot/Scripts/app/client/tradeMarketReport.js +++ b/YLErpWeb/wwwroot/Scripts/app/client/tradeMarketReport.js @@ -1,4 +1,4 @@ -var g_grid = {}; +var g_grid = {}; $(function () { var PostData = {}; var calcDate = page.ValueDate; @@ -291,7 +291,8 @@ function SearchClientBalance() { main.post("/trade_span/GetClientLatestBalance", { clientId: $("#ClientId").val(), ValueDateFrom: param.ValueDateStart, ValueDateTo: param.ValueDateEnd, IsClientBalanceGap: param.IsClientBalanceGap, IsGetOuterMarginGap: param.IsGetOuterMarginGap, ParentFlag: param.ParentFlag }).done(function (data) { $("#LastDayRemainFund").text(numFormart(data.LastDayRemainFund)); //R2 阶段三 §3.2 口径(BUG-07 修正,与邮件/Excel 报告 SettlementReportService 同源): - //初始保证金=SwapInitMargin(应付预付金净额)、维持保证金=−MySideMargin(client_span 维持保证金反号聚合) + //初始保证金=SwapInitMargin(应付预付金净额+初始预付金授信占用净额)、维持保证金=−MySideMargin(client_span 维持保证金反号聚合)、 + //授信额度=OriginalTotalCredit(原始授信值,未经最大可用比例折算;公式计算仍用折算后 TotalCredit) $("#SwapInitMargin").text(numFormart(data.SwapInitMargin)); $("#WinLoss").text(numFormart(data.WinLoss)); $("#CashInCashOutChange").text(numFormart(data.NetFundAll)); @@ -305,7 +306,7 @@ function SearchClientBalance() { $("#MarginByPayableMargin").text(numFormart(data.MarginByPayableMarginTotal)); $("#ToDayRemainFund").text(numFormart(data.AmountFund)); $("#DesirableFund").text(numFormart(data.DesirableFundTotal)); - $("#TotalCredit").text(numFormart(data.TotalCredit)); + $("#TotalCredit").text(numFormart(data.OriginalTotalCredit)); $("#UsedCredit").text(numFormart(data.UsedCredit)); });