using YLErp.BLL; using YLErp.DBModels; using YLErp.Enums; using YLErp.Modules.MarginModule; using YLErp.Modules.TradeModule; using YLErp.Helpers; namespace YLErp.Modules.SwapModule.Margin { /// /// R3 阶段四 §4.1:合约维度(MarginWatchRule==0)规则15 交易日终结算产生"追加保证金"。 /// 交易维度追加保证金 = 维持保证金(阶段三引擎 trade_span 产出)− 已缴保证金净额 /// (应付预付金现金净收额 + 初始授信占用净额—— /// 2026-08-27 修正:授信垫付的初始预付金不产生应付预付金流水,此前未计入已缴导致每个结算日按维持全额重复开追加); /// 需求上升只增不减、回落不返还。 /// 腿化改造(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 { private static readonly IYcLogger logger = LogFactory.GetLogger("EOD追保"); /// /// EOD 追保腿打标(OptName):与手工追加预付金腿(OptName=操作员实名)区分, /// 幂等清理、RemoveByTrade 保护与时间轴回退清理均以此识别。拆单现金腿落库时被打服务身份,簿记后回打本标识。 /// public const string EodOptName = "EOD追保"; public SwapAdditionalMarginService(OptUserInfo userInfo) : base(userInfo) { } public SwapAdditionalMarginService(YLBaseService baseService) : base(baseService) { } /// /// 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 请求一致)。 /// 顶层兜异常日志(Error 级)后原样抛出,避免异常栈被 EOD 框架层吞掉无从定位。 /// public void SettleAdditionalMargin(DateTime settleDate, List clientFilter = null) { try { SettleAdditionalMarginCore(settleDate, clientFilter); } catch (Exception ex) { var filterDesc = clientFilter != null && clientFilter.Any() ? string.Join(",", clientFilter) : "全部"; logger.Error($"EOD追保:结算日{settleDate:yyyy-MM-dd}(客户范围:{filterDesc})追保腿生成异常", ex); throw; } } private void SettleAdditionalMarginCore(DateTime settleDate, List clientFilter) { //合约维度盯市客户 var watchClientIds = DbContextFactory.GetClientDbContext(OptUser).client.AsNoTracking() .Where(t => t.ProcessStatus != "未提交" && t.MarginWatchRule == 0) .Select(t => t.id) .ToList(); if (clientFilter != null && clientFilter.Any()) { watchClientIds = watchClientIds.Where(t => clientFilter.Contains(t)).ToList(); } if (watchClientIds.Count == 0) { logger.Info($"EOD追保:结算日{settleDate:yyyy-MM-dd} 无合约维度盯市(MarginWatchRule=0)客户,结束"); return; } logger.Info($"EOD追保:结算日{settleDate:yyyy-MM-dd} 合约维度盯市客户{watchClientIds.Count}个({string.Join(",", watchClientIds)})"); //存续中的互换交易(状态口径与 eodSwapQuery 一致,含当日已了结) var tradeStatuses = ConsTrade.TradeStatusAfterConfirmed; var trades = DbContext.trade.AsNoTracking() .Where(t => tradeStatuses.Contains(t.TradeStatus) && t.ValidState != ConsGlobal.InValid && t.TradeType == "收益互换" && t.TradeDate <= settleDate && watchClientIds.Contains(t.ClientId)) .ToList(); if (trades.Count == 0) { logger.Info($"EOD追保:结算日{settleDate:yyyy-MM-dd} 盯市客户名下无存续收益互换交易,结束"); return; } //开始日门槛:未到交易开始日(未起息)的不参与追保——维持保证金引擎从成交日就产出 trade_span, //初始预付金流水却到开始日才入账,不过滤会在 成交日~开始日 之间把未到期的初始预付金 //误判成缺口、按维持全额追加(2026-08-28 交易2571实证:07-31成交/08-03起息,重刷07-31误追500000) var notStartedTrades = trades.Where(t => (t.StartDate ?? t.TradeDate) > settleDate).ToList(); if (notStartedTrades.Count > 0) { logger.Info($"EOD追保:{notStartedTrades.Count}笔交易未到开始日(id=[{string.Join(",", notStartedTrades.Select(t => t.id))}]),不参与追保结算"); trades = trades.Except(notStartedTrades).ToList(); if (trades.Count == 0) { logger.Info($"EOD追保:结算日{settleDate:yyyy-MM-dd} 交易均未到开始日,结束"); return; } } //规则15(区间追保结构)交易:R1 三层级解析(BUG-02 修正,与引擎/确认书同口径)—— //交易绑定→客户默认→全局默认 找到即停,只配客户/全局默认模板的交易同样纳入追保结算 var candidateCount = trades.Count; var templatesByTrade = MarginTemplateV2RateHelper.ResolveTieredTemplates(trades, settleDate, DbContext); trades = trades.Where(t => templatesByTrade.TryGetValue(t.id, out var tpl) && tpl.RuleType == (int)MarginRuleTypeEnum.区间追保结构).ToList(); if (candidateCount > trades.Count) { logger.Info($"EOD追保:{candidateCount - trades.Count}笔交易模板非区间追保结构(或三级未命中),不参与追保结算"); } var tradeIds = trades.Select(t => t.id).ToList(); if (tradeIds.Count == 0) { logger.Info($"EOD追保:结算日{settleDate:yyyy-MM-dd} 规则15交易为0,结束"); return; } //当日维持保证金(引擎产出,我方净收取为正),按交易合计(与 SwapSpanBalanceQueryService 缺口口径一致) var maintenanceByTrade = DbContext.trade_span.AsNoTracking() .Where(x => x.ValueDate == settleDate && tradeIds.Contains(x.TradeId) && x.Spv != null) .GroupBy(x => x.TradeId) .Select(g => new { TradeId = g.Key, Spv = g.Sum(x => x.Spv ?? 0d) }) .ToDictionary(x => x.TradeId, x => x.Spv); //应付预付金净收额(客户付钱记负 → 取反为正;平仓返还自动冲减;口径与 EOD canonical 一致:非作废+已确认/已结算) var payableNetByTrade = DbContext.ClientCashInCashOut.AsNoTracking() .Where(x => x.TradeId != null && tradeIds.Contains(x.TradeId ?? 0) && x.Action == ClientCashInCashOut.系统操作_应付预付金 && x.HappenDate <= settleDate && x.ValidState != ConsGlobal.InValid && (x.State == ClientCashInCashOut.已确认 || x.State == ClientCashInCashOut.已结算) && x.Money != null) .GroupBy(x => x.TradeId) .Select(g => new { TradeId = g.Key ?? 0, Sum = -g.Sum(x => x.Money ?? 0d) }) .ToDictionary(x => x.TradeId, x => x.Sum); //追加保证金资金记录累计值(逐日增量记录求和即累计,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.Money != null) .GroupBy(x => x.TradeId) .Select(g => new { TradeId = g.Key ?? 0, Funded = -g.Sum(x => x.Money ?? 0d) }) .ToDictionary(x => x.TradeId, x => x.Funded); //追加保证金授信占用累计(amount 占用记正 → 直接求和(BUG-01 修正口径);remark 前缀标识,见 ClientCreditInoutService) var addCreditByTrade = DbContext.client_credit_inout.AsNoTracking() .Where(x => x.trade_id != null && tradeIds.Contains(x.trade_id ?? 0) && x.remark.StartsWith(ClientCreditInoutService.AdditionalMarginRemark)) .GroupBy(x => x.trade_id) .Select(g => new { TradeId = g.Key ?? 0, Funded = g.Sum(x => x.amount) }) .ToDictionary(x => x.TradeId, x => x.Funded); //初始预付金的授信占用净额(非"追加保证金"前缀:簿记初始占用 + 平仓释放取负,Σ(amount) 即净已缴): //授信腿不产生应付预付金流水,目标追加里只扣现金净收额会把授信垫付的初始预付金漏掉—— //每个结算日都按维持保证金全额重复开追加(BUG:多收授信占用/现金,2026-08-27 交易2538实证:初始授信200万未扣、首日全额追加280.8万) var initCreditByTrade = DbContext.client_credit_inout.AsNoTracking() .Where(x => x.trade_id != null && tradeIds.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, Funded = g.Sum(x => x.amount) }) .ToDictionary(x => x.TradeId, x => x.Funded); var fundTagService = new SwapFundTagService(this); var cashService = new ClientCashInCashOutService(this); var eodPositionService = new SwapEodPositionService(this); var flowEventService = new SwapFlowEventService(this); var settledTrades = 0; var settledLegs = 0; var totalIncrement = 0d; foreach (var clientGroup in trades.GroupBy(t => t.ClientId).OrderBy(g => g.Key)) { foreach (var td in clientGroup.OrderBy(t => t.id)) { if (!maintenanceByTrade.TryGetValue(td.id, out var maintenance) || maintenance <= 0) { logger.Info($"EOD追保:交易{td.id}(客户{td.ClientId})跳过——当日无trade_span维持保证金或维持≤0(maintenance={maintenance:0.00})"); continue; } //目标追加 = 维持保证金 − 已缴初始保证金净额(现金应付预付金净收额 + 授信初始占用净额, //授信垫付与现金垫付同等对待,杜绝授信初始预付金被重复追加) var payable = payableNetByTrade.TryGetValue(td.id, out var p) ? p : 0; var initCredit = initCreditByTrade.TryGetValue(td.id, out var ic) ? ic : 0; var payableNet = payable + initCredit; var target = SwapAdditionalMarginCalc.CalcTarget(maintenance, payableNet); if (target <= 0) { logger.Info($"EOD追保:交易{td.id}(客户{td.ClientId})跳过——目标追加≤0:维持={maintenance:0.00},已缴={payableNet:0.00}(应付净额{payable:0.00}+初始授信占用{initCredit:0.00})已覆盖"); continue; } var fundedCash = addRecordByTrade.TryGetValue(td.id, out var cash) ? cash : 0; var fundedCredit = addCreditByTrade.TryGetValue(td.id, out var credit) ? credit : 0; var increment = Math.Round(target - fundedCash - fundedCredit, 2, MidpointRounding.AwayFromZero); if (increment <= 0) { //已补足;追保回落(目标下降)不返还——负缺口在可用资金公式(Σ维持−累计)体现 logger.Info($"EOD追保:交易{td.id}(客户{td.ClientId})跳过——增量≤0:目标={target:0.00},已补足(现金{fundedCash:0.00}+授信{fundedCredit:0.00}),追保回落不返还"); continue; } logger.Info($"EOD追保:交易{td.id}(客户{td.ClientId})需追加:维持={maintenance:0.00},已缴={payableNet:0.00}(应付净额{payable:0.00}+初始授信占用{initCredit:0.00}),目标={target:0.00},已补足(现金{fundedCash:0.00}+授信{fundedCredit:0.00}),本次增量={increment:0.00}"); //幂等清理:先删本结算日起 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(); logger.Info($"EOD追保:交易{td.id} 追保腿落库完成——新腿{newLegs.Count}条(id=[{string.Join(",", newLegs.Select(x => x.id))}],含拆单现金腿)"); settledTrades++; settledLegs += newLegs.Count; totalIncrement += increment; //实时持仓克隆 + 开仓事件(参照 TradeConfirmService 簿记后动作,但只针对本次新腿—— //整交易 InitialPosition 会把浮动腿实时持仓重置回开仓态、AddPositionEvent 会为全部腿重复建开仓事件,EOD 场景不可用); //克隆继承定稿标签,供平仓返还分流与后续快照链使用 foreach (var newLeg in newLegs) { var clone = newLeg.Clone(); clone.id = 0; clone.PositionId = newLeg.id; clone.IsInitial = false; DbContext.swap_position.Add(clone); } DbContext.SaveChanges(); flowEventService.InitEvent(newLegs, td, EodOptName); DbContext.SaveChanges(); //方案A:腿生成晚于当日 SwapPositionCompose(当日 trade_span 结算后才可算增量),显式补写当日 eod_swap_position 行, //使当日报表明细(PostionMarginGain 等)不漏计;下一结算日快照由 Compose 先清后建正常接管 foreach (var newLeg in newLegs) { eodPositionService.SaveEodAdditionalMarginPosition(td, newLeg, settleDate); } } } logger.Info($"EOD追保:结算日{settleDate:yyyy-MM-dd} 完成——规则15交易{tradeIds.Count}笔,{settledTrades}笔产生追保(腿{settledLegs}条,合计增量{totalIncrement:0.00})"); } /// /// 幂等清理:删除本交易 本结算日起 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(); logger.Info($"EOD追保:交易{td.id} 幂等清理(重跑)——删EOD追保腿{oldLegs.Count}条(id=[{string.Join(",", oldLegs.Select(x => x.id))}])及其簿记:现金流水{oldCashRecords.Count}条、授信占用{oldCreditRecords.Count}条、开仓事件{oldEvents.Count}条、eod快照{oldEodRows.Count}行"); DbContext.eod_swap_position.RemoveRange(oldEodRows); DbContext.swap_position.RemoveRange(oldLegs); DbContext.SaveChanges(); } } }