From 7fff6e8236c1656f6ae0a507ec16a636a6221de5 Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 25 Aug 2026 11:07:26 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(swap-eod):=20=E7=94=A8=20MarginModes.Fo?= =?UTF-8?q?rLinq=20=E6=9B=BF=E6=8D=A2=20EF=20LINQ=20=E4=B8=AD=E7=9A=84=20M?= =?UTF-8?q?arginModes.Contains?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit swap_position 的 EF 查询在 Where 里使用 MarginModes.Contains(x.InterestMode), 该静态方法无法被 EF Core 翻译成 SQL,导致有数据时抛 Translation failed。 改用 MarginModes.ForLinq(List 形态)即可翻译为 IN (...)。 内存集合(details/interests 已 ToList)不受影响,无需改动。 --- YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index d13a96ec..f9fb7204 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -3556,7 +3556,7 @@ namespace YLErp.Modules.SwapModule // 主查询分页后再取同交易、同估值日的全部辅助腿,避免利息/保证金归集跨估值日串数据。 var eodPositions = DbContext.eod_swap_position.Where(interestPredicate).ToList(); var marginPositions = DbContext.swap_position - .Where(x => tradeIds.Contains(x.SwapTradeId) && MarginModes.Contains(x.InterestMode) && x.IsInitial && !x.Invalid) + .Where(x => tradeIds.Contains(x.SwapTradeId) && MarginModes.ForLinq.Contains(x.InterestMode) && x.IsInitial && !x.Invalid) .ToList(); var tradeExtends = DbContext.trade_extend.Where(x => tradeIds.Contains(x.TradeId)).ToList(); Dictionary tradeDic = new Dictionary(); From 8379f3fc2c42fc95d78b512b877a72aea9c23390 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A9=AC=E5=86=B0=E5=86=B0?= <437394478@qq.com> Date: Tue, 25 Aug 2026 11:20:25 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feature:=20=E4=B8=BA=E7=BB=93=E7=AE=97?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E4=B9=A6=E4=B8=AD=E7=9A=84=E9=99=84=E4=BB=B6?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../YLErp.Core/Models/SwapEndConfirmModel.cs | 21 ++ .../settlement_template/nodma_01.xlsx | Bin 6130 -> 4961 bytes .../TradeSettleBillGenerator.cs | 135 ++++++------ .../Modules/SwapModule/EodPnlCalculator.cs | 3 +- .../SwapSettlementBillRowBuilder.cs | 204 ++++++++++++++++++ 5 files changed, 289 insertions(+), 74 deletions(-) create mode 100644 YLErpDAL/Modules/TradeModule/DocGenerateModule/SwapSettlementBillRowBuilder.cs diff --git a/Framework/YLErp.Core/Models/SwapEndConfirmModel.cs b/Framework/YLErp.Core/Models/SwapEndConfirmModel.cs index 55ddcf9e..234f5c8a 100644 --- a/Framework/YLErp.Core/Models/SwapEndConfirmModel.cs +++ b/Framework/YLErp.Core/Models/SwapEndConfirmModel.cs @@ -46,6 +46,27 @@ namespace YLErp.Models public string Quantity { get; set; } + /// 融资腿挂钩的基准利率代码;未挂钩时展示“固定利率”。 + public string BaseRate { get; set; } + + /// ETF 在持有期间产生的客户视角分红金额。 + public string PeriodDividend { get; set; } + + /// 现券交易期初成交收益率。 + public string InitYtm { get; set; } + + /// 利率收益金额除以标的名义本金得到的绝对浮动利率。 + public string FloatRateAbs { get; set; } + + /// InterestMode = 5 的期初预付金方向汇总金额。 + public string InitialMargin { get; set; } + + /// InterestMode = 6 且结算日前已生效的追加预付金方向汇总金额。 + public string AdditionalMargin { get; set; } + + /// 净额结算金额加期初预付金、追加预付金后的到期结算金额。 + public string MaturitySettlementAmount { get; set; } + /// /// 期末标的结算收益率(EQD-6953)。普通债券类收益互换平仓收益率,展示态百分数, /// 固定 4 位小数不去零("0.0000");非债券/历史无值时为空串。 diff --git a/Plugins/YLErp.Plugins.GuoLian/App_Docs/settlement_template/nodma_01.xlsx b/Plugins/YLErp.Plugins.GuoLian/App_Docs/settlement_template/nodma_01.xlsx index 0415cc61533f2eda60684484d8bb2899bc0992c7..1b289c003853dc0b7f1a2a78ff8466976938a349 100644 GIT binary patch literal 4961 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 literal 6130 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 dic) { + // 普通 XLSX 结算单的公共上下文一次性加载,后续按平仓流水在内存中关联。 var client = Context.GetClient(); var flowEvents = Context.GetFlowEvents(); var allFlowEvents = Context.GetAllFlowEvents(); @@ -35,16 +37,18 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator foreach (var flowEventGroup in flowEvents) { + // 为每条平仓流水组装交易、初始持仓腿及同 EventId 的结算流水。 var tradeId = flowEventGroup.SwapTradeId; var positions = swapPositions.Where(x => x.SwapTradeId == tradeId && x.IsInitial).ToList(); - var posi = positions.FirstOrDefault(f => f.id == flowEventGroup.PositionId); - var unwindFlowEvents = allFlowEvents.Where(x => x.EventId == flowEventGroup.EventId); - var trade = trades.FirstOrDefault(x => x.id == tradeId); - var preSettleDate = flowEventGroup.UnwindDate.Value.AddDays(-1); - var swapEodPositions = Context.GetEodPositions(tradeId, preSettleDate); + var posi = positions.FirstOrDefault(f => f.id == flowEventGroup.PositionId) + ?? throw new ServiceException($"平仓事件{flowEventGroup.id}未找到对应初始持仓"); + var unwindFlowEvents = allFlowEvents.Where(x => x.EventId == flowEventGroup.EventId).ToList(); + var trade = trades.OfType().FirstOrDefault(x => x.id == tradeId); + if (trade == null) + { + throw new ServiceException($"平仓事件{flowEventGroup.id}未找到对应交易"); + } var tradeExtend = tradeExtends.FirstOrDefault(x => x.TradeId == tradeId); - var ratio = posi.PositionType == (int)PositionTypeFlag.Long ? 1 : -1; - ExcelReportModel row = new ExcelReportModel(); var confirmNo = Context.Gettrade_contract_r(tradeId, ContractTypeEnum.Trade); if (string.IsNullOrEmpty(confirmNo)) { @@ -52,78 +56,40 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator // 报错交易常是扩张拉入的同客户同日平仓,并非用户勾选的那笔。 throw new ServiceException($"{trade.TradeNumber}未生成交易确认书(平仓事件id={flowEventGroup.id}, tradeId={tradeId}, 客户={client.Name}, 平仓日={flowEventGroup.UnwindDate?.ToString("yyyy-MM-dd")});请先为该笔交易生成交易确认书后重试"); } - row.TradeNumber = confirmNo; - row.ClientName = client.Name; - row.UnderlyingCode = flowEventGroup.UnderlyingCode; - row.StartDate = trade.StartDate.Value.ToString("yyyy-MM-dd"); - var eventDate = flowEventGroup.UnwindDate.Value; - row.EventDate = eventDate.ToString("yyyy-MM-dd"); - var payDate = flowEventGroup.PayDate; - var sr = tradeExtend == null ? 0 : tradeExtend.ExtendObj.SettlementRules; - if (!payDate.HasValue) - { - payDate = QdpCalendarHelper.GetNonHoliday(flowEventGroup.EventDate.AddDays(sr)); - } - row.PayDate = payDate.Value.ToString("yyyy-MM-dd"); - row.ClosePrice = ((flowEventGroup.TradingAmountAvg) * 100).ToString("0.00000000"); - decimal interestRate = unwindFlowEvents.Where(x => ConsTrade.InterestModels.Contains(x.InterestMode)).Sum(s => s.InterestRate); - row.InterestRate = interestRate.ToString("0.00%"); - var PosiNotionalValue = flowEventGroup.Quantity * flowEventGroup.ContractSize * posi.PosiGrossPrice; - // EQD-6953 期末标的结算收益率:平仓簿记时随 UnwindData 存进 swap_event.EventData, - // 此处从浮动腿(PositionType>0)回读。存储态=展示态百分数(6.3721),导出固定 4 位不去零。 - decimal? exitYtm = null; + + // 历史流水可能未落 PayDate,按交易结算规则补算后再进入统一构造器。 + flowEventGroup.PayDate = ResolvePayDate(flowEventGroup, tradeExtend); + + // UnwindData 提供平仓名义本金及浮动腿期末收益率,均属于事件快照数据。 + UnwindData? unwindData = null; if (flowEventGroup.EventId.HasValue) { var swapEvent = Context.GetEvent(flowEventGroup.EventId.Value); if (swapEvent != null) { - swapEvent.unwindData = JsonHelper.Deserialize(swapEvent.EventData); - PosiNotionalValue = swapEvent.unwindData.CloseNotionalValue; - exitYtm = swapEvent.unwindData.FlowEvents? - .FirstOrDefault(f => f.PositionType > 0)? - .ExitYtm; + unwindData = JsonHelper.Deserialize(swapEvent.EventData); } } - row.ExitYtm = exitYtm?.ToString("0.0000") ?? string.Empty; - row.Quantity = flowEventGroup.Quantity.ToString("0.00"); - row.PosiNotionalValue = PosiNotionalValue.ToString("0.00"); - row.PosiNetPrice = (posi.PosiGrossPrice * 100).ToString("0.00000000"); - //var tradingFee = (flowEventGroup.TradingAmountFeeAvg- posi.PosiNetPrice- flowEventGroup.TradingAmountAvg+ posi.PosiGrossPrice)* PosiNotionalValue* ratio; - var tradingFee = flowEventGroup.TradingFee + flowEventGroup.TradingFeePending; - row.Fee = (-tradingFee).ToString("0.00"); - var RateDays = (eventDate - trade.StartDate.Value).Days; - RateDays = RateDays < 0 ? 0 : RateDays; - row.RateDays = RateDays.ToString(); - var MarginAmout = positions.Where(x => x.InterestMode == (int)InterestModeEnum.初始预付金).Sum(s => s.InterestPrincipalFix * (s.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1)); - row.MarginAmout = Math.Abs(MarginAmout).ToString("0.00"); - var MarginRebateRate = positions.Where(x => x.InterestMode == (int)InterestModeEnum.初始预付金).Sum(s => s.InterestRateDefault); - row.MarginRebateRate = MarginRebateRate.ToString("0.00%"); + var underlying = Context.GetTradeUnderlying(flowEventGroup.UnderlyingCode); + var closeNotionalValue = unwindData?.CloseNotionalValue + ?? flowEventGroup.Quantity * flowEventGroup.ContractSize * posi.PosiGrossPrice; - var MarginInterestAmount = unwindFlowEvents.Where(x => ConsTrade.InterestMarginModels.Contains(x.InterestMode)).Sum(s => s.InterestClosePnL); - row.MarginInterestAmount = (-MarginInterestAmount).ToString("0.00"); - - var InterestAmount = unwindFlowEvents.Where(x => ConsTrade.InterestModels.Contains(x.InterestMode)).Sum(s => s.InterestClosePnL) * -1; - row.InterestAmount = InterestAmount.ToString("0.00"); - - //var lastInterestAmount= swapEodPositions.Where(x => ConsTrade.InterestModels.Contains(x.InterestMode)).Sum(s => s.InterestIncomeSum); - //var lastPosiNotionalValue = swapEodPositions.Sum(s=>s.PosiNotionalValue); - //var InterestAmount = lastPosiNotionalValue==0?0: - lastInterestAmount* PosiNotionalValue/ lastPosiNotionalValue; - //row.InterestAmount = InterestAmount.ToString("0.00"); - - //var lastMarginAmount = swapEodPositions.Where(x => ConsTrade.InterestMarginModels.Contains(x.InterestMode)).Sum(s => s.InterestIncomeSum); - //var MarginInterestAmount = lastMarginAmount == 0 ? 0 : -lastMarginAmount * PosiNotionalValue / lastMarginAmount; - //row.MarginInterestAmount = MarginInterestAmount.ToString("0.00"); - var FloatRate = PosiNotionalValue == 0 ? 0 : InterestAmount / PosiNotionalValue; - row.FloatRate = FloatRate.ToString("0.0000%"); - var PosiPnl = -flowEventGroup.MarkClosePnl; - row.PosiPnl = PosiPnl.ToString("0.00"); - var markClosePnl = -flowEventGroup.FloatPnlSum; - row.MarkClosePnl = markClosePnl.ToString("0.00"); - row.DividendIn = (-flowEventGroup.DividendIn).ToString("0.00"); - var marginBackAmount = unwindFlowEvents.Where(x => ConsTrade.InterestMarginModels.Contains(x.InterestMode)).Sum(s => s.InterestPrincipal); - var NetSettleAmout = unwindFlowEvents.Sum(x => x.InterestClosePnL) * -1 + flowEventGroup.FloatPnlSum * -1 + marginBackAmount; - row.NetSettleAmout = NetSettleAmout.ToString("0.00"); + // 行构造器统一处理客户视角、结算公式、品种差异和模板展示精度。 + var row = SwapSettlementBillRowBuilder.Build(new SwapSettlementBillRowInput + { + ConfirmNo = confirmNo, + ClientName = client.Name, + Trade = trade, + CloseFlow = flowEventGroup, + EventFlows = unwindFlowEvents, + Positions = positions, + UnderlyingInstrumentType = underlying?.UnderlyingInstrumentType, + CloseNotionalValue = closeNotionalValue, + ExitYtm = unwindData?.FlowEvents? + .FirstOrDefault(x => x.PositionType > 0)?.ExitYtm, + IncludePeriodPaymentInNetting = (tradeExtend?.ExtendObj?.DividendPayDate ?? 1) == 0 + }); table.Add(row); } outPut.table = table; @@ -133,6 +99,7 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator protected override void PrepareViewData(Dictionary dic) { + // DMA/定义文件型结算确认书沿用原 DOCX 数据结构,与普通 XLSX 模板相互独立。 var client = Context.GetClient(); if (string.IsNullOrEmpty(client.SettleFileNumber)) { @@ -191,6 +158,8 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator { var trades = Context.Trades; var templatePath = string.Empty; + + // 多空组合及定义文件型债券使用 DMA DOCX,其余普通结算单使用 27 列 XLSX。 if (trades.Any(s => s.StructureType == "多空组合" || s.StructureType == "定义文件型债券收益互换")) { templatePath = MosPathHelper.Combine(GlobalConfig.PluginFolder, "App_Docs/settlement_template/dma_01.docx"); @@ -209,13 +178,33 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator var valueDate = eventDate.ToString("yyyy-MM-dd"); var count = Context.GetDMASelttementCount(client.id, eventDate); var countStr = count == 0 ? "" : "-" + (count + 1).ToString(); - var FullName = $"结算单-{client.Name}-{valueDate}{countStr}"; if (trades.Any(s => s.StructureType == "多空组合" || s.StructureType == "定义文件型债券收益互换")) { - FullName = $"{client.Name}-结算确认书-{valueDate}-交易平仓{countStr}"; + var FullName = $"{client.Name}-结算确认书-{valueDate}-交易平仓{countStr}"; return $"{FullName}.docx"; } - return $"{FullName}.xlsx"; + + // 普通结算单以支付日命名;同一附件中的支付日必须唯一。 + var tradeExtends = Context.GetTrade_Extends(trades.Select(x => x.id).ToList()); + var payDate = SwapSettlementBillRowBuilder.GetSinglePayDate( + flowEvents.Select(flow => (DateTime?)ResolvePayDate( + flow, + tradeExtends.FirstOrDefault(x => x.TradeId == flow.SwapTradeId)))); + return SwapSettlementBillRowBuilder.BuildOutputFileName(client.Name, payDate, count); + } + + /// + /// 优先使用平仓流水已保存的支付日;历史数据缺失时按事件日和交易结算规则补算。 + /// + private static DateTime ResolvePayDate(swap_flow_event flowEvent, trade_extend? tradeExtend) + { + if (flowEvent.PayDate.HasValue) + { + return flowEvent.PayDate.Value; + } + + var settlementRules = tradeExtend?.ExtendObj?.SettlementRules ?? 0; + return QdpCalendarHelper.GetNonHoliday(flowEvent.EventDate.AddDays(settlementRules)); } } } diff --git a/YLErpDAL/Modules/SwapModule/EodPnlCalculator.cs b/YLErpDAL/Modules/SwapModule/EodPnlCalculator.cs index 3532be45..62a831af 100644 --- a/YLErpDAL/Modules/SwapModule/EodPnlCalculator.cs +++ b/YLErpDAL/Modules/SwapModule/EodPnlCalculator.cs @@ -148,8 +148,9 @@ namespace YLErp.Modules.SwapModule /// /// 计算预付金利率。多条初始/追加预付金腿按本金绝对值加权, /// 不按收付方向轧差,避免相反方向本金抵消后放大利率。 + /// 对外公开以便结算单与每日估值复用同一计算口径。 /// - internal static decimal CalculateWeightedMarginRate(IEnumerable margins) + public static decimal CalculateWeightedMarginRate(IEnumerable margins) { var marginList = margins.ToList(); var totalWeight = marginList.Sum(x => Math.Abs(x.InterestPrincipalFix)); diff --git a/YLErpDAL/Modules/TradeModule/DocGenerateModule/SwapSettlementBillRowBuilder.cs b/YLErpDAL/Modules/TradeModule/DocGenerateModule/SwapSettlementBillRowBuilder.cs new file mode 100644 index 00000000..2f49e8e9 --- /dev/null +++ b/YLErpDAL/Modules/TradeModule/DocGenerateModule/SwapSettlementBillRowBuilder.cs @@ -0,0 +1,204 @@ +using YLErp.DBModels; +using YLErp.DBModels.Consts; +using YLErp.DBModels.Enums; +using YLErp.Models; +using YLErp.Modules.SwapModule; +using YLErp.Modules.SwapModule.ReturnLegs; + +namespace YLErp.Modules.TradeModule.DocGenerateModule; + +/// +/// 构造普通收益互换结算单单行数据所需的业务输入。 +/// 数据均来自平仓事件及其关联交易,避免依赖平仓后可能已不存在的日终持仓。 +/// +public sealed class SwapSettlementBillRowInput +{ + /// 有效交易确认书编号。 + public string ConfirmNo { get; set; } + + /// 结算单所属交易对手名称。 + public string ClientName { get; set; } + + /// 平仓事件关联的收益互换交易。 + public trade Trade { get; set; } + + /// 当前生成行对应的平仓流水。 + public swap_flow_event CloseFlow { get; set; } + + /// 与当前平仓事件同 EventId 的利息、预付金等流水。 + public IReadOnlyList EventFlows { get; set; } + + /// 交易的初始持仓腿,用于识别融资腿、浮动腿和预付金。 + public IList Positions { get; set; } + + /// 标的品种,用于区分现券付息、ETF 分红和收益率字段。 + public string UnderlyingInstrumentType { get; set; } + + /// 本次平仓标的名义本金。 + public decimal CloseNotionalValue { get; set; } + + /// 平仓事件浮动腿记录的期末结算收益率(展示态数值)。 + public decimal? ExitYtm { get; set; } + + /// 期间付息或分红是否计入本次净额结算。 + public bool IncludePeriodPaymentInNetting { get; set; } +} + +/// +/// 将平仓事件数据按国联普通结算单口径计算并格式化为模板行。 +/// +public static class SwapSettlementBillRowBuilder +{ + /// + /// 计算一条结算记录。金额方向统一转换为客户视角,返回值已按模板精度格式化。 + /// + public static ExcelReportModel Build(SwapSettlementBillRowInput input) + { + ArgumentNullException.ThrowIfNull(input); + ArgumentNullException.ThrowIfNull(input.Trade); + ArgumentNullException.ThrowIfNull(input.CloseFlow); + + var positions = input.Positions ?? Array.Empty(); + var eventFlows = input.EventFlows ?? Array.Empty(); + + // 三个业务日期是结算单和文件命名的必填项,缺失时直接阻止生成不完整附件。 + var startDate = input.Trade.StartDate + ?? throw new ServiceException("结算单缺少交易起始日"); + var eventDate = input.CloseFlow.UnwindDate + ?? throw new ServiceException("结算单缺少结算日"); + var payDate = input.CloseFlow.PayDate + ?? throw new ServiceException("结算单缺少支付日"); + + // 将同一事件的普通利息与预付金利息分开;预付金本金仅统计结算日前已生效的腿。 + var floatingPosition = positions.FirstOrDefault(x => x.PositionType > 0) + ?? positions.FirstOrDefault(x => !ConsTrade.InterestMarginModels.Contains(x.InterestMode)); + var interestEvents = eventFlows + .Where(x => ConsTrade.InterestModels.Contains(x.InterestMode)) + .ToList(); + var marginEvents = eventFlows + .Where(x => ConsTrade.InterestMarginModels.Contains(x.InterestMode)) + .ToList(); + var effectiveMargins = positions + .Where(x => ConsTrade.InterestMarginModels.Contains(x.InterestMode) + && (!x.HappenDate.HasValue || x.HappenDate.Value <= eventDate)) + .ToList(); + + // 数据库存储的是系统/券商视角金额,结算单统一取反为客户视角;预付金本金按收付方向换算。 + var interestAmount = -interestEvents.Sum(x => x.InterestClosePnL); + var floatingAmount = -input.CloseFlow.MarkClosePnl; + var fee = -(input.CloseFlow.TradingFee + input.CloseFlow.TradingFeePending); + var marginInterest = -marginEvents.Sum(x => x.InterestClosePnL); + var periodAmount = -input.CloseFlow.DividendIn; + var initialMargin = SumMargin(effectiveMargins, InterestModeEnum.初始预付金); + var additionalMargin = SumMargin(effectiveMargins, InterestModeEnum.追加预付金); + + // 净额结算按实际轧差项求和;到期结算在净额基础上返还或收取期初、追加预付金。 + var netSettlementAmount = interestAmount + floatingAmount + fee + marginInterest + + (input.IncludePeriodPaymentInNetting ? periodAmount : 0m); + var maturitySettlementAmount = netSettlementAmount + initialMargin + additionalMargin; + var floatRateAbs = input.CloseNotionalValue == 0m + ? 0m + : interestAmount / input.CloseNotionalValue; + + // 同一数据库字段在模板中按标的类型拆分:现券展示期间付息,ETF 展示期间分红。 + var isCashBond = ConsGlobal.InstrumentType.IsBond(input.UnderlyingInstrumentType); + var isEtf = ConsGlobal.InstrumentType.Fund.Equals( + input.UnderlyingInstrumentType, + StringComparison.OrdinalIgnoreCase); + + // 此处集中完成模板字段映射和展示精度处理,生成器只负责组装原始业务数据。 + return new ExcelReportModel + { + TradeNumber = input.ConfirmNo, + ClientName = input.ClientName, + StartDate = startDate.ToString("yyyy-MM-dd"), + EventDate = eventDate.ToString("yyyy-MM-dd"), + PayDate = payDate.ToString("yyyy-MM-dd"), + UnderlyingCode = input.CloseFlow.UnderlyingCode, + BaseRate = ResolveBaseRate(positions), + InterestRate = interestEvents.Sum(x => x.InterestRate).ToString("0.00%"), + PosiNotionalValue = input.CloseNotionalValue.ToString("0.00"), + Quantity = input.CloseFlow.Quantity.ToString("0.00"), + DividendIn = isCashBond ? periodAmount.ToString("0.00") : string.Empty, + PeriodDividend = isEtf ? periodAmount.ToString("0.00") : string.Empty, + PosiNetPrice = ((floatingPosition?.PosiGrossPrice ?? 0m) * 100m).ToString("0.00000000"), + InitYtm = isCashBond && input.Trade.InitYtm.HasValue + ? input.Trade.InitYtm.Value.ToString("0.####%") + : string.Empty, + ClosePrice = (input.CloseFlow.TradingAmountAvg * 100m).ToString("0.00000000"), + ExitYtm = isCashBond && input.ExitYtm.HasValue + ? input.ExitYtm.Value.ToString("0.0000") + : string.Empty, + RateDays = Math.Max(0, (eventDate - startDate).Days + 1).ToString(), + FloatRateAbs = floatRateAbs.ToString("0.0000%"), + FloatRate = floatRateAbs.ToString("0.0000%"), + InterestAmount = interestAmount.ToString("0.00"), + PosiPnl = floatingAmount.ToString("0.00"), + Fee = fee.ToString("0.00"), + MarginRebateRate = EodPnlCalculator.CalculateWeightedMarginRate(effectiveMargins) + .ToString("0.0000%"), + MarginInterestAmount = marginInterest.ToString("0.00"), + InitialMargin = initialMargin.ToString("0.00"), + AdditionalMargin = additionalMargin.ToString("0.00"), + MarginAmout = Math.Abs(initialMargin).ToString("0.00"), + MarkClosePnl = (-input.CloseFlow.FloatPnlSum).ToString("0.00"), + NetSettleAmout = netSettlementAmount.ToString("0.00"), + MaturitySettlementAmount = maturitySettlementAmount.ToString("0.00") + }; + } + + /// + /// 校验同一附件中的支付日唯一,并返回用于文件名的支付日。 + /// + public static DateTime GetSinglePayDate(IEnumerable payDates) + { + var dates = payDates?.ToList() + ?? throw new ServiceException("结算单缺少支付日"); + if (dates.Count == 0 || dates.Any(x => !x.HasValue)) + { + throw new ServiceException("结算单缺少支付日"); + } + + var distinctDates = dates.Select(x => x.Value.Date).Distinct().OrderBy(x => x).ToList(); + if (distinctDates.Count != 1) + { + throw new ServiceException( + $"同一结算单存在多个支付日:{string.Join("、", distinctDates.Select(x => x.ToString("yyyy-MM-dd")))}"); + } + + return distinctDates[0]; + } + + /// + /// 按交易对手和支付日生成普通结算单文件名;重复生成时追加顺序号。 + /// + public static string BuildOutputFileName(string clientName, DateTime payDate, int count) + { + var suffix = count == 0 ? string.Empty : $"-{count + 1}"; + return $"结算单-{clientName}-{payDate:yyyy-MM-dd}{suffix}.xlsx"; + } + + /// + /// 按预付金类型汇总本金,并根据收付方向转换为结算方向金额。 + /// + private static decimal SumMargin( + IEnumerable margins, + InterestModeEnum interestMode) + { + return margins + .Where(x => x.InterestMode == (int)interestMode) + .Sum(x => x.InterestPrincipalFix * DirectionRatio.ReceivePay(x.InterestDirection)); + } + + /// + /// 从非预付金融资腿读取挂钩基准;未配置浮动基准时按固定利率展示。 + /// + private static string ResolveBaseRate(IEnumerable positions) + { + return positions + .Where(x => !ConsTrade.InterestMarginModels.Contains(x.InterestMode)) + .Select(x => x.FloatRateUnderlyingCode) + .FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)) + ?? "固定利率"; + } +}