using MoreLinq.Extensions; using Newtonsoft.Json; using YLErp.BLL; using YLErp.BLL.Eod; using YLErp.DBModels.Enums; using YLErp.Derivatives.Interest; using YLErp.Helpers; using YLErp.Modules.DataProviderModule; using YLErp.Modules.EodModule; using YLErp.Modules.SwapModule.Accrual; using YLErp.Modules.SwapModule.FundingLegs; using YLErp.Modules.SwapModule.Margin; using YLErp.Modules.SwapModule.ReturnLegs; using YLErp.Modules.TradeModule; using YLErp.Modules.TradeModule.DealModule; using YLErp.QdpModule; namespace YLErp.Modules.SwapModule { public class SwapDealService : SwapTradeBaseService { private static readonly IYcLogger Logger = LogFactory.GetLogger(nameof(SwapDealService)); protected virtual bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate) { return EodPriceQueryService.TryGetPrice(valueDate, underlyingCode, out rate); } private IIndexFixer _indexFixer; /// FR007 取价器,委托 TryGetFloatRate(保留 virtual 接缝供测试 stub)。 protected virtual IIndexFixer IndexFixer => _indexFixer ??= new SwapDealIndexFixer((d, c) => TryGetFloatRate(d, c, out double r) ? (true, r) : (false, 0d)); #region 可测试化接缝(Seams)——override 这些虚方法可在测试中替换 DB/外部调用,生产代码行为不变 // FindTrade 已上提到基类 SwapTradeBaseService(三子类实现一致,消除重复) /// 添加资金记录(生产: AddClientCashInCashOut;测试: 计数并记录金额) protected virtual int AddClientCash(trade td, double amount, string action, DateTime valueDate) { return AddClientCashInCashOut(td, amount, action, valueDate); } /// 保存互换/平仓事件(生产: 落库+建事件;测试: 收集 unwindData 入内存列表)。 /// 原 private 改 protected virtual,使测试 stub 可整体 override,规避内部 new SwapEventService 连库。 protected virtual long SaveSwapDeal(UnwindData unwindData, int eventType, int clientCashId, string eventResason = "", bool approve = false) { UnwindNormalizer.NormalizeNotionalValues(unwindData); return SaveSwapDealInternal(unwindData, eventType, clientCashId, eventResason, approve); } // 待实现利息会进入 decimal(30,12) 日终快照;精度常量统一引用 InterestMath.FundingLegPrecision,消除重复定义。 private const int InterestCalculationPrecision = InterestMath.FundingLegPrecision; // 客户现金在 SaveSwapDeal 之前创建,手工结算必须先收敛流水并重算汇总金额。 private void NormalizeManualSettlementAmounts(UnwindData unwindData, int eventType, string eventReason) { if (!UnwindNormalizer.NormalizeSettledInterestAmounts(unwindData.FlowEvents, eventType, eventReason)) { return; } if (unwindData.FlowEvents.Any(x => !string.IsNullOrEmpty(x.UnderlyingCode))) { CalcCloseAmount(unwindData); if (eventType == (int)SwapEventTypeEnum.互换) { unwindData.SwapMarginAmount = 0; } return; } unwindData.SwapCloseAmount = Math.Round(unwindData.SwapCloseAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); unwindData.SwapRealizedPnL = Math.Round(unwindData.SwapRealizedPnL, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); } /// 保存所有变更(生产: DbContext.SaveChanges;测试: 空操作) protected virtual void SaveAllChanges() { DbContext.SaveChanges(); } /// 在事务中执行(生产: BeginTransaction/Commit/Rollback;测试: 直接执行不包事务) protected virtual void ExecuteInTransaction(Action action) { var trans = DbContext.Database.BeginTransaction(); try { action(); trans.Commit(); } catch { trans.Rollback(); throw; } finally { trans.Dispose(); } } /// 保存互换交易资金记录(生产: new ClientCashInCashOutService;测试: 空操作)。 /// 仅 SwapUnwind 全平仓且 NeedOpenFee=false 时调用。 protected virtual void CallSaveSwapTradeClientCash(trade td, DateTime valueDate) { td.trade_extend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == td.id); if (td.trade_extend != null && !td.trade_extend.ExtendObj.NeedOpenFee) { new ClientCashInCashOutService(this).SaveSwapTradeClientCash(td, td.TradePrice ?? 0, valueDate, 0); } } /// 触发互换实时持仓计算(生产: Task.Run 异步 RealtimePnlCalc;测试: 空操作)。 /// 仅 SwapUnwind 成功后调用。 protected virtual void TriggerRealtimeSwapPosition() { Task.Run(() => { try { RealtimePnlCalc.RealtimeSwapPosition(new OptUserInfo(0, "互换实时持仓服务", OptUserFrom.Service)); } catch (Exception ex) { LogFactory.GetLogger().Error("互换实时持仓服务计算失败", ex); } }); } /// 查找待审核的互换/平仓事件(生产: DbContext.swap_event 查询;测试: 返回内存对象) protected virtual swap_event FindSwapEvent(int tradeId, int eventType) { return DbContext.swap_event .Where(x => x.SwapTradeId == tradeId && !x.Invalid && x.EventType == eventType) .OrderByDescending(o => o.id).FirstOrDefault(); } /// 查找事件关联的流水事件(生产: DbContext.swap_flow_event 查询;测试: 返回内存列表) protected virtual List FindFlowEventsByEventId(long eventId) { return DbContext.swap_flow_event.Where(x => x.EventId == eventId).ToList(); } /// 平仓/互换审核的前置校验与状态设置(生产: new TradeUnwindService;测试: 空操作或计数) protected virtual void CloseReCheckSetTrade(int swapTradeId, bool isSwap, bool needCheck) { new TradeUnwindService(this).CloseReCheck_SetTrade(swapTradeId, isSwap, needCheck); } protected virtual DateTime GetMaxIncomeValueDate(trade td) { return td.ExerciseDate.Value.AddDays(-1); } #endregion public SwapDealService(OptUserInfo optUser) : base(optUser) { } public SwapDealService(YLBaseService baseService) : base(baseService) { } #region 前端盈亏只读校验(不阻断交易) /// /// 用 FrontendCalcReference 公式重算盈亏,与前端传来的 unwindData 比对, /// 差异 > 0.01 记 Error 日志。整体 try/catch 吞异常——校验自身错误绝不阻断交易。 /// /// 目的:前端保持快速反馈(用户改输入立即算),后端不替代前端,仅做合理性兜底, /// 为将来公式统一积累"前后端差异"数据。 /// 核心比对逻辑已抽到 SwapFrontendPnlValidator.BuildFrontendValidationDiffs 纯函数,便于单测覆盖。 /// /// 前端算好传入的结算数据 /// true=结息页(income公式),false=平仓页(unwind公式) private void ValidateFrontendPnL(UnwindData unwindData, bool isIncome) { try { var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome); if (diffs == null) return; // 取浮动腿用于日志上下文(与原实现一致) var floatLeg = unwindData.FlowEvents?.FirstOrDefault(x => !string.IsNullOrEmpty(x.UnderlyingCode)); foreach (var d in diffs) { Logger.Error($"[互换盈亏校验分歧] tradeId={unwindData.SwapTradeId} field={d.Field} frontend={d.FrontendValue} backend={d.BackendValue} diff={d.Delta} " + $"floatLeg=[gross={floatLeg?.PosiGrossPrice} avg={floatLeg?.TradingAmountAvg} qty={floatLeg?.Quantity} payDir={floatLeg?.PayDirection} posType={floatLeg?.PositionType}]"); } } catch (Exception ex) { // 校验自身错误绝不阻断交易 Logger.Error($"[互换盈亏校验异常] tradeId={unwindData.SwapTradeId} isIncome={isIncome}", ex); } } #endregion /// /// 平仓初始化 /// /// /// /// public UnwindData InitUnwind(int tradeId) { var td = DbContext.trade.Find(tradeId); var positions = DbContext.swap_position.ActiveByTrade(tradeId); var um = DataCacheProvider.GetUnderlyingDataSource().GetData(td.UnderlyingCode); bool commodity = ConsGlobal.InstrumentType.CalcTypeIsFutures(um.UnderlyingInstrumentType); List eventTyps = new List() { (int)SwapEventTypeEnum.自动互换, (int)SwapEventTypeEnum.互换 }; var dealDate = valuedateBLL.ValueDate <= td.ExerciseDate.Value ? valuedateBLL.ValueDate : td.ExerciseDate.Value; //CheckLastEod(dealDate, td.TradeDate.Value, tradeId); //去掉平仓收盘限制 var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == tradeId); td.trade_extend = tradeExtend; var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault(); var oriPosition = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && x.IsInitial).FirstOrDefault(); var preDealDate = GetPreDealDate(tradeId, dealDate, eventTyps); var hasProcess = HasTradeProcess(); swap_flow_event floatEvent = new swap_flow_event(); UnwindData unwindData = new UnwindData(); if (((valuedateBLL.SystemDate.CloseReCheck == 1) || (valuedateBLL.SystemDate.CloseReApprove == 1 && hasProcess)) && td.TradeStatus == ConsTrade.平仓待复核) { var swapEvent = GetSwapEvent(tradeId, (int)SwapEventTypeEnum.平仓); if (swapEvent == null) { throw new Exception("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效"); } unwindData = swapEvent.unwindData; } else { unwindData.TradeStartDate = td.StartDate; unwindData.CloseType = commodity ? 1 : 2; unwindData.StartDate = td.TradeDate.Value; if (preDealDate.HasValue) { unwindData.StartDate = preDealDate.Value; } unwindData.ValueDate = dealDate; unwindData.UnwindDate = dealDate; floatEvent.EventDate = dealDate; floatEvent.UnwindDate = unwindData.UnwindDate; floatEvent.PayDate = QdpCalendarHelper.GetNonHoliday(unwindData.UnwindDate.Value.AddDays(td.trade_extend.ExtendObj.SettlementRules)); unwindData.PayDate = floatEvent.PayDate; floatEvent.SwapTradeId = tradeId; floatEvent.SwapTradeNo = td.TradeNumber; unwindData.SwapTradeId = tradeId; unwindData.StructureType = td.StructureType; unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0); unwindData.NotionalQty = positions.Where(x => x.IsInitial).Sum(s => s.PosiQuantity); unwindData.PosiNotionalValue = Convert.ToDecimal(td.StockEqvNotional); unwindData.PositionQty = position != null ? position.PosiQuantity : Convert.ToDecimal(td.TradeAmount); unwindData.AnnualDays = tradeExtend == null ? 365 : tradeExtend.ExtendObj.AnnualDays; unwindData.CloseMethod = (int)CloseMethodEnum.全部平仓; // 占期初(original)语义(A):默认"平掉剩余全部持仓" = 剩余名义本金/期初名义本金。 // 未平仓时 PosiNotionalValue==NotionalValue → 1(平100%);部分平仓后自动变为剩余比例(如已平10%则默认90%)。 // 与互换/提前终止 InitIncome(L447) 保持一致。 unwindData.ClosePercent = CalcDefaultInitClosePercent(unwindData.NotionalValue, unwindData.PosiNotionalValue); unwindData.CloseNotionalValue = unwindData.PosiNotionalValue; unwindData.CloseQty = unwindData.PositionQty; if (position != null) { floatEvent.PositionId = position.PositionId; floatEvent.EventType = (int)SwapEventTypeEnum.平仓; floatEvent.EventReason = "交易"; // 方案C:分红收益改由上一收盘日 EOD PosiDividendSum 提供(单一可信源), // 前端 getDivindIn 不再覆盖;消除"期初持仓×totalInterest"对已平仓部分的重复计入。 // 同一 EOD 值取一次喂两栏: // DividendIn = "浮动端平仓盈亏·分红"(本次动作要落袋的,落库后被前端按需展示) // DividendPending = "待结算分红收益"(仍挂在账上、未来才结的存量 = PosiDividendSum 全量口径, // 见 GetPreEodDividendSum 注释的口径论证;切勿改回硬0或分摊,会落库回归) decimal preEodDividendSum = GetPreEodDividendSum(tradeId, position.PositionId, dealDate); Logger.Info($"[分红-平仓预览] 方案C DividendIn=DividendPending=PosiDividendSum全量 tradeId={tradeId} positionId={position.PositionId} dealDate={dealDate:yyyy-MM-dd} 值={preEodDividendSum}"); floatEvent.DividendIn = preEodDividendSum; floatEvent.DividendPending = preEodDividendSum; floatEvent.UnderlyingCode = position.UnderlyingCode; floatEvent.UnderlyingInstrumentType = position.UnderlyingInstrumentType; floatEvent.CloseFee = 0; floatEvent.BeforeCloseFee = oriPosition.PosiTradingFeePending; floatEvent.TradingFee = TradingFeeCalc.CalcInitTradingFee(oriPosition, unwindData); floatEvent.PosiTradingFeeUnit = oriPosition?.PosiTradingFeeUnit ?? 0; floatEvent.PosiFeeType = oriPosition?.PosiFeeType ?? 0; floatEvent.MarkClosePnl = 0; floatEvent.PayDirection = position.PosiDirection; floatEvent.PosiGrossPrice = position.PosiGrossPrice; floatEvent.PosiNetPrice = position.PosiNetPrice; floatEvent.TradingAmountNetAvg = position.PosiNetNoFeePrice; floatEvent.PositionType = position.PositionType; floatEvent.TradingAmountNetFeeAvg = position.PosiNetFeePrice; floatEvent.Quantity = position.PosiQuantity; floatEvent.PositionQty = 0; floatEvent.ContractSize = position.ContractSize; floatEvent.TradingAmount = floatEvent.Quantity * floatEvent.ContractSize; var ratio = -DirectionRatio.ReceivePay(position.PosiDirection); floatEvent.TradingFeePending = TradingFeeCalc.CalcInitTradingFeePending(oriPosition, position, unwindData); floatEvent.DataState = (int)SwapFlowDateStateEnum.完成; floatEvent.InterestMode = position.InterestMode; floatEvent.ClientId = td.ClientId; floatEvent.SetOpt(UserInfo); } unwindData.FlowEvents.Add(floatEvent); } return unwindData; } /// /// 校验上日是否收盘 /// /// public void CheckEodTrade(int tradeId) { var td = DbContext.trade.Find(tradeId); var dealDate = valuedateBLL.ValueDate <= td.ExerciseDate.Value ? valuedateBLL.ValueDate : td.ExerciseDate.Value; //CheckLastEod(dealDate, td.StartDate.Value, tradeId); } /// /// 校验收益结算操作(不检查收盘限制) /// /// public void CheckEodTradeForIncome(int tradeId) { var td = DbContext.trade.Find(tradeId); // 收益结算不检查收盘限制,只检查交易状态 if (td.TradeType != "收益互换") { throw new ServiceException("该交易不是收益互换类型"); } if (td.ValidState == "InValid") { throw new ServiceException("该交易已无效"); } if (td.TradeStatus != ConsTrade.确认成交 && td.TradeStatus != ConsTrade.提前终止拒绝) { throw new ServiceException($"该交易状态为【{td.TradeStatus}】,无法进行收益结算"); } } /// /// 多空组合 平仓初始化 /// /// /// /// /// /// 平仓初始化 /// /// /// /// public UnwindData InitIncome(int tradeId) { var checkEventTypes = new List() { (int)SwapEventTypeEnum.互换, (int)SwapEventTypeEnum.自动互换 }; var td = DbContext.trade.Find(tradeId); var positions = DbContext.swap_position.ActiveByTrade(tradeId); var um = DataCacheProvider.GetUnderlyingDataSource().GetData(td.UnderlyingCode); List eventTypes = new List() { (int)SwapFlowEventTypeEnum.互换, (int)SwapFlowEventTypeEnum.自动互换 }; var maxIncomeValueDate = GetMaxIncomeValueDate(td); var dealDate = valuedateBLL.ValueDate.Date > maxIncomeValueDate.Date ? maxIncomeValueDate : valuedateBLL.ValueDate; // 收益结算不检查收盘限制 var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == tradeId); td.trade_extend = tradeExtend; var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault(); //var preSettleDate = CheckLastEod(dealDate, td.StartDate.Value, tradeId);//上一交易日期 var preDealDate = GetPreDealDate(tradeId, dealDate, eventTypes); var hasProcess = HasTradeProcess(); swap_flow_event floatEvent = new swap_flow_event(); UnwindData unwindData = new UnwindData(); if (((valuedateBLL.SystemDate.CloseReCheck == 1) || (valuedateBLL.SystemDate.CloseReApprove == 1 && hasProcess)) && td.TradeStatus == ConsTrade.互换待复核) { var swapEvent = GetSwapEvent(tradeId, (int)SwapEventTypeEnum.互换); if (swapEvent == null) { throw new Exception("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效"); } unwindData = swapEvent.unwindData; } else { unwindData.StartDate = td.TradeDate.Value; if (preDealDate.HasValue) { unwindData.StartDate = preDealDate.Value; } unwindData.TradeStartDate = td.StartDate; unwindData.ValueDate = dealDate; unwindData.UnwindDate = dealDate; floatEvent.UnwindDate = unwindData.UnwindDate; floatEvent.EventDate = dealDate; unwindData.PayDate = valuedateBLL.ValueDate; floatEvent.PayDate = unwindData.PayDate; floatEvent.SwapTradeId = tradeId; floatEvent.SwapTradeNo = td.TradeNumber; floatEvent.EventType = (int)SwapFlowEventTypeEnum.互换; floatEvent.EventReason = "交易"; unwindData.SwapTradeId = tradeId; unwindData.StructureType = td.StructureType; unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0); unwindData.NotionalQty = positions.Where(x => x.IsInitial).Sum(s => s.PosiQuantity); unwindData.PosiNotionalValue = Convert.ToDecimal(td.StockEqvNotional); unwindData.PositionQty = position != null ? position.PosiQuantity : Convert.ToDecimal(td.TradeAmount); unwindData.AnnualDays = tradeExtend == null ? 365 : tradeExtend.ExtendObj.AnnualDays; unwindData.ClosePercent = unwindData.PosiNotionalValue / unwindData.NotionalValue; unwindData.CloseNotionalValue = unwindData.PosiNotionalValue; if (position != null) { floatEvent.PositionId = position.PositionId; // 方案C:分红收益改由上一收盘日 EOD PosiDividendSum 提供(单一可信源), // 前端 getDivindIn 不再覆盖;消除"期初持仓×totalInterest"对已平仓部分的重复计入。 decimal preEodDividendSum = GetPreEodDividendSum(tradeId, position.PositionId, dealDate); Logger.Info($"[分红-收益结算] DividendIn=PosiDividendSum全量 tradeId={tradeId} positionId={position.PositionId} dealDate={dealDate:yyyy-MM-dd} 值={preEodDividendSum}"); floatEvent.DividendIn = preEodDividendSum; floatEvent.UnderlyingCode = position.UnderlyingCode; floatEvent.UnderlyingInstrumentType = position.UnderlyingInstrumentType; floatEvent.CloseFee = 0; floatEvent.MarkClosePnl = 0; floatEvent.PayDirection = position.PosiDirection; floatEvent.PosiGrossPrice = position.PosiGrossPrice; floatEvent.PosiNetPrice = position.PosiNetPrice; // 注意:TradingAmountNetAvg 字段名为"成交净价(期末语义)",但收益结算/平仓初始化时装入的是期初净价(PosiNetNoFeePrice),前端展示期初净价时取此字段 floatEvent.TradingAmountNetAvg = position.PosiNetNoFeePrice; floatEvent.TradingAmountNetFeeAvg = position.PosiNetFeePrice; floatEvent.PositionType = position.PositionType; floatEvent.Quantity = position.PosiQuantity; floatEvent.PositionQty = 0; floatEvent.ContractSize = position.ContractSize; floatEvent.TradingAmount = floatEvent.Quantity * floatEvent.ContractSize; floatEvent.ClientId = td.ClientId; floatEvent.DataState = (int)SwapFlowDateStateEnum.完成; } unwindData.FlowEvents.Add(floatEvent); } unwindData.MaxIncomeValueDate = maxIncomeValueDate; return unwindData; } /// /// 获取平仓利息端信息 /// /// 平仓日期 /// 交易id /// 平仓比例 /// /// public List GetUnwindInterests(DateTime valueDate, DateTime unwindDate, int tradeId, decimal closePercent, int eventType) { List interests = new List(); if (closePercent > 1) { closePercent = 1;//防篡改 } else if (closePercent < 0) { closePercent = 0; } var td = DbContext.trade.Find(tradeId); if (td == null) { throw new ServiceException("未找到交易信息"); } var allpositions = DbContext.swap_position.ActiveByTrade(tradeId).ToList(); var origPositions = allpositions.Where(x => x.IsInitial).ToList(); var realPostitions = allpositions.Where(x => !x.IsInitial).ToList(); // 根因修复(多次部分平仓预付金返还错误):见 ResolveInterestLegPositions 注释。 // 迭代源仍用 origPositions(保留 orig.id → eod_swap_position.PositionId 的日终匹配), // 仅对预付金腿以实时腿的剩余本金克隆覆盖,故此处不改任何日终匹配行为。 var positions = ResolveInterestLegPositions(origPositions, realPostitions); var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == tradeId); List eventTypes = new List() { (int)SwapEventTypeEnum.平仓, (int)SwapEventTypeEnum.互换, (int)SwapEventTypeEnum.自动互换 }; var lastEod = DbContext.eod_swap.Where(x => x.ValueDate < unwindDate && x.SwapTradeId == tradeId).OrderByDescending(o => o.ValueDate).FirstOrDefault(); var _preSetteDate = lastEod == null ? unwindDate.AddDays(-1) : lastEod.ValueDate; List lastEodPositions = new SwapEodPositionService(this).GetPreEodPositions(tradeId, _preSetteDate);//上一交易数据 var stockEqvNotional = realPostitions.Where(x => x.PosiDirection > 0).Sum(s => s.PosiNotionalValue); // 当前平仓前的实时剩余本金 var posiNotionalValue = stockEqvNotional * closePercent;// 本次平仓名义本金 var orginPv = ResolveUnwindPreviousNotional(lastEod, lastEodPositions, stockEqvNotional); // 上一日终的浮动端本金 var closeList = DbContext.swap_flow_event.Where(x => x.SwapTradeId == tradeId && x.UnwindDate == unwindDate && eventTypes.Contains(x.EventType) && x.DataState == (int)SwapFlowDateStateEnum.完成).ToList(); bool tdClose = closeList.Count > 0; // 显式入口:平仓前剩余本金 + 实际平掉额 + B语义比例,盘中重放(语义见 InterestCalcRequest.IntradayUnwind) interests = GetIntradayUnwindInterests(InterestCalcRequest.IntradayUnwind( td, tradeExtend, valueDate, unwindDate, lastEodPositions, positions, stockEqvNotional, posiNotionalValue, closePercent, eventType, tdClose, orginPv, add: true, newCalcLast: false, closeList)); return interests; } /// /// 解析利息腿(PosiDirection==0)持仓,供 GetUnwindInterests 使用。抽为纯函数以便无库单测。 /// 根因(多次部分平仓预付金返还错误):预付金腿(初始/追加)的"当前剩余本金"存于实时持仓 /// realPositions.InterestPrincipalFix,每次平仓由 UpdateInitalPosition 递减;而原始腿 /// origPositions(IsInitial=1)的 InterestPrincipalFix 恒为初始值。GetInterests 算 /// closePrincipal = Fix × closePercent 时读 position.InterestPrincipalFix,若沿用原始腿, /// 会在多次部分平仓后仍返还/计算初始本金(如始终 99000)。 /// 修复:迭代源仍用 origPositions(保留 orig.id → eod_swap_position.PositionId 的日终匹配, /// 全库实测 eod 均按 orig.id 归档;若换 realPositions 会破坏 preEod 匹配导致利息重算错误),仅对预付金腿 /// Clone 覆盖其本金值为实时腿的剩余本金。real 与 orig 通过 real.PositionId == orig.id 精确 1:1 关联。 /// 首次平仓时 orig==real 行为不变;仅在发生过部分平仓后 real≠orig 时用实时腿本金纠正。 /// /// 原始腿(IsInitial=1)全集 /// 实时腿(IsInitial=0)全集,其 PositionId 指向对应 orig 的 id /// 利息腿(PosiDirection==0)列表:预付金腿本金已对齐实时剩余本金,其余保持原始腿 public static List ResolveInterestLegPositions(List origPositions, List realPositions) { realPositions ??= new List(); return origPositions.Where(x => x.PosiDirection == 0).Select(p => { if (MarginModes.Contains(p.InterestMode)) { var realLeg = realPositions.FirstOrDefault(r => r.PositionId == p.id); if (realLeg != null && realLeg.InterestPrincipalFix != p.InterestPrincipalFix) { var clone = p.Clone(); clone.InterestPrincipalFix = realLeg.InterestPrincipalFix; return clone; } } 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 => MarginModes.Contains(x.InterestMode)) .GroupBy(x => x.PositionId) .ToDictionary(x => x.Key, x => x.Sum(v => v.InterestPrincipal)); return origPositions.Where(x => x.PosiDirection == 0).Select(p => { if (MarginModes.Contains(p.InterestMode)) { var realLeg = realPositions.FirstOrDefault(r => r.PositionId == p.id); if (realLeg != null) { 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, IEnumerable lastEodPositions, decimal currentNotional) => ClosePercentMath.ResolveUnwindPreviousNotional(lastEod, lastEodPositions, currentNotional); /// /// 获取利息腿"已通过历史互换结出的累计利息"(用于复利重算时扣除,类比分红的 CalcConsumedDividend)。 /// 数据源为事件级 swap_flow_event.InterestAmount(互换/自动互换 完成态事件,互换当时即落库,不依赖日终归档)。 /// /// 交易id /// 利息腿id /// 结算日(不含,仅汇总此日之前的历史已结利息;当日事件由 closeList 去重逻辑单独处理) /// 历史已结利息累计金额(绝对值) public virtual decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate) { List swapEventTypes = new List() { (int)SwapEventTypeEnum.互换, (int)SwapEventTypeEnum.自动互换 }; var consumed = DbContext.swap_flow_event .Where(x => x.SwapTradeId == tradeId && x.PositionId == positionId && swapEventTypes.Contains(x.EventType) && x.DataState == (int)SwapFlowDateStateEnum.完成 && x.EventDate < beforeDate) .Sum(s => (decimal?)s.InterestAmount) ?? 0m; return consumed; } /// /// 计算利息腿计息详细 /// /// 交易 /// 交易扩展数据 /// 操作日期 /// 上一日终持仓 /// 期初利率端 /// 持仓名义本金 /// 平仓名义本金 /// /// /// /// /// /// /// 【盘中平仓/互换结息】显式入口——GetInterests(settment:false) 盘中语义的具名封装(2026-08 显式化重构)。 /// 语义契约见 InterestCalcRequest.IntradayUnwind 工厂注释;计息走 CalcUnwindInterest 全区间重放。 /// public List GetIntradayUnwindInterests(InterestCalcRequest req) => GetInterests(req.Td, req.TradeExtend, req.ValueDate, req.UnwindDate, req.EodPositions, req.Positions, req.PosiNotionalValue, req.ClosePosiNotionalValue, req.ClosePercent, req.EventType, req.TdClose, req.OrginPv, req.Add, settment: false, req.NewCalcLast, req.CloseList); public List GetInterests( trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate, List eodPositions, List positions, decimal posiNotionalValue, decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast= false, List closeList = null) { List interests = new List(); var annualDays = tradeExtend == null ? 365 : tradeExtend.ExtendObj.AnnualDays; bool calcFirst = tradeExtend?.ExtendObj.CalcFirst ?? true; bool calcLast = tradeExtend?.ExtendObj.CalcLast ?? true; // 计息到尾日(含平仓场景覆盖):交易本身算尾 或 本次平仓指定算尾(newCalcLast) bool effectiveCalcLast = calcLast || newCalcLast; foreach (var position in positions) { // 初始化持仓信息 var preEodPosition = eodPositions.FirstOrDefault(x => x.PositionId == position.id) ?? new eod_swap_position(); var positionClone = position.Clone(); DateTime? preDealDate = preEodPosition.id != 0 ? preEodPosition.ValueDate : null; // 计算计息区间 int interestPeriod = position.interest_rest_days ?? 1; // true=计息窗口为空(不计利息,利率与金额归零;典型触发=不算头首日/不算尾到期日回拨翻转, // 判定只看日期窗口与事件类型无关;当日已结息日期相等时窗口非空,归零由下方 closeList 净额层处理) bool interestWindowEmpty = InitInterestDate(unwindDate, preDealDate, td, tdClose, out DateTime startDate, out DateTime endDate); // 获取利率(保证金/融资腿共用:SwapIntervalList 取当日适用固定利率 + 精度收口) decimal rate = Math.Round(GetFixedRate(position, unwindDate), InterestCalculationPrecision, MidpointRounding.AwayFromZero); // 做精度调整 原数据有精度误差 // ── 边界隔离:保证金腿(5/6)在循环最外层路由,后续融资腿分支树不感知保证金概念 ── // 有意跳过 GetFloatRate:CalcMarginInterest 纯固定利率(FundingLegRate.Fixed)且 FloatRate 恒 0, // 浮动取价/回写对保证金无意义;即使脏数据填了 FloatRateUnderlyingCode 且缺价,也不应阻断保证金结算。 if (MarginModes.Contains(position.InterestMode)) { positionClone.InterestDirection = MarginCalc.FlipDirection(position.InterestDirection); // 保证金腿: 计息基数 = InterestPrincipalFix(保证金余额),无融资腿差分公式与 orginPv 维度 hack interests.Add(CalcMarginInterest(td, valueDate, endDate, positionClone, rate, position.InterestPrincipalFix * closePrecent, position.InterestPrincipalFix, closePrecent, annualDays, calcFirst, effectiveCalcLast, preEodPosition, eventType, add, settment, interestWindowEmpty)); continue; } // 计算名义本金(以下仅融资腿 1/2/9:走策略工厂) var mode = (InterestModeEnum)position.InterestMode; var r = FundingLegStrategyFactory.Get(mode) .CalcNotional(position.InterestPrincipalFix, posiNotionalValue, closePrecent); decimal closePrincipal = r.ClosePrincipal; decimal posiPrincipal = r.PosiPrincipal; decimal newClosePercent = r.ClosePercent; // 根因位置:SwapEodPositionService.SaveAutoEodWithCloseInterestPosition 在平仓后收盘时传入 // “收盘后剩余本金 + closePercent=1”,与盘中“平仓前本金 + 实际关闭比例”不是同一语义。 // GetInterests 同时被盘中试算和 EOD 平仓后收盘调用:后者传入的 // posiNotionalValue 是收盘后的剩余本金,closePosiNotionalValue 才是本次实际平掉的本金。 // 例如平仓前 100、平掉 30、收盘后剩余 70 时,EOD 传入 posi=70、close=30、closePercent=1。 // 模式2(合约名义本金规模)的本次结息本金必须始终是实际平仓额,因此无条件覆盖, // 否则会错误地用剩余 70 结算本次平掉的 30。模式9(标的期初全价)的部分平仓 // 仍保留既有的剩余/复利动态本金承接逻辑;仅最终全平时 posi=0,才覆盖以避免结息本金为 0。 if (mode == InterestModeEnum.合约名义本金规模 || (mode == InterestModeEnum.标的期初全价 && posiNotionalValue == 0m)) { closePrincipal = closePosiNotionalValue; } decimal floatRate = GetFloatRate(position, preEodPosition, td.StartDate.Value, endDate, interestPeriod, interestWindowEmpty, positionClone, effectiveCalcLast); // 根据场景计算利息 if (settment) { // 收盘归档场景,使用 CalcEodInterest interests.Add(CalcEodInterest(td, valueDate, positionClone, rate, floatRate, closePrincipal, posiPrincipal, annualDays, calcFirst, calcLast, preEodPosition, eventType, add)); } else { // 盘中互换场景,使用 CalcUnwindInterest // 取历史已结利息(事件级,互换当时落库),供复利重算扣除(仅复利需要;单利基于日终快照自带状态) var consumedInterest = position.InterestType == (int)InterestTypeEnum.复利 ? GetConsumedInterest(td.id, position.id, endDate) : 0m; interests.Add(CalcUnwindInterest(td, valueDate, endDate, positionClone, rate, floatRate, posiPrincipal, closePrincipal, newClosePercent, annualDays, preEodPosition, eventType, add, interestWindowEmpty, orginPv, calcFirst, effectiveCalcLast, consumedInterest)); } } //当日有平仓或互换记录时,避免重复结算 if (closeList != null && closeList.Count > 0) { foreach (var item in interests) { var closeEvent = closeList.Where(x => x.PositionId == item.PositionId); if (eventType == (int)SwapEventTypeEnum.互换 || eventType == (int)SwapEventTypeEnum.自动互换) { // 互换:该仓位当天有同类型的完成事件,直接归0 var swapCloseEvent = closeEvent.Where(x => x.EventType == eventType); if (swapCloseEvent.Any()) { item.InterestAmount = 0; item.TdInterestAmount = 0; item.InterestClosePnL = 0; } } else if (!effectiveCalcLast) { // 平仓不算尾:扣除已结算的利息(算尾时利息已包含关闭日,无重叠) var closePnl = closeEvent.Sum(s => s.InterestClosePnL); var closeAmount = closeEvent.Sum(s => s.InterestAmount); var closeTdAmount = closeEvent.Sum(s => s.TdInterestAmount); item.InterestAmount = item.InterestAmount - closeAmount; item.TdInterestAmount = item.TdInterestAmount - closeTdAmount; item.InterestClosePnL = item.InterestClosePnL - closePnl; } } } return interests; } /// /// 平仓比例口径转换(解决"显示占期初 / 计算占剩余"双语义问题)。 /// 前端与事件列表展示用"占期初(original)"语义(A);后端计息基数计算 / 费用递减 / /// 全平判定均按"占剩余(remaining)"语义(B)消费。 /// A → B:B = A × 期初名义本金(NotionalValue) / 剩余名义本金(PosiNotionalValue),并 cap 到 1。 /// B → A:A = B × 剩余名义本金 / 期初名义本金。 /// 分母为 0(无持仓等异常场景)时原样返回,避免除零。 /// public static decimal ToRemainingClosePercent(decimal originalClosePercent, decimal notionalValue, decimal posiNotionalValue) => ClosePercentMath.ToRemainingClosePercent(originalClosePercent, notionalValue, posiNotionalValue); /// /// B(占剩余) → A(占期初),用于落库 / 事件列表展示还原。见 ToRemainingClosePercent。 /// public static decimal ToOriginalClosePercent(decimal remainingClosePercent, decimal notionalValue, decimal posiNotionalValue) => ClosePercentMath.ToOriginalClosePercent(remainingClosePercent, notionalValue, posiNotionalValue); /// /// 计算 InitUnwind 默认占期初(A)平仓比例 = PosiNotionalValue / NotionalValue。 /// public static decimal CalcDefaultInitClosePercent(decimal notionalValue, decimal posiNotionalValue) => ClosePercentMath.CalcDefaultInitClosePercent(notionalValue, posiNotionalValue); /// /// 读取"上一收盘日"浮动腿的待实现分红(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 preEod = GetPreEodPositionByDate(tradeId, positionId, dealDate); var sum = preEod == null ? 0m : preEod.PosiDividendSum; Logger.Info($"[分红-读取] GetPreEodDividendSum tradeId={tradeId} positionId={positionId} dealDate={dealDate:yyyy-MM-dd} 取EOD日期={(preEod?.ValueDate):yyyy-MM-dd} PosiDividendSum={sum}"); return sum; } /// /// 取 dealDate 对应"上一收盘日"持仓的累计分红快照。 /// GLMS-20260105-0006:登记日当天手动平仓/互换时,当日 EOD 快照已含分红,应取到当日而非 T-1。 /// 故由 ValueDate 严格小于 dealDate 改为 小于等于:当日 EOD 存在则读当日,否则回退上一收盘日(原口径不变)。 /// protected virtual eod_swap_position GetPreEodPositionByDate(int tradeId, long positionId, DateTime dealDate) { var lastEod = QueryPreEodSwaps(tradeId) .Where(x => x.ValueDate <= dealDate) .OrderByDescending(o => o.ValueDate).FirstOrDefault(); var preEodDate = lastEod == null ? dealDate.AddDays(-1) : lastEod.ValueDate; Logger.Info($"[分红-快照定位] GetPreEodPositionByDate tradeId={tradeId} positionId={positionId} dealDate={dealDate:yyyy-MM-dd} 取<=当日EOD, 命中日期={(lastEod?.ValueDate):yyyy-MM-dd}, 回退={lastEod == null}"); return QueryPreEodPosition(tradeId, positionId, preEodDate); } /// 可测性 seam:返回某交易的全部 eod_swap 行(不做日期过滤)。测试可 override 注入内存数据。 protected virtual IQueryable QueryPreEodSwaps(int tradeId) => DbContext.eod_swap.Where(x => x.SwapTradeId == tradeId); /// 可测性 seam:取指定收盘日的持仓累计分红快照。测试可 override 注入内存数据。 protected virtual eod_swap_position QueryPreEodPosition(int tradeId, long positionId, DateTime valueDate) => new SwapEodPositionService(this) .GetPreEodPositions(tradeId, valueDate) .FirstOrDefault(x => x.PositionId == positionId); /// /// 获取固定利率 /// private decimal GetFixedRate(swap_position position, DateTime startDate) { var swapIntervalToday = position.SwapIntervalList?.Where(x => x.Date <= startDate).OrderByDescending(o => o.Date).FirstOrDefault(); if (swapIntervalToday != null) return swapIntervalToday.Rate; var nextInterval = position.SwapIntervalList?.Where(x => x.Date > startDate).OrderBy(o => o.Date).FirstOrDefault(); return nextInterval?.Rate ?? position.InterestRateDefault; } /// /// 重置日判定:自锚点日起每 period 天一遇,锚点当日即首个重置日((日-锚点)%period==0)。 /// EQD-6968 取价依赖三判定之一。锚点口径注记:GetFloatRate 传交易起始日 td.StartDate, /// 分段重放/当日归周期判定传 position.PosiStartDate——非初始持仓(部分平仓剩余仓)两锚点可能不同, /// 本方法只收口公式、不统一锚点(统一属行为变更,需业务定调)。 /// SwapEodPositionService 的"持仓延续腿重置日再定盘"亦用本判定(td.StartDate 锚点)。 /// internal static bool IsResetDay(DateTime date, DateTime anchorDate, int period) => (date - anchorDate).Days % period == 0; /// /// 获取浮动利率 /// private decimal GetFloatRate(swap_position position, eod_swap_position preEod, DateTime startDate, DateTime endDate, int period, bool interestWindowEmpty, swap_position positionClone, bool calcLast = true) { if (string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) return position.FloatRate; int days = (endDate - startDate).Days; bool isResetDay = IsResetDay(endDate, startDate, period); // 重置日恰为到期日(endDate)时,取价日=endDate;否则=startDate(原逻辑)。 DateTime rateDate = IndexFixerBase.GetFixingDate( isResetDay ? endDate : startDate, position.interest_rule); // 历史上有"取错重置日利率"的线上 bug,取价决策必须常驻落盘(SwapCalcTrace.Critical 无条件 Info)。 SwapCalcTrace.Critical( $"FIX GetFloatRate p{position.id} [{startDate:yyyy-MM-dd}→{endDate:yyyy-MM-dd}] days={days} period={period} " + $"重置日={isResetDay} rule={position.interest_rule} 取价日={rateDate:yyyy-MM-dd} calcLast={calcLast} " + $"preEod={(preEod.id != 0 ? $"{preEod.ValueDate:yyyy-MM-dd}:{preEod.FloatRate:P6}" : "无")}"); if (preEod.id != 0 && !isResetDay) { SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} 非重置日→沿用昨日终FloatRate={preEod.FloatRate:P6}"); position.FloatRate = positionClone.FloatRate = preEod.FloatRate; return preEod.FloatRate; } // EQD-6968 口径自洽化:不算尾(calcLast=false)时 endDate 当天不计息,其定盘一概不取 // (有价也不取)——事件/回写利率与金额同源(末段已消费利率),杜绝"上午/下午落库利率不同"。 // 剩余持仓的新周期利率由 SwapEodPositionService 的"重置日再定盘"显式获取,不靠此处顺带。 if (!interestWindowEmpty && !calcLast && isResetDay) { var keptNoFetch = preEod.id != 0 ? preEod.FloatRate : position.FloatRate; SwapCalcTrace.Critical( $"FIX GetFloatRate p{position.id} 不算尾重置日→不取尾日定盘,沿用已有利率={keptNoFetch:P6}(来源={(preEod.id != 0 ? "preEod.FloatRate" : "position.FloatRate")})"); return keptNoFetch; } if (IndexFixer.TryGetFixing(rateDate, position.FloatRateUnderlyingCode, out decimal rate)) { SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} 重置日→取{rateDate:yyyy-MM-dd}定盘={rate:P6}"); position.FloatRate = positionClone.FloatRate = rate; return position.FloatRate; } if (!interestWindowEmpty) { if (calcLast) { SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} {rateDate:yyyy-MM-dd}缺价且算尾→抛异常拦截"); throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{rateDate:yyyy年MM月dd日}的价格"); } // 算头不算尾(calcLast=false):endDate 当天不计息,其 FR007 利率不参与计息, // 缺价时直接沿用已有利率,不回退取其他日期利率,不告警。 var kept = preEod.id != 0 ? preEod.FloatRate : position.FloatRate; SwapCalcTrace.Critical( $"FIX GetFloatRate p{position.id} {rateDate:yyyy-MM-dd}缺价且不算尾→沿用已有利率={kept:P6}(来源={(preEod.id != 0 ? "preEod.FloatRate" : "position.FloatRate")})"); return kept; } SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} 计息窗口为空→利率不参与,返回0"); return 0m; } /// /// 计算收盘利息(EOD) /// private swap_flow_event CalcEodInterest(trade td, DateTime valueDate, swap_position position, decimal rate, decimal floatRate, decimal closePrincipal, decimal posiPrincipal, int annualDays, bool calcFirst, bool calcLast, eod_swap_position preEod, int eventType, bool add) { // 判断当日是否计息:首日不算头或到期日不算尾则不计息 bool calcToday = true; if (calcFirst == false && valueDate == td.StartDate.Value) calcToday = false; // 首日不算头 if (calcLast == false && valueDate == td.ExerciseDate.Value) calcToday = false; // 到期日不算尾 if (valueDate < position.PosiStartDate) { calcToday = false; } // 初始化EOD持仓信息 if (preEod.id == 0) { preEod.FloatRate = floatRate; preEod.TdInterestPrincipal = posiPrincipal; preEod.PosiNotionalValue = posiPrincipal; } // 构建利息事件 var interest = new swap_flow_event { SwapTradeId = td.id, SwapTradeNo = td.TradeNumber, EventType = eventType, EventReason = "交易", EventDate = valueDate, PositionId = position.id, InterestDirection = position.InterestDirection, InterestRate = rate, InterestPrincipal = closePrincipal, InterestSwapInterval = position.InterestSwapInterval, InterestMode = position.InterestMode, FloatRate = floatRate, DataState = (int)SwapFlowDateStateEnum.完成, ClientId = td.ClientId, UnwindDate = valueDate }; // 收盘场景使用 preEod.FloatRate(历史浮动利率),与 InitSwapDealInterest 收盘场景保持一致 decimal eodFloatRate = preEod.id != 0 ? preEod.FloatRate : floatRate; decimal interestAmount = 0; decimal tdInterestAmount = 0; if (calcToday) { if (position.InterestType == (int)InterestTypeEnum.复利) { // 复利计算 CalcDailyCompoundInterestByEod(preEod, valueDate, td.StartDate.Value, position, closePrincipal, posiPrincipal, interest, annualDays, eodFloatRate, 1m, ref interestAmount, ref tdInterestAmount); } else { // 单利计算 CalcDailySimpleInterestByEod(preEod, valueDate, td.StartDate.Value, position, closePrincipal, posiPrincipal, interest, annualDays, eodFloatRate, 1m, ref interestAmount, ref tdInterestAmount); } } // 四舍五入并赋值 interest.InterestAmount = Math.Round(interestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero); interest.TdInterestAmount = Math.Round(tdInterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero); // 计算InterestClosePnL(方向:收取=1为正,支付=-1为负) var interestRatio = DirectionRatio.ReceivePay(position.InterestDirection); interest.InterestClosePnL = interest.InterestAmount * interestRatio; if (add) UpdateDbOption(interest); return interest; } /// /// 保证金腿(InterestMode 5/6)专属计息——替代 CalcEodInterest/CalcUnwindInterest 对保证金的处理。 /// /// 保证金是纯固定利率单利:浮动利率(FR007)/分段利率/复利对其均为死分支(前端无入口、 /// 确认书不含、FundingLegRate.Build 对空 FloatRateUnderlyingCode 恒返回 Fixed)。故本方法直接用 /// SimpleInterestAccrual 纯函数计息,本金取保证金余额: /// EOD = 昨日终本金 preEod.TdInterestPrincipal(与旧 CalcDailySimpleInterestByEod 同源,无差分) /// 盘中 = accrualBasis(preEod.TdInterestPrincipal + posiPrincipal - orginPv) /// 盘中保留差分是必要的:posiPrincipal 是否经 ResolveInterestLegPositions 对齐到实时剩余是路径相关的 /// (生产对齐 / 诊断测试用原始腿),单一本金变量无法覆盖两种状态,差分经 orginPv 自适应。orginPv 在 /// 本方法内部按保证金维度计算(PreviousBalance),消除原 InitSwapDealInterest 的外部维度 hack /// (融资腿 orginPv=浮动端名义本金)。保留累计语义(priorAccrued + 增量),满足下游字段契约。 /// /// /// 前提(由前端保证金表单 + SwapTradeService 构造保证): /// 1. InterestType=单利。本方法恒走 SimpleInterestAccrual 单利,不查 InterestType; /// 若库内 InterestMode=5/6 且 InterestType=复利(脏数据),会与旧 CalcEodInterest 复利分支不一致。 /// 2. rate 由 GetFixedRate 提供(SwapDealService.cs:866)——从 SwapIntervalList 取 Date ≤ unwindDate 最近段的 Rate, /// 空表/单段时返回 InterestRateDefault。SwapIntervalList 是"互换观察日排期"(阶梯利率表 + 结息日历,非 FR007 浮动—— /// 浮动由 FloatRateUnderlyingCode + interest_rest_days 独立驱动);保证金前端亦开放"设置观察日"分段录入。 /// 盘中用该 rate 覆盖全程,与旧 CalcDailySimpleInterest 完全一致(BuildSegmentRates 的 spread 同样是 GetFixedRate 单一值全程, /// 不按 SwapIntervalList 切段)——SwapIntervalList 阶梯利率在盘中半路变更的精细处理是既有未覆盖口径,非本次引入; /// EOD 路径因每日重取 GetFixedRate(valueDate) 故能正确反映阶梯。 /// 契约与副作用: /// 3. position.InterestDirection 须已由调用方翻转(GetInterests:742 FlipDirection);本方法不翻转。 /// 4. preEod 在 id==0 时被就地修改(设 TdInterestPrincipal/PosiNotionalValue/FloatRate),与旧 CalcEodInterest 一致。 /// 定位:SwapCalcTrace 落盘 AccrueEod/AccrualPeriod 的 notional/days/rate/accrued;盘中 accrualBasis 可从 trace 的 notional 反推。 /// /// true=收盘归档(EOD),false=盘中平仓/互换。 /// 计息窗口为空(仅盘中生效,true 时利息归零,同 InitSwapDealInterest;典型场景=互换当日已结息)。 public swap_flow_event CalcMarginInterest( trade td, DateTime valueDate, DateTime endDate, swap_position position, decimal rate, decimal closePrincipal, decimal posiPrincipal, decimal closePercent, int annualDays, bool calcFirst, bool calcLast, eod_swap_position preEod, int eventType, bool add, bool settment, bool interestWindowEmpty) { // 当日是否计息(算头算尾)——同 CalcEodInterest bool calcToday = true; if (!calcFirst && valueDate == td.StartDate.Value) calcToday = false; if (!calcLast && valueDate == td.ExerciseDate.Value) calcToday = false; if (valueDate < position.PosiStartDate) calcToday = false; // 首日初始化 preEod——同 CalcEodInterest if (preEod.id == 0) { preEod.FloatRate = 0m; preEod.TdInterestPrincipal = posiPrincipal; preEod.PosiNotionalValue = posiPrincipal; } // 字段映射(保证金 FloatRate 恒 0;方向 position.InterestDirection 已由 GetInterests 翻转) var interest = new swap_flow_event { SwapTradeId = td.id, SwapTradeNo = td.TradeNumber, EventType = eventType, EventReason = "交易", EventDate = valueDate, PositionId = position.id, InterestDirection = position.InterestDirection, InterestRate = rate, InterestPrincipal = closePrincipal, InterestSwapInterval = position.InterestSwapInterval, InterestMode = position.InterestMode, FloatRate = 0m, DataState = (int)SwapFlowDateStateEnum.完成, ClientId = td.ClientId, UnwindDate = settment ? valueDate : endDate }; // 计息窗口为空:利息归零(同 InitSwapDealInterest;典型场景=互换事件) if (interestWindowEmpty && !settment) { interest.InterestAmount = 0m; interest.TdInterestAmount = 0m; interest.InterestClosePnL = 0m; if (add) UpdateDbOption(interest); return interest; } decimal interestAmount = 0m; decimal tdInterestAmount = 0m; var legRate = FundingLegRate.Fixed(rate); // 保证金纯固定(无浮动) if (calcToday) { if (settment) { // EOD:单日增量,累计 = 昨日累计 + 今日增量;notional = 昨日终本金(无差分) var policy = AccrualPolicy.BuildEod(position, annualDays, isCompound: false); var r = SimpleInterestAccrual.AccrueEod( priorAccrued: preEod.InterestProfitSum, priorNotional: preEod.TdInterestPrincipal, unwindFraction: 1m, rate: legRate, policy: policy, eodDate: valueDate); interestAmount = r.Accrued; tdInterestAmount = r.AccruedToday; } else { // 盘中:accrualBasis 自适应"实时剩余本金"——posiPrincipal 已对齐(ResolveInterestLegPositions) // 时 = posiPrincipal;未对齐的原始腿经 orginPv(=PreviousBalance 昨日终) 修正回昨日终剩余。 // 单一本金变量无法覆盖两种 position 状态,故保留差分(与 EOD 直接用 preEod.TdInterestPrincipal 不同)。 // orginPv 在此内部按保证金维度计算,消除原 InitSwapDealInterest 的外部维度 hack。 var orginPv = MarginCalc.PreviousBalance(preEod, posiPrincipal); var accrualBasis = preEod.TdInterestPrincipal + posiPrincipal - orginPv; var segmentRates = new List<(DateTime, decimal)> { (position.PosiStartDate, rate) }; var r = SimpleInterestAccrual.AccruePeriod( priorAccrued: preEod.InterestProfitSum * closePercent, notional: accrualBasis, unwindFraction: closePercent, segmentRates: segmentRates, startDate: position.PosiStartDate, endDate: endDate, priorValueDate: preEod.ValueDate, boundary: AccrualBoundary.Of(calcFirst, calcLast), annualDays: annualDays, isAnnualized: position.IsAnnualized); interestAmount = r.Accrued; tdInterestAmount = r.AccruedToday; interest.InterestPrincipal = accrualBasis * closePercent; // 同 CalcDailySimpleInterest:1304 } } interest.InterestAmount = Math.Round(interestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero); interest.TdInterestAmount = Math.Round(tdInterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero); var interestRatio = DirectionRatio.ReceivePay(position.InterestDirection); interest.InterestClosePnL = interest.InterestAmount * interestRatio; if (add) UpdateDbOption(interest); return interest; } /// /// 计算盘中利息(平仓/互换) /// private swap_flow_event CalcUnwindInterest(trade td, DateTime valueDate, DateTime endDate, swap_position position, decimal rate, decimal floatRate, decimal posiPrincipal, decimal closePrincipal, decimal closePercent, int annualDays, eod_swap_position preEod, int eventType, bool add, bool interestWindowEmpty, decimal orginPv, bool calcFirst, bool calcLast, decimal consumedInterest = 0m) { if (preEod.id == 0) { preEod.FloatRate = floatRate; preEod.TdInterestPrincipal = posiPrincipal; preEod.PosiNotionalValue = posiPrincipal; // priorValueDate 恒取 开始日-1:首个重置日(=开始日)的定盘覆盖 [开始日,下一重置日) 全部计息日 //(不算头时 7/7 起的计息日仍属首段),必须落在取价窗内。原实现仅算头回拨一天, // 不算头时 fetchAfter=开始日 会跳过首重置日取价、首段误用种子利率 //(历史上靠 GetFloatRate 尾日取价回填种子掩盖;EQD-6968 自洽化后暴露并根治)。 // "不算头少计一天"由计息边界 IncludeStart=false 承担,与此处无关。 preEod.ValueDate = td.StartDate.Value.AddDays(-1); } return InitSwapDealInterest(td, valueDate, endDate, rate, position, add, interestWindowEmpty, posiPrincipal, closePrincipal, closePercent, annualDays, eventType, preEod, orginPv, calcFirst, calcLast, consumedInterest); } /// /// 写入保证金的资金记录:应付预付金(SwapMarginAmount)和预付金返息(SwapMarginRebatePnl)。 /// 依赖实例方法 AddClientCash/AddClientCashInCashOut,暂留此处。 /// private void RecordMarginCashFlow(trade td, UnwindData unwindData) => RecordMarginCashFlow(td, unwindData.ValueDate, unwindData.SwapMarginAmount, unwindData.SwapMarginRebatePnl, AddClientCashInCashOut); /// /// 写入保证金资金记录的通用重载,接受资金写入委托。 /// AddClientCash(virtual,测试可stub) 和 AddClientCashInCashOut(非virtual,直接写库) /// 都可通过此重载统一。 /// private void RecordMarginCashFlow(trade td, DateTime valueDate, decimal marginAmount, decimal marginRebate, Func writeCash) { if (marginAmount != 0) writeCash(td, Convert.ToDouble(marginAmount), ClientCashInCashOut.系统操作_应付预付金, valueDate); if (marginRebate != 0) writeCash(td, Convert.ToDouble(-marginRebate), ClientCashInCashOut.系统操作_预付金返息, valueDate); } /// /// 初始化利息腿信息 /// /// 交易编码 /// 计息开始日期 /// 计息结束日期 /// 计息年化利率 /// 利息腿 /// 是否新增 /// 计息窗口为空(InitInterestDate 判定:true=本次不计利息,利率与金额归零;典型场景=互换当日已结息) /// 上一日终归档 /// 当日适用名义本金 /// 当日平仓名义本金 /// 年化天数 /// private swap_flow_event InitSwapDealInterest(trade td, DateTime valueDate, DateTime endDate, decimal rate, swap_position position, bool add, bool interestWindowEmpty, decimal posiNotionalValue, decimal closePosiNotionalValue, decimal closePrecent, int annualDays, int eventType, eod_swap_position preEodPosition, decimal orginPv, bool calcFirst, bool calcLast, decimal consumedInterest = 0m ) { decimal interestProfitSum = preEodPosition.InterestProfitSum; swap_flow_event interest = new swap_flow_event(); interest.SwapTradeId = td.id; interest.SwapTradeNo = td.TradeNumber; interest.EventType = eventType; interest.EventReason = "交易"; interest.EventDate = valueDate; interest.PositionId = position.id; interest.InterestDirection = position.InterestDirection; interest.InterestRate = rate; interest.InterestPrincipal = closePosiNotionalValue; interest.InterestSwapInterval = position.InterestSwapInterval; interest.InterestMode = position.InterestMode; interest.FloatRate = position.FloatRate; interest.DataState = (int)SwapFlowDateStateEnum.完成; interest.ClientId = td.ClientId; interest.UnwindDate = endDate; // 保证金腿已走 CalcMarginInterest(不经过本方法),orginPv 维度重映射不再需要; // orginPv 此处仅对融资腿生效(差分公式 accrualBasis = TdInterestPrincipal + posiPrincipal - orginPv)。 if (interestWindowEmpty) { interest.InterestAmount = 0; // 利息金额 interest.TdInterestAmount = 0; // 当日新增利息 interest.InterestAmount = 0; interest.InterestClosePnL = 0; // 利息端平仓盈亏 } else { decimal InterestAmount = 0; decimal TdInterestAmount = 0; var interestRatio = DirectionRatio.ReceivePay(position.InterestDirection); var floateRate = preEodPosition.FloatRate; if (position.InterestType == (int)InterestTypeEnum.复利) { var daysFromPreEod = preEodPosition.id != 0 ? (endDate - preEodPosition.ValueDate).Days : 0; // 不算尾 + 当日即新周期首日 + 未到重置日 ==> 说明这一天应归入下一个计息周期 当天无需单独计息 if (!calcLast && daysFromPreEod == 1 && !IsResetDay(endDate, position.PosiStartDate, position.interest_rest_days ?? 1)) { interest.InterestPrincipal = preEodPosition.TdInterestPrincipal * closePrecent; // 计息基数 interest.FloatRate = preEodPosition.FloatRate; InterestAmount = preEodPosition.InterestIncomeSum * closePrecent; // 利息金额 = 待实现 * 平仓比例 TdInterestAmount = preEodPosition.InterestIncomeSum; // 当日新增利息 interest.InterestAmount = Math.Round(InterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero); interest.TdInterestAmount = Math.Round(TdInterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero); interest.InterestClosePnL = interest.InterestAmount * interestRatio; // 利息端平仓盈亏 = 利息金额 * 方向 return interest; } // remainingPercent 只用于把上一日待实现分配给本次计算对应的本金。 // 按照利息腿的实际 计息基数 重新计算一个历史待实现利息的 平仓比例。不替代全局的平仓比例 // 部分平仓计算关闭 30% 时取 30%;最终全平剩余仓位时取 100%。 var remainingPercent = preEodPosition.TdInterestPrincipal > 0m ? closePosiNotionalValue / preEodPosition.TdInterestPrincipal : 1m; remainingPercent = Math.Max(0m, Math.Min(1m, remainingPercent)); // resetCarryInterest 是重置日并入复利本金的历史待实现,不是当天新增利息。 // 把上日尚未实现的的利息 按本次平掉的这部分计息基数分给本次平仓 并在重置日并入计息基数 // 它只在当前 endDate 恰好为重置日时使用,避免把同一笔历史利息重复资本化。 var resetCarryInterest = preEodPosition.InterestIncomeSum * remainingPercent; CalcDailyCompoundInterest(endDate, position, closePosiNotionalValue, interest, annualDays, floateRate, closePrecent, calcFirst, calcLast, ref InterestAmount, ref TdInterestAmount, consumedInterest, resetCarryInterest); if (preEodPosition.id != 0 && closePrecent == 1m) { // 【全平专属分支触发标记】(快速定位):设计意图=真全平(尾差一次带走)与观察日恒1全额结息。 // 普通部分平仓经 EOD 恒1惯例也会进入本分支(重算中间值不进结算现金流);本行日志用于监控进入者分布。 Logger.Info($"[利息-全平专属分支] tradeId={td.id} posiId={position.id} valueDate={valueDate:yyyy-MM-dd} " + $"closePrecent={closePrecent} preEod.InterestIncomeSum={preEodPosition.InterestIncomeSum}"); // 最终全平只重放上一日终之后的新增利息;历史部分平仓的两位结算尾差已在日终待实现中。 // InterestAmount 是本次最终应结金额;TdInterestAmount 是不按关闭比例缩放的参考累计值。 // 二者在全平时都以上一日 InterestIncomeSum 为起点,保证之前攒下的尾差最后一次带走。 var interestAtEnd = new swap_flow_event { InterestRate = rate }; decimal amountAtEnd = 0m; decimal tdAmountAtEnd = 0m; // InitInterestDate 在最终日不算尾时会先把 endDate 回拨一天; // 历史差分的 amountAtEnd 需补回该日,但计算器仍使用交易 calcLast, // 并将重放日期限制在合约到期日,避免提前全平或超期重复计息。 // 如果算尾 重放日 = 正常到期日 // 不算尾 且未超过到期日 重放日 = endDate+1 (补齐不算尾那天漏计的利息) // 加1天超过到期日 截断到到期日 var replayEndDate = endDate; if (!calcLast && endDate < valueDate) { replayEndDate = endDate.AddDays(1); if (replayEndDate > td.ExerciseDate.Value) { replayEndDate = td.ExerciseDate.Value; } } // 计算截至本次平仓日的累计利息 amountAtEnd CalcDailyCompoundInterest(replayEndDate, position, closePosiNotionalValue, interestAtEnd, annualDays, floateRate, closePrecent, calcFirst, calcLast, ref amountAtEnd, ref tdAmountAtEnd, consumedInterest, exclusionStart: endDate); var interestAtPreviousEod = new swap_flow_event { InterestRate = rate }; decimal amountAtPreviousEod = 0m; decimal tdAmountAtPreviousEod = 0m; // 最终日重放仍遵守交易的 calcLast;上一日终是历史截点而非合约尾日, // 因此此处按闭区间包含上一日终当天,避免算头不算尾时重复加入该日利息。 // 计算截至上一日终累积的利息 amountAtPreviousEod CalcDailyCompoundInterest(preEodPosition.ValueDate, position, closePosiNotionalValue, interestAtPreviousEod, annualDays, floateRate, closePrecent, calcFirst, true, ref amountAtPreviousEod, ref tdAmountAtPreviousEod, consumedInterest); // 例如 0004:5/18 待实现 -118631.261797,加 5/19 新增约 -4648.912760, // 得到最终应结 -123280.174557,按金额两位落为 Excel BN 的 -123280.17。 // 上一日终已保存的待实现利息 + 截至平仓日累计利息 - 截至上一日终累计利息 // 这样只带走“上一日终以后新增的利息”,同时保留历史部分平仓时因两位金额结算留下的尾差,最终全平一次性结清。 InterestAmount = preEodPosition.InterestIncomeSum + amountAtEnd - amountAtPreviousEod; TdInterestAmount = preEodPosition.InterestIncomeSum + tdAmountAtEnd - tdAmountAtPreviousEod; } } else { CalcDailySimpleInterest(preEodPosition, endDate, position, posiNotionalValue, interest, annualDays, floateRate, closePrecent, orginPv, calcFirst, calcLast, ref InterestAmount, ref TdInterestAmount); } interest.InterestAmount = Math.Round(InterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero); interest.TdInterestAmount = Math.Round(TdInterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero); interest.InterestClosePnL = interest.InterestAmount * interestRatio; } if (add) { UpdateDbOption(interest); } return interest; } /// /// 按 interest_rule 取 FR007 定盘价。无浮动标的时返回 fallback;取不到抛异常。 /// EOD 单日取率 + BuildSegmentRates 多日取率共用此方法,FR007 定盘逻辑收口到一处。 /// private decimal ResolveFloatRate(swap_position position, DateTime date, decimal fallback) { if (string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) return fallback; var fixingDate = IndexFixerBase.GetFixingDate(date, position.interest_rule); if (IndexFixer.TryGetFixing(fixingDate, position.FloatRateUnderlyingCode, out decimal fixing)) { if (fixing != 0m) { SwapCalcTrace.Critical( $"FIX Resolve p{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd} rule={position.interest_rule})→定盘={fixing:P6}"); return fixing; } SwapCalcTrace.Critical( $"FIX Resolve p{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd})→定盘=0视为缺价,沿用fallback={fallback:P6}"); return fallback; } SwapCalcTrace.Critical( $"FIX Resolve p{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd} rule={position.interest_rule})→缺价,抛异常"); throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fixingDate:yyyy年MM月dd日}的价格"); } /// /// 按重置周期切分利率段,每段记录 all-in 利率(spread+fixing)。返回 (分段列表, 末段浮动利率)。 /// fetchAfterDate: 仅该日期之后的重置日才取 FR007(单利传 ValueDate,复利传 null 全程取)。 /// exclusionStart: 排除区间起点——该日期起(含)的重置日视为"排除日"(不算尾的不计息边界日), /// 一概不取价;缺省=endDate。仅不算尾(calcLast=false)生效;算尾所有重置日照常强制取价。 /// private (List<(DateTime StartDate, decimal Rate)> Segments, decimal LastFloat) BuildSegmentRates( DateTime startDate, DateTime endDate, int interestPeriod, swap_position position, decimal spread, decimal initialFloat, DateTime? fetchAfterDate, bool calcLast = true, DateTime? exclusionStart = null) { var rates = new List<(DateTime, decimal)>(); var calcDays = (endDate - startDate).Days; decimal currentFloat = initialFloat; SwapCalcTrace.Critical( $"FIX Segments p{position.id} [{startDate:yyyy-MM-dd}→{endDate:yyyy-MM-dd}] period={interestPeriod} " + $"fetchAfter={(fetchAfterDate?.ToString("yyyy-MM-dd") ?? "全程")} calcLast={calcLast} " + $"排除起点={(exclusionStart?.ToString("yyyy-MM-dd") ?? (calcLast ? "无" : endDate.ToString("yyyy-MM-dd")))} seed={initialFloat:P6} spread={spread:P6}"); for (int i = 0; i <= calcDays; i += interestPeriod) { var resetDate = startDate.AddDays(i); bool needFetch = (fetchAfterDate == null || resetDate > fetchAfterDate.Value); // 算头不算尾(calcLast=false)时,endDate 当天不计息,其重置日利率不参与计息—— // 排除日(EQD-6968 自洽化)一概不取价(有价也不取),currentFloat 保持末段已消费利率: // 事件/快照回写的浮动利率与金额同源、与平仓时刻无关。剩余持仓的新周期利率由 // SwapEodPositionService 的"重置日再定盘"显式获取,不靠排除日顺带。算尾照常强制取价。 bool isExcludedEnd = !calcLast && resetDate >= (exclusionStart ?? endDate); if (needFetch && !isExcludedEnd) { currentFloat = ResolveFloatRate(position, resetDate, currentFloat); } else if (needFetch && isExcludedEnd) { SwapCalcTrace.Critical( $"FIX Segment p{position.id} {resetDate:yyyy-MM-dd} 排除日(不计息)→不取价,沿用末段={currentFloat:P6}"); } rates.Add((resetDate, spread + currentFloat)); } return (rates, currentFloat); } /// /// 计算复利 盘中 /// /// 上一互换日 /// 结算日期 /// 浮动标的 /// 计息基数 /// 固定利率 /// 是否年化 /// 年化天数 /// public void CalcDailyCompoundInterest(DateTime endDate, swap_position position, decimal principal, swap_flow_event flowEvent, int annualDays, decimal floateRate, decimal closePercent, bool calcFirst, bool calcLast, ref decimal InterestAmount, ref decimal TdInterestAmount, decimal consumedInterest = 0m, decimal resetCarryInterest = 0m, DateTime? exclusionStart = null) { var startDate = position.PosiStartDate; int interestPeriod = position.interest_rest_days ?? 1; // 分段取率:复利全程重放,每个重置日(含 startDate)取 FR007(fetchAfterDate=null)。 // calcLast 透传,与下方 AccruePeriod 的边界同源,避免两处漂移; // exclusionStart 仅重放补计不算尾漏计利息时非 null(=真实平仓日),锚定尾日跳过取价起点。 var (segmentRates, currentFloat) = BuildSegmentRates( startDate, endDate, interestPeriod, position, flowEvent.InterestRate, floateRate, fetchAfterDate: null, calcLast: calcLast, exclusionStart: exclusionStart); // 纯函数复利计息:分段重置日并本金 + resetCarryInterest + 扣 consumedInterest var interestTrace = new AccrualTrace(); var result = CompoundInterestAccrual.AccruePeriod( notional: principal, segmentRates: segmentRates, startDate: startDate, endDate: endDate, boundary: AccrualBoundary.Of(calcFirst, calcLast), annualDays: annualDays, isAnnualized: position.IsAnnualized, resetCarryInterest: resetCarryInterest, realizedInterest: consumedInterest, unwindFraction: closePercent, finalBasis: out var finalBasis, trace: interestTrace); SwapCalcTrace.Write(interestTrace); // flowEvent 副作用:FloatRate=末段浮动利率;InterestPrincipal=复利终期本金(最后一次并本金后的基数)。 flowEvent.FloatRate = currentFloat; flowEvent.InterestPrincipal = finalBasis; InterestAmount = result.Accrued; TdInterestAmount = result.AccruedToday; } /// /// 计算单利 盘中(按重置天数分段,每段使用对应浮动利率) /// public void CalcDailySimpleInterest(eod_swap_position preEodPosition, DateTime endDate, swap_position position, decimal posiPrincipal, swap_flow_event flowEvent, int annualDays, decimal floateRate, decimal closePercent, decimal orginPv, bool calcFirst, bool calcLast, ref decimal InterestAmount, ref decimal TdInterestAmount, decimal consumedInterest = 0m, DateTime? exclusionStart = null) { var startDate = position.PosiStartDate; int interestPeriod = position.interest_rest_days ?? 1; // orginPv 是路径相关参考本金(资金腿=上一日终浮动端名义本金)。保证金腿已走 CalcMarginInterest,不经此方法。 // 单利差分:accrualBasis 全程恒定 = 昨日终滚动基数 + 当日名义本金 - 参考本金。 var accrualBasis = preEodPosition.TdInterestPrincipal + posiPrincipal - orginPv; // 分段取率:仅 ValueDate 之后的重置日才取 FR007(fetchAfterDate=ValueDate)。 // calcLast 透传,与下方 AccruePeriod 的边界同源,避免两处漂移。 var (segmentRates, currentFloat) = BuildSegmentRates( startDate, endDate, interestPeriod, position, flowEvent.InterestRate, floateRate, fetchAfterDate: preEodPosition.ValueDate, calcLast: calcLast, exclusionStart: exclusionStart); // 纯函数计息:Accrued=缩放累计(InterestAmount),AccruedToday=未缩放累计(TdInterestAmount) var interestTrace = new AccrualTrace(); var result = SimpleInterestAccrual.AccruePeriod( priorAccrued: preEodPosition.InterestProfitSum * closePercent, notional: accrualBasis, unwindFraction: closePercent, segmentRates: segmentRates, startDate: startDate, endDate: endDate, priorValueDate: preEodPosition.ValueDate, boundary: AccrualBoundary.Of(calcFirst, calcLast), annualDays: annualDays, isAnnualized: position.IsAnnualized, trace: interestTrace); SwapCalcTrace.Write(interestTrace); // flowEvent 副作用(下游 EOD 用 InterestPrincipal 播种次日 TdInterestPrincipal) flowEvent.InterestPrincipal = accrualBasis * closePercent; flowEvent.FloatRate = currentFloat; InterestAmount = result.Accrued; TdInterestAmount = result.AccruedToday; } /// /// 计算复利 收盘 /// /// 上一互换日 /// 结算日期 /// 开仓日 /// 浮动标的 /// 计息基数 /// 固定利率 /// 是否年化 /// 年化天数 /// public void CalcDailyCompoundInterestByEod(eod_swap_position preEodPosition, DateTime endDate, DateTime tradeDate, swap_position position, decimal principal, decimal posiPrincipal, swap_flow_event flowEvent, int annualDays, decimal floateRate, decimal closePercent, ref decimal InterestAmount, ref decimal TdInterestAmount) { int interestPeriod = position.interest_rest_days ?? 1; var isResetDay = (endDate - tradeDate).Days % interestPeriod == 0; // 重置日按 interest_rule 重新定盘浮动利率(GLMS-JIATT-20260805 根因—— // 重置日=平仓日必须用新利率,否则沿用旧周期利率并污染后续 EOD)。非重置日沿用 floateRate。 var effectiveFloat = isResetDay ? ResolveFloatRate(position, endDate, floateRate) : floateRate; flowEvent.FloatRate = effectiveFloat; // remainingFraction:重置日把上一日终待实现利息按本次平仓基数分摊(EOD 全量为 1)。 var remainingFraction = posiPrincipal > 0m ? Math.Max(0m, Math.Min(1m, principal / posiPrincipal)) : 1m; // 纯数学下沉至 CompoundInterestAccrual.AccrueEod(DDD 命名 + 末位生产精度 12 舍入)。 var legRate = FundingLegRate.Build(position, flowEvent.InterestRate, effectiveFloat); var accrualPolicy = AccrualPolicy.BuildEod(position, annualDays, isCompound: true); // 完整计息 trace:前后日期/基数/利率/重置标志全过程,经 SwapCalcTrace 常驻落盘(关键路径日志)。 var interestTrace = new AccrualTrace(); var result = CompoundInterestAccrual.AccrueEod( priorAccrued: preEodPosition.InterestProfitSum, priorNotional: preEodPosition.TdInterestPrincipal, notional: posiPrincipal, unwindFraction: closePercent, rate: legRate, policy: accrualPolicy, isResetDay: isResetDay, remainingFraction: remainingFraction, eodDate: endDate, trace: interestTrace); SwapCalcTrace.Write(interestTrace); // flowEvent.InterestPrincipal:当日计息基数(已按平仓比例缩放)——下游 EOD 用它播种次日 TdInterestPrincipal。 // 复用 CompoundEodBasis 单一真相源(与 CompoundInterestAccrual.AccrueEod 内部同一公式,见其 EodBasis 调用)。 flowEvent.InterestPrincipal = CompoundInterestAccrual.EodBasis( isResetDay, posiPrincipal, preEodPosition.InterestProfitSum, remainingFraction, preEodPosition.TdInterestPrincipal) * closePercent; InterestAmount = result.Accrued; TdInterestAmount = result.AccruedToday; } /// /// 计算单利 收盘(按重置天数分段,每段使用对应浮动利率) /// public void CalcDailySimpleInterestByEod(eod_swap_position preEodPosition, DateTime endDate, DateTime tradeDate, swap_position position, decimal principal, decimal posiPrincipal, swap_flow_event flowEvent, int annualDays, decimal floateRate, decimal closePercent, ref decimal InterestAmount, ref decimal TdInterestAmount) { // 首次操作(preEod.id == 0):计息基数按存量本金初始化——保留旧行为(含对 preEod 的就地修正)。 if (preEodPosition.id == 0) { preEodPosition.TdInterestPrincipal = posiPrincipal; } // 取率:重置日按 interest_rule 重新定盘浮动利率(GLMS-JIATT-20260805 根因—— // 重置日=平仓日必须用新利率,否则沿用旧周期利率并污染后续 EOD)。 int interestPeriod = position.interest_rest_days ?? 1; var isResetDay = (endDate - tradeDate).Days % interestPeriod == 0; var effectiveFloat = isResetDay ? ResolveFloatRate(position, endDate, floateRate) : floateRate; flowEvent.FloatRate = effectiveFloat; // 纯数学下沉至 SimpleInterestAccrual(末位生产精度 12 舍入)。 var legRate = FundingLegRate.Build(position, flowEvent.InterestRate, effectiveFloat); var accrualPolicy = AccrualPolicy.BuildEod(position, annualDays, isCompound: false); // 完整计息 trace:收集器由适配器创建,随后经 SwapCalcTrace 常驻落盘(关键路径日志,无条件)。 var interestTrace = new AccrualTrace(); var result = SimpleInterestAccrual.AccrueEod( priorAccrued: preEodPosition.InterestProfitSum, priorNotional: preEodPosition.TdInterestPrincipal, unwindFraction: closePercent, rate: legRate, policy: accrualPolicy, eodDate: endDate, trace: interestTrace); InterestAmount = result.Accrued; TdInterestAmount = result.AccruedToday; SwapCalcTrace.Write(interestTrace); } /// /// 单标的平仓 /// /// /// public void SwapUnwind(UnwindData unwindData) { var td = FindTrade(unwindData.SwapTradeId); if (td == null) { throw new ServiceException("未找到交易信息"); } UnwindNormalizer.NormalizeEventUnwindDate(unwindData); UnwindNormalizer.NormalizeNotionalValues(unwindData); NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum.平仓, "系统操作_平仓"); //CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制 // 前端按"占期初(original)"语义传 ClosePercent(A);后端全链路按"占剩余(remaining)"语义(B)消费。 // 入口统一转换为 B,落库展示用的 A 由 SaveSwapDealInternal 还原。 unwindData.ClosePercent = ToRemainingClosePercent(unwindData.ClosePercent, unwindData.NotionalValue, unwindData.PosiNotionalValue); if (UnwindNormalizer.NormalizeFullCloseRequest(unwindData)) { UnwindNormalizer.RecalculateNormalizedUnwindAmounts(unwindData); } ValidateFrontendPnL(unwindData, isIncome: false); // 只读校验告警,不阻断交易 bool cofirm = false; ExecuteInTransaction(() => { int clientCashId = AddClientCash(td, Convert.ToDouble(-unwindData.SwapRealizedPnL), ClientCashInCashOut.系统操作_平仓费, unwindData.ValueDate); RecordMarginCashFlow(td, unwindData.ValueDate, unwindData.SwapMarginAmount, 0m, AddClientCash); DealFloatPosition(unwindData); var flowList = new List(unwindData.FlowEvents); var eventId = SaveSwapDeal(unwindData, (int)SwapEventTypeEnum.平仓, clientCashId, "系统操作_平仓"); var remainingStockEqvNotional = Math.Round(td.StockEqvNotional - Convert.ToDouble(unwindData.CloseNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); var remainingTradeAmount = td.TradeAmount - Convert.ToDouble(unwindData.CloseQty); var isFullClose = UnwindNormalizer.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; SaveAllChanges(); cofirm = true; }); if (cofirm) { TriggerRealtimeSwapPosition(); } } /// /// 自动全平仓 /// /// /// /// public void AuotoSwapUnwind(int tradeid, decimal unwindPrice, decimal unwindPriceFee, decimal unwindNetFee, decimal unwindNet, DateTime valueDate, decimal unwindQty, decimal closeFee) { unwindPriceFee = decimal.Parse(unwindPriceFee.ToString("F10")); var td = DbContext.trade.Find(tradeid); var positions = DbContext.swap_position.ActiveByTrade(td.id); List eventTypes = new List() { (int)SwapEventTypeEnum.平仓, (int)SwapEventTypeEnum.互换 }; var dealDate = valueDate; var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == td.id); td.trade_extend = tradeExtend; var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault(); var storagePriceRound = ConsGlobal.InstrumentType.IsBond(position?.UnderlyingInstrumentType) ? ConsGlobal.PriceRound : ConsGlobal.SwapDeliveryPriceRound; unwindPrice = Math.Round(unwindPrice, storagePriceRound, MidpointRounding.AwayFromZero); var preDealDate = GetPreDealDate(td.id, dealDate, eventTypes); swap_flow_event floatEvent = new swap_flow_event(); UnwindData unwindData = new UnwindData(); unwindData.CloseType = 2; unwindData.StartDate = td.TradeDate.Value; if (preDealDate.HasValue) { unwindData.StartDate = preDealDate.Value; } unwindData.ValueDate = dealDate; unwindData.UnwindDate = QdpCalendarHelper.GetNonHoliday(dealDate.AddDays(1)); floatEvent.EventDate = dealDate; floatEvent.UnwindDate = unwindData.UnwindDate; floatEvent.PayDate = QdpCalendarHelper.GetNonHoliday(unwindData.UnwindDate.Value.AddDays(td.trade_extend.ExtendObj.SettlementRules)); unwindData.PayDate = floatEvent.PayDate; floatEvent.SwapTradeId = td.id; floatEvent.SwapTradeNo = td.TradeNumber; unwindData.SwapTradeId = td.id; unwindData.StructureType = td.StructureType; unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0); unwindData.NotionalQty = positions.Where(x => x.IsInitial).Sum(s => s.PosiQuantity); unwindData.PosiNotionalValue = position != null ? position.PosiNotionalValue : Convert.ToDecimal(td.StockEqvNotional); unwindData.PositionQty = Convert.ToDecimal(td.TradeAmount); var unwindPercent = unwindData.PositionQty == 0 ? 0 : unwindQty / unwindData.PositionQty; unwindData.AnnualDays = tradeExtend == null ? 365 : tradeExtend.ExtendObj.AnnualDays; unwindData.CloseMethod = unwindQty == unwindData.PositionQty ? (int)CloseMethodEnum.全部平仓 : (int)CloseMethodEnum.部分平仓; unwindData.ClosePercent = unwindData.PositionQty == 0 ? 0 : unwindPercent; unwindData.CloseNotionalValue = position == null ? 0 : unwindQty * position.PosiGrossPrice * position.ContractSize; unwindData.CloseNotionalValue = unwindPercent >= 1 ? unwindData.PosiNotionalValue : Math.Round(unwindData.CloseNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); unwindData.CloseQty = unwindQty; if (position != null) { decimal floatRatio = position.PosiDirection == 1 ? 1m : -1m; decimal longRatio = position.PositionType == 1 ? 1m : -1m; floatEvent.PositionId = position.PositionId; floatEvent.EventType = (int)SwapFlowEventTypeEnum.平仓; floatEvent.EventReason = "交易"; floatEvent.DividendIn = 0; floatEvent.UnderlyingCode = position.UnderlyingCode; floatEvent.UnderlyingInstrumentType = position.UnderlyingInstrumentType; floatEvent.CloseFee = 0; floatEvent.BeforeCloseFee = position.PosiTradingFee + position.PosiTradingFeePending; floatEvent.PayDirection = position.PosiDirection; floatEvent.PosiGrossPrice = position.PosiGrossPrice; floatEvent.PosiNetPrice = position.PosiNetPrice; floatEvent.PositionType = position.PositionType; floatEvent.Quantity = unwindData.CloseQty; floatEvent.PositionQty = unwindData.PositionQty - unwindData.CloseQty; floatEvent.ContractSize = position.ContractSize; floatEvent.DataState = (int)SwapFlowDateStateEnum.完成; floatEvent.InterestMode = position.InterestMode; floatEvent.TradingAmountAvg = unwindPrice; floatEvent.TradingAmountFeeAvg = unwindPriceFee; floatEvent.TradingAmountNetFeeAvg = unwindNetFee; floatEvent.TradingAmountNetAvg = unwindNet; floatEvent.TradingFeePending = position.PosiTradingFeePending * unwindData.ClosePercent; floatEvent.TradingFeePending = Math.Round(floatEvent.TradingFeePending, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); floatEvent.TradingFee = closeFee; floatEvent.MarkClosePnl = (unwindPrice - position.PosiGrossPrice) * unwindQty * floatRatio * longRatio; //MarkClosePnl 纯盯市不要计算交易费用和分红 floatEvent.MarkClosePnl = Math.Round(floatEvent.MarkClosePnl, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); floatEvent.TradingAmount = unwindPrice * floatEvent.Quantity * floatEvent.ContractSize; floatEvent.TradingAmount = Math.Round(floatEvent.TradingAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); floatEvent.OptLog = "流水自动"; floatEvent.ClientId = td.ClientId; floatEvent.SetOpt(UserInfo); EnrichDividendIn(floatEvent, unwindQty, td); } unwindData.FlowEvents.Add(floatEvent); var interestPositions = GetUnwindInterests(unwindData.ValueDate, unwindData.UnwindDate.Value, td.id, unwindPercent, (int)SwapEventTypeEnum.平仓); interestPositions.ForEach(item => { item.OptLog = "流水自动"; }); foreach (var item in interestPositions) { item.TdInterestAmount = Math.Round(item.TdInterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); item.InterestAmount = Math.Round(item.InterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); item.InterestClosePnL = Math.Round(item.InterestClosePnL, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); } unwindData.FlowEvents.AddRange(interestPositions); CalcCloseAmount(unwindData); DealUnwind(unwindData, td); } private void EnrichDividendIn(swap_flow_event flowEvent, decimal unwindQty, trade td) { if (flowEvent.UnwindDate == null) { throw new ArgumentNullException("平仓日期缺失"); } var date = flowEvent.EventDate; BondPaymentService servie = new BondPaymentService(UserInfo); var payments = servie.GetBondPayments(flowEvent.UnderlyingCode, td.StartDate.Value, date); int shortRatio = DirectionRatio.LongShort(flowEvent.PositionType); int directionRatio = DirectionRatio.ReceivePay(flowEvent.PayDirection); // + 付息日>上日日终且小于等于平仓日期的分红数据 var dividendIn = servie.CalcPayment(payments, unwindQty, shortRatio, directionRatio); var um = DataCacheProvider.GetUnderlyingDataSource().GetData(flowEvent.UnderlyingCode); decimal tax = um.ValueAddedTax ?? 0; dividendIn = DividendCalc.AfterTaxRaw(dividendIn, tax); flowEvent.DividendIn = Math.Round(dividendIn, 2, MidpointRounding.AwayFromZero); } public decimal GetUnderlyingTax(string code) { var data = DataCacheProvider.GetUnderlyingDataSource().GetData(code); if (data == null) { return 0; } return data.ValueAddedTax ?? 0; } private void DealUnwind(UnwindData unwindData, trade td, string actionMsg = "系统操作_自动平仓") { int clientCashId = AddClientCashInCashOut(td, Convert.ToDouble(-unwindData.SwapRealizedPnL), ClientCashInCashOut.系统操作_平仓费, unwindData.ValueDate); RecordMarginCashFlow(td, unwindData.ValueDate, unwindData.SwapMarginAmount, 0m, AddClientCashInCashOut); var flowList = new List(unwindData.FlowEvents); var eventId = SaveSwapDeal(unwindData, (int)SwapEventTypeEnum.平仓, clientCashId, actionMsg); if (unwindData.CloseMethod == (int)CloseMethodEnum.全部平仓) { td.TradeStatus = "已平仓"; td.trade_extend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == td.id); if (td.trade_extend != null && !td.trade_extend.ExtendObj.NeedOpenFee) { new ClientCashInCashOutService(this).SaveSwapTradeClientCash(td, td.TradePrice ?? 0, unwindData.ValueDate, 0); } } else { td.HasPartialUnWind = 1; } td.UnWindDate = unwindData.UnwindDate; td.StockEqvNotional = Math.Round(td.StockEqvNotional - Convert.ToDouble(unwindData.CloseNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); td.TradeAmount -= Convert.ToDouble(unwindData.CloseQty); td.Notional = td.TradeAmount; td.OptDate = DateTime.Now; td.OptId = UserId; td.OptName = UserName; DbContext.SaveChanges(); } /// /// 计算平仓总额 /// /// private void CalcCloseAmount(UnwindData unwindData) { var floatPosition = unwindData.FlowEvents.FirstOrDefault(x => !string.IsNullOrEmpty(x.UnderlyingCode)); var interestList = unwindData.FlowEvents.Where(x => string.IsNullOrEmpty(x.UnderlyingCode)); decimal floatRatio = floatPosition.PayDirection == 1 ? 1m : -1m; var pnl = floatPosition.FloatPnlSum; unwindData.SwapCloseAmount = pnl; unwindData.SwapRealizedPnL = pnl; unwindData.SwapMarginRebatePnl = 0; unwindData.SwapMarginAmount = 0; if (interestList != null) { MarginCalc.AccumulateSettlement(interestList.ToList(), unwindData); interestList.ForEach(x => { unwindData.SwapRealizedPnL += x.InterestClosePnL; unwindData.SwapCloseAmount += x.InterestClosePnL; }); } unwindData.SwapCloseAmount = Math.Round(unwindData.SwapCloseAmount, 2, MidpointRounding.AwayFromZero); unwindData.SwapRealizedPnL = Math.Round(unwindData.SwapRealizedPnL, 2, MidpointRounding.AwayFromZero); } /// /// 互换 /// /// /// public void SwapIncome(UnwindData unwindData) { var td = FindTrade(unwindData.SwapTradeId); if (td == null) { throw new ServiceException("未找到交易信息"); } UnwindNormalizer.NormalizeEventUnwindDate(unwindData); ValidateIncomeValueDate(unwindData, td); NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum.互换, "系统操作_互换"); //CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制 ValidateFrontendPnL(unwindData, isIncome: true); // 只读校验告警,不阻断交易 ExecuteInTransaction(() => { int clientCashId = AddClientCash(td, Convert.ToDouble(-unwindData.SwapRealizedPnL), ClientCashInCashOut.系统操作_互换, unwindData.ValueDate); RecordMarginCashFlow(td, unwindData.ValueDate, 0m, unwindData.SwapMarginRebatePnl, AddClientCash); foreach (var item in unwindData.FlowEvents) { item.OptLog = "手工操作"; } SaveSwapDeal(unwindData, (int)SwapEventTypeEnum.互换, clientCashId, "系统操作_互换"); if (td.ExerciseDate <= unwindData.ValueDate) { td.Notional = 0; td.StockEqvNotional = 0; td.TradeStatus = "已到期"; } td.UnWindDate = unwindData.UnwindDate; SaveAllChanges(); }); } /// /// 互换/平仓审核通过 /// /// /// /// public void ApproveSwapTrade(trade td, int eventType) { var swapEvent = FindSwapEvent(td.id, eventType); if (swapEvent == null) { throw new Exception("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效"); } swapEvent.unwindData = JsonConvert.DeserializeObject(swapEvent.EventData); UnwindNormalizer.NormalizeEventUnwindDate(swapEvent.unwindData); UnwindNormalizer.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); foreach (var item in flowList) { item.EventDate = swapEvent.unwindData.ValueDate; item.UnwindDate = swapEvent.unwindData.UnwindDate; } swapEvent.unwindData.FlowEvents = flowList; if (eventType == (int)SwapEventTypeEnum.平仓) { if (UnwindNormalizer.NormalizeFullCloseRequest(swapEvent.unwindData)) { UnwindNormalizer.RecalculateNormalizedUnwindAmounts(swapEvent.unwindData); } } if (eventType == (int)SwapEventTypeEnum.互换) { ValidateIncomeValueDate(swapEvent.unwindData, td); } 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 (eventType == (int)SwapEventTypeEnum.平仓) { RecordMarginCashFlow(td, swapEvent.unwindData.ValueDate, swapEvent.unwindData.SwapMarginAmount, 0m, AddClientCash); } swapEvent.ClientCashId = clientCashId; 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 = UnwindNormalizer.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); } else { td.TradeStatus = ConsTrade.确认成交; td.HasPartialUnWind = 1; } td.Notional = td.TradeAmount; UpdateInitalPosition(flowList, swapEvent.unwindData, eventType); SaveAllChanges(); } /// /// 互换/平仓提交审核 /// /// /// /// public void ApplySwapTrade(UnwindData unwindData, int eventType) { var td = FindTrade(unwindData.SwapTradeId); if (td == null) { throw new ServiceException("未找到交易信息"); } UnwindNormalizer.NormalizeEventUnwindDate(unwindData); if (eventType == (int)SwapEventTypeEnum.互换) { ValidateIncomeValueDate(unwindData, td); } unwindData.SwapRealizedPnL = unwindData.SwapCloseAmount; NormalizeManualSettlementAmounts(unwindData, eventType, eventType == (int)SwapEventTypeEnum.互换 ? "系统操作_互换" : "系统操作_平仓"); // 前端按"占期初(original)"语义传 ClosePercent(A);后端全链路按"占剩余(remaining)"语义(B)消费。 // 入口统一转换为 B,落库展示用的 A 由 SaveSwapDealInternal 还原。 // 与 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 (UnwindNormalizer.NormalizeFullCloseRequest(unwindData)) { UnwindNormalizer.RecalculateNormalizedUnwindAmounts(unwindData); } } string action = eventType == (int)SwapEventTypeEnum.互换 ? ClientCashInCashOut.系统操作_互换 : ClientCashInCashOut.系统操作_平仓费; ExecuteInTransaction(() => { CloseReCheckSetTrade(unwindData.SwapTradeId, eventType == (int)SwapEventTypeEnum.互换, true); SaveSwapDeal(unwindData, eventType, 0, action, true); SaveAllChanges(); // 需求①:若触发条件判定无需审批(CloseReCheck_SetTrade 已将 ProcessOrderId 设为审批通过), // 在 swap_event 记录创建完成后再执行审批通过流程。 td = FindTrade(unwindData.SwapTradeId); if (td.ProcessOrderId == ProcessTradeLog.审批通过) { new TradeOpenService(this).UpdateTradeProcessLog(new TradeOpenReqModel { tradeId = td.id, status = "pass", comments = "触发条件未满足,自动跳过审批", notNeedOperationHistory = false }); } }); } private void ValidateIncomeValueDate(UnwindData unwindData, trade td) { var maxIncomeValueDate = GetMaxIncomeValueDate(td).Date; if (unwindData.ValueDate.Date > maxIncomeValueDate) { throw new ServiceException($"手动互换结算日期不能晚于当前交易结束日期T-1:{maxIncomeValueDate:yyyy-MM-dd}"); } } /// /// 保存平仓/互换事件 /// /// /// private long SaveSwapDealInternal(UnwindData unwindData, int eventType, int clientCashId, string eventResason = "", bool approve = false) { var td = DbContext.trade.Find(unwindData.SwapTradeId); if (td == null) { throw new ServiceException("未找到交易信息"); } var flowList = new List(unwindData.FlowEvents); UnwindNormalizer.NormalizeSettledInterestAmounts(flowList, eventType, eventResason); unwindData.FlowEvents.Clear(); // 落库展示用"占期初(original)"语义(A);计算链(费用递减/全平判定)用"占剩余(remaining)"语义(B)。 // 序列化前把 ClosePercent 还原为 A,序列化后立即还原回 B 供后续使用。 var storedClosePercent = ToOriginalClosePercent(unwindData.ClosePercent, unwindData.NotionalValue, unwindData.PosiNotionalValue); var incomingClosePercent = unwindData.ClosePercent; unwindData.ClosePercent = storedClosePercent; string data = JsonConvert.SerializeObject(unwindData); unwindData.ClosePercent = incomingClosePercent; var swapEvent = new SwapEventService(this).AddSwapEventDate(unwindData.ValueDate, unwindData.SwapTradeId, eventType, data, clientCashId, true, eventResason);//将平仓、互换总额存入事件 foreach (var item in flowList) { if (item.PositionType > 0 && eventType == (int)SwapEventTypeEnum.平仓) { item.Quantity = unwindData.CloseQty; item.PositionQty = unwindData.ClosePercent == 1 ? 0 : unwindData.PositionQty - unwindData.CloseQty; } item.PayDate = unwindData.PayDate; item.UnwindDate = unwindData.UnwindDate; item.EventDate = unwindData.ValueDate; FillSwapFlowEvent(item, unwindData, td, eventType, swapEvent.id); DbContext.swap_flow_event.Add(item); } if (!approve) { UpdateInitalPosition(flowList, unwindData, eventType); } DbContext.SaveChanges(); var savedFlowEvents = DbContext.swap_flow_event.Where(x => x.EventId == swapEvent.id).ToList(); foreach (var item in savedFlowEvents) { FillSwapFlowEvent(item, unwindData, td, eventType, swapEvent.id); } DbContext.SaveChanges(); return swapEvent.id; } private void FillSwapFlowEvent(swap_flow_event item, UnwindData unwindData, trade td, int eventType, long eventId) { item.EventId = eventId; item.SwapTradeId = unwindData.SwapTradeId; item.SwapTradeNo = td.TradeNumber; item.EventType = eventType; item.EventReason = string.IsNullOrWhiteSpace(item.EventReason) ? "交易" : item.EventReason; UpdateDbOption(item); } /// /// 计算平仓数据 /// /// private void DealFloatPosition(UnwindData unwindData) { foreach (var item in unwindData.FlowEvents) { item.OptLog = "手工操作"; if (item.PositionType > 0 && item.EventType == (int)SwapEventTypeEnum.平仓) { decimal shortRatio = item.PositionType == (int)PositionTypeFlag.Long ? -1m : 1m; item.TradingAmountFeeAvg = item.TradingAmountAvg + item.TradingFeePending / unwindData.CloseQty * shortRatio; item.TradingAmountNetFeeAvg = item.TradingAmountNetAvg + item.TradingFeePending / unwindData.CloseQty * shortRatio; item.TradingAmount = item.TradingAmountAvg * unwindData.CloseQty; } } } /// /// 单标的互换更新实时持仓信息 /// /// private void UpdateInitalPosition(List flowList, UnwindData unwindData, int eventType) { var positions = DbContext.swap_position.Where(x => !x.IsInitial && x.SwapTradeId == unwindData.SwapTradeId && !x.Invalid); foreach (var position in positions) { if (!string.IsNullOrEmpty(position.UnderlyingCode)) { // 收益结算(互换)不改变持仓数量和名义本金,只更新费用 if (eventType == (int)SwapEventTypeEnum.互换) { position.PosiTradingFee -= position.PosiTradingFee * unwindData.ClosePercent; position.PosiTradingFeePending -= position.PosiTradingFeePending * unwindData.ClosePercent; } else { // 平仓时才扣减持仓 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; } } else { var interest = flowList.FirstOrDefault(x => x.PositionId == position.PositionId); if (interest != null) { position.InterestAmount += interest.InterestAmount; position.InterestFeePending += interest.InterestFee; if (MarginModes.Contains(interest.InterestMode) && eventType == (int)SwapEventTypeEnum.平仓) { var remainingInterestPrincipal = Math.Round( position.InterestPrincipalFix - interest.InterestPrincipal, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); position.InterestPrincipalFix = unwindData.ClosePercent == 1 ? 0 : remainingInterestPrincipal; } } } } } } }