diff --git a/Framework/YLErp.Core/DBModels/EodSwap.cs b/Framework/YLErp.Core/DBModels/EodSwap.cs index 4d6294a0..f0cacf4e 100644 --- a/Framework/YLErp.Core/DBModels/EodSwap.cs +++ b/Framework/YLErp.Core/DBModels/EodSwap.cs @@ -52,19 +52,21 @@ namespace YLErp.DBModels [DataChange] public string StructureType { get; set; } /// - /// 合约名义本金 + /// 合约名义本金。取交易原始等价名义本金,表示合约约定规模; + /// 不等于多头与空头日终腿的代数和。 /// [DisplayName("合约名义本金")] [DataChange] public decimal NotionalValue { get; set; } /// - /// 合约多头名义本金 + /// 合约多头名义本金。框架合约展示口径中多头始终为正数。 /// [DisplayName("合约多头名义本金")] [DataChange] public decimal NotionalValueLong { get; set; } /// - /// 合约空头名义本金 + /// 合约空头名义本金。框架合约展示口径中空头始终为负数, + /// 以便与多头直接相加得到净方向。 /// [DisplayName("合约空头名义本金")] [DataChange] @@ -174,5 +176,46 @@ namespace YLErp.DBModels public int ClientId { get; set; } public string SwapTradeTypeStr { get; set; } + + public string UnderlyingType { get; set; } + + /// + /// 合约期内已实现加待实现的付息/分红金额。 + /// 该字段用于框架合约风险展示,不按每日估值报告的“期间付息/期间分红”列拆分。 + /// + public decimal PeriodAmount { get; set; } + + /// + /// 合约浮动端待实现收益,仅取浮动腿盯市收益, + /// 不包含期间付息/分红,避免与 重复。 + /// + public decimal FloatingUnrealizedPnl { get; set; } + + /// + /// 付息/分红支付方式:到期轧差时计入到期轧差估值,派息日支付时在期间支付口径展示。 + /// + public string InterestPaymentMethod { get; set; } + + /// + /// 合约估值(到期轧差口径)= 浮动端待实现收益 + 利率端待实现收益 + 期间付息/分红。 + /// 仅当支付方式为到期轧差时赋值。 + /// + public decimal? MaturityNettingValuation { get; set; } + + /// + /// 合约估值(派息日支付口径)= 浮动端待实现收益 + 利率端待实现收益。 + /// 派息/分红在支付日独立结算,因此不计入该估值。 + /// + public decimal? PeriodPaymentValuation { get; set; } + + /// + /// 我方收取的保证金利息累计额,按保证金腿方向归集并取绝对值展示。 + /// + public decimal MarginInterestGain { get; set; } + + /// + /// 我方支付的保证金利息累计额,按保证金腿方向归集并取绝对值展示。 + /// + public decimal MarginInterestLoss { get; set; } } } diff --git a/Framework/YLErp.Core/DBModels/EodSwapPositionResponse.cs b/Framework/YLErp.Core/DBModels/EodSwapPositionResponse.cs index 18533989..7364d7d3 100644 --- a/Framework/YLErp.Core/DBModels/EodSwapPositionResponse.cs +++ b/Framework/YLErp.Core/DBModels/EodSwapPositionResponse.cs @@ -20,8 +20,7 @@ namespace YLErp.DBModels /// public string StructureType { get; set; } /// - /// - /// 交易对手方名称 + /// 交易对手方名称。每日估值报告页面当前不展示该列,但发送报告与其他调用方仍可使用。 /// public string ClientName { get; set; } /// @@ -33,44 +32,65 @@ namespace YLErp.DBModels /// public string TradeNumber { get; set; } /// - /// 期间付息 + /// 期间付息。仅现券标的赋值;ETF、指数及其他标的返回 null,由前端和 Excel 显示为空白。 /// - public decimal PeriodAmount { get; set; } + public decimal? PeriodAmount { get; set; } + /// + /// 到期结算日,直接取日终浮动腿的到期日期,不叠加结算规则或节假日顺延。 + /// + public DateTime? MaturitySettlementDate { get; set; } + /// + /// 期间分红。仅 ETF 标的赋值;现券、指数及其他标的返回 null,避免同一金额在不适用列展示。 + /// + public decimal? DividendAmount { get; set; } + /// + /// 期初标的成交收益率。仅现券标的直接取交易录入的 trade.InitYtm;其他标的返回 null。 + /// public decimal? InitYtm { get; set; } /// - /// 期限 + /// 实际期限,按估值日与起始日的自然日差加一计算,包含起始日。 /// public int DayCount { get; set; } /// - /// 期初预付金-不包含追加预付金 取轧差 + /// 期初预付金本金,仅汇总初始预付金交易腿;收取为正、支付为负。 /// public decimal OpenMarginAmount { get; set; } /// - /// 期初预付金利率-不包含追加预付金 取轧差 + /// 预付金利率,初始和追加预付金腿按本金规模加权平均 /// public decimal OpenMarginRate { get; set; } /// - /// 预付金利息 取轧差 + /// 预付金利息,初始和追加预付金腿按本金规模加权平均 /// public decimal MarginInterestAmount { get; set; } /// - /// 浮动利率(绝对)利率端待实现收益/(标的名义金额/期初标的交割价格全价) + /// 追加预付金本金,仅汇总估值日前已生效的追加预付金交易腿;收取为正、支付为负。 + /// + public decimal AdditionalMarginAmount { get; set; } + /// + /// 浮动利率(绝对)= 利率收益金额 / 标的名义金额。 + /// 该字段是展示型比例,不参与净额结算金额计算。 /// public decimal FloatRateAbs { get; set; } /// - /// 利差 + /// 利差,汇总非预付金利息腿的约定利率。 /// public decimal InterestRate { get; set; } /// - /// 利率收益金额 利率端待实现收益 + /// 利率收益金额,汇总非预付金利息腿的 InterestIncomeSum,并转换为我方视角。 /// public decimal InterestAmount { get; set; } /// - /// 净额结算金额 互换持仓价值+待返还的预付金本金 + /// 净额结算金额 = 利率收益金额 + 浮动收益金额 + 开平仓交易费用 + 预付金利息 + /// + 到期轧差方式下应计入的期间付息/分红;不包含两类预付金本金。 /// public decimal NetSettmentAmount { get; set; } /// + /// TRS估值 = 净额结算金额 + 期初预付金 + 追加预付金。 + /// + public decimal TrsValue { get; set; } + /// /// 交易费用 /// public decimal TradingFee { get; set; } diff --git a/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs new file mode 100644 index 00000000..fcaa469f --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs @@ -0,0 +1,234 @@ +using Newtonsoft.Json; +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 预付金(保证金)腿 平仓"应返还本金" bug 的回归测试(根因修复后应为全绿)。 + /// --------------------------------------------------------------- + /// 业务预期:平仓"应返还本金"(swap_flow_event.InterestPrincipal) 应等于该预付金腿的 + /// 保证金本金(InterestPrincipalFix * closePercent),且与逐日利息计算无关; + /// 同时预付金腿的逐日利息计息基数也应基于"保证金本金"自身,而非整笔交易的名义本金。 + /// + /// 根因:GetUnwindInterests 对全部腿统一用 orginPv = lastEod.NotionalValue ?? stockEqvNotional(整笔交易名义本金), + /// 缺了"预付金腿用自身保证金"的分支;公式 dynomicPrincipal = TdInterestPrincipal + posiPrincipal - orginPv + /// 把交易名义本金(千万~亿级)当减项扣掉,使 InterestPrincipal 与计息基数变成巨负值。 + /// + /// 根因修复(SwapDealService.InitSwapDealInterest):对预付金腿(初始/追加)在利息计算前把 + /// orginPv 对齐为 position.InterestPrincipalFix,与日终路径(SwapEodPositionService)一致。 + /// 仅作用于 InterestMode 5/6;债券本金腿(标的期初全价=9)等仍用交易名义本金,不受影响。 + /// + /// 设计:标的名义本金 100万、预付金(保证金)本金 10万(维度不同,放大错配); + /// 另含客户截图级 / 真实库 Trade1813 的精确复现用例。 + /// + [TestClass] + public class SwapUnwindPrepayPrincipalBugTdd + { + private sealed class StubSwapDealService : SwapDealService + { + public StubSwapDealService(OptUserInfo optUser) : base(optUser) { } + + protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate) + { + rate = 0; + return false; // 预付金腿无浮动标的,不查库 + } + } + + private const decimal UnderlyingNotional = 1_000_000m; // 标的名义本金(股票维度) + private const decimal PrepayPrincipal = 100_000m; // 预付金/保证金本金(预付金维度) + private const int AnnualDays = 365; + private static readonly DateTime StartDate = new(2026, 4, 27); + private static readonly DateTime ExerciseDate = new(2027, 4, 27); + private static readonly DateTime UnwindDate = new(2026, 4, 28); + + private SwapDealService _svc; + + [TestInitialize] + public void Init() => _svc = new StubSwapDealService(new OptUserInfo(0, nameof(SwapUnwindPrepayPrincipalBugTdd), OptUserFrom.UnitTest)); + + private static trade MakeTrade(decimal notional = UnderlyingNotional) + { + var extend = new trade_extend + { + TradeId = 1, + ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { + AnnualDays = AnnualDays, + InterestCalcMode = "10", // 算头不算尾 + SettlementRules = 0 + }) + }; + return new trade + { + id = 1, TradeNumber = "UT-PREPAY-TDD", ClientId = 999998, + TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate, + ExerciseDate = ExerciseDate, TradeStatus = "确认成交", ValidState = "Valid", + StockEqvNotional = (double)notional, Notional = (double)notional, + trade_extend = extend + }; + } + + private static swap_position MakePrepayPosition(decimal fix = PrepayPrincipal, decimal rate = 0.01m) + { + return new swap_position + { + id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.初始预付金, + InterestRateDefault = rate, InterestPrincipalFix = fix, + PosiStartDate = StartDate, PosiMatuirityDate = ExerciseDate, + IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.单利, + IsAnnualized = true, interest_rest_days = 1, + interest_rule = 0, FloatRateUnderlyingCode = null, + InterestSwapInterval = "[]" + }; + } + + private swap_flow_event CalcUnwind(decimal closePercent, List eodPositions) + { + eodPositions ??= new List(); + var td = MakeTrade(); + var position = MakePrepayPosition(); + var interests = _svc.GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, + eodPositions, new List { position }, + UnderlyingNotional, UnderlyingNotional, UnderlyingNotional, UnderlyingNotional, closePercent, + (int)SwapEventTypeEnum.平仓, + false, false, 0, UnderlyingNotional, false, settment: false, newCalcLast: false, closeList: null); + Assert.AreEqual(1, interests.Count, "预付金腿应生成 1 条 flow_event"); + return interests[0]; + } + + /// + /// 客户/真实库场景:自定义 标的名义本金(notional) 与 保证金本金(fix)。 + /// orginPv 用 notional(与 GetUnwindInterests 行为一致:lastEod.NotionalValue ?? stockEqvNotional)。 + /// + private swap_flow_event CalcUnwindWith(decimal closePercent, List eodPositions, decimal notional, decimal fix, decimal rate = 0.01m) + { + eodPositions ??= new List(); + var td = MakeTrade(notional); + var position = MakePrepayPosition(fix, rate); + var interests = _svc.GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, + eodPositions, new List { position }, + notional, notional, notional, notional, closePercent, + (int)SwapEventTypeEnum.平仓, + false, false, 0, notional, false, settment: false, newCalcLast: false, closeList: null); + Assert.AreEqual(1, interests.Count, "预付金腿应生成 1 条 flow_event"); + return interests[0]; + } + + [TestMethod] + public void 无历史归档_全平_应返还本金应等于保证金本金() + { + var fe = CalcUnwind(1m, null); // 无 eod 归档 → preEod.id==0 + Console.WriteLine($"[TDD] 无归档 实测 InterestPrincipal={fe.InterestPrincipal} (期望={PrepayPrincipal})"); + Assert.AreEqual(PrepayPrincipal, fe.InterestPrincipal, + "无归档全平: InterestPrincipal(应返还本金) 应=保证金本金(预付金本金),不应被利息公式改写为含 -orginPv 与 double 的怪值"); + } + + [TestMethod] + public void 有历史归档_全平_应返还本金应等于保证金本金() + { + var eod = new List + { + new eod_swap_position + { + id = 1, SwapTradeId = 1, PositionId = 1001, + ValueDate = new DateTime(2026, 4, 27), + TdInterestPrincipal = PrepayPrincipal, + PosiNotionalValue = PrepayPrincipal, + InterestProfitSum = 0m + } + }; + var fe = CalcUnwind(1m, eod); + Console.WriteLine($"[TDD] 有归档 实测 InterestPrincipal={fe.InterestPrincipal} (期望={PrepayPrincipal})"); + Assert.AreEqual(PrepayPrincipal, fe.InterestPrincipal, + "有归档全平: 计息区间被跳过,InterestPrincipal 应保持初始正确值=保证金本金"); + } + + // ---- 客户截图级 / 真实库场景(验证"前后是否真 Fix")---- + + [TestMethod] + public void 客户截图级_全平_应返还本金应等于保证金本金() + { + // 生产铁证(用户提供真实交易):TradeAmount=3亿,StockEqvNotional=306,191,860.26, + // StructureType=普通债券类收益互换;预付金腿 swap_position id=34009 InterestMode=5 + // InterestPrincipalFix=9,185,755.81。 + // swap_flow_event(该腿, mode5) 三条: + // 9202 EventId=null dir2 IP=9,185,755.81 (建仓支付预付金 ✓) + // 9489 EventId=15997 dir1 IP=-287,820,348.64 (平仓, 盘中路径 BUG ✗) + // 9492 EventId=15998 dir1 IP=9,185,755.81 (平仓, EOD正确路径 ✓) + // 同一腿出现"盘中错 / EOD对"两条平仓记录,恰好佐证修复方向(盘中 orginPv 对齐 EOD=Fix)正确。 + // 根因复现:2*Fix - Notional = 2*9,185,755.81 - 306,191,860.26 = -287,820,348.64(与生产 15997 精确 0 误差)。 + // 该预付金腿三条 event 的 InterestAmount 全=0(债券类预付金腿不计息), + // 故本笔生产仅 InterestPrincipal 中招、计息基数未受影响 → rate=0 贴合生产。 + const decimal notional = 306_191_860.26m; + const decimal fix = 9_185_755.81m; + var fe = CalcUnwindWith(1m, null, notional, fix, rate: 0m); + Console.WriteLine($"[TDD][客户] 实测 InterestPrincipal={fe.InterestPrincipal} InterestAmount={fe.InterestAmount} (期望Principal={fix})"); + Assert.AreEqual(fix, fe.InterestPrincipal, + "客户级: 应返还本金应=保证金本金 9,185,755.81,不应被算成 -287,820,348.64"); + Assert.AreEqual(0m, fe.InterestAmount, + "客户级: 该预付金腿不计息,InterestAmount 应=0(与生产三条 event 全为 0 一致);仅 InterestPrincipal 中招"); + } + + [TestMethod] + public void 真实库Trade1813_全平_应返还本金应等于保证金本金() + { + // 测试库 Trade=1813 / Pos=34204:Fix=35,140,Notional=12,100,000, + // 实际存储 InterestPrincipal=-12,029,720.00(=2*35,140-12,100,000,公式精确 0 误差)。 + // 同属债券类预付金腿(与生产同模式,不计息),rate=0 贴合生产,仅验证 InterestPrincipal 修复。 + const decimal notional = 12_100_000m; + const decimal fix = 35_140m; + var fe = CalcUnwindWith(1m, null, notional, fix, rate: 0m); + Console.WriteLine($"[TDD][Trade1813] 实测 InterestPrincipal={fe.InterestPrincipal} InterestAmount={fe.InterestAmount} (期望Principal={fix})"); + Assert.AreEqual(fix, fe.InterestPrincipal, + "Trade1813: 应返还本金应=保证金本金 35,140,不应被算成 -12,029,720.00"); + Assert.AreEqual(0m, fe.InterestAmount, + "Trade1813: 同属债券类预付金腿不计息,InterestAmount 应=0;仅 InterestPrincipal 中招"); + } + + // ---- 多次部分平仓(验证最小修复是否覆盖"多次部分成交")---- + + [TestMethod] + public void 多次部分平仓_显示值每次返回比例份额且总计等于保证金() + { + // 模拟分 3 次平仓:0.3 / 0.5 / 1.0(剩余)。每次传入的 fix = 该次剩余保证金本金 + // (真实系统中每次部分平仓后 position.InterestPrincipalFix 会被扣减,下一笔用剩余值)。 + // 根因修复后:InterestPrincipal 由利息公式基于 Fix 正确得出 = fix * closePercent。 + decimal total = 0; + var r1 = CalcUnwindWith(0.3m, null, 306_191_860.26m, 100_000m); + total += r1.InterestPrincipal; + var r2 = CalcUnwindWith(0.5m, null, 306_191_860.26m, 70_000m); // 剩余 7万 + total += r2.InterestPrincipal; + var r3 = CalcUnwindWith(1.0m, null, 306_191_860.26m, 35_000m); // 剩余 3.5万 + total += r3.InterestPrincipal; + + Console.WriteLine($"[TDD][多次部分] r1={r1.InterestPrincipal} r2={r2.InterestPrincipal} r3={r3.InterestPrincipal} 合计={total}"); + Assert.AreEqual(30_000m, r1.InterestPrincipal, "第1次(30%)应返还 3万"); + Assert.AreEqual(35_000m, r2.InterestPrincipal, "第2次(50% of 剩余7万)应返还 3.5万"); + Assert.AreEqual(35_000m, r3.InterestPrincipal, "第3次(剩余全平)应返还 3.5万"); + Assert.AreEqual(100_000m, total, "多次部分平仓合计应=保证金本金 10万"); + } + + [TestMethod] + public void 多次部分平仓_计息基数也被根因修复_利息基于保证金本金() + { + // 显式带息加固用例(合成,非用户那笔生产的真实症状): + // 用户那笔生产(3亿债券类TRS)预付金腿不计息(InterestAmount 全=0),仅 InterestPrincipal 中招; + // 本例用 rate=0.01 构造"若该腿计息"的场景,验证根因修复后计息基数也基于保证金本金自身 + // (而非交易名义本金):InterestAmount 为小额正、且 < fix。 + const decimal notional = 306_191_860.26m; + const decimal fix = 9_185_755.81m; + var fe = CalcUnwindWith(1m, null, notional, fix, rate: 0.01m); + + Assert.AreEqual(fix, fe.InterestPrincipal, "显示值(应返还本金)已=保证金本金"); + Console.WriteLine($"[TDD][计息基数] InterestPrincipal={fe.InterestPrincipal} InterestAmount={fe.InterestAmount}"); + Assert.IsTrue(fe.InterestAmount > 0, + "根因修复后(显式带息): 预付金腿 InterestAmount 应基于保证金本金算出小额正值(约 fix*rate),不再是巨负"); + Assert.IsTrue(fe.InterestAmount < fix, + "利息基数必须为保证金维度(远小于 fix),证明 orginPv 已用预付金自身 Fix,而非交易名义本金 notional"); + } + } +} diff --git a/YLErpDAL/Model/ClientSwapPositionRequest.cs b/YLErpDAL/Model/ClientSwapPositionRequest.cs index c4883a06..50783450 100644 --- a/YLErpDAL/Model/ClientSwapPositionRequest.cs +++ b/YLErpDAL/Model/ClientSwapPositionRequest.cs @@ -15,12 +15,27 @@ namespace YLErp.Model { /// - /// - /// + /// 估值日。每日估值报告的互换估值查询当前按该日期精确筛选, + /// 交易日期同时不得晚于该日期。 + /// public DateTime? ValueDate { get; set; } + /// + /// 请求携带的估值日下界。互换持仓明细、交易流水等调用方可使用该字段; + /// 当前 GetSearchEodPositionList 未启用该下界,仍是单日估值查询。 + /// public DateTime? ValueDateFrom { get; set; } + /// + /// 对手方筛选条件。为空时不按对手方收窄结果。 + /// public int? ClientId { get; set; } + /// + /// 簿记账户筛选条件,对应 trade.AssetId;为空时包含该对手方下全部簿记账户。 + /// + public int? BookId { get; set; } + /// + /// 调用方传入的结构类型。互换估值查询当前固定同时覆盖普通债券类收益互换和普通收益互换。 + /// public string StructureType { get; set; } } /// diff --git a/YLErpDAL/Model/Configcolumn.cs b/YLErpDAL/Model/Configcolumn.cs index 9ce1a72c..b3af09ea 100644 --- a/YLErpDAL/Model/Configcolumn.cs +++ b/YLErpDAL/Model/Configcolumn.cs @@ -156,6 +156,7 @@ namespace YLErp.Model public const string 交易预付金配置 = "tradeMarginTemplateList"; public const string 互换估值 = "SettmentEodSwapPositionList"; + public const string 互换估值V1 = "SettmentEodSwapPositionListV1"; public const string 衡泰对账 = "compare_heitai_data"; } diff --git a/YLErpDAL/Modules/DataProviderModule/EodPriceQueryService.cs b/YLErpDAL/Modules/DataProviderModule/EodPriceQueryService.cs index 02e7787b..c660b0c6 100644 --- a/YLErpDAL/Modules/DataProviderModule/EodPriceQueryService.cs +++ b/YLErpDAL/Modules/DataProviderModule/EodPriceQueryService.cs @@ -137,6 +137,8 @@ namespace YLErp.Modules.DataProviderModule if (data != null) { + // FR007 行 ReferencePrice 已是小数口径(无论 bond-sync 自动同步还是界面手工录入,写入时均已 ÷100), + // 利息腿计算直接作为 floatRate 参与 principal*(fixedRate+floatRate)/annualDays,无需再 ÷100。 price = data.ReferencePrice ?? 0; return true; diff --git a/YLErpDAL/Modules/ReportModule/SettlementReportModule/DingShiReportEmail.cs b/YLErpDAL/Modules/ReportModule/SettlementReportModule/DingShiReportEmail.cs index 8572925a..077f9a76 100644 --- a/YLErpDAL/Modules/ReportModule/SettlementReportModule/DingShiReportEmail.cs +++ b/YLErpDAL/Modules/ReportModule/SettlementReportModule/DingShiReportEmail.cs @@ -28,6 +28,13 @@ /// public string MarginDetail { get; set; } public int ClientId { get; set; } + + /// + /// 每日估值报告页面选择的簿记账户。为空时按客户维度生成全量报告; + /// 有值时仅筛选互换估值页的交易所属账户。 + /// + public int? BookId { get; set; } + public DateTime From { get; set; } public DateTime To { get; set; } public double PayableMargin { get; set; } diff --git a/YLErpDAL/Modules/ReportModule/SettlementReportModule/SettlementReportFotShanXiService.cs b/YLErpDAL/Modules/ReportModule/SettlementReportModule/SettlementReportFotShanXiService.cs index 8df595e0..4a4e4c51 100644 --- a/YLErpDAL/Modules/ReportModule/SettlementReportModule/SettlementReportFotShanXiService.cs +++ b/YLErpDAL/Modules/ReportModule/SettlementReportModule/SettlementReportFotShanXiService.cs @@ -4,6 +4,7 @@ using OfficeOpenXml.Style; using Org.BouncyCastle.Ocsp; using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Text; using System.Text.RegularExpressions; @@ -14,6 +15,7 @@ using YLErp.BLL; using YLErp.BLL.EodSettlement; using YLErp.Configuration; using YLErp.Core.Helpers; +using YLErp.DBModels; using YLErp.Enums; using YLErp.Helpers; using YLErp.Model; @@ -60,7 +62,16 @@ namespace YLErp.Modules.ReportModule.SettlementReportModule #endregion if (emailData.SendContent.Contains("互换估值")) { - var eodReq = new ClientSwapPositionRequest { ClientId = emailData.ClientId, ValueDate = emailData.To, ValueDateFrom = emailData.From,page=1, rows=10000,StructureType= "普通债券类收益互换" }; + var eodReq = new ClientSwapPositionRequest + { + ClientId = emailData.ClientId, + BookId = emailData.BookId, + ValueDate = emailData.To, + ValueDateFrom = emailData.From, + page = 1, + rows = 10000, + StructureType = "普通债券类收益互换" + }; report.EodSwapPositions = swapEodPositionService.SearchEodPositionList(eodReq).rows.ToList(); } if (emailData.SendContent.Contains("互换持仓明细")) @@ -208,7 +219,7 @@ namespace YLErp.Modules.ReportModule.SettlementReportModule Directory.CreateDirectory(targetPath); } var clientName = report.client.Name; - var fileName = report.ReportFrom == DateTime.MinValue ? $"证券_估值表_{report.ReportEnd:yyyyMMdd}_{clientName}" : $"证券_估值表_{report.ReportFrom:yyyyMMdd}_{report.ReportEnd:yyyyMMdd}_{clientName}"; + var fileName = $"{clientName}_每日估值报告_{report.ReportEnd:yyyyMMdd}"; var targetFileName = Path.Combine(targetPath, $"{fileName}.xlsx"); var excelDeclareModel = new ExcelDeclareModel() @@ -238,14 +249,22 @@ namespace YLErp.Modules.ReportModule.SettlementReportModule { modelDict.Add("互换估值", new { + // 明细列表供模板渲染;以下 *Sum 字段用于“互换估值”页签合计行。 EodSwapPositions = report.EodSwapPositions, + // 持仓规模、期间收益和利息/分红类金额合计。 PosiNotionalValueSum= report.EodSwapPositions.Sum(x => x.position.PosiNotionalValue), - PosiQuantitySum= report.EodSwapPositions.Sum(x => x.position.PosiQuantity), - PeriodAmountSum= report.EodSwapPositions.Sum(x => x.PeriodAmount), + PeriodAmountSum= report.EodSwapPositions.Sum(x => x.PeriodAmount) ?? 0m, + DividendAmountSum = report.EodSwapPositions.Sum(x => x.DividendAmount) ?? 0m, InterestAmountSum= report.EodSwapPositions.Sum(x => x.InterestAmount), PosiFeePendingSum = report.EodSwapPositions.Sum(x => x.position.PosiFeePending), PosiProfitSum= report.EodSwapPositions.Sum(x => x.position.PosiProfitSum), + // 保证金相关收益和保证金占用金额合计。 + MarginInterestAmountSum = report.EodSwapPositions.Sum(x => x.MarginInterestAmount), + OpenMarginAmountSum = report.EodSwapPositions.Sum(x => x.OpenMarginAmount), + AdditionalMarginAmountSum = report.EodSwapPositions.Sum(x => x.AdditionalMarginAmount), + // 净结算金额为日终估值口径;TRS价值在净结算金额基础上叠加期初/追加保证金。 NetSettmentAmountSum= report.EodSwapPositions.Sum(x => x.NetSettmentAmount), + TrsValueSum = report.EodSwapPositions.Sum(x => x.TrsValue), }); } if (report.SwapPositions != null) @@ -287,7 +306,10 @@ namespace YLErp.Modules.ReportModule.SettlementReportModule string sourceFileName = Path.Combine(sourcePath, $"结算报告模板.xlsx"); ExcelPackage.LicenseContext = LicenseContext.NonCommercial; var pdffile = ExcelTemplate.GeneratePDFFromExeclTemplate(sourcePath, sourceFileName, modelDict, targetPath, targetFileName - , shouldDeleteSheet: true, needToPdf: false); + , shouldDeleteSheet: true, needToPdf: false, callback: sheets => + { + FormatSwapValuationDisplayCells(sheets, report.EodSwapPositions); + }); if (needToPdf) { var targetPdfFileName = FileHelper.ReplaceExtension(targetFileName, ".pdf"); @@ -297,6 +319,53 @@ namespace YLErp.Modules.ReportModule.SettlementReportModule return Path.Combine(targetPath, targetFileName); } + /// + /// 部分 Office 版本会将可选小数格式(例如 #,##0.##)错误显示为 20,000.。 + /// 互换估值中的这些列是展示字段,不参与 Excel 公式计算,因此在模板替换完成后写为已格式化文本, + /// 既保留去尾零口径,也避免留下孤立的小数点。 + /// + private static void FormatSwapValuationDisplayCells(IEnumerable sheets, IEnumerable positions) + { + var worksheet = sheets.FirstOrDefault(x => x.Name == "互换估值"); + var positionList = positions?.ToList() ?? new List(); + if (worksheet == null || !positionList.Any()) + { + return; + } + + const int dataStartRow = 2; + for (var index = 0; index < positionList.Count; index++) + { + var row = dataStartRow + index; + var item = positionList[index]; + SetTrimmedExcelText(worksheet.Cells[row, 8], item.position.PosiNotionalValue, 2); + SetTrimmedExcelText(worksheet.Cells[row, 9], item.position.PosiQuantity, 9); + SetTrimmedExcelText(worksheet.Cells[row, 10], item.PeriodAmount, 2); + SetTrimmedExcelText(worksheet.Cells[row, 11], item.DividendAmount, 2); + SetTrimmedExcelText(worksheet.Cells[row, 13], item.InitYtm, 4, percent: true); + } + + var totalRow = dataStartRow + positionList.Count; + SetTrimmedExcelText(worksheet.Cells[totalRow, 8], positionList.Sum(x => x.position.PosiNotionalValue), 2); + SetTrimmedExcelText(worksheet.Cells[totalRow, 10], positionList.Sum(x => x.PeriodAmount) ?? 0m, 2); + SetTrimmedExcelText(worksheet.Cells[totalRow, 11], positionList.Sum(x => x.DividendAmount) ?? 0m, 2); + } + + private static void SetTrimmedExcelText(ExcelRange cell, decimal? value, int decimalPlaces, bool percent = false) + { + if (!value.HasValue) + { + cell.Value = null; + return; + } + + var displayValue = percent ? value.Value * 100m : value.Value; + var format = "#,##0." + new string('#', decimalPlaces); + var text = displayValue.ToString(format, CultureInfo.InvariantCulture).TrimEnd('.'); + cell.Value = percent ? text + "%" : text; + cell.Style.Numberformat.Format = "@"; + } + /// /// 财务状况 /// diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 16730262..12b6442c 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -867,6 +867,19 @@ namespace YLErp.Modules.SwapModule interest.DataState = (int)SwapFlowDateStateEnum.完成; interest.ClientId = td.ClientId; interest.UnwindDate = endDate; + + // 根因修复:预付金(保证金)腿的计息基数维度应为"保证金本金"自身,而非整笔交易的名义本金(orginPv)。 + // 否则公式 dynomicPrincipal = TdInterestPrincipal + posiPrincipal - orginPv + // 会把交易名义本金(千万~亿级)当减项扣掉,使"应返还本金"(InterestPrincipal)与计息基数变成巨负值。 + // 此处将预付金腿的 orginPv 对齐为其自身保证金(InterestPrincipalFix), + // 与日终路径(SwapEodPositionService 对预付金腿 orginPv=InterestPrincipalFix)保持一致。 + // 仅作用于初始预付金(5)/追加预付金(6);其它计息模式(含债券本金腿 标的期初全价=9)仍用交易名义本金,不受影响。 + if (position.InterestMode == (int)InterestModeEnum.初始预付金 + || position.InterestMode == (int)InterestModeEnum.追加预付金) + { + orginPv = position.InterestPrincipalFix; + } + if (swap) { interest.InterestAmount = 0; diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index b313e99c..d982c917 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -1,6 +1,7 @@ using BaseOUDAL; using Newtonsoft.Json; using NPOI.POIFS.Properties; +using System; using System.Linq.Expressions; using YLErp.DBModels; using YLErp.DBModels.Consts; @@ -155,6 +156,36 @@ namespace YLErp.Modules.SwapModule return UnderlyingCodePrice(code, settleDate, out vobp); } + /// + /// 获取用于互换浮动腿盯市的标的价格。 + /// + /// 普通债券类收益互换的新录入页面将全价按小数保存,例如页面录入 20% 后 + /// PosiGrossPrice 为 0.2;而历史交易中仍可能存在直接保存为 20 的展示态价格。 + /// 中债估值正常经 EodPriceQueryService 转换后应为小数价格,但手工维护的历史 + /// 行情可能仍以展示态进入该服务,例如 2000 经一次转换后得到 20。若将 20 + /// 与 0.2 直接相减,会把 20% 的价格差误算成 1,980,000 的浮动损益。 + /// + /// 因此仅当交易期初价已经是小数口径、且当前债券价明显仍处于展示态时,再做 + /// 一次展示态到存储态转换。期初价本身是历史展示态口径的存量交易保持原价格, + /// 避免修改日终估值链路后改变其既有损益。 + /// + private decimal GetSwapValuationPrice(string code, decimal posiGrossPrice, DateTime settleDate, out decimal vobp) + { + var price = GetUnderlyingPrice(code, settleDate, out vobp); + var underlying = GetUnderlyingData(code); + var usesStoragePrice = Math.Abs(posiGrossPrice) < 2m; + var usesDisplayPrice = Math.Abs(price) >= 10m; + + if (underlying?.IsBond() == true && usesStoragePrice && usesDisplayPrice) + { + var normalizedPrice = BondPriceConverter.ToStorage(price); + Log.Error($"互换债券日终价格按展示态返回,已转换为存储态: UnderlyingCode={code}, ValueDate={settleDate:yyyy-MM-dd}, PosiGrossPrice={posiGrossPrice}, SourcePrice={price}, NormalizedPrice={normalizedPrice}"); + return normalizedPrice; + } + + return price; + } + /// 获取标的缓存数据(生产: DataCacheProvider;测试: 返回内存对象) protected virtual underlying_manager GetUnderlyingData(string underlyingCode) { @@ -1158,7 +1189,8 @@ namespace YLErp.Modules.SwapModule { ratio = -ratio; } - var lastInterestIncomeSum = eodPayPosition.InterestIncomeSum; + // 首次日终结算可能包含当日收盘,因此尚无先前的日终利息持仓。 + var lastInterestIncomeSum = eodPayPosition?.InterestIncomeSum ?? 0m; eodPayPosition = new eod_swap_position(); eodPayPosition.ClientId = td.ClientId; eodPayPosition.SwapTradeId = td.id; @@ -1524,7 +1556,7 @@ namespace YLErp.Modules.SwapModule int shortRatio = eod.PositionType == (int)PositionTypeFlag.Long ? 1 : -1; int directionRatio = eod.PosiDirection == (int)SwapDirectionEnum.收取 ? 1 : -1; curretEod.PosiStatus = curretEod.PosiQuantity == 0 ? 1 : 0; - var price = GetUnderlyingPrice(eod.UnderlyingCode, dealDate, out decimal vobp); + var price = GetSwapValuationPrice(eod.UnderlyingCode, eod.PosiGrossPrice, dealDate, out decimal vobp); curretEod.dv01 = Dv01Helper.CalcDv01(eod.UnderlyingCode, curretEod.PosiQuantity, eod.PosiDirection, eod.PositionType, vobp); decimal tax = um.ValueAddedTax ?? 0; if (valueDate > td.StartDate.Value && curretEod.PosiQuantity > 0) @@ -1604,7 +1636,7 @@ namespace YLErp.Modules.SwapModule var dealDate = curretEod.ValueDate; int shortRatio = eod.PositionType == (int)PositionTypeFlag.Long ? 1 : -1; int directionRatio = eod.PosiDirection == (int)SwapDirectionEnum.收取 ? 1 : -1; - var price = GetUnderlyingPrice(eod.UnderlyingCode, dealDate, out decimal vobp); + var price = GetSwapValuationPrice(eod.UnderlyingCode, eod.PosiGrossPrice, dealDate, out decimal vobp); var todayConsumedDividend = CalcConsumedDividend(curretEod, unwindEvents); var originNotional = (decimal)td.OriginalStockEqvNotional / swapPosition.PosiNetPrice; decimal totalPayment = CalcBondPayment(curretEod.UnderlyingCode, td.StartDate.Value, valueDate, (decimal)originNotional, shortRatio, directionRatio); @@ -1791,7 +1823,7 @@ namespace YLErp.Modules.SwapModule curretEod.ContractSize = position.ContractSize; curretEod.CountRatio = position.CountRatio; curretEod.PosiTradingFee = position.PosiTradingFee; - curretEod.UnderlyingPrice = GetUnderlyingPrice(position.UnderlyingCode, dealDate, out decimal vobp); + curretEod.UnderlyingPrice = GetSwapValuationPrice(position.UnderlyingCode, position.PosiGrossPrice, dealDate, out decimal vobp); SetPriceInfoByFlowEvent(eod, curretEod, unwindEvents, position); curretEod.dv01 = Dv01Helper.CalcDv01(curretEod.UnderlyingCode, curretEod.PosiQuantity, curretEod.PosiDirection, curretEod.PositionType, vobp); //if (settleDate == td.TradeDate) @@ -1898,12 +1930,16 @@ namespace YLErp.Modules.SwapModule eod_Swap = new eod_swap(); } var tradeSpan = DbContext.trade_span.FirstOrDefault(x => x.TradeId == td.id && x.ValueDate == settleDate); + // eod_swap 是交易级汇总;eod_swap_position 是浮动腿、利息腿和保证金腿的明细。 + // 以下先按日终明细拆腿,再按框架合约展示口径汇总。 var eodSwapPositions = DbContext.eod_swap_position.Where(x => x.SwapTradeId == td.id && x.ValueDate == settleDate && !x.Invalid).ToList(); var interestPositions = eodSwapPositions.Where(x => string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//利息腿 var positions = eodSwapPositions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//持仓腿 + // 框架合约的方向约定:多头为正、空头为负;总名义本金取交易原始规模, + // 不能直接用多空腿相加,否则会把对冲方向误当成合约规模变化。 eod_Swap.NotionalValueLong = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.PosiNotionalValue); - eod_Swap.NotionalValueShort = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue); - eod_Swap.NotionalValue = eod_Swap.NotionalValueLong + eod_Swap.NotionalValueShort; + eod_Swap.NotionalValueShort = -Math.Abs(positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue)); + eod_Swap.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? td.StockEqvNotional); eod_Swap.SwapTradeId = td.id; eod_Swap.SwapTradeNo = td.TradeNumber; eod_Swap.ClientId = td.ClientId; @@ -1915,6 +1951,8 @@ namespace YLErp.Modules.SwapModule eod_Swap.FloatingPnL = positions.Sum(s => s.PosiProfitSum); eod_Swap.dv01 = positions.Sum(s => s.dv01 ?? 0); decimal interestPnL = 0; + // 利息腿按我方视角归集。保证金腿的利息现金流方向与普通利息腿相反, + // 因此保证金腿需要额外反转符号,确保 InterestPnL 表示我方的合约利率端收益。 interestPositions.ForEach(x => { decimal ratio = x.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1;//收取为正,支付为负 @@ -1969,12 +2007,13 @@ namespace YLErp.Modules.SwapModule eod_Swap.ValueDate = settleDate; DbContext.eod_swap.Add(eod_Swap); } + // 单标的调整与首次归档使用同一套框架合约汇总口径,避免重算后多空和名义本金展示不一致。 var eodSwapPositions = DbContext.eod_swap_position.Where(x => x.SwapTradeId == td.id && x.ValueDate == settleDate && !x.Invalid).ToList(); var interestPositions = eodSwapPositions.Where(x => string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//利息腿 var positions = eodSwapPositions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//持仓腿 - eod_Swap.NotionalValue = Convert.ToDecimal(td.StockEqvNotional); + eod_Swap.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? td.StockEqvNotional); eod_Swap.NotionalValueLong = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.PosiNotionalValue); - eod_Swap.NotionalValueShort = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue); + eod_Swap.NotionalValueShort = -Math.Abs(positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue)); eod_Swap.MarketValueLong = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.UnderlyingMarketValue); eod_Swap.MarketValueShort = positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.UnderlyingMarketValue); eod_Swap.FloatingPnL = positions.Sum(s => s.PosiProfitSum); @@ -2162,10 +2201,62 @@ namespace YLErp.Modules.SwapModule } DbContext.SetDebugLog(); var retListResult = query.ToSearchList(req); + var tradeIds = retListResult.rows.Select(x => x.position.SwapTradeId).Distinct().ToList(); + var valueDates = retListResult.rows.Select(x => x.position.ValueDate).Distinct().ToList(); + var tradeNotionals = DbContext.trade + .Where(x => tradeIds.Contains(x.id)) + .Select(x => new { x.id, x.OriginalStockEqvNotional, x.StockEqvNotional }) + .ToDictionary(x => x.id); + var eodPositionDetails = DbContext.eod_swap_position + .Where(x => tradeIds.Contains(x.SwapTradeId) && valueDates.Contains(x.ValueDate) && !x.Invalid) + .ToList(); + var tradeExtends = DbContext.trade_extend.Where(x => tradeIds.Contains(x.TradeId)).ToList(); + var underlyingDataSource = DataCacheProvider.GetUnderlyingDataSource(); + var varietyDataSource = DataCacheProvider.GetVarietyDataSource(); foreach (var item in retListResult.rows) { + item.position.NotionalValueShort = -Math.Abs(item.position.NotionalValueShort); + if (tradeNotionals.TryGetValue(item.position.SwapTradeId, out var tradeNotional)) + { + item.position.NotionalValue = Convert.ToDecimal(tradeNotional.OriginalStockEqvNotional ?? tradeNotional.StockEqvNotional); + } var client = DataCacheProvider.GetClientDataSource().GetData(item.ClientId); item.SwapTradeTypeStr = client?.SwapTradeTypeStr; + var details = eodPositionDetails + .Where(x => x.SwapTradeId == item.position.SwapTradeId && x.ValueDate == item.position.ValueDate) + .ToList(); + var floatingLegs = details.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode)).ToList(); + var marginLegs = details.Where(x => marginTypes.Contains(x.InterestMode)).ToList(); + var tradeExtend = tradeExtends.FirstOrDefault(x => x.TradeId == item.position.SwapTradeId); + var dividendPayDate = tradeExtend?.ExtendObj?.DividendPayDate ?? 1; + + item.UnderlyingType = string.Join(",", floatingLegs + .Select(x => + { + var underlying = underlyingDataSource.GetData(x.UnderlyingCode); + return varietyDataSource.GetData(underlying?.UnderlyingTypeId ?? 0)?.AssetType + ?? underlying?.UnderlyingInstrumentTypeCn + ?? underlying?.UnderlyingType; + }) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct()); + item.PeriodAmount = floatingLegs.Sum(x => x.RealizedDividend + x.PosiDividendSum); + item.FloatingUnrealizedPnl = floatingLegs.Sum(x => x.PosiMtmPnL); + item.InterestPaymentMethod = dividendPayDate == 0 ? "到期轧差" : "派息日支付"; + if (dividendPayDate == 0) + { + item.MaturityNettingValuation = item.FloatingUnrealizedPnl + item.position.InterestPnL + item.PeriodAmount; + } + else + { + item.PeriodPaymentValuation = item.FloatingUnrealizedPnl + item.position.InterestPnL; + } + item.MarginInterestGain = marginLegs + .Where(x => x.InterestDirection == (int)SwapDirectionEnum.收取) + .Sum(x => Math.Abs(x.InterestIncomeSum)); + item.MarginInterestLoss = marginLegs + .Where(x => x.InterestDirection == (int)SwapDirectionEnum.支付) + .Sum(x => Math.Abs(x.InterestIncomeSum)); } var dv01 = query.Sum(O => O.position.dv01??0); @@ -2300,6 +2391,7 @@ namespace YLErp.Modules.SwapModule private SearchListResult GetSearchEodPositionList(ClientSwapPositionRequest req) { + // 每日估值报告以有数量的浮动腿为主记录;利息腿和保证金腿仅作为同交易、同估值日的辅助数据参与汇总。 var predicate = PredicateBuilder.Create(n => !n.Invalid && n.PosiQuantity > 0); var interestPredicate = PredicateBuilder.Create(n => !n.Invalid && n.InterestDirection > 0); var tradePredicate = PredicateBuilder.Create(n => n.ValidState != "InValid"); @@ -2310,10 +2402,15 @@ namespace YLErp.Modules.SwapModule { predicate = predicate.And(x => x.ClientId == req.ClientId); } - if (req.ValueDateFrom != null) + if (req.BookId > 0) { - predicate = predicate.And(x => x.ValueDate >= req.ValueDateFrom); + tradePredicate = tradePredicate.And(x => x.AssetId == req.BookId.Value); } + // ValueDateFrom 保留在请求模型中,但当前互换估值查询按 ValueDate 单日取数。 + // if (req.ValueDateFrom != null) + // { + // predicate = predicate.And(x => x.ValueDate >= req.ValueDateFrom); + // } if (req.ValueDate != null) { predicate = predicate.And(x => x.ValueDate == req.ValueDate); @@ -2343,53 +2440,132 @@ namespace YLErp.Modules.SwapModule } var retListResult = query.ToSearchList(req); var tradeIds = retListResult.rows.Select(s => s.position.SwapTradeId).ToList(); + if (!tradeIds.Any()) + { + return retListResult; + } interestPredicate = interestPredicate.And(x => tradeIds.Contains(x.SwapTradeId)); var valueDates = retListResult.rows.Select(s => s.position.ValueDate).Distinct().ToList(); + interestPredicate = interestPredicate.And(x => valueDates.Contains(x.ValueDate)); + // 主查询分页后再取同交易、同估值日的全部辅助腿,避免利息/保证金归集跨估值日串数据。 var eodPositions = DbContext.eod_swap_position.Where(interestPredicate).ToList(); - var positions = DbContext.swap_position.Where(x => tradeIds.Contains(x.SwapTradeId) && x.InterestMode == (int)InterestModeEnum.初始预付金 && x.IsInitial && !x.Invalid).ToList(); + var marginPositions = DbContext.swap_position + .Where(x => tradeIds.Contains(x.SwapTradeId) && marginTypes.Contains(x.InterestMode) && x.IsInitial && !x.Invalid) + .ToList(); var tradeExtends = DbContext.trade_extend.Where(x => tradeIds.Contains(x.TradeId)).ToList(); Dictionary tradeDic = new Dictionary(); foreach (var item in retListResult.rows) { var tradeExtend = tradeExtends.FirstOrDefault(x => x.TradeId == item.position.SwapTradeId); - var eventDate = item.position.ValueDate; - if (tradeExtend != null) - { - eventDate = QdpCalendarHelper.GetNonHoliday(eventDate.AddDays(tradeExtend.ExtendObj.SettlementRules)); - } - item.DayCount = Math.Max(0, (eventDate - item.position.PosiStartDate).Days + 1); + // 到期结算日按合同到期日展示;实际期限按自然日且包含起始日,二者均不使用结算规则偏移。 + item.MaturitySettlementDate = item.position.PosiMatuirityDate; + item.DayCount = Math.Max(0, (item.position.ValueDate - item.position.PosiStartDate).Days + 1); //item.position.PosiProfitSum += item.position.VTradingFee-item.position.PosiFeePending; SetClientEodPosition(item.position); //item.position.PosiProfitSum += item.TradingFee; var posiProfitSum = item.position.PosiProfitSum; //item.position.PosiProfitSum 不需要加交易费用 - item.position.PosiProfitSum = item.position.PosiProfitSum - item.position.PosiFeePending - item.position.PosiDividendSum; - item.NetSettmentAmount = item.position.PosiProfitSum + item.position.PosiDividendSum + item.position.PosiFeePending; - item.PeriodAmount = item.position.PosiDividendSum; - var margins = positions.Where(x => x.SwapTradeId == item.position.SwapTradeId); + // PosiProfitSum 原值包含交易费用和期间付息/分红。先拆出这两部分, + // 使“浮动收益金额”仅反映标的盯市收益,后续净额公式再按支付方式决定是否加回期间金额。 + var pendingDividend = item.position.PosiDividendSum; + item.position.PosiProfitSum = item.position.PosiProfitSum - item.position.PosiFeePending - pendingDividend; + // 现券仅展示期间付息和期初成交收益率;ETF(标的主数据类型 Fund)仅展示期间分红。 + // 其余标的的三列均不适用,返回 null 使页面和 Excel 模板保持空白,而不是展示 0。 + var isCashBond = ConsGlobal.InstrumentType.IsBond(item.position.UnderlyingInstrumentType); + var isEtf = ConsGlobal.InstrumentType.Fund.Equals( + item.position.UnderlyingInstrumentType, + StringComparison.OrdinalIgnoreCase); + if (isCashBond) + { + item.PeriodAmount = pendingDividend; + item.DividendAmount = null; + // 期初标的成交收益率是债券现券成交口径,非现券不展示该交易录入值。 + } + else if (isEtf) + { + item.PeriodAmount = null; + item.DividendAmount = pendingDividend; + } + else + { + item.PeriodAmount = null; + item.DividendAmount = null; + } + if (!isCashBond) + { + item.InitYtm = null; + } + // 预付金本金和利率来自交易腿,并以发生日判断在估值日是否已生效; + // 预付金利息则来自当日日终腿,以获得截至估值日的 InterestIncomeSum。 + var tradeMargins = marginPositions + .Where(x => x.SwapTradeId == item.position.SwapTradeId + && (!x.HappenDate.HasValue || x.HappenDate.Value <= item.position.ValueDate)) + .ToList(); var interests = eodPositions.Where(x => x.SwapTradeId == item.position.SwapTradeId && x.ValueDate == item.position.ValueDate); - var eodMargins = interests.Where(x => marginTypes.Contains(x.InterestMode)); - var eodInterests = interests.Where(x => !marginTypes.Contains(x.InterestMode)); + var eodMargins = interests.Where(x => marginTypes.Contains(x.InterestMode)).ToList(); + var eodInterests = interests.Where(x => !marginTypes.Contains(x.InterestMode)).ToList(); + var initialMargins = tradeMargins.Where(x => x.InterestMode == (int)InterestModeEnum.初始预付金).ToList(); + var additionalMargins = tradeMargins.Where(x => x.InterestMode == (int)InterestModeEnum.追加预付金).ToList(); var floatRateInterest = eodInterests.Where(x => !string.IsNullOrEmpty(x.FloatRateUnderlyingCode)).FirstOrDefault(); item.position.FloatRateUnderlyingCode = floatRateInterest?.FloatRateUnderlyingCode; item.position.FloatRate = floatRateInterest?.FloatRate ?? 0; - item.OpenMarginAmount = margins.Sum(s => s.InterestPrincipalFix * (s.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1)); - item.OpenMarginRate = margins.Sum(s => s.InterestRateDefault * (s.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1)); - item.MarginInterestAmount = eodMargins.Sum(s => s.InterestIncomeSum * (s.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1)); + item.OpenMarginAmount = initialMargins.Sum(s => s.InterestPrincipalFix * (s.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1)); + item.OpenMarginRate = CalculateWeightedMarginRate(tradeMargins); + item.AdditionalMarginAmount = additionalMargins.Sum(s => s.InterestPrincipalFix * (s.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1)); + item.MarginInterestAmount = CalculateWeightedMarginInterest(eodMargins); item.InterestAmount = eodInterests.Sum(s => s.InterestIncomeSum * (s.InterestDirection == (int)SwapDirectionEnum.收取 ? -1 : 1)); item.InterestRate = eodInterests.Sum(s => s.InterestRateDefault); - item.NetSettmentAmount += item.InterestAmount + item.MarginInterestAmount + eodMargins.Sum(s => s.InterestPrincipalFix * (s.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1)); + // 到期轧差才把期间付息/分红并入净额结算;派息日支付已在现金流层独立结算,不能重复计入估值。 + var nettingDividend = (tradeExtend?.ExtendObj?.DividendPayDate ?? 1) == 0 ? pendingDividend : 0m; + item.NetSettmentAmount = item.InterestAmount + + item.position.PosiProfitSum + + item.position.PosiFeePending + + item.MarginInterestAmount + + nettingDividend; item.NetSettmentAmount = Math.Round(item.NetSettmentAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + item.TrsValue = Math.Round(item.NetSettmentAmount + item.OpenMarginAmount + item.AdditionalMarginAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); if (item.position.PosiNotionalValue != 0 && item.position.PosiNetPrice != 0) { item.FloatRateAbs = item.position.PosiNotionalValue == 0 ? 0 : item.InterestAmount / item.position.PosiNotionalValue; } - SetPosiPrice(item.position); + // 交易录入的债券类收益互换价格以小数保存,展示时转为百分比价格; + // 普通收益互换录入的是数量/原始数值,不做乘 100 转换。 + SetPosiPrice(item.position, item.StructureType == "普通债券类收益互换"); } return retListResult; } + /// - /// 设置客户视角 + /// 计算预付金利率。多条初始/追加预付金腿按本金绝对值加权, + /// 不按收付方向轧差,避免相反方向本金抵消后放大利率。 + /// + private static decimal CalculateWeightedMarginRate(IEnumerable margins) + { + var marginList = margins.ToList(); + var totalWeight = marginList.Sum(x => Math.Abs(x.InterestPrincipalFix)); + return totalWeight == 0 + ? 0 + : marginList.Sum(x => x.InterestRateDefault * Math.Abs(x.InterestPrincipalFix)) / totalWeight; + } + + /// + /// 计算预付金利息。先按收取为正、支付为负转换为我方视角, + /// 再按日终本金绝对值加权平均;本金合计为零时返回零。 + /// + private static decimal CalculateWeightedMarginInterest(IEnumerable margins) + { + var marginList = margins.ToList(); + var totalWeight = marginList.Sum(x => Math.Abs(x.InterestPrincipalFix)); + return totalWeight == 0 + ? 0 + : marginList.Sum(x => x.InterestIncomeSum + * (x.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1) + * Math.Abs(x.InterestPrincipalFix)) / totalWeight; + } + /// + /// 将数据库中以公司/交易簿记方向保存的日终字段转换为客户视角。 + /// 该转换必须在拆分浮动收益、费用和期间付息/分红之前完成, + /// 否则页面、Excel 和净额结算金额会出现相反符号。 /// /// private void SetClientEodPosition(eod_swap_position position) @@ -2412,10 +2588,10 @@ namespace YLErp.Modules.SwapModule position.SwapPositionValue = -position.SwapPositionValue; position.PosiDividendSum = -position.PosiDividendSum; } - private void SetPosiPrice(eod_swap_position position) + private void SetPosiPrice(eod_swap_position position, bool? useBondPriceScale = null) { var um = DataCacheProvider.GetUnderlyingDataSource().GetData(position.UnderlyingCode); - if (um != null && um.IsBond()) + if (useBondPriceScale ?? (um != null && um.IsBond())) { position.PosiNetPrice *= 100; position.UnderlyingPrice *= 100; diff --git a/YLErpWeb/App_Docs/导出模板/结算报告模板.xlsx b/YLErpWeb/App_Docs/导出模板/结算报告模板.xlsx index ba6be62b..ae0f519f 100644 Binary files a/YLErpWeb/App_Docs/导出模板/结算报告模板.xlsx and b/YLErpWeb/App_Docs/导出模板/结算报告模板.xlsx differ diff --git a/YLErpWeb/Views/SwapTrade2/TradeMarketReport_EodPosition.cshtml b/YLErpWeb/Views/SwapTrade2/TradeMarketReport_EodPosition.cshtml index 1429aaec..3516c460 100644 --- a/YLErpWeb/Views/SwapTrade2/TradeMarketReport_EodPosition.cshtml +++ b/YLErpWeb/Views/SwapTrade2/TradeMarketReport_EodPosition.cshtml @@ -6,9 +6,8 @@ var pageObj = new { EndTime = ViewBag.EndTime?.ToString(), - StartTime = ViewBag.StartTime?.ToString(), valueDate = valuedateBLL.ValueDate.ToString("yyyy-MM-dd"), - configcolumn = configcolumn_data.互换估值, + configcolumn = configcolumn_data.互换估值V1, StructureType = ViewBag.StructureType }; } @@ -23,7 +22,7 @@ width: 250px !important; } - #ValueDateFrom, #ValueDate { + #ValueDate { width: 152px !important; font-size: 1rem !important; } @@ -51,9 +50,8 @@
@Html.MyAceDropdownInput("ClientId", "客户名称", ClientDataModel.GetAllClient(), false, true, null, false) - @Html.MyAceDropdownInput("BookId", "簿记账户", AssetunitController.GetClientassetunit(), false, true, null, false) @Html.CheckBox("ParentFlag", false) @Html.Label(null,"包含子级", new { @style = "font-size:12px;" }) - @Html.ShortInput("ValueDateFrom", "起始日期:") + @Html.MyAceDropdownInput("BookId", "簿记账户", AssetunitController.GetClientassetunit(), false, true, null, false) @Html.ShortInput("ValueDate", "结束日期:") @MyControls.SearchBtn() @if (CurUser.结算管理_每日估值报告邮件发送) @@ -61,7 +59,6 @@ @MyControls.Btn("配置报告", "PopDesc()") @MyControls.Btn("发送报告", "SendReport()") } -

diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/EodPositionRisks.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/EodPositionRisks.js index a011f68f..045d5dc2 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/EodPositionRisks.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/EodPositionRisks.js @@ -456,10 +456,6 @@ function colModelGridEodPosition() { return colModelGrid; } //框架合约table -//TODO(估值模块V1-缺失字段): 以下需求字段本轮未实现,待后端确认数据来源后补充: -// - 期间付息/分红(浮动端分组) -// - 付息方式、合约估值(到期轧差口径/期间支付派息口径)(估值与实现收益分组) -// - 标的类型(基本信息分组) function colModelGridEodSwap() { //按需求《估值模块V1》2.2 分组定义排列列顺序,确保组内列连续(setGroupHeaders 要求) var colModelGrid = [{ @@ -526,6 +522,13 @@ function colModelGridEodSwap() { width: 100, align: 'center', sortable: false + }, { + name: 'UnderlyingType', + label: '标的类型', + index: 'UnderlyingType', + width: 100, + align: 'center', + sortable: false }, //=== 名义本金 === { @@ -568,12 +571,22 @@ function colModelGridEodSwap() { }, //=== 浮动端 === { - name: 'position.FloatingPnL', + name: 'FloatingUnrealizedPnl', label: '合约浮动端待实现收益', - index: 'position.FloatingPnL', + index: 'FloatingUnrealizedPnl', width: 150, align: 'center', formatter: StockEqvNotionalFormat, + }, { + name: 'PeriodAmount', + label: '期间付息/分红', + index: 'PeriodAmount', + width: 150, + align: 'center', + formatter: StockEqvNotionalFormat, + cellattr: function () { + return ' title="合约期间内的期间付息金额(无关乎派息支付日)"'; + }, }, //=== 利息端 === { @@ -613,30 +626,23 @@ function colModelGridEodSwap() { width: 150, align: 'center', formatter: StockEqvNotionalFormat, - }, - //=== 估值与实现收益 === - { - name: 'position.PostionValue', - label: '合约持仓价值', - index: 'position.PostionValue', - width: 150, - align: 'center', - formatter: StockEqvNotionalFormat }, { - name: 'position.TdRealizedPnL', - label: '合约当日实现收益', - index: 'position.TdRealizedPnL', - width: 150, - align: 'center', - formatter: StockEqvNotionalFormat - }, { - name: 'position.RealizedPnL', - label: '合约已实现收益', - index: 'position.RealizedPnL', - width: 150, + name: 'MarginInterestGain', + label: '收取对手方保证金利息', + index: 'MarginInterestGain', + width: 170, align: 'center', formatter: StockEqvNotionalFormat, }, { + name: 'MarginInterestLoss', + label: '支付对手方保证金利息', + index: 'MarginInterestLoss', + width: 170, + align: 'center', + formatter: StockEqvNotionalFormat, + }, + //=== 估值与实现收益 === + { name: 'position.dv01', label: 'DV', index: 'position.dv01', @@ -648,6 +654,33 @@ function colModelGridEodSwap() { if (abs < 0.0001) return "0"; return cellvalue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 }); }, + }, { + name: 'InterestPaymentMethod', + label: '付息方式', + index: 'InterestPaymentMethod', + width: 120, + align: 'center', + }, { + name: 'MaturityNettingValuation', + label: '合约估值(到期轧差口径)', + index: 'MaturityNettingValuation', + width: 190, + align: 'center', + formatter: NullableStockEqvNotionalFormat, + }, { + name: 'PeriodPaymentValuation', + label: '合约估值(期间支付派息口径)', + index: 'PeriodPaymentValuation', + width: 210, + align: 'center', + formatter: NullableStockEqvNotionalFormat, + }, { + name: 'position.RealizedPnL', + label: '合约已实现收益', + index: 'position.RealizedPnL', + width: 150, + align: 'center', + formatter: StockEqvNotionalFormat, } ]; @@ -657,13 +690,13 @@ function colModelGridEodSwap() { //框架合约分组配置(对应需求《估值模块V1》2.2 字段定义) //columns 使用 colModel.name;组内列在 colModel 中必须连续 var eodSwapGroupConfig = [ - { title: '基本信息', columns: ['position.ValueDate', 'AssetBookName', 'ClientName', 'SwapTradeNo', 'StructureType', 'SwapTradeTypeStr'] }, + { title: '基本信息', columns: ['position.ValueDate', 'AssetBookName', 'ClientName', 'SwapTradeNo', 'StructureType', 'SwapTradeTypeStr', 'UnderlyingType'] }, { title: '名义本金', columns: ['position.NotionalValue', 'position.NotionalValueLong', 'position.NotionalValueShort'] }, { title: '标的市值', columns: ['position.MarketValueLong', 'position.MarketValueShort'] }, - { title: '浮动端', columns: ['position.FloatingPnL'] }, + { title: '浮动端', columns: ['FloatingUnrealizedPnl', 'PeriodAmount'] }, { title: '利息端', columns: ['position.InterestPnL'] }, - { title: '保证金', columns: ['position.InitMarginGain', 'position.PostionMarginGain', 'position.InitMarginLoss', 'position.PostionMarginLoss'] }, - { title: '估值与实现收益', columns: ['position.PostionValue', 'position.TdRealizedPnL', 'position.RealizedPnL', 'position.dv01'] } + { title: '保证金', columns: ['position.InitMarginGain', 'position.PostionMarginGain', 'position.InitMarginLoss', 'position.PostionMarginLoss', 'MarginInterestGain', 'MarginInterestLoss'] }, + { title: '估值与实现收益', columns: ['position.dv01', 'InterestPaymentMethod', 'MaturityNettingValuation', 'PeriodPaymentValuation', 'position.RealizedPnL'] } ]; @@ -677,7 +710,11 @@ function gridComplete() { if (page.tabIndex == 2) { var defer = main.setcolumnChooser(jgrid, page.configcolumn_data); $.when(defer).done(function () { + normalizeEodSwapColumnsForGroupHeaders(jgrid, eodSwapGroupConfig); main.initCollapsibleGroupHeaders(jgrid, eodSwapGroupConfig); + jgrid.jqGrid('setLabel', 'PeriodAmount', null, null, { + title: '合约期间内的期间付息金额(无关乎派息支付日)' + }); }); } else { main.setcolumnChooser(jgrid, page.configcolumn_data); @@ -704,7 +741,69 @@ function exportVisibleColumns() { var dateStr = $("#DateValueDate").val() || ''; var tabName = page.tabIndex == 2 ? '框架合约' : '日终持仓'; var fileName = '日终持仓风险_互换_' + tabName + (dateStr ? '_' + dateStr : ''); - main.exportVisibleColumnsToExcel(jgrid, fileName); + var groupConfig = page.tabIndex == 2 ? eodSwapGroupConfig : null; + if (page.tabIndex != 2) { + main.exportVisibleColumnsToExcel(jgrid, fileName, groupConfig); + return; + } + + var exportPostData = $.extend({}, GetPostData(), { + page: 1, + rows: 0, + sidx: jgrid.jqGrid('getGridParam', 'sortname'), + sord: jgrid.jqGrid('getGridParam', 'sortorder') + }); + $.ajax({ + url: queryurl, + type: 'POST', + dataType: 'json', + traditional: true, + data: exportPostData + }).done(function (result) { + main.exportVisibleColumnsToExcel(jgrid, fileName, groupConfig, result && result.rows ? result.rows : []); + }).fail(function () { + main.message && main.message('导出失败,无法获取筛选后的全部数据'); + }); +} + +function normalizeEodSwapColumnsForGroupHeaders(jgrid, groupConfig) { + var colModel = jgrid.jqGrid('getGridParam', 'colModel') || []; + var currentNames = colModel.map(function (c) { return c.name; }); + var groupedNames = []; + var groupedNameMap = {}; + groupConfig.forEach(function (group) { + (group.columns || []).forEach(function (name) { + if (!groupedNameMap[name] && currentNames.indexOf(name) >= 0) { + groupedNameMap[name] = true; + groupedNames.push(name); + } + }); + }); + + var firstGroupedIndex = currentNames.findIndex(function (name) { return groupedNameMap[name]; }); + if (firstGroupedIndex < 0) { + return; + } + + var prefixNames = currentNames.slice(0, firstGroupedIndex).filter(function (name) { + return !groupedNameMap[name]; + }); + var suffixNames = currentNames.slice(firstGroupedIndex).filter(function (name) { + return !groupedNameMap[name]; + }); + var desiredNames = prefixNames.concat(groupedNames).concat(suffixNames); + if (desiredNames.join('|') === currentNames.join('|')) { + return; + } + + var perm = desiredNames.map(function (name) { + return currentNames.indexOf(name); + }).filter(function (idx) { + return idx >= 0; + }); + if (perm.length === currentNames.length) { + jgrid.jqGrid("remapColumns", perm, true); + } } //---------------------------Formatter--------------------------------- @@ -719,6 +818,12 @@ function RealizedPnlFormat(cellValue, options, rowObject) { function StockEqvNotionalFormat(cellValue, options, rowObject) { return otcformat.trading.StockEqvNotional(cellValue); } +function NullableStockEqvNotionalFormat(cellValue, options, rowObject) { + if (cellValue === null || cellValue === undefined || cellValue === '') { + return ''; + } + return StockEqvNotionalFormat(cellValue, options, rowObject); +} function PosiStatusFormat(cellValue, options, rowObject) { return cellValue == 1 ? "已平" : "正常"; } @@ -817,4 +922,4 @@ function SearchClick(isSearchclick) { function showcolumnChooser() { var jgrid = jQuery('#listGrid'); main.showcolumnChooser(jgrid, cloumnTargetName, page.configcolumn_data); -} \ No newline at end of file +} diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/TradeMarketReport_EodPosition.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/TradeMarketReport_EodPosition.js index 9a6deb23..1642cdaf 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/TradeMarketReport_EodPosition.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/TradeMarketReport_EodPosition.js @@ -2,32 +2,16 @@ $(function () { var PostData = {}; - //控件选择时的触发事件 - main.setTradeDatePicker("", "#ValueDate", page.calcDate, function (selectedDate) { - if (selectedDate) { - $("#ValueDateFrom").datepicker("option", "maxDate", selectedDate); - } - }); - main.setTradeDatePicker("", "#ValueDateFrom", page.calcDate, function (selectedDate) { - if (selectedDate) { - $("#ValueDate").datepicker("option", "minDate", selectedDate); - } - }); - //手动修改时的触发事件 - $("#ValueDate").change(function () { - $("#ValueDateFrom").datepicker("option", "maxDate", $("#ValueDate").val()); - }); - $("#ValueDateFrom").change(function () { - $("#ValueDate").datepicker("option", "minDate", $("#ValueDateFrom").val()); - }); + main.setTradeDatePicker("", "#ValueDate", page.calcDate); - //默认初始值赋值逻辑 + // 互换估值页仅暴露一个估值日。当前后端按 ValueDate 单日查询, + // 因此 ValueDateFrom 传同日仅用于保持请求对象和报告参数的日期语义一致。 $("#ValueDate").val(page.EndTime || page.calcDate); - $("#ValueDateFrom").val(page.StartTime && page.EndTime ? page.StartTime : ''); PostData.StructureType = page.StructureType; - PostData.ValueDateFrom = $("#ValueDateFrom").val(); + PostData.ValueDateFrom = $("#ValueDate").val(); PostData.ValueDate = $("#ValueDate").val(); PostData.ClientId = $("#ClientId").val(); + PostData.BookId = $("#BookId").val(); var grid = jQuery('#listGrid').jqGrid({ url: '/swaptrade2/clientEodSwapPositionQuery', @@ -50,8 +34,7 @@ $(function () { pagerpos: 'left', rowNum: 100, rowList: [100, 1000], - loadComplete: gridComplete, - grouping: true + loadComplete: gridComplete }); g_grid = jQuery('#listGrid'); @@ -110,11 +93,7 @@ function searchPositionDetials(isSearchclick) { // main.message("结束日期不能大于当前系统日期!"); // return; //} - if ($("#ValueDate").val() < $("#ValueDateFrom").val()) { - main.message("起始日期不能大于结束日期!"); - return; - } - listGrid.appendPostData({ ValueDateFrom: $("#ValueDateFrom").val() }); + listGrid.appendPostData({ ValueDateFrom: $("#ValueDate").val() }); listGrid.appendPostData({ ValueDate: $("#ValueDate").val() }); listGrid.appendPostData({ StructureType: page.StructureType }); if (typeof (isSearchclick) != "undefined" && isSearchclick) { @@ -134,7 +113,7 @@ function onSortCol(index, icol, sortorder) { var i = 0; var colModelGrid = [ - //隐藏列(不参与分组) + //隐藏列 { name: 'position.EncryptId', label: 'EncryptId', @@ -156,14 +135,6 @@ var colModelGrid = [ index: 'UnwindDate', hidden: true, optionHide: true - }, { - name: 'TradeNumber', - label: '交易编号', - index: 'TradeNumber', - sortIndex: i++, - width: 180, - align: 'center', - sortable: false }, { name: 'ConfrimNo', label: '确认书编号', @@ -173,11 +144,11 @@ var colModelGrid = [ align: 'center', sortable: false }, { - name: 'ClientName', - label: '交易对手', - index: 'ClientName', + name: 'TradeNumber', + label: '交易编号', + index: 'TradeNumber', sortIndex: i++, - width: 120, + width: 180, align: 'center', sortable: false }, { @@ -189,6 +160,15 @@ var colModelGrid = [ align: 'center', sortable: false, formatter:'date', + }, { + name: 'MaturitySettlementDate', + label: '到期结算日', + index: 'MaturitySettlementDate', + sortIndex: i++, + width: 100, + align: 'center', + sortable: false, + formatter: 'date', }, { name: 'position.ValueDate', label: '估值日', @@ -212,22 +192,7 @@ var colModelGrid = [ width: 120, align: 'center', sortable: false, - formatter: RateFormat - }, { - name: 'position.FloatRateUnderlyingCode', - label: '基准利率', - index: 'position.FloatRateUnderlyingCode', - width: 120, - align: 'center', - sortable: false - }, { - name: 'position.FloatRate', - label: '当日适用基准利率', - index: 'position.FloatRate', - width: 120, - align: 'center', - sortable: false, - formatter: RateFormat + formatter: SpreadRateFormat }, { name: 'position.PosiNotionalValue', label: '标的名义金额', @@ -235,7 +200,7 @@ var colModelGrid = [ width: 150, align: 'center', sortable: false, - formatter: StockEqvNotionalFormat, + formatter: AmountFormat, }, { name: 'position.PosiQuantity', label: '标的数量', @@ -250,6 +215,15 @@ var colModelGrid = [ index: 'PeriodAmount', width: 100, align: 'center', + formatter: NullableAmountFormat, + sortable: false + }, { + name: 'DividendAmount', + label: '期间分红', + index: 'DividendAmount', + width: 100, + align: 'center', + formatter: NullableAmountFormat, sortable: false }, { name: 'position.PosiGrossPrice', @@ -266,12 +240,7 @@ var colModelGrid = [ width: 150, align: 'center', sortable: false, - formatter: function (cellValue, options, rowObject) { - if (cellValue == null) { - return ""; - } - return otcformat.trading.premiumRateP(cellValue); - } + formatter: YieldRateFormat }, { name: 'position.UnderlyingPrice', @@ -302,15 +271,7 @@ var colModelGrid = [ index: 'InterestAmount', width: 120, align: 'center', - formatter: StockEqvNotionalFormat, - sortable: false, - }, { - name: 'position.PosiFeePending', - label: '开仓交易费用', - index: 'position.PosiFeePending', - width: 120, - align: 'center', - formatter: StockEqvNotionalFormat, + formatter: FixedAmountFormat, sortable: false, }, { name: 'position.PosiProfitSum', @@ -318,7 +279,47 @@ var colModelGrid = [ index: 'position.PosiProfitSum', width: 120, align: 'center', - formatter: StockEqvNotionalFormat, + formatter: FixedAmountFormat, + sortable: false, + }, { + name: 'position.PosiFeePending', + label: '开平仓交易费用', + index: 'position.PosiFeePending', + width: 120, + align: 'center', + formatter: FixedAmountFormat, + sortable: false, + }, { + name: 'OpenMarginRate', + label: '预付金利率', + index: 'OpenMarginRate', + width: 120, + align: 'center', + formatter: TrimmedRateFormat, + sortable: false, + }, { + name: 'MarginInterestAmount', + label: '预付金利息', + index: 'MarginInterestAmount', + width: 120, + align: 'center', + formatter: FixedAmountFormat, + sortable: false, + }, { + name: 'OpenMarginAmount', + label: '期初预付金', + index: 'OpenMarginAmount', + width: 120, + align: 'center', + formatter: FixedAmountFormat, + sortable: false, + }, { + name: 'AdditionalMarginAmount', + label: '追加预付金', + index: 'AdditionalMarginAmount', + width: 120, + align: 'center', + formatter: FixedAmountFormat, sortable: false, }, { name: 'NetSettmentAmount', @@ -326,23 +327,23 @@ var colModelGrid = [ index: 'NetSettmentAmount', width: 120, align: 'center', - formatter: StockEqvNotionalFormat, + formatter: FixedAmountFormat, + sortable: false, + }, { + name: 'TrsValue', + label: 'TRS估值', + index: 'TrsValue', + width: 120, + align: 'center', + formatter: FixedAmountFormat, sortable: false, } - //TODO(估值模块V1-缺失字段): 以下需求字段本轮未实现,待后端确认数据来源后补充: - // - 期间分红(ETF适用,利率与规模分组) - // - 预付金利率、预付金利息、期初预付金、追加预付金(预付金分组) - // - TRS估值(汇总分组) -]; - -//互换估值分组配置(对应需求《估值模块V1》3.2.2 字段定义) -//columns 使用 colModel.name;现有列顺序已天然满足分组连续性,无需重排 -var eodSwapValuationGroupConfig = [ - { title: '基本信息', columns: ['TradeNumber', 'ConfrimNo', 'ClientName', 'position.PosiStartDate', 'position.ValueDate', 'position.UnderlyingCode'] }, - { title: '利率与规模', columns: ['InterestRate', 'position.FloatRateUnderlyingCode', 'position.FloatRate', 'position.PosiNotionalValue', 'position.PosiQuantity', 'PeriodAmount'] }, - { title: '价格', columns: ['position.PosiGrossPrice', 'InitYtm', 'position.UnderlyingPrice', 'DayCount'] }, - { title: '收益', columns: ['FloatRateAbs', 'InterestAmount', 'position.PosiFeePending', 'position.PosiProfitSum', 'NetSettmentAmount'] } ]; +// 页面字段顺序以《估值模块V1》第二部分为准;历史个人列配置只能控制显隐,不能打乱业务列顺序。 +var defaultVisibleColumnNames = colModelGrid + .filter(function (column) { return column.hidden !== true; }) + .map(function (column) { return column.name; }); +var documentColumnOrder = colModelGrid.map(function (column) { return column.name; }); function formatter6(cellvalue, options, rowObject) { return main.formatNumber(cellvalue, 6); @@ -372,25 +373,37 @@ function gridComplete() { } } //jgrid.sortGrid(g_sort.name, g_sort.order); - //在列设置应用完成后初始化可折叠分组表头(setcolumnChooser 会 remapColumns,须在其 done 回调后建表头) - var defer = main.setcolumnChooser(jgrid, page.configcolumn); - $.when(defer).done(function () { - main.initCollapsibleGroupHeaders(jgrid, eodSwapValuationGroupConfig); + $.when(main.setcolumnChooser(jgrid, page.configcolumn)).always(function () { + restoreDocumentColumnOrder(jgrid); + ensureBusinessColumnsVisible(jgrid); }); $(".selftooltip").tooltip({ html: true, show: 50000, trigger: "hover" }); $(window).off('resize.jqGrid'); } -//导出互换估值当前可见列(需求《估值模块V1》3.2 + 4.2) -//与现有 DownLoadReport(整份PDF/Excel报告)独立,互不影响 -function exportSwapValuationVisibleColumns() { - var jgrid = jQuery('#listGrid'); - var clientName = $("#ClientId :selected").text() || ''; - var dateStr = $("#ValueDate").val() || ''; - var fileName = (clientName ? clientName + '_' : '') + '每日估值报告_互换估值' + (dateStr ? '_' + dateStr : ''); - main.exportVisibleColumnsToExcel(jgrid, fileName); +function restoreDocumentColumnOrder(jgrid) { + var colModel = jgrid.jqGrid('getGridParam', 'colModel') || []; + var currentNames = colModel.map(function (column) { return column.name; }); + var targetNames = currentNames + .filter(function (name) { return documentColumnOrder.indexOf(name) < 0; }) + .concat(documentColumnOrder); + var permutation = targetNames.map(function (name) { return currentNames.indexOf(name); }); + if (permutation.length === currentNames.length + && permutation.every(function (index) { return index >= 0; }) + && permutation.some(function (index, targetIndex) { return index !== targetIndex; })) { + jgrid.jqGrid('remapColumns', permutation, true); + } } +function ensureBusinessColumnsVisible(jgrid) { + var colModel = jgrid.jqGrid('getGridParam', 'colModel') || []; + var hasVisibleBusinessColumn = colModel.some(function (column) { + return defaultVisibleColumnNames.indexOf(column.name) >= 0 && column.hidden !== true; + }); + if (!hasVisibleBusinessColumn) { + jgrid.jqGrid('showCol', defaultVisibleColumnNames); + } +} function reloadTradeMarketReport() { //重新加载 @@ -430,10 +443,6 @@ function SendReport() { main.message("结束日期不能大于当前系统日期!"); return; } - if ($("#ValueDate").val() < $("#ValueDateFrom").val()) { - main.message("起始日期不能大于结束日期!"); - return; - } main.open("向{0}发送报告".template(clientName), "/clientbalance/TradeMarketClientSend?clientid=" + param.ClientId + "&ParentFlag=" + param.ParentFlag); } function DownLoadReport() { @@ -454,18 +463,16 @@ function DownLoadReport() { main.message("结束日期不能大于当前系统日期!"); return; } - if ($("#ValueDate").val() < $("#ValueDateFrom").val()) { - main.message("起始日期不能大于结束日期!"); - return; - } - main.post("/clientbalance/ViewTradeMarketFile", screenData()).done(function (res) { window.open(res.obj); }); } function screenData() { - var data = { From: $("#ValueDateFrom").val(), To: $("#ValueDate").val() }; + // 已移除起始日期控件,预览报告按单个估值日生成,From/To 保持同日。 + var data = { From: $("#ValueDate").val(), To: $("#ValueDate").val() }; data.ClientId = $("#ClientId").val(); + // 报告下载和发送弹窗均从 screenData 取参数,必须保留当前簿记账户筛选。 + data.BookId = $("#BookId").val(); if ($("#ParentFlag").prop("checked")) data.ParentFlag = true; else @@ -477,12 +484,35 @@ function showChiCang() { main.showcolumnChooser(jQuery('#listGrid'), page.configcolumn); } function PriceFormat(cellValue, options, rowObject) { - return otcformat.trading.umprice(cellValue); + return main.formatNumber(cellValue, 9, { grouping: true }); } function StockEqvNotionalFormat(cellValue, options, rowObject) { return otcformat.trading.StockEqvNotional(cellValue); } +function AmountFormat(cellValue, options, rowObject) { + return main.formatNumber(cellValue, 2, { trimTailZeros: true }); +} +function NullableAmountFormat(cellValue, options, rowObject) { + if (cellValue === null || cellValue === undefined || cellValue === '') { + return ''; + } + return AmountFormat(cellValue, options, rowObject); +} +function FixedAmountFormat(cellValue, options, rowObject) { + return main.formatNumber(cellValue, 2); +} +function YieldRateFormat(cellValue, options, rowObject) { + if (cellValue === null || cellValue === undefined || cellValue === '') { + return ''; + } + // percent 格式会在数字末尾添加 %,通用 trimTailZeros 无法识别其后的 0。 + // 先将小数收益率转为百分比数值,再格式化并追加 %,确保最多保留四位小数且去尾零。 + return main.formatNumber(cellValue * 100, 4, { trimTailZeros: true }) + '%'; +} +function TrimmedRateFormat(cellValue, options, rowObject) { + return main.formatNumber(cellValue, 4, { percent: true, trimTailZeros: true }); +} function RateFormat(cellValue, options, rowObject) { if (cellValue) { var num = new Number(cellValue) * 100; @@ -491,19 +521,23 @@ function RateFormat(cellValue, options, rowObject) { return "0.0000%"; } } +function SpreadRateFormat(cellValue, options, rowObject) { + if (cellValue) { + var num = new Number(cellValue) * 100; + return num.toFixed(2) + "%"; + } else { + return "0.00%"; + } +} function locationChange(tab) { if ($("#ValueDate").val() > page.valueDate) { main.message("结束日期不能大于当前系统日期!"); return; } - if ($("#ValueDate").val() < $("#ValueDateFrom").val()) { - main.message("起始日期不能大于结束日期!"); - return; - } if ($("#ParentFlag").prop("checked")) ParentFlag = true; else ParentFlag = false; - window.location.href = tab + "?clientId=" + $("#ClientId").val() + "&startTime=" + $("#ValueDateFrom").val() + "&endTime=" + $("#ValueDate").val() + "&ParentFlag=" + ParentFlag; + window.location.href = tab + "?clientId=" + $("#ClientId").val() + "&startTime=" + $("#ValueDate").val() + "&endTime=" + $("#ValueDate").val() + "&ParentFlag=" + ParentFlag; return; } diff --git a/YLErpWeb/wwwroot/Scripts/utils.js b/YLErpWeb/wwwroot/Scripts/utils.js index 993ad26c..1114e8e7 100644 --- a/YLErpWeb/wwwroot/Scripts/utils.js +++ b/YLErpWeb/wwwroot/Scripts/utils.js @@ -1209,21 +1209,41 @@ main.refreshCollapsibleGroupHeaders = function (jgrid) { * * @param {jQuery} jgrid jqGrid 容器 * @param {string} fileName 导出文件名(不含扩展名) + * @param {Array} groupConfig 可选,分组表头配置,每项 { title: string, columns: string[] } + * @param {Array} exportRows 可选,后端返回的全部筛选结果;未传时导出当前页 */ -main.exportVisibleColumnsToExcel = function (jgrid, fileName) { +main.exportVisibleColumnsToExcel = function (jgrid, fileName, groupConfig, exportRows) { var colModel = jgrid.jqGrid('getGridParam', 'colModel'); // 只导出可见列(hidden !== true),与折叠状态联动:收起的列自动不可见 var visibleCols = colModel.filter(function (c) { return c.hidden !== true && c.name !== 'cb' && c.name !== 'rn'; }); if (!visibleCols.length) { main.message && main.message("没有可导出的列"); return; } - var rows = jgrid.jqGrid('getRowData'); // 获取当前页数据(已格式化) + var rows = exportRows || jgrid.jqGrid('getRowData'); + if (exportRows) { + var gridElement = jgrid[0]; + rows = exportRows.map(function (row, rowIndex) { + var formattedRow = {}; + visibleCols.forEach(function (col) { + var colIndex = colModel.indexOf(col); + var rawValue = $.jgrid.getAccessor(row, col.name); + var formattedValue = gridElement && gridElement.formatter + ? gridElement.formatter(rowIndex + 1, rawValue, colIndex, row, 'add') + : rawValue; + formattedRow[col.name] = $('
').html(formattedValue == null ? '' : String(formattedValue)).text().replace(/\u00a0/g, ''); + }); + return formattedRow; + }); + } var headerLabels = visibleCols.map(function (c) { return c.label || c.name; }); // 构建 HTML table,用 style 保持 mso-number-format 让金额不被科学计数法破坏 var html = ''; html += ''; html += ''; - //表头 + //表头:有分组配置时导出两级表头(分类 + 字段),否则保持原单级表头。 + if (groupConfig && groupConfig.length) { + html += buildGroupHeaderHtml(visibleCols, groupConfig); + } html += '' + headerLabels.map(function (l) { return ''; }).join('') + ''; @@ -1252,5 +1272,25 @@ main.exportVisibleColumnsToExcel = function (jgrid, fileName) { function escapeXml(s) { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } + + function buildGroupHeaderHtml(cols, config) { + var titleByColumn = {}; + config.forEach(function (group) { + (group.columns || []).forEach(function (name) { + titleByColumn[name] = group.title || ''; + }); + }); + + var cells = []; + for (var i = 0; i < cols.length; i++) { + var title = titleByColumn[cols[i].name] || ''; + var colspan = 1; + while (i + colspan < cols.length && (titleByColumn[cols[i + colspan].name] || '') === title) { + colspan++; + } + cells.push(''); + i += colspan - 1; + } + return '' + cells.join('') + ''; + } }; - \ No newline at end of file
' + escapeXml(l) + '
' + escapeXml(title) + '