diff --git a/UnitTestProject/Modules/SwapModule/Margin/MarginInterestShadowTest.cs b/UnitTestProject/Modules/SwapModule/Margin/MarginInterestShadowTest.cs new file mode 100644 index 00000000..603c49c0 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/Margin/MarginInterestShadowTest.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; +using YLErp; +using YLErp.DBModels; +using YLErp.DBModels.Enums; +using YLErp.Modules.SwapModule; +using YLErp.Modules.SwapModule.Accrual; +using YLErp.Modules.SwapModule.Margin; +using YLErp.Derivatives.Interest; + +namespace UnitTestProject.Modules.SwapModule.Margin +{ + /// + /// 影子对账:保证金腿新方法 CalcMarginInterest(无 orginPv/差分)vs + /// 旧通用管线 CalcDailySimpleInterestByEod/CalcDailySimpleInterest(带差分 + orginPv hack)。 + /// + /// 保证金是纯固定利率单利(FloatRateUnderlyingCode 恒空、InterestType 恒单利、SwapIntervalList 单段), + /// 旧管线差分公式 accrualBasis = TdInterestPrincipal + posiPrincipal - orginPv 对保证金恒等于 posiPrincipal + /// (因 orginPv 经 PreviousBalance 对齐到昨日终保证金余额),故新方法直接用 posiPrincipal/昨日终本金作 + /// notional 应与旧管线严格数值一致。本测试即在多种场景下证明这一等价,为提交2 切换生产路径提供安全网。 + /// + [TestClass] + public class MarginInterestShadowTest + { + private const decimal Principal = 2_000_000m; // 保证金本金(InterestPrincipalFix) + private const decimal Rate = 0.03m; // 3% 年化固定利率 + private const int AnnualDays = 365; + private static readonly DateTime StartDate = new(2026, 7, 1); + private static readonly DateTime ExerciseDate = new(2027, 6, 30); + + private sealed class StubSvc : SwapDealService + { + public StubSvc() : base(new OptUserInfo(0, nameof(MarginInterestShadowTest), OptUserFrom.UnitTest)) { } + } + + private static trade CreateTrade() => new trade + { + id = 1, TradeNumber = "UT-MARGIN-SHADOW", ClientId = 999998, + TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate, + ExerciseDate = ExerciseDate, TradeStatus = "确认成交", ValidState = "Valid", + trade_extend = new trade_extend + { + TradeId = 1, + ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { AnnualDays = AnnualDays, InterestCalcMode = "10", SettlementRules = 0 }) + } + }; + + /// 保证金腿(初始预付金 mode 5):固定利率、单利、年化、无浮动标的。 + private static swap_position CreateMarginPosition() => new swap_position + { + id = 2001, SwapTradeId = 1, PosiDirection = 0, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.初始预付金, + InterestRateDefault = Rate, InterestPrincipalFix = Principal, + PosiStartDate = StartDate, PosiMatuirityDate = ExerciseDate, + IsInitial = true, Invalid = false, + InterestType = (int)InterestTypeEnum.单利, + IsAnnualized = true, interest_rest_days = 1, interest_rule = 0, + FloatRateUnderlyingCode = null, InterestSwapInterval = "[]" + }; + + /// 构造昨日终 eod_swap_position(已含累计利息 InterestProfitSum 与昨日终本金)。 + private static eod_swap_position CreatePreEod(DateTime valueDate, decimal profitSum) => new eod_swap_position + { + id = 1, SwapTradeId = 1, PositionId = 2001, + ValueDate = valueDate, + TdInterestPrincipal = Principal, InterestPrincipalFix = Principal, + InterestProfitSum = profitSum, PosiNotionalValue = Principal, FloatRate = 0m + }; + + // ──────────────────────────── EOD 路径 ──────────────────────────── + + /// EOD 续接单日:有历史归档,notional=昨日终本金。 + [TestMethod] + public void 影子_EOD续接单日_新旧一致() + { + var td = CreateTrade(); + var position = CreateMarginPosition(); + var valueDate = StartDate.AddDays(5); + const decimal profitSum = 820m; + + // 旧方法 + decimal oldI = 0, oldTd = 0; + var svc = new StubSvc(); + svc.CalcDailySimpleInterestByEod(CreatePreEod(StartDate.AddDays(4), profitSum), + valueDate, td.StartDate.Value, position, Principal, Principal, + new swap_flow_event { InterestRate = Rate }, AnnualDays, 0m, 1m, ref oldI, ref oldTd); + + // 新方法(独立 preEod,相同初始值) + var newEvt = svc.CalcMarginInterest(td, valueDate, position, Rate, Principal, Principal, 1m, + AnnualDays, calcFirst: true, calcLast: true, + CreatePreEod(StartDate.AddDays(4), profitSum), 0, add: false, settment: true, swap: false); + + Console.WriteLine($"旧: I={oldI} Td={oldTd}"); + Console.WriteLine($"新: I={newEvt.InterestAmount} Td={newEvt.TdInterestAmount} ClosePnL={newEvt.InterestClosePnL}"); + Assert.AreEqual(oldI, newEvt.InterestAmount, "InterestAmount 一致"); + Assert.AreEqual(oldTd, newEvt.TdInterestAmount, "TdInterestAmount 一致"); + } + + /// EOD 首日(preEod.id==0):首日初始化 notional=posiPrincipal。 + [TestMethod] + public void 影子_EOD首日_新旧一致() + { + var td = CreateTrade(); + var position = CreateMarginPosition(); + var valueDate = StartDate; + + decimal oldI = 0, oldTd = 0; + var svc = new StubSvc(); + svc.CalcDailySimpleInterestByEod(new eod_swap_position { id = 0 }, + valueDate, td.StartDate.Value, position, Principal, Principal, + new swap_flow_event { InterestRate = Rate }, AnnualDays, 0m, 1m, ref oldI, ref oldTd); + + var newEvt = svc.CalcMarginInterest(td, valueDate, position, Rate, Principal, Principal, 1m, + AnnualDays, calcFirst: true, calcLast: true, + new eod_swap_position { id = 0 }, 0, add: false, settment: true, swap: false); + + Assert.AreEqual(oldI, newEvt.InterestAmount, "InterestAmount 一致"); + Assert.AreEqual(oldTd, newEvt.TdInterestAmount, "TdInterestAmount 一致"); + } + + // ──────────────────────────── 盘中路径 ──────────────────────────── + + /// 盘中全平(closePercent=1):新方法 notional=posiPrincipal,旧方法差分 accrualBasis 恒=posiPrincipal。 + [TestMethod] + public void 影子_盘中全平_新旧一致() + { + var td = CreateTrade(); + var position = CreateMarginPosition(); + var valueDate = StartDate.AddDays(5); + const decimal profitSum = 820m; + + // 旧方法:orginPv 经 PreviousBalance 对齐到昨日终保证金余额 → accrualBasis 恒= Principal + decimal oldI = 0, oldTd = 0; + var svc = new StubSvc(); + var preEodOld = CreatePreEod(StartDate.AddDays(4), profitSum); + decimal orginPv = MarginCalc.PreviousBalance(preEodOld, Principal); + svc.CalcDailySimpleInterest(preEodOld, valueDate, position, Principal, + new swap_flow_event { InterestRate = Rate }, AnnualDays, 0m, 1m, orginPv, + calcFirst: true, calcLast: false, ref oldI, ref oldTd); + + // 新方法:notional = posiPrincipal(无差分、无 orginPv) + var newEvt = svc.CalcMarginInterest(td, valueDate, position, Rate, Principal, Principal, 1m, + AnnualDays, calcFirst: true, calcLast: false, + CreatePreEod(StartDate.AddDays(4), profitSum), 0, add: false, settment: false, swap: false); + + Console.WriteLine($"旧: I={oldI} Td={oldTd}"); + Console.WriteLine($"新: I={newEvt.InterestAmount} Td={newEvt.TdInterestAmount}"); + Assert.AreEqual(oldI, newEvt.InterestAmount, "InterestAmount 一致"); + Assert.AreEqual(oldTd, newEvt.TdInterestAmount, "TdInterestAmount 一致"); + } + + /// 盘中部分平仓(closePercent=0.5):缩放累计,新旧线性等价。 + [TestMethod] + public void 影子_盘中部分平仓_新旧一致() + { + var td = CreateTrade(); + var position = CreateMarginPosition(); + var valueDate = StartDate.AddDays(5); + const decimal profitSum = 820m; + const decimal closePct = 0.5m; + + decimal oldI = 0, oldTd = 0; + var svc = new StubSvc(); + var preEodOld = CreatePreEod(StartDate.AddDays(4), profitSum); + decimal orginPv = MarginCalc.PreviousBalance(preEodOld, Principal); + svc.CalcDailySimpleInterest(preEodOld, valueDate, position, Principal, + new swap_flow_event { InterestRate = Rate }, AnnualDays, 0m, closePct, orginPv, + calcFirst: true, calcLast: false, ref oldI, ref oldTd); + + var newEvt = svc.CalcMarginInterest(td, valueDate, position, Rate, + Principal * closePct, Principal, closePct, + AnnualDays, calcFirst: true, calcLast: false, + CreatePreEod(StartDate.AddDays(4), profitSum), 0, add: false, settment: false, swap: false); + + Console.WriteLine($"旧: I={oldI} Td={oldTd}"); + Console.WriteLine($"新: I={newEvt.InterestAmount} Td={newEvt.TdInterestAmount}"); + Assert.AreEqual(oldI, newEvt.InterestAmount, "InterestAmount 一致"); + Assert.AreEqual(oldTd, newEvt.TdInterestAmount, "TdInterestAmount 一致"); + } + + /// 互换事件(swap=true,盘中):利息应归零。 + [TestMethod] + public void 影子_盘中互换_利息归零() + { + var td = CreateTrade(); + var position = CreateMarginPosition(); + var valueDate = StartDate.AddDays(5); + + var svc = new StubSvc(); + var newEvt = svc.CalcMarginInterest(td, valueDate, position, Rate, Principal, Principal, 1m, + AnnualDays, calcFirst: true, calcLast: false, + CreatePreEod(StartDate.AddDays(4), 820m), 0, add: false, settment: false, swap: true); + + Assert.AreEqual(0m, newEvt.InterestAmount, "互换利息归零"); + Assert.AreEqual(0m, newEvt.TdInterestAmount, "互换 TdInterestAmount 归零"); + Assert.AreEqual(0m, newEvt.InterestClosePnL, "互换 InterestClosePnL 归零"); + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index beeeef4e..fec4dfaf 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -345,56 +345,6 @@ namespace YLErp.Modules.SwapModule /// /// /// - public UnwindData InitLongShortUnwind(int tradeId, SwapEventTypeEnum eventTypeEnum) - { - var td = DbContext.trade.Find(tradeId); - if (td == null) - { - throw new ServiceException("未找到交易信息"); - } - var positions = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && x.IsInitial && !x.Invalid); - List eventTyps = new List() { (int)SwapEventTypeEnum.平仓, (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 preDealDate = GetPreDealDate(tradeId, dealDate, eventTyps); - double stockEqvNotional = td.StockEqvNotional;//剩余名义本金 - 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.平仓待复核 || td.TradeStatus == ConsTrade.互换待复核)) - { - var swapEvent = GetSwapEvent(tradeId, (int)eventTypeEnum); - if (swapEvent == null) - { - throw new Exception("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效"); - } - unwindData = swapEvent.unwindData; - } - else - { - unwindData.StartDate = td.TradeDate.Value; - if (preDealDate.HasValue) - { - unwindData.StartDate = preDealDate.Value; - } - unwindData.ValueDate = dealDate; - unwindData.UnwindDate = dealDate; - unwindData.PayDate = QdpCalendarHelper.GetNonHoliday(dealDate.AddDays(td.trade_extend.ExtendObj.SettlementRules)); - unwindData.SwapTradeId = tradeId; - unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0); - unwindData.NotionalQty = positions.Sum(s => s.PosiQuantity); - unwindData.PosiNotionalValue = Convert.ToDecimal(stockEqvNotional); - unwindData.PositionQty = 0;//平仓只做了结为0,互换用不上 - unwindData.AnnualDays = tradeExtend == null ? 365 : tradeExtend.ExtendObj.AnnualDays; - if (eventTypeEnum == SwapEventTypeEnum.平仓) - { - unwindData.FlowEvents = GetUnwindInterests(dealDate, unwindData.UnwindDate.Value, tradeId, 1, (int)SwapEventTypeEnum.平仓); - } - } - return unwindData; - } /// /// 平仓初始化 /// @@ -966,6 +916,118 @@ namespace YLErp.Modules.SwapModule return interest; } + /// + /// 保证金腿(InterestMode 5/6)专属计息——替代 CalcEodInterest/CalcUnwindInterest 对保证金的处理。 + /// + /// 保证金是纯固定利率单利:浮动利率(FR007)/分段利率/复利对其均为死分支(前端无入口、 + /// 确认书不含、FundingLegRate.Build 对空 FloatRateUnderlyingCode 恒返回 Fixed)。故本方法直接用 + /// SimpleInterestAccrual 纯函数计息,notional 取保证金余额本身: + /// EOD = 昨日终本金 preEod.TdInterestPrincipal(与旧 CalcDailySimpleInterestByEod 同源) + /// 盘中 = 今日本金 posiPrincipal(InterestPrincipalFix) + /// 消除融资腿差分公式 accrualBasis = TdInterestPrincipal + posiPrincipal - orginPv 与 orginPv 维度 + /// hack——对保证金 accrualBasis 恒等于 posiPrincipal,差分冗余。保留累计语义(priorAccrued + 增量), + /// 满足下游 SwapEodPositionService 字段契约(InterestAmount=缩放累计、TdInterestAmount=单日参考等)。 + /// + /// true=收盘归档(EOD),false=盘中平仓/互换。 + /// 互换事件(仅盘中生效,true 时利息归零,同 InitSwapDealInterest)。 + public swap_flow_event CalcMarginInterest( + trade td, DateTime valueDate, 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 swap) + { + // 当日是否计息(算头算尾)——同 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 = valueDate + }; + + // 互换事件:利息归零(同 InitSwapDealInterest) + if (swap && !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 + { + // 盘中:notional = 今日本金 posiPrincipal(无差分、无 orginPv) + var segmentRates = new List<(DateTime, decimal)> { (position.PosiStartDate, rate) }; + var r = SimpleInterestAccrual.AccruePeriod( + priorAccrued: preEod.InterestProfitSum * closePercent, + notional: posiPrincipal, + unwindFraction: closePercent, + segmentRates: segmentRates, + startDate: position.PosiStartDate, + endDate: valueDate, + priorValueDate: preEod.ValueDate, + boundary: AccrualBoundary.Of(calcFirst, calcLast), + annualDays: annualDays, + isAnnualized: position.IsAnnualized); + interestAmount = r.Accrued; + tdInterestAmount = r.AccruedToday; + interest.InterestPrincipal = posiPrincipal * 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; + } + /// /// 计算盘中利息(平仓/互换) /// @@ -1797,84 +1859,6 @@ namespace YLErp.Modules.SwapModule unwindData.SwapRealizedPnL = Math.Round(unwindData.SwapRealizedPnL, 2, MidpointRounding.AwayFromZero); } /// - /// 多空组合平仓 - /// - /// - /// - public void SwapLongShortUnwind(UnwindData unwindData) - { - var td = DbContext.trade.Find(unwindData.SwapTradeId); - if (td == null) - { - throw new ServiceException("未找到交易信息"); - } - UnwindNormalizer.NormalizeEventUnwindDate(unwindData); - unwindData.SwapRealizedPnL = unwindData.SwapCloseAmount; - NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum.平仓, "系统操作_平仓"); - var trans = DbContext.Database.BeginTransaction(); - try - { - int clientCashId = AddClientCashInCashOut(td, Convert.ToDouble(unwindData.SwapCloseAmount), ClientCashInCashOut.系统操作_平仓费, unwindData.ValueDate); - RecordMarginCashFlow(td, unwindData); - SaveSwapDeal(unwindData, (int)SwapEventTypeEnum.平仓, clientCashId, "系统操作_平仓"); - td.UnWindDate = unwindData.UnwindDate; - td.StockEqvNotional = 0; - td.TradeStatus = "已平仓"; - DbContext.SaveChanges(); - trans.Commit(); - } - catch (Exception ex) - { - trans.Rollback(); - throw; - } - finally - { - trans.Dispose(); - } - - } - /// - /// 多空组合互换 - /// - /// - /// - public void SwapLongShort(UnwindData unwindData) - { - var td = DbContext.trade.Find(unwindData.SwapTradeId); - if (td == null) - { - throw new ServiceException("未找到交易信息"); - } - UnwindNormalizer.NormalizeEventUnwindDate(unwindData); - unwindData.SwapRealizedPnL = unwindData.SwapCloseAmount; - NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum.互换, "系统操作_互换"); - var trans = DbContext.Database.BeginTransaction(); - try - { - int clientCashId = AddClientCashInCashOut(td, Convert.ToDouble(unwindData.SwapCloseAmount), ClientCashInCashOut.系统操作_互换, unwindData.ValueDate); - SaveSwapDeal(unwindData, (int)SwapEventTypeEnum.互换, clientCashId, "系统操作_互换"); - td.UnWindDate = unwindData.UnwindDate; - if (td.ExerciseDate <= unwindData.ValueDate) - { - td.Notional = 0; - td.StockEqvNotional = 0; - td.TradeStatus = "已到期"; - } - DbContext.SaveChanges(); - trans.Commit(); - } - catch (Exception ex) - { - trans.Rollback(); - throw; - } - finally - { - trans.Dispose(); - } - } - /// /// 互换 /// ///