diff --git a/Framework/YLErp.Core/DBModels/BondPayment.cs b/Framework/YLErp.Core/DBModels/BondPayment.cs index aae29465..3de640b4 100644 --- a/Framework/YLErp.Core/DBModels/BondPayment.cs +++ b/Framework/YLErp.Core/DBModels/BondPayment.cs @@ -65,6 +65,13 @@ namespace YLErp.DBModels [DisplayName("实际付息(兑付)日")] [Column("pay_date_act")] public DateTime? payment_date { get; set; } + + /// + /// 债权登记日(除息/归属截止日)——票息归属按此判定,而非支付日 + /// + [DisplayName("债权登记日")] + [Column("reg_date")] + public DateTime? reg_date { get; set; } /// /// 每张兑付利息额 /// diff --git a/Framework/YLErp.Core/DBModels/EodSwapPosition.cs b/Framework/YLErp.Core/DBModels/EodSwapPosition.cs index f4c8f745..77b3dbbf 100644 --- a/Framework/YLErp.Core/DBModels/EodSwapPosition.cs +++ b/Framework/YLErp.Core/DBModels/EodSwapPosition.cs @@ -205,7 +205,7 @@ namespace YLErp.DBModels [DataChange] public int InterestDirection { get; set; } /// - /// 计息基本类型 1:固定值,2:合约名义本金规模,3:持仓名义本金,4:持仓市值,5:初始预付金,6:追加预付金 + /// 计息基本类型 1:固定值,2:合约名义本金规模,5:初始预付金,6:追加预付金,9:标的期初全价 /// [DisplayName("计息基本类型")] [DataChange] diff --git a/Framework/YLErp.Core/DBModels/SwapFlowEvent.cs b/Framework/YLErp.Core/DBModels/SwapFlowEvent.cs index 632ed8c3..b635b672 100644 --- a/Framework/YLErp.Core/DBModels/SwapFlowEvent.cs +++ b/Framework/YLErp.Core/DBModels/SwapFlowEvent.cs @@ -280,7 +280,7 @@ namespace YLErp.DBModels } } /// - /// 计息方式 1:固定值,2:合约名义本金规模,3:持仓名义本金,4:持仓市值 + /// 计息方式 1:固定值,2:合约名义本金规模,5:初始预付金,6:追加预付金,9:标的期初全价 /// [DisplayName("计息方式")] [DataChange] diff --git a/Framework/YLErp.Core/DBModels/SwapPosition.cs b/Framework/YLErp.Core/DBModels/SwapPosition.cs index 04a306f9..a5d66d81 100644 --- a/Framework/YLErp.Core/DBModels/SwapPosition.cs +++ b/Framework/YLErp.Core/DBModels/SwapPosition.cs @@ -163,7 +163,7 @@ namespace YLErp.DBModels /// public string FloatRateUnderlyingCode { get; set; } /// - /// 计息基本类型 1:固定值,2:合约名义本金规模,3:持仓名义本金,4:持仓市值,5:初始预付金,6:追加预付金 + /// 计息基本类型 1:固定值,2:合约名义本金规模,5:初始预付金,6:追加预付金,9:标的期初全价 /// [DisplayName("计息基本类型")] [DataChange] diff --git a/Framework/YLErp.Core/Interest/AccrualContext.cs b/Framework/YLErp.Core/Interest/AccrualContext.cs index 3cdbf5fb..8e7dd77f 100644 --- a/Framework/YLErp.Core/Interest/AccrualContext.cs +++ b/Framework/YLErp.Core/Interest/AccrualContext.cs @@ -18,7 +18,7 @@ public readonly struct AccrualContext /// 年化天数(365 / 360)。 public int AnnualDays { get; } - /// 舍入精度位数。默认 11(保证金腿);资金腿调用方应显式传 FundingLegPrecision=12。 + /// 舍入精度位数。默认 11(仅未接线的 MarginAccount.AccrueInterest 走此默认;生产融资腿/保证金腿均显式用 FundingLegPrecision=12)。 public int Precision { get; } /// 可选 trace 收集器;为 null 时不记录(纯计算场景直接传 null,与开关无关)。 diff --git a/Framework/YLErp.Core/Interest/InterestRate.cs b/Framework/YLErp.Core/Interest/InterestRate.cs index 716d48b7..b5a6def1 100644 --- a/Framework/YLErp.Core/Interest/InterestRate.cs +++ b/Framework/YLErp.Core/Interest/InterestRate.cs @@ -53,7 +53,7 @@ public readonly struct InterestRate /// /// :decimal 精确运算。 /// / :闭式(double 计算后回 decimal), - /// 满足通用定价 / 保证金场景;若要 decimal 精度的离散重置日复利,请用 Simple 按段计息并滚动本金。 + /// 满足通用定价;若要 decimal 精度的离散重置日复利,请用 Simple 按段计息并滚动本金。 /// /// public decimal CompoundFactor(decimal t) diff --git a/Framework/YLErp.Core/Interest/SwapInterest.cs b/Framework/YLErp.Core/Interest/SwapInterest.cs index d66ce7c4..ddec05bf 100644 --- a/Framework/YLErp.Core/Interest/SwapInterest.cs +++ b/Framework/YLErp.Core/Interest/SwapInterest.cs @@ -106,11 +106,11 @@ public readonly struct InterestResult /// public static class SwapInterest { - /// 系统统一价格精度位数(保证金腿)。 + /// 默认舍入精度位数(历史值;生产融资腿与保证金腿均用 FundingLegPrecision=12)。 public const int Precision = 11; - /// 资金腿计息精度(生产口径)。资金腿所有落库/对账均以 12 位为准, - /// 与保证金腿的 Precision=11 不同。提升至公共常量,消除 SwapDealService 与 FundingLegAccrual 的重复定义。 + /// 资金腿与保证金腿的生产计息精度(落库/对账均以 12 位为准)。 + /// 提升至公共常量,消除 SwapDealService 与 SimpleInterestAccrual 的重复定义。 public const int FundingLegPrecision = 12; /// 年化天数常量(合约字段存的是 int,故不用 enum)。 diff --git a/UnitTestProject/Modules/SwapModule/Accrual/CompoundEodShadowTest.cs b/UnitTestProject/Modules/SwapModule/Accrual/CompoundEodShadowTest.cs index 4c2af32c..625d2254 100644 --- a/UnitTestProject/Modules/SwapModule/Accrual/CompoundEodShadowTest.cs +++ b/UnitTestProject/Modules/SwapModule/Accrual/CompoundEodShadowTest.cs @@ -13,7 +13,7 @@ using YLErp.Core.Interest; namespace UnitTestProject.Modules.SwapModule.Accrual { /// - /// 影子测试:CalcDailyCompoundInterestByEod(旧逐日循环)vs FundingLegAccrual.AccrueCompoundEod(新纯函数)。 + /// 影子测试:CalcDailyCompoundInterestByEod(旧逐日循环)vs CompoundInterestAccrual.AccrueEod(新纯函数)。 /// 构造同一组参数,两套实现并行跑,断言结果一致(到分)。 /// [TestClass] @@ -92,14 +92,14 @@ namespace UnitTestProject.Modules.SwapModule.Accrual decimal oldInterest = 0, oldTd = 0; var svc = new StubSvc(); svc.CalcDailyCompoundInterestByEod(preEod, EodDate, TradeDate, position, - Notional, Notional, flowEvent, AnnualDays, false, 0m, 1m, Notional, + Notional, Notional, flowEvent, AnnualDays, 0m, 1m, ref oldInterest, ref oldTd); // 新方法 var rate = FundingLegRate.Fixed(FixedRate); var policy = new AccrualPolicy(AccrualBoundary.Both, true, 7, AnnualDays, true); var remainingPercent = Math.Max(0m, Math.Min(1m, Notional / Notional)); - var result = FundingLegAccrual.AccrueCompoundEod( + var result = CompoundInterestAccrual.AccrueEod( 50_000m, Notional, Notional, 1m, rate, policy, isResetDay: true, remainingPercent, EodDate); @@ -125,13 +125,13 @@ namespace UnitTestProject.Modules.SwapModule.Accrual decimal oldInterest = 0, oldTd = 0; var svc = new StubSvc(); svc.CalcDailyCompoundInterestByEod(preEod, nonResetDate, TradeDate, position, - Notional, Notional, flowEvent, AnnualDays, false, 0m, 1m, Notional, + Notional, Notional, flowEvent, AnnualDays, 0m, 1m, ref oldInterest, ref oldTd); // 新方法 var rate = FundingLegRate.Fixed(FixedRate); var policy = new AccrualPolicy(AccrualBoundary.Both, true, 7, AnnualDays, true); - var result = FundingLegAccrual.AccrueCompoundEod( + var result = CompoundInterestAccrual.AccrueEod( 30_000m, Notional, Notional, 1m, rate, policy, isResetDay: false, 0m, nonResetDate); diff --git a/UnitTestProject/Modules/SwapModule/Accrual/CompoundPeriodShadowTest.cs b/UnitTestProject/Modules/SwapModule/Accrual/CompoundPeriodShadowTest.cs index ce55867b..305fe802 100644 --- a/UnitTestProject/Modules/SwapModule/Accrual/CompoundPeriodShadowTest.cs +++ b/UnitTestProject/Modules/SwapModule/Accrual/CompoundPeriodShadowTest.cs @@ -7,7 +7,7 @@ using YLErp.Modules.SwapModule.Accrual; namespace UnitTestProject.Modules.SwapModule.Accrual { /// - /// 影子测试:CalcDailyCompoundInterest(旧逐日循环)vs FundingLegAccrual.AccrueCompoundPeriod(新分段纯函数)。 + /// 影子测试:CalcDailyCompoundInterest(旧逐日循环)vs CompoundInterestAccrual.AccruePeriod(新分段纯函数)。 /// [TestClass] public class CompoundPeriodShadowTest @@ -74,7 +74,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual decimal oldI = 0, oldTd = 0; var svc = new StubSvc(); svc.CalcDailyCompoundInterest(EndDate, position, Notional, flowEvent, - AnnualDays, false, 0m, 1m, Notional, true, false, + AnnualDays, 0m, 1m, true, false, ref oldI, ref oldTd); // 新方法:固定利率全段相同,分段点 = PosiStartDate + k×7 @@ -86,7 +86,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual (StartDate.AddDays(7), allInRate), (StartDate.AddDays(14), allInRate), }; - var result = FundingLegAccrual.AccrueCompoundPeriod( + var result = CompoundInterestAccrual.AccruePeriod( notional: Notional, segmentRates: segRates, startDate: StartDate, @@ -122,7 +122,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual decimal oldI = 0, oldTd = 0; var svc = new StubSvc(); svc.CalcDailyCompoundInterest(EndDate, position, Notional * closePct, flowEvent, - AnnualDays, false, 0m, closePct, Notional, true, false, + AnnualDays, 0m, closePct, true, false, ref oldI, ref oldTd, consumedInterest: consumed, resetCarryInterest: carry); // 新方法 @@ -133,7 +133,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual (StartDate.AddDays(7), allInRate), (StartDate.AddDays(14), allInRate), }; - var result = FundingLegAccrual.AccrueCompoundPeriod( + var result = CompoundInterestAccrual.AccruePeriod( notional: Notional * closePct, segmentRates: segRates, startDate: StartDate, @@ -166,7 +166,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual decimal oldI = 0, oldTd = 0; var svc = new StubSvc(); svc.CalcDailyCompoundInterest(EndDate, position, Notional, flowEvent, - AnnualDays, false, 0m, 1m, Notional, true, true, + AnnualDays, 0m, 1m, true, true, ref oldI, ref oldTd); // 新方法 @@ -177,7 +177,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual (StartDate.AddDays(7), allInRate), (StartDate.AddDays(14), allInRate), }; - var result = FundingLegAccrual.AccrueCompoundPeriod( + var result = CompoundInterestAccrual.AccruePeriod( notional: Notional, segmentRates: segRates, startDate: StartDate, diff --git a/UnitTestProject/Modules/SwapModule/Accrual/SimplePeriodShadowTest.cs b/UnitTestProject/Modules/SwapModule/Accrual/SimplePeriodShadowTest.cs index d932e035..a1a8a516 100644 --- a/UnitTestProject/Modules/SwapModule/Accrual/SimplePeriodShadowTest.cs +++ b/UnitTestProject/Modules/SwapModule/Accrual/SimplePeriodShadowTest.cs @@ -12,7 +12,7 @@ using YLErp.Derivatives.Interest; namespace UnitTestProject.Modules.SwapModule.Accrual { /// - /// 影子测试:CalcDailySimpleInterest(旧逐日循环)vs FundingLegAccrual.AccrueSimplePeriod(新分段纯函数)。 + /// 影子测试:CalcDailySimpleInterest(旧逐日循环)vs SimpleInterestAccrual.AccruePeriod(新分段纯函数)。 /// [TestClass] public class SimplePeriodShadowTest @@ -100,13 +100,13 @@ namespace UnitTestProject.Modules.SwapModule.Accrual decimal oldI = 0, oldTd = 0; var svc = new StubSvc(); svc.CalcDailySimpleInterest(preEod, EndDate, position, Notional, flowEvent, - AnnualDays, false, 0m, 1m, Notional, true, false, ref oldI, ref oldTd); + AnnualDays, 0m, 1m, Notional, true, false, ref oldI, ref oldTd); // 新方法:固定利率全段相同 // 旧代码差分: dynomicPrincipal = preEod.TdInterestPrincipal(=0) + posiPrincipal - orginPv = 0 var segRates = new List<(DateTime, decimal)> { (StartDate, Spread) }; var accrualPrincipal = 0m + Notional - Notional; // 差分 = 0 - var result = FundingLegAccrual.AccrueSimplePeriod( + var result = SimpleInterestAccrual.AccruePeriod( priorAccrued: 0m, notional: 0m, unwindFraction: 1m, @@ -146,13 +146,13 @@ namespace UnitTestProject.Modules.SwapModule.Accrual decimal oldI = 0, oldTd = 0; var svc = new StubSvc(); svc.CalcDailySimpleInterest(preEod, EndDate, position, Notional, flowEvent, - AnnualDays, false, 0m, 0.5m, Notional, true, false, ref oldI, ref oldTd); + AnnualDays, 0m, 0.5m, Notional, true, false, ref oldI, ref oldTd); // 新方法 // 差分本金 = preEod.TdInterestPrincipal + posiPrincipal - orginPv var accrualPrincipal = Notional + Notional - Notional; var segRates = new List<(DateTime, decimal)> { (StartDate, Spread) }; - var result = FundingLegAccrual.AccrueSimplePeriod( + var result = SimpleInterestAccrual.AccruePeriod( priorAccrued: 200_000m * 0.5m, notional: accrualPrincipal, unwindFraction: 0.5m, @@ -200,7 +200,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual decimal oldI = 0, oldTd = 0; var svc = new FloatStubSvc(new StubIndexFixer(fixingAtReset)); svc.CalcDailySimpleInterest(preEod, EndDate, position, Notional, flowEvent, - AnnualDays, false, floatRateIn, closePct, Notional, true, false, ref oldI, ref oldTd); + AnnualDays, floatRateIn, closePct, Notional, true, false, ref oldI, ref oldTd); // 新方法:手算 segmentRates(对齐旧代码取价循环的逻辑) // 4/21 <= preEodDate(4/30) → 跳过取价,currentFloat 保持入参 floatRateIn @@ -215,7 +215,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual // 差分本金 = preEod.TdInterestPrincipal + posiPrincipal - orginPv var accrualPrincipal = Notional + Notional - Notional; - var result = FundingLegAccrual.AccrueSimplePeriod( + var result = SimpleInterestAccrual.AccruePeriod( priorAccrued: 200_000m * closePct, notional: accrualPrincipal, unwindFraction: closePct, diff --git a/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs b/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs index e9b542c8..85052b9c 100644 --- a/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs @@ -124,6 +124,17 @@ namespace YLErp.Modules.SwapModule return new swap_event { id = SwapEvents.Count }; } + /// + /// 捕获 SaveAutoSwapDeal 落库的 flow_event(生产写 DbContext.swap_flow_event)。 + /// 同步到 PersistedFlowEvents 供 AS_009/010/011 断言;基类 FlowEvents 仍由它填充, + /// 供 GetConsumedInterest 真实计算已结利息。 + /// + protected override void PersistFlowEvent(swap_flow_event flowEvent) + { + base.PersistFlowEvent(flowEvent); + PersistedFlowEvents.Add(flowEvent); + } + /// /// 利息腿金额直接给定(付息金额),避免把 GetInterests 的计息细节混入本用例—— /// 本文件关注的是「自动互换是否触发 / 几条 / 资金发生日 / 金额量级」, diff --git a/UnitTestProject/Modules/SwapModule/ConsumedInterestScenarioTest.cs b/UnitTestProject/Modules/SwapModule/ConsumedInterestScenarioTest.cs index 6ad2800d..866b4f3a 100644 --- a/UnitTestProject/Modules/SwapModule/ConsumedInterestScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/ConsumedInterestScenarioTest.cs @@ -509,7 +509,7 @@ namespace YLErp.Modules.SwapModule decimal tdInterestAmount = 0m; service.CalcDailyCompoundInterestByEod(preEod, resetDate, startDate, position, - principal, principal, flowEvent, AnnualDays, false, 0.013502m, 1m, principal, + principal, principal, flowEvent, AnnualDays, 0.013502m, 1m, ref interestAmount, ref tdInterestAmount); AssertDecimal(principal + pendingInterest, flowEvent.InterestPrincipal, diff --git a/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs b/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs index 29c1b37a..07f9b289 100644 --- a/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs @@ -1724,15 +1724,15 @@ namespace YLErp.Modules.SwapModule decimal expectedAmountAtEnd = 0m; decimal expectedTdAmountAtEnd = 0m; dealService.CalcDailyCompoundInterest( - finalCloseDate, position, remainingNotional, expectedEndFlow, AnnualDays, false, - intermediateEod.FloatRate, 1m, originalNotional, true, false, + finalCloseDate, position, remainingNotional, expectedEndFlow, AnnualDays, + intermediateEod.FloatRate, 1m, true, false, ref expectedAmountAtEnd, ref expectedTdAmountAtEnd); var expectedPreviousFlow = new swap_flow_event { InterestRate = spread }; decimal expectedAmountAtPreviousEod = 0m; decimal expectedTdAmountAtPreviousEod = 0m; dealService.CalcDailyCompoundInterest( - intermediateDate, position, remainingNotional, expectedPreviousFlow, AnnualDays, false, - intermediateEod.FloatRate, 1m, originalNotional, true, true, + intermediateDate, position, remainingNotional, expectedPreviousFlow, AnnualDays, + intermediateEod.FloatRate, 1m, true, true, ref expectedAmountAtPreviousEod, ref expectedTdAmountAtPreviousEod); var expectedFinalInterest = intermediateEod.InterestIncomeSum + expectedAmountAtEnd - expectedAmountAtPreviousEod; diff --git a/UnitTestProject/Modules/SwapModule/FundingLegs/FundingLegStrategyTest.cs b/UnitTestProject/Modules/SwapModule/FundingLegs/FundingLegStrategyTest.cs index c3afb09f..ebd0803f 100644 --- a/UnitTestProject/Modules/SwapModule/FundingLegs/FundingLegStrategyTest.cs +++ b/UnitTestProject/Modules/SwapModule/FundingLegs/FundingLegStrategyTest.cs @@ -6,16 +6,14 @@ using YLErp.Modules.SwapModule.FundingLegs; namespace UnitTestProject.Modules.SwapModule.FundingLegs { /// - /// 融资腿策略单测。验证每个策略的 CalcNotional 与现有 CalcNotionalByMode switch 完全一致。 - /// 这组测试是后续"迁移调用点"的安全网——迁移前后行为必须不变。 + /// 融资腿策略单测。验证每个 IFundingLegStrategy 实现的 CalcNotional 计息基数公式正确。 + /// 原 CalcNotionalByMode switch 已重构为策略类(见 FundingLegStrategyFactory)。 /// [TestClass] public class FundingLegStrategyTest { private const decimal Fix = 2_000_000m; private const decimal Notional = 100_000_000m; - private const decimal LongNotional = 60_000_000m; - private const decimal ShortNotional = 40_000_000m; #region 固定值(mode 1) @@ -23,7 +21,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs public void 固定值_部分平仓_计息基数恒等于Fix() { var leg = new FixedAmountLeg(); - var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0.5m); + var r = leg.CalcNotional(Fix, Notional, 0.5m); Assert.AreEqual(Fix, r.ClosePrincipal, "平仓本金恒=Fix"); Assert.AreEqual(Fix, r.PosiPrincipal, "持仓本金恒=Fix"); @@ -34,7 +32,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs public void 固定值_全平_计息基数仍等于Fix() { var leg = new FixedAmountLeg(); - var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 1m); + var r = leg.CalcNotional(Fix, Notional, 1m); Assert.AreEqual(Fix, r.ClosePrincipal); } @@ -46,7 +44,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs public void 合约名义本金_部分平仓_本金按比例缩放() { var leg = new ContractNotionalLeg(); - var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0.5m); + var r = leg.CalcNotional(Fix, Notional, 0.5m); Assert.AreEqual(50_000_000m, r.ClosePrincipal); Assert.AreEqual(Notional, r.PosiPrincipal); @@ -57,7 +55,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs public void 合约名义本金_全平_本金等于全额() { var leg = new ContractNotionalLeg(); - var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 1m); + var r = leg.CalcNotional(Fix, Notional, 1m); Assert.AreEqual(Notional, r.ClosePrincipal); } @@ -65,7 +63,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs public void 合约名义本金_零平仓_本金为零() { var leg = new ContractNotionalLeg(); - var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0m); + var r = leg.CalcNotional(Fix, Notional, 0m); Assert.AreEqual(0m, r.ClosePrincipal); Assert.AreEqual(Notional, r.PosiPrincipal); @@ -79,7 +77,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs public void 标的期初全价_部分平仓_主路径公式同mode2() { var leg = new UnderlyingEntryFullPriceLeg(); - var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0.5m); + var r = leg.CalcNotional(Fix, Notional, 0.5m); Assert.AreEqual(50_000_000m, r.ClosePrincipal); Assert.AreEqual(Notional, r.PosiPrincipal); @@ -90,7 +88,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs public void 标的期初全价_全平_本金等于全额() { var leg = new UnderlyingEntryFullPriceLeg(); - var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 1m); + var r = leg.CalcNotional(Fix, Notional, 1m); Assert.AreEqual(Notional, r.ClosePrincipal); } diff --git a/UnitTestProject/Modules/SwapModule/GLMS20260105_0006_RegisterDateDividendTest.cs b/UnitTestProject/Modules/SwapModule/GLMS20260105_0006_RegisterDateDividendTest.cs new file mode 100644 index 00000000..4ad902b2 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/GLMS20260105_0006_RegisterDateDividendTest.cs @@ -0,0 +1,112 @@ +using YLErp.Modules.EodModule; + +namespace YLErp.Modules.SwapModule +{ + /// + /// GLMS-20260105-0006 回归:债券 TRS 登记日当天手动平仓/互换,分红收益应为 36160 而非 0。 + /// 根因双成因: + /// A. BondPaymentService.GetBondPayments 用支付日(pay_date_PL/pay_date_act)而非债权登记日(reg_date)判定谁享有票息 + /// -> 登记日(4/3)当日 EOD 不计提,跨过支付日(4/6)才计提(巧合:4/4-4/5周末,下一交易日恰=支付日,掩盖缺陷) + /// B. SwapDealService.GetPreEodDividendSum 用 ValueDate 严格小于 dealDate 读 T-1 EOD 快照 + /// -> 登记日当天手动平仓读不到当日 EOD,拿到 0 + /// 本文件用手工合成内存数据(不连 96 库),通过 virtual seam 注入,真实跑生产日期逻辑。 + /// + [TestClass] + public class GLMS20260105_0006_RegisterDateDividendTest + { + private const string BondCode = "230004.IB"; + private const int TradeId = 6006; + private const long PositionId = 60061; + private const decimal Qty = 20_000_000m; + private const decimal PaymentPer100 = 0.1808m; + private const decimal ExpectedDividend = 36_160m; // 20,000,000 × 0.1808 / 100 + + // 付息日历(截图):登记日 4/3,支付日 4/6 + private static readonly DateTime RegDate = new(2026, 4, 3); + private static readonly DateTime PayDate = new(2026, 4, 6); + private static readonly DateTime PreRegDate = new(2026, 4, 2); + + #region 成因 A:日期口径 seam + + private sealed class TestableBondPaymentService : BondPaymentService + { + private readonly List _data; + public TestableBondPaymentService(List data) : base(OptUserInfo.UnitTestUser) { _data = data; } + + protected override IQueryable QueryBondPayments(string underlyingCode) + => _data.Where(x => x.underlyingCode == underlyingCode).AsQueryable(); + } + + [TestMethod] + public void CauseA_登记日当日EOD_应按登记日口径选中付息记录() + { + var record = new BondPayment + { + underlyingCode = BondCode, + reg_date = RegDate, // 债权登记日 4/3(关键:分红归属按此判定) + payment_date_pl = PayDate, // 理论付息日 4/6 + payment_date = PayDate, // 实际付息日 4/6 + payment_interest = PaymentPer100 + }; + var svc = new TestableBondPaymentService(new List { record }); + + // 登记日当日的 EOD 计提区间 (4/2, 4/3] + var payments = svc.GetBondPayments(BondCode, PreRegDate, RegDate); + + // 修复前:用支付日(pay_date_PL=4/6)过滤 -> 4/6 不在 (4/2,4/3] -> 0 条(漏计分红) + // 修复后:用债权登记日(reg_date=4/3)过滤 -> 4/3 落在区间 -> 1 条(GLMS-20260105-0006 已修复) + Assert.AreEqual(1, payments.Count, + "登记日(4/3)当日 EOD 应按债权登记日(reg_date)选中该笔付息;" + + "当前按支付日(pay_date_PL=4/6)过滤会漏选->0条,导致分红不计提。"); + } + + #endregion + + #region 成因 B:T-1 快照 seam + + private sealed class TestableSwapDealService : SwapDealService + { + private readonly List _eodSwaps; + private readonly List _eodPositions; + public TestableSwapDealService(List eodSwaps, List eodPositions) + : base(OptUserInfo.UnitTestUser) { _eodSwaps = eodSwaps; _eodPositions = eodPositions; } + + public decimal ExposeGetPreEodDividendSum(int tradeId, long positionId, DateTime dealDate) + => GetPreEodDividendSum(tradeId, positionId, dealDate); + + protected override IQueryable QueryPreEodSwaps(int tradeId) + => _eodSwaps.Where(x => x.SwapTradeId == tradeId).AsQueryable(); + + protected override eod_swap_position QueryPreEodPosition(int tradeId, long positionId, DateTime valueDate) + => _eodPositions.FirstOrDefault(x => x.SwapTradeId == tradeId && x.PositionId == positionId && x.ValueDate == valueDate); + } + + [TestMethod] + public void CauseB_登记日当天手动平仓_应读到当日EOD分红36160() + { + // 4/2 EOD:累计分红 0;4/3 EOD(登记日):累计分红 36160(即登记日应有的状态) + var eodSwaps = new List + { + new eod_swap { SwapTradeId = TradeId, ValueDate = PreRegDate }, + new eod_swap { SwapTradeId = TradeId, ValueDate = RegDate } + }; + var eodPositions = new List + { + new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = PreRegDate, PosiDividendSum = 0m, PosiQuantity = Qty }, + new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = RegDate, PosiDividendSum = ExpectedDividend, PosiQuantity = Qty } + }; + var svc = new TestableSwapDealService(eodSwaps, eodPositions); + + // 登记日(4/3)当天手动平仓 + var dividend = svc.ExposeGetPreEodDividendSum(TradeId, PositionId, RegDate); + + // 修复前:ValueDate 严格小于 dealDate 读 T-1(4/2) -> 0(漏读当日分红) + // 修复后:ValueDate 小于等于 dealDate 读当日(4/3) -> 36160(GLMS-20260105-0006 已修复) + Assert.AreEqual(ExpectedDividend, dividend, 0.01m, + "登记日(4/3)当天手动平仓应读到当日 EOD 累计分红 36,160;" + + "当前 GetPreEodDividendSum 用 ValueDate < dealDate 读 T-1 快照->0。"); + } + + #endregion + } +} diff --git a/UnitTestProject/Modules/SwapModule/GLMS20260805ClosePercentDiffDiagnoseTest.cs b/UnitTestProject/Modules/SwapModule/GLMS20260805ClosePercentDiffDiagnoseTest.cs index 8d34bc48..6bccc538 100644 --- a/UnitTestProject/Modules/SwapModule/GLMS20260805ClosePercentDiffDiagnoseTest.cs +++ b/UnitTestProject/Modules/SwapModule/GLMS20260805ClosePercentDiffDiagnoseTest.cs @@ -246,8 +246,19 @@ namespace YLErp.Modules.SwapModule var valueDate = DateTime.Today; var unwindDate = DateTime.Today; - var interests100 = new SwapDealService(user).GetUnwindInterests(valueDate, unwindDate, td.id, cp100_B, (int)SwapEventTypeEnum.平仓); - var interests40 = new SwapDealService(user).GetUnwindInterests(valueDate, unwindDate, td.id, cp40_B, (int)SwapEventTypeEnum.平仓); + // FR007 fixing 是外部数据依赖:非交易日/数据未发布时取价会抛 Exception。 + // 与"连不上库自动 Inconclusive"同语义——外部数据不可用不应判为测试失败。 + List interests100, interests40; + try + { + interests100 = new SwapDealService(user).GetUnwindInterests(valueDate, unwindDate, td.id, cp100_B, (int)SwapEventTypeEnum.平仓); + interests40 = new SwapDealService(user).GetUnwindInterests(valueDate, unwindDate, td.id, cp40_B, (int)SwapEventTypeEnum.平仓); + } + catch (Exception ex) when (ex.Message.Contains("获取不到") && ex.Message.Contains("价格")) + { + Assert.Inconclusive($"FR007 fixing 数据不可用({valueDate:yyyy-MM-dd} 非交易日或数据未发布):{ex.Message}"); + return; + } PrintInterestComparison(interests100, interests40, cp100_B, cp40_B); } diff --git a/UnitTestProject/Modules/SwapModule/InitUnwindTradingFeeTest.cs b/UnitTestProject/Modules/SwapModule/InitUnwindTradingFeeTest.cs index 8ebfb199..34000da7 100644 --- a/UnitTestProject/Modules/SwapModule/InitUnwindTradingFeeTest.cs +++ b/UnitTestProject/Modules/SwapModule/InitUnwindTradingFeeTest.cs @@ -1,4 +1,3 @@ -using System.Reflection; using YLErp.DBModels; namespace YLErp.Modules.SwapModule @@ -7,26 +6,10 @@ namespace YLErp.Modules.SwapModule public class InitUnwindTradingFeeTest { private static decimal InvokeCalcInitTradingFee(swap_position position, UnwindData unwindData) - { - var method = typeof(SwapDealService).GetMethod( - "CalcInitTradingFee", - BindingFlags.NonPublic | BindingFlags.Static); - - Assert.IsNotNull(method, "未找到 CalcInitTradingFee 私有静态方法"); - - return (decimal)method.Invoke(null, new object[] { position, unwindData }); - } + => TradingFeeCalc.CalcInitTradingFee(position, unwindData); private static decimal InvokeCalcInitTradingFeePending(swap_position oriPosition, swap_position position, UnwindData unwindData) - { - var method = typeof(SwapDealService).GetMethod( - "CalcInitTradingFeePending", - BindingFlags.NonPublic | BindingFlags.Static); - - Assert.IsNotNull(method, "CalcInitTradingFeePending was not found"); - - return (decimal)method.Invoke(null, new object[] { oriPosition, position, unwindData }); - } + => TradingFeeCalc.CalcInitTradingFeePending(oriPosition, position, unwindData); [TestMethod] public void 百分比模式_按平仓名义本金计算并四舍五入到两位() diff --git a/UnitTestProject/Modules/SwapModule/Margin/MarginInterestGoldenReplayTest.cs b/UnitTestProject/Modules/SwapModule/Margin/MarginInterestGoldenReplayTest.cs new file mode 100644 index 00000000..bb9a65fa --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/Margin/MarginInterestGoldenReplayTest.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using YLErp; +using YLErp.DBModels; +using YLErp.DBModels.Enums; +using YLErp.Modules.SwapModule; +using YLErp.Modules.SwapModule.Margin; + +namespace UnitTestProject.Modules.SwapModule.Margin +{ + /// + /// 黄金回放验证(连真实测试库 192.168.2.96):对真实保证金交易逐日 EOD 比对 + /// GetInterests(settment=true,保证金分支现走 CalcMarginInterest) vs 直接调 CalcMarginInterest, + /// 验证 GetInterests→CalcMarginInterest 接线的参数对齐(rate/posiPrincipal/preEod 等)正确。 + /// + /// 数据来自 96 库的真实保证金交易,覆盖追加预付金多行、多次部分平仓(InterestPrincipalFix 下台阶)、 + /// 跨 EOD 续接等单元测试够不到的边界。作为保证金计息迁移后的真实库回归守护。 + /// + [TestClass] + public class MarginInterestGoldenReplayTest + { + // 96 库里已确认含真实保证金腿的交易 + private static readonly string[] TradeNumbers = + { + "GLMS-20260701-0008", + "GLMS-20260701-0013", + "GLMS-20260701-0006", + }; + + private sealed class StubSvc : SwapDealService + { + public StubSvc() : base(new OptUserInfo(0, nameof(MarginInterestGoldenReplayTest), OptUserFrom.UnitTest)) { } + } + + /// + /// 逐交易、逐 EOD 日,比对保证金腿新旧计息 InterestAmount/TdInterestAmount。 + /// 入参对齐口径(与 GetInterests 内部一致): + /// posiPrincipal = InterestPrincipalFix;closePrincipal = Fix×closePercent(EOD=1); + /// rate = oldEvt.InterestRate(严格取旧管线算出的 rate,消除 GetFixedRate 差异); + /// 方向 = FlipDirection(position.InterestDirection)(GetInterests:742 对保证金翻转); + /// preEod = 该 PositionId 上一日终 eod_swap_position;annualDays/calcFirst/calcLast 来自 trade_extend。 + /// + [TestMethod] + [TestCategory("DbDiagnose")] + public void 保证金腿_真实库_EOD逐日新旧比对() + { + YLContext db; + try { db = DbContextFactory.GetYLDbContext(); } + catch (Exception ex) { Assert.Inconclusive($"无法连接测试库(192.168.2.96):{ex.Message}"); return; } + + var svc = new StubSvc(); + int totalCompared = 0, mismatches = 0, skipped = 0; + var diffLog = new StringBuilder(); + + foreach (var tradeNumber in TradeNumbers) + { + var td = db.trade.FirstOrDefault(t => t.TradeNumber == tradeNumber); + if (td == null) { Console.WriteLine($"跳过:库无 {tradeNumber}"); skipped++; continue; } + + var extend = db.trade_extend.FirstOrDefault(x => x.TradeId == td.id); + int annualDays = extend?.ExtendObj.AnnualDays ?? 365; + bool calcFirst = extend?.ExtendObj.InterestCalcMode?.StartsWith("1") ?? true; + bool calcLast = extend?.ExtendObj.InterestCalcMode?.EndsWith("1") ?? true; + + var marginPositions = db.swap_position + .Where(p => p.SwapTradeId == td.id && !p.Invalid + && (p.InterestMode == (int)InterestModeEnum.初始预付金 + || p.InterestMode == (int)InterestModeEnum.追加预付金)) + .ToList(); + if (marginPositions.Count == 0) { Console.WriteLine($"跳过:{tradeNumber} 无保证金腿"); skipped++; continue; } + + // 该交易保证金腿的 EOD 日期序列 + var eodDates = db.eod_swap_position + .Where(e => e.SwapTradeId == td.id && (e.InterestMode == 5 || e.InterestMode == 6)) + .Select(e => e.ValueDate).Distinct().OrderBy(d => d).ToList(); + + Console.WriteLine($"===== {tradeNumber} (id={td.id}):{marginPositions.Count} 条保证金腿,{eodDates.Count} 个 EOD 日 ====="); + + foreach (var valueDate in eodDates) + { + // 上一日终 preEod(取 eod_swap 最近 < valueDate 的日期) + var preDate = db.eod_swap + .Where(e => e.SwapTradeId == td.id && e.ValueDate < valueDate) + .OrderByDescending(e => e.ValueDate) + .Select(e => (DateTime?)e.ValueDate).FirstOrDefault(); + var preEods = preDate == null + ? new List() + : db.eod_swap_position + .Where(e => e.SwapTradeId == td.id && e.ValueDate == preDate.Value + && (e.InterestMode == 5 || e.InterestMode == 6)) + .ToList(); + + // 旧管线:GetInterests(settment=true)。保证金分支不用 posiNotionalValue/closePosiNotionalValue/grossPrice/orginPv,传 0。 + List oldList; + try + { + oldList = svc.GetInterests(td, extend, valueDate, valueDate, + preEods, marginPositions, + 0m, 0m, 0m, 0m, 1.0m, + (int)SwapEventTypeEnum.自动互换, tdClose: false, needPrice: false, + grossPrice: 0m, orginPv: 0m, + add: false, settment: true, newCalcLast: false, closeList: null); + } + catch (Exception ex) + { + Console.WriteLine($" {tradeNumber} @ {valueDate:yyyy-MM-dd} 旧管线异常:{ex.GetType().Name} {ex.Message}"); + continue; + } + + // 新方法:逐保证金腿 + foreach (var pos in marginPositions) + { + var oldEvt = oldList.FirstOrDefault(i => i.PositionId == pos.id); + if (oldEvt == null) continue; + + var preEod = preEods.FirstOrDefault(e => e.PositionId == pos.id) ?? new eod_swap_position { id = 0 }; + var posClone = pos.Clone(); + posClone.InterestDirection = MarginCalc.FlipDirection(pos.InterestDirection); + decimal rate = oldEvt.InterestRate; // 严格对齐旧管线 rate(含 GetFixedRate + Round(12)) + + swap_flow_event newEvt; + try + { + newEvt = svc.CalcMarginInterest(td, valueDate, valueDate, posClone, rate, + pos.InterestPrincipalFix, pos.InterestPrincipalFix, 1.0m, + annualDays, calcFirst, calcLast, preEod, + (int)SwapEventTypeEnum.自动互换, add: false, settment: true, swap: false); + } + catch (Exception ex) + { + mismatches++; + diffLog.AppendLine($"✗ {tradeNumber} PosId={pos.id} @ {valueDate:yyyy-MM-dd} 新方法异常:{ex.GetType().Name} {ex.Message}"); + continue; + } + + totalCompared++; + decimal diffI = Math.Abs(newEvt.InterestAmount - oldEvt.InterestAmount); + decimal diffTd = Math.Abs(newEvt.TdInterestAmount - oldEvt.TdInterestAmount); + const decimal tol = 0.000001m; + if (diffI > tol || diffTd > tol) + { + mismatches++; + diffLog.AppendLine($"✗ {tradeNumber} PosId={pos.id} Mode={pos.InterestMode} @ {valueDate:yyyy-MM-dd}: " + + $"旧 I={oldEvt.InterestAmount} Td={oldEvt.TdInterestAmount} | " + + $"新 I={newEvt.InterestAmount} Td={newEvt.TdInterestAmount} | " + + $"diffI={diffI} diffTd={diffTd} | " + + $"preEod.id={preEod.id} TdIntPrin={preEod.TdInterestPrincipal} ProfitSum={preEod.InterestProfitSum} | " + + $"Fix={pos.InterestPrincipalFix} rate={rate} IntType={pos.InterestType} IsAnnualized={pos.IsAnnualized}"); + } + } + } + } + + Console.WriteLine($"\n===== 比对汇总:共 {totalCompared} 条,不一致 {mismatches} 条,跳过 {skipped} 个交易 ====="); + if (diffLog.Length > 0) Console.WriteLine(diffLog.ToString()); + + Assert.IsTrue(mismatches == 0, + $"保证金新旧管线 EOD 真实库比对有 {mismatches}/{totalCompared} 条不一致——提交2 前必须解决(详见输出)"); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/Margin/MarginInterestShadowTest.cs b/UnitTestProject/Modules/SwapModule/Margin/MarginInterestShadowTest.cs new file mode 100644 index 00000000..34740fc2 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/Margin/MarginInterestShadowTest.cs @@ -0,0 +1,202 @@ +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(EOD 用昨日终本金、盘中用 accrualBasis 差分)vs + /// 旧通用管线 CalcDailySimpleInterestByEod/CalcDailySimpleInterest。 + /// + /// 保证金是纯固定利率单利(FloatRateUnderlyingCode 恒空、InterestType 恒单利、SwapIntervalList 单段)。 + /// 本测试在生产切到 CalcMarginInterest 后作为回归守护,确认其 InterestAmount/TdInterestAmount + /// 与旧纯函数(SimpleInterestAccrual)数值一致。覆盖 EOD 续接/首日、盘中全平/部分平仓/互换。 + /// + [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, 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, 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, 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, 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, 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/UnitTestProject/Modules/SwapModule/SwapUnwindFloatingLegDiagnosticTdd.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindFloatingLegDiagnosticTdd.cs index 84a7230d..69f7ca72 100644 --- a/UnitTestProject/Modules/SwapModule/SwapUnwindFloatingLegDiagnosticTdd.cs +++ b/UnitTestProject/Modules/SwapModule/SwapUnwindFloatingLegDiagnosticTdd.cs @@ -5,12 +5,12 @@ using YLErp.DBModels.Enums; namespace YLErp.Modules.SwapModule { /// - /// 诊断测试:验证「浮动腿 fpositions 仍用 origPositions(orig 100M)」对本 deal 的 - /// 预付金/返回预付金结果是否产生影响。结论预期:本 deal 利息腿只有 mode 9(标的期初全价) - /// 与 mode 5(初始预付金),CalcNotionalByMode 中 posiLong/posiShort 仅在「多头/空头存续名义本金」 - /// 分支被消费(L709-716),故本 deal 即便 fpositions 用 orig 100M,预付金腿结果也不受其影响。 - /// 本测试仅做诊断/验证,不改动任何生产代码;用反射调用 private CalcNotionalByMode 以直接证明 - /// “mode 9 / mode 5 的 closePrincipal 不依赖 posiLong/posiShort”。 + /// 诊断测试骨架:针对 GLMS 双轨持仓(orig/real)构造预付金腿(mode 5)与标的期初全价腿(mode 9), + /// 用于验证“浮动腿 fpositions 用 origPositions 对预付金/标的端计息基数的影响”。 + /// 计息基数现由 FundingLegStrategyFactory + 各 IFundingLegStrategy 策略类计算 + /// (原 private CalcNotionalByMode 已重构移除);多空存续腿的 posiLong/posiShort 因界面禁用 + /// 已从策略接口删除,故预付金/标的端计息基数不依赖多空头寸。 + /// 注:当前仅含数据构造,反射诊断方法尚未实现(无 [TestMethod])。 /// [TestClass] public class SwapUnwindFloatingLegDiagnosticTdd diff --git a/UnitTestProject/Modules/SwapModule/SwapUnwindSameDayDoublePartialTest.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindSameDayDoublePartialTest.cs index e6442d6c..5e18c5d1 100644 --- a/UnitTestProject/Modules/SwapModule/SwapUnwindSameDayDoublePartialTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapUnwindSameDayDoublePartialTest.cs @@ -8,7 +8,7 @@ namespace YLErp.Modules.SwapModule /// 【同日多次部分平仓 · unwind 基数滚动表征测试】 /// ============================================================================ /// 背景:unwind 计息基数公式 basis = priorNotional + notional - baseNotional - /// (FundingLegAccrual / CalcDailyCompoundInterestByEod 同源),其中 + /// (CompoundInterestAccrual / CalcDailyCompoundInterestByEod 同源),其中 /// - priorNotional = 上一日终归档 eod_swap_position.TdInterestPrincipal /// - baseNotional = orginPv = ResolveUnwindPreviousNotional(lastEod)(上一日终浮动端名义本金) /// - notional = 当前持仓名义本金(posiNotionalValue,来自实时持仓) diff --git a/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs b/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs index 6539abe3..ce5faa45 100644 --- a/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs +++ b/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs @@ -62,7 +62,7 @@ namespace YLErp.Modules.SwapModule /// 捕获真实收盘产生的 swap_flow_event(生产写 DbContext.swap_flow_event)。 /// 与 PersistEodSwapPosition 同理,这里只收集不写库,供 GetConsumedInterest 真实计算。 /// - protected void PersistFlowEvent(swap_flow_event flowEvent) + protected override void PersistFlowEvent(swap_flow_event flowEvent) { if (flowEvent.id == 0) flowEvent.id = _nextId++; FlowEvents.Add(flowEvent); @@ -83,5 +83,16 @@ namespace YLErp.Modules.SwapModule ClientCashCalls.Add((amount, action)); return _nextId++; } + + /// + /// AddClientCashInCashOut 生产实现会查 DataCacheProvider.GetClientDataSource().GetData(ClientId), + /// 纯内存测试无客户缓存会抛"客户信息未找到"。与 AddClientCash 同构 no-op, + /// 仅捕获调用记录,供断言使用。 + /// + public override int AddClientCashInCashOut(OtcTradeBase td, double amount, string action, DateTime valueDate) + { + ClientCashCalls.Add((amount, action)); + return _nextId++; + } } } diff --git a/YLErpDAL/Modules/EodModule/BondPaymentService.cs b/YLErpDAL/Modules/EodModule/BondPaymentService.cs index a10caab3..3e89b5b4 100644 --- a/YLErpDAL/Modules/EodModule/BondPaymentService.cs +++ b/YLErpDAL/Modules/EodModule/BondPaymentService.cs @@ -98,10 +98,21 @@ namespace YLErp.Modules.EodModule /// public List GetBondPayments(string underlyingCode, DateTime startDate, DateTime endDate) { - var result = DbContext.bondPayment.Where(x => x.underlyingCode == underlyingCode && x.payment_date > startDate && x.payment_date <= endDate).AsNoTracking().ToList(); + // GLMS-20260105-0006:票息归属按债权登记日(reg_date)判定,而非支付日(pay_date_PL/pay_date_act)。 + // 登记日当天 EOD 即应计提;原按支付日口径会让"登记日≠支付日"的债券漏计(二者恰差一工作日时缺陷被掩盖)。 + var result = QueryBondPayments(underlyingCode) + .Where(x => x.reg_date > startDate && x.reg_date <= endDate) + .AsNoTracking().ToList(); return result; } + /// + /// 可测性 seam:返回某债券的全部付息记录(未做日期过滤)。测试可 override 注入内存数据, + /// 以验证日期口径(GLMS-20260105-0006:应按债权登记日 reg_date 而非支付日 pay_date_PL/pay_date_act 判定)。 + /// + protected virtual IQueryable QueryBondPayments(string underlyingCode) + => DbContext.bondPayment.Where(x => x.underlyingCode == underlyingCode); + public List GetTargetDatePayments(string underlyingCode, DateTime targetDate) { diff --git a/YLErpDAL/Modules/SwapModule/Accrual/AccrualPolicy.cs b/YLErpDAL/Modules/SwapModule/Accrual/AccrualPolicy.cs index 34b2a1f7..9f7e4ec4 100644 --- a/YLErpDAL/Modules/SwapModule/Accrual/AccrualPolicy.cs +++ b/YLErpDAL/Modules/SwapModule/Accrual/AccrualPolicy.cs @@ -29,4 +29,8 @@ public sealed class AccrualPolicy public AccrualPolicy(AccrualBoundary convention, bool isCompound, int resetPeriodDays, int annualDays, bool isAnnualized = false) => (Convention, IsCompound, ResetPeriodDays, AnnualDays, IsAnnualized) = (convention, isCompound, resetPeriodDays, annualDays, isAnnualized); + + /// 从 swap_position 构造 EOD 计息政策(算头算尾,重置周期取 interest_rest_days)。 + public static AccrualPolicy BuildEod(DBModels.swap_position position, int annualDays, bool isCompound) + => new(AccrualBoundary.Both, isCompound, position.interest_rest_days ?? 1, annualDays, position.IsAnnualized); } diff --git a/YLErpDAL/Modules/SwapModule/Accrual/CompoundInterestAccrual.cs b/YLErpDAL/Modules/SwapModule/Accrual/CompoundInterestAccrual.cs new file mode 100644 index 00000000..9418aa46 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/Accrual/CompoundInterestAccrual.cs @@ -0,0 +1,132 @@ +using YLErp.Core.Interest; +using YLErp.Derivatives.Interest; + +namespace YLErp.Modules.SwapModule.Accrual; + +/// +/// 复利计息纯函数——EOD 单日 + intraday 多日。 +/// 复利特征:每个重置日把累计利息并入本金(basis = notional + accrued)。 +/// +public static class CompoundInterestAccrual +{ + private const int Precision = SwapInterest.FundingLegPrecision; + + /// 复利日终计息基数(单一真相源,纯函数与调用方共用): + /// 重置日 = notional + 累计利息×剩余比例(利息并入本金);非重置日 = priorNotional(昨日滚动基数)。 + /// remainingFraction 对齐 legacy 钳制到 [0,1]。 + public static decimal EodBasis( + bool isResetDay, decimal notional, decimal priorAccrued, decimal remainingFraction, decimal priorNotional) + => isResetDay + ? notional + priorAccrued * Math.Max(0m, Math.Min(1m, remainingFraction)) + : priorNotional; + + /// + /// 复利日终计息(替换 CalcDailyCompoundInterestByEod 的纯数学部分)。 + /// 重置日:basis = notional + priorAccrued × remainingFraction(利息并入本金)。 + /// 非重置日:basis = priorNotional(昨日终滚动计息基数)。 + /// + public static InterestResult AccrueEod( + decimal priorAccrued, + decimal priorNotional, + decimal notional, + decimal unwindFraction, + FundingLegRate rate, + AccrualPolicy policy, + bool isResetDay, + decimal remainingFraction, + DateTime eodDate, + AccrualTrace? trace = null) + { + var basis = EodBasis(isResetDay, notional, priorAccrued, remainingFraction, priorNotional); + var displayBasis = basis * unwindFraction; + + var allInRate = rate.AllInRate; + trace?.EodContext(eodDate, isResetDay, unwindFraction, priorAccrued, priorNotional, notional, remainingFraction); + var dayInterest = displayBasis * allInRate; + var tdInterest = basis * allInRate; + if (policy.IsAnnualized) + { + dayInterest /= policy.AnnualDays; + tdInterest /= policy.AnnualDays; + } + + var totalAccrued = priorAccrued * unwindFraction + dayInterest; + var result = new InterestResult( + SwapInterest.Round(totalAccrued, Precision), + SwapInterest.Round(tdInterest, Precision)); + + trace?.Day(0, eodDate, allInRate, displayBasis, dayInterest, totalAccrued); + trace?.MarkEnd(result.Accrued, result.AccruedToday); + return result; + } + + /// + /// 复利多日计息(替换 CalcDailyCompoundInterest 的纯数学部分)。 + /// 从 startDate 到 endDate 全程重放,每个重置日把累计利息并入本金。 + /// + public static InterestResult AccruePeriod( + decimal notional, + IReadOnlyList<(DateTime StartDate, decimal Rate)> segmentRates, + DateTime startDate, + DateTime endDate, + AccrualBoundary boundary, + int annualDays, + bool isAnnualized, + decimal resetCarryInterest, + decimal realizedInterest, + decimal unwindFraction, + out decimal finalBasis, + AccrualTrace? trace = null) + { + decimal accrualBasis = notional; + decimal accrued = 0m; + + trace?.MarkStart(startDate, endDate, boundary, annualDays, isAnnualized); + + for (int si = 0; si < segmentRates.Count; si++) + { + var isLastSegment = si == segmentRates.Count - 1; + var segEnd = isLastSegment + ? endDate + : segmentRates[si + 1].StartDate; + + // 重置日并本金 + accrualBasis = si == 0 ? notional : notional + accrued; + + // 末日恰好是重置日且 carry 非零:用存量替代(旧代码 i%interestPeriod==0 && accrueDate==endDate)。 + var usedCarry = false; + if (isLastSegment && si > 0 && resetCarryInterest != 0m + && segmentRates[si].StartDate == endDate) + { + accrualBasis = notional + resetCarryInterest; + usedCarry = true; + } + + if (si > 0) + trace?.Rollover(segmentRates[si].StartDate, usedCarry ? resetCarryInterest : accrued, accrualBasis); + + var segIncludeStart = (si == 0) ? boundary.IncludeStart : true; + var segIncludeEnd = isLastSegment ? boundary.IncludeEnd : false; + var days = SwapInterest.AccrualDays(segmentRates[si].StartDate, segEnd, + AccrualBoundary.Of(segIncludeStart, segIncludeEnd)); + if (days <= 0) continue; + + var dailyRate = isAnnualized ? segmentRates[si].Rate / annualDays : segmentRates[si].Rate; + var segInterest = accrualBasis * dailyRate * days; + accrued += segInterest; + trace?.Segment(si, segmentRates[si].StartDate, segEnd, days, segmentRates[si].Rate, accrualBasis, segInterest, accrued); + } + + finalBasis = accrualBasis; + + if (realizedInterest != 0m) + trace?.Unwind(endDate, unwindFraction, realizedInterest * unwindFraction, accrued - realizedInterest * unwindFraction); + accrued -= realizedInterest * unwindFraction; + + var result = new InterestResult( + SwapInterest.Round(accrued, Precision), + SwapInterest.Round(accrued, Precision)); + trace?.MarkEnd(result.Accrued, result.AccruedToday); + return result; + } +} diff --git a/YLErpDAL/Modules/SwapModule/Accrual/FundingLegAccrual.cs b/YLErpDAL/Modules/SwapModule/Accrual/FundingLegAccrual.cs deleted file mode 100644 index 90d3500d..00000000 --- a/YLErpDAL/Modules/SwapModule/Accrual/FundingLegAccrual.cs +++ /dev/null @@ -1,239 +0,0 @@ -using YLErp.Core.Interest; -using YLErp.Derivatives.Interest; - -namespace YLErp.Modules.SwapModule.Accrual; - -/// -/// 融资腿计息编排层——纯数学部分(替换 SwapDealService 内 CalcDaily* 家族的纯计算)。 -/// -/// 命名规范(对齐 QuantLib / Strata): -/// - notional → 计息名义本金(不用 principal,swap leg 用 notional 是业界标准) -/// - accrued → 累计应计利息 -/// - unwindFraction → 平仓比例(0~1) -/// - realizedInterest → 历史已结利息(legacy: consumedInterest) -/// - priorNotional → 昨日终滚动计息基数(legacy: TdInterestPrincipal / dynomicPrincipal) -/// -public static class FundingLegAccrual -{ - private const int Precision = SwapInterest.FundingLegPrecision; - - /// 复利日终计息基数(单一真相源,纯函数与调用方共用): - /// 重置日 = notional + 累计利息×剩余比例(利息并入本金);非重置日 = priorNotional(昨日滚动基数)。 - /// remainingFraction 对齐 legacy 钳制到 [0,1]。 - public static decimal CompoundEodBasis( - bool isResetDay, decimal notional, decimal priorAccrued, decimal remainingFraction, decimal priorNotional) - => isResetDay - ? notional + priorAccrued * Math.Max(0m, Math.Min(1m, remainingFraction)) - : priorNotional; - - /// - /// 单利日终计息(替换 CalcDailySimpleInterestByEod 的纯数学部分)。 - /// EOD 无差分:basis = priorNotional(昨日终滚动计息基数)。 - /// - public static InterestResult AccrueSimpleEod( - decimal priorAccrued, - decimal priorNotional, - decimal unwindFraction, - FundingLegRate rate, - AccrualPolicy policy, - DateTime eodDate, - AccrualTrace? trace = null) - { - var basis = priorNotional; - var displayBasis = basis * unwindFraction; - - var allInRate = rate.AllInRate; - var dayInterest = displayBasis * allInRate; - var tdInterest = basis * allInRate; - if (policy.IsAnnualized) - { - dayInterest /= policy.AnnualDays; - tdInterest /= policy.AnnualDays; - } - - var totalAccrued = priorAccrued + dayInterest; - var result = new InterestResult( - SwapInterest.Round(totalAccrued, Precision), - SwapInterest.Round(tdInterest, Precision)); - - trace?.Day(0, eodDate, allInRate, displayBasis, dayInterest, totalAccrued); - trace?.MarkEnd(result.Accrued, result.AccruedToday); - return result; - } - - /// - /// 复利日终计息(替换 CalcDailyCompoundInterestByEod 的纯数学部分)。 - /// 重置日:basis = notional + priorAccrued × remainingFraction(利息并入本金)。 - /// 非重置日:basis = priorNotional(昨日终滚动计息基数)。 - /// - public static InterestResult AccrueCompoundEod( - decimal priorAccrued, - decimal priorNotional, - decimal notional, - decimal unwindFraction, - FundingLegRate rate, - AccrualPolicy policy, - bool isResetDay, - decimal remainingFraction, - DateTime eodDate, - AccrualTrace? trace = null) - { - var basis = CompoundEodBasis(isResetDay, notional, priorAccrued, remainingFraction, priorNotional); - var displayBasis = basis * unwindFraction; - - var allInRate = rate.AllInRate; - trace?.EodContext(eodDate, isResetDay, unwindFraction, priorAccrued, priorNotional, notional, remainingFraction); - var dayInterest = displayBasis * allInRate; - var tdInterest = basis * allInRate; - if (policy.IsAnnualized) - { - dayInterest /= policy.AnnualDays; - tdInterest /= policy.AnnualDays; - } - - var totalAccrued = priorAccrued * unwindFraction + dayInterest; - var result = new InterestResult( - SwapInterest.Round(totalAccrued, Precision), - SwapInterest.Round(tdInterest, Precision)); - - trace?.Day(0, eodDate, allInRate, displayBasis, dayInterest, totalAccrued); - trace?.MarkEnd(result.Accrued, result.AccruedToday); - return result; - } - - /// - /// 单利多日计息(替换 CalcDailySimpleInterest 的纯数学部分)。 - /// 本金全程恒定,按重置日分段取利率。 - /// Accrued = 缩放累计(InterestAmount),AccruedToday = 未缩放累计(TdInterestAmount)。 - /// - public static InterestResult AccrueSimplePeriod( - decimal priorAccrued, - decimal notional, - decimal unwindFraction, - IReadOnlyList<(DateTime StartDate, decimal Rate)> segmentRates, - DateTime startDate, - DateTime endDate, - DateTime priorValueDate, - AccrualBoundary boundary, - int annualDays, - bool isAnnualized, - AccrualTrace? trace = null) - { - var displayBasis = notional * unwindFraction; - decimal accrued = priorAccrued; // 缩放累计 → InterestAmount - decimal accruedUnscaled = priorAccrued; // 未缩放累计 → TdInterestAmount - - trace?.MarkStart(startDate, endDate, boundary, annualDays, isAnnualized); - - var segStart = startDate; - - for (int si = 0; si < segmentRates.Count; si++) - { - var segEnd = si < segmentRates.Count - 1 - ? segmentRates[si + 1].StartDate - : endDate; - - var effectiveStart = segStart > priorValueDate ? segStart : priorValueDate.AddDays(1); - if (effectiveStart > segEnd) { segStart = segEnd; continue; } - - // calcFirst 只跳过 startDate 本身;其余天(含重置日、ValueDate+1)只要 > ValueDate 恒纳入。 - // 与旧逐日循环一致:if (!calcFirst && accrueDate == startDate) continue 是唯一的首日跳过。 - // 中间段的 segIncludeStart 被 days<=0 跳过后误置 false,此处按 startDate 判定而非继承标记。 - var includeStart = effectiveStart == startDate ? boundary.IncludeStart : true; - // calcLast 只影响 endDate 本身——只有真正的末段(si==Count-1)才算尾, - // 不能用 segEnd==endDate 判断(interestPeriod=1 时中间段 segEnd 也可能==endDate)。 - var isLastSegment = si == segmentRates.Count - 1; - var segBoundary = AccrualBoundary.Of(includeStart, isLastSegment && boundary.IncludeEnd); - var days = SwapInterest.AccrualDays(effectiveStart, segEnd, segBoundary); - if (days <= 0) { segStart = segEnd; continue; } - - var dailyRate = isAnnualized ? segmentRates[si].Rate / annualDays : segmentRates[si].Rate; - var segInterest = displayBasis * dailyRate * days; - accrued += segInterest; - accruedUnscaled += notional * dailyRate * days; - trace?.Segment(si, effectiveStart, segEnd, days, segmentRates[si].Rate, displayBasis, segInterest, accrued); - - segStart = segEnd; - } - - var result = new InterestResult( - SwapInterest.Round(accrued, Precision), - SwapInterest.Round(accruedUnscaled, Precision)); - trace?.MarkEnd(result.Accrued, result.AccruedToday); - return result; - } - - /// - /// 复利多日计息(替换 CalcDailyCompoundInterest 的纯数学部分)。 - /// 从 startDate 到 endDate 全程重放,每个重置日把累计利息并入本金。 - /// - public static InterestResult AccrueCompoundPeriod( - decimal notional, - IReadOnlyList<(DateTime StartDate, decimal Rate)> segmentRates, - DateTime startDate, - DateTime endDate, - AccrualBoundary boundary, - int annualDays, - bool isAnnualized, - decimal resetCarryInterest, - decimal realizedInterest, - decimal unwindFraction, - out decimal finalBasis, - AccrualTrace? trace = null) - { - decimal accrualBasis = notional; - decimal accrued = 0m; - - trace?.MarkStart(startDate, endDate, boundary, annualDays, isAnnualized); - - for (int si = 0; si < segmentRates.Count; si++) - { - var isLastSegment = si == segmentRates.Count - 1; - var segEnd = isLastSegment - ? endDate - : segmentRates[si + 1].StartDate; - - // 重置日并本金 - accrualBasis = si == 0 ? notional : notional + accrued; - - // 末日恰好是重置日且 carry 非零:用存量替代(旧代码 i%interestPeriod==0 && accrueDate==endDate)。 - // 注意:必须同时判断 startDate==endDate——endDate 非重置日时最后一段起点 < endDate,不应触发。 - var usedCarry = false; - if (isLastSegment && si > 0 && resetCarryInterest != 0m - && segmentRates[si].StartDate == endDate) - { - accrualBasis = notional + resetCarryInterest; - usedCarry = true; - } - - // 复利每段起点:记录并本金瞬间(非首段 = 利息滚入计息基数) - if (si > 0) - trace?.Rollover(segmentRates[si].StartDate, usedCarry ? resetCarryInterest : accrued, accrualBasis); - - // 半开区间:重置日归下一段(旧代码逐日循环中重置日先更新本金再算息) - var segIncludeStart = (si == 0) ? boundary.IncludeStart : true; - var segIncludeEnd = isLastSegment ? boundary.IncludeEnd : false; - var days = SwapInterest.AccrualDays(segmentRates[si].StartDate, segEnd, - AccrualBoundary.Of(segIncludeStart, segIncludeEnd)); - if (days <= 0) continue; - - var dailyRate = isAnnualized ? segmentRates[si].Rate / annualDays : segmentRates[si].Rate; - var segInterest = accrualBasis * dailyRate * days; - accrued += segInterest; - trace?.Segment(si, segmentRates[si].StartDate, segEnd, days, segmentRates[si].Rate, accrualBasis, segInterest, accrued); - } - - finalBasis = accrualBasis; - - // 扣除历史已结利息 - if (realizedInterest != 0m) - trace?.Unwind(endDate, unwindFraction, realizedInterest * unwindFraction, accrued - realizedInterest * unwindFraction); - accrued -= realizedInterest * unwindFraction; - - var result = new InterestResult( - SwapInterest.Round(accrued, Precision), - SwapInterest.Round(accrued, Precision)); - trace?.MarkEnd(result.Accrued, result.AccruedToday); - return result; - } -} diff --git a/YLErpDAL/Modules/SwapModule/Accrual/FundingLegRate.cs b/YLErpDAL/Modules/SwapModule/Accrual/FundingLegRate.cs index 6240eae6..731860e4 100644 --- a/YLErpDAL/Modules/SwapModule/Accrual/FundingLegRate.cs +++ b/YLErpDAL/Modules/SwapModule/Accrual/FundingLegRate.cs @@ -1,3 +1,5 @@ +using YLErp.DBModels; + namespace YLErp.Modules.SwapModule.Accrual; /// @@ -26,4 +28,10 @@ public readonly struct FundingLegRate /// 构造浮动腿利率(all-in = 加点利差 + 指数定盘)。 public static FundingLegRate Floating(decimal spread, decimal indexFixing) => new(spread + indexFixing); + + /// 从 swap_position 构造:固定腿→Fixed(spread),浮动腿→Floating(spread+fixing)。 + public static FundingLegRate Build(swap_position position, decimal spread, decimal effectiveFloat) + => string.IsNullOrEmpty(position.FloatRateUnderlyingCode) + ? Fixed(spread) + : Floating(spread, effectiveFloat); } diff --git a/YLErpDAL/Modules/SwapModule/Accrual/SimpleInterestAccrual.cs b/YLErpDAL/Modules/SwapModule/Accrual/SimpleInterestAccrual.cs new file mode 100644 index 00000000..fb1378ab --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/Accrual/SimpleInterestAccrual.cs @@ -0,0 +1,106 @@ +using YLErp.Core.Interest; +using YLErp.Derivatives.Interest; + +namespace YLErp.Modules.SwapModule.Accrual; + +/// +/// 单利计息纯函数——EOD 单日 + intraday 多日。 +/// 单利特征:本金全程恒定(无并本金),按重置日分段取利率。 +/// +public static class SimpleInterestAccrual +{ + private const int Precision = SwapInterest.FundingLegPrecision; + + /// + /// 单利日终计息(替换 CalcDailySimpleInterestByEod 的纯数学部分)。 + /// EOD 无差分:basis = priorNotional(昨日终滚动计息基数)。 + /// + public static InterestResult AccrueEod( + decimal priorAccrued, + decimal priorNotional, + decimal unwindFraction, + FundingLegRate rate, + AccrualPolicy policy, + DateTime eodDate, + AccrualTrace? trace = null) + { + var basis = priorNotional; + var displayBasis = basis * unwindFraction; + + var allInRate = rate.AllInRate; + var dayInterest = displayBasis * allInRate; + var tdInterest = basis * allInRate; + if (policy.IsAnnualized) + { + dayInterest /= policy.AnnualDays; + tdInterest /= policy.AnnualDays; + } + + var totalAccrued = priorAccrued + dayInterest; + var result = new InterestResult( + SwapInterest.Round(totalAccrued, Precision), + SwapInterest.Round(tdInterest, Precision)); + + trace?.Day(0, eodDate, allInRate, displayBasis, dayInterest, totalAccrued); + trace?.MarkEnd(result.Accrued, result.AccruedToday); + return result; + } + + /// + /// 单利多日计息(替换 CalcDailySimpleInterest 的纯数学部分)。 + /// 本金全程恒定,按重置日分段取利率。 + /// Accrued = 缩放累计(InterestAmount),AccruedToday = 未缩放累计(TdInterestAmount)。 + /// + public static InterestResult AccruePeriod( + decimal priorAccrued, + decimal notional, + decimal unwindFraction, + IReadOnlyList<(DateTime StartDate, decimal Rate)> segmentRates, + DateTime startDate, + DateTime endDate, + DateTime priorValueDate, + AccrualBoundary boundary, + int annualDays, + bool isAnnualized, + AccrualTrace? trace = null) + { + var displayBasis = notional * unwindFraction; + decimal accrued = priorAccrued; // 缩放累计 → InterestAmount + decimal accruedUnscaled = priorAccrued; // 未缩放累计 → TdInterestAmount + + trace?.MarkStart(startDate, endDate, boundary, annualDays, isAnnualized); + + var segStart = startDate; + + for (int si = 0; si < segmentRates.Count; si++) + { + var segEnd = si < segmentRates.Count - 1 + ? segmentRates[si + 1].StartDate + : endDate; + + var effectiveStart = segStart > priorValueDate ? segStart : priorValueDate.AddDays(1); + if (effectiveStart > segEnd) { segStart = segEnd; continue; } + + // calcFirst 只跳过 startDate 本身;其余天(含重置日、ValueDate+1)只要 > ValueDate 恒纳入。 + var includeStart = effectiveStart == startDate ? boundary.IncludeStart : true; + var isLastSegment = si == segmentRates.Count - 1; + var segBoundary = AccrualBoundary.Of(includeStart, isLastSegment && boundary.IncludeEnd); + var days = SwapInterest.AccrualDays(effectiveStart, segEnd, segBoundary); + if (days <= 0) { segStart = segEnd; continue; } + + var dailyRate = isAnnualized ? segmentRates[si].Rate / annualDays : segmentRates[si].Rate; + var segInterest = displayBasis * dailyRate * days; + accrued += segInterest; + accruedUnscaled += notional * dailyRate * days; + trace?.Segment(si, effectiveStart, segEnd, days, segmentRates[si].Rate, displayBasis, segInterest, accrued); + + segStart = segEnd; + } + + var result = new InterestResult( + SwapInterest.Round(accrued, Precision), + SwapInterest.Round(accruedUnscaled, Precision)); + trace?.MarkEnd(result.Accrued, result.AccruedToday); + return result; + } +} diff --git a/YLErpDAL/Modules/SwapModule/ClosePercentMath.cs b/YLErpDAL/Modules/SwapModule/ClosePercentMath.cs new file mode 100644 index 00000000..9269a449 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/ClosePercentMath.cs @@ -0,0 +1,65 @@ +using YLErp.DBModels; + +namespace YLErp.Modules.SwapModule; + +/// +/// 平仓比例(ClosePercent) 数学——占期初(A) / 占剩余(B) 两种口径的转换。 +/// 从 SwapDealService 提取为共享模块,两个 service 均可引用。 +/// +public static class ClosePercentMath +{ + /// + /// 取上一日终的浮动端名义本金(orginPv 的来源)。 + /// 优先取浮动腿 PosiNotionalValue 之和,取不到用 eod_swap 多空绝对值之和,都没有用 currentNotional 兜底。 + /// + public static decimal ResolveUnwindPreviousNotional( + eod_swap lastEod, + IEnumerable lastEodPositions, + decimal currentNotional) + { + var floatingPositions = lastEodPositions?.Where(x => x.PosiDirection > 0).ToList(); + decimal previousNotional; + if (floatingPositions?.Count > 0) + { + previousNotional = floatingPositions.Sum(x => x.PosiNotionalValue); + } + else + { + previousNotional = lastEod == null + ? currentNotional + : Math.Abs(lastEod.NotionalValueLong) + Math.Abs(lastEod.NotionalValueShort); + } + + return previousNotional == 0m && currentNotional != 0m + ? currentNotional + : previousNotional; + } + + /// + /// A(占期初) → B(占剩余),用于把前端传入的占期初比例换算成后端计算用的占剩余比例。 + /// + public static decimal ToRemainingClosePercent(decimal originalClosePercent, decimal notionalValue, decimal posiNotionalValue) + { + if (posiNotionalValue <= 0) return originalClosePercent; + var remaining = originalClosePercent * notionalValue / posiNotionalValue; + return remaining > 1 ? 1 : remaining; + } + + /// + /// B(占剩余) → A(占期初),用于落库 / 事件列表展示还原。见 ToRemainingClosePercent。 + /// + public static decimal ToOriginalClosePercent(decimal remainingClosePercent, decimal notionalValue, decimal posiNotionalValue) + { + if (notionalValue <= 0) return remainingClosePercent; + return remainingClosePercent * posiNotionalValue / notionalValue; + } + + /// + /// 计算 InitUnwind 默认占期初(A)平仓比例 = PosiNotionalValue / NotionalValue。 + /// 未平仓时 =1(平100%);部分平仓后自动变为剩余比例。 + /// + public static decimal CalcDefaultInitClosePercent(decimal notionalValue, decimal posiNotionalValue) + { + return notionalValue > 0 ? posiNotionalValue / notionalValue : 1; + } +} diff --git a/YLErpDAL/Modules/SwapModule/EodPnlCalculator.cs b/YLErpDAL/Modules/SwapModule/EodPnlCalculator.cs new file mode 100644 index 00000000..3532be45 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/EodPnlCalculator.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using YLErp; +using YLErp.DBModels; +using YLErp.DBModels.Enums; +using YLErp.Modules.SwapModule.ReturnLegs; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 互换日终盈亏/精度计算纯函数集合。 + /// 自 SwapEodPositionService 抽出,支持无库单测;同类内部调用无需前缀。 + /// + public static class EodPnlCalculator + { + // 日终利息待实现需跨日累计,按表设计保留 12 位;已实现结算仍按金额两位处理。 + private const int EodInterestStoragePrecision = 12; + + internal static decimal RoundMoney(decimal value) + { + return Math.Round(value, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + } + + internal static decimal RoundEodInterest(decimal value) + { + return Math.Round(value, EodInterestStoragePrecision, MidpointRounding.AwayFromZero); + } + + /// + /// 仅在写入 eod_swap_position 前统一快照精度。 + /// 浮动腿收益最终以金额两位展示和存储;利息腿的待实现、计息基数及利率保留 12 位, + /// 使部分结算后的尾差可继续参与后续计息。 + /// + internal static void NormalizeEodPositionForStorage(eod_swap_position position) + { + if (string.IsNullOrEmpty(position.UnderlyingCode)) + { + // 利息腿没有标的代码:待实现字段保留高精度,已实现结算字段收敛到金额两位。 + position.InterestPrincipalFix = RoundEodInterest(position.InterestPrincipalFix); + position.InterestRateDefault = RoundEodInterest(position.InterestRateDefault); + position.InterestFeePending = RoundEodInterest(position.InterestFeePending); + position.TdInterestPrincipal = RoundEodInterest(position.TdInterestPrincipal); + position.TdInterestRate = RoundEodInterest(position.TdInterestRate); + position.TdInterestIncome = RoundEodInterest(position.TdInterestIncome); + position.TdInterestFee = RoundEodInterest(position.TdInterestFee); + position.InterestIncomeSum = RoundEodInterest(position.InterestIncomeSum); + position.InterestFeeSum = RoundEodInterest(position.InterestFeeSum); + position.InterestProfitSum = RoundEodInterest(position.InterestProfitSum); + position.FloatRate = RoundEodInterest(position.FloatRate); + position.SwapPositionValue = RoundEodInterest(position.SwapPositionValue); + position.TdCloseInterest = RoundMoney(position.TdCloseInterest); + position.TdCloseInterestFee = RoundMoney(position.TdCloseInterestFee); + position.RealizedInterest = RoundMoney(position.RealizedInterest); + position.RealizedInterestFee = RoundMoney(position.RealizedInterestFee); + } + else + { + // 浮动腿有标的代码:其损益作为金额结果落库,统一按两位四舍五入。 + position.TdPosiDividend = RoundMoney(position.TdPosiDividend); + position.PosiMtmPnL = RoundMoney(position.PosiMtmPnL); + position.PosiDividendSum = RoundMoney(position.PosiDividendSum); + position.PosiFeePending = RoundMoney(position.PosiFeePending); + position.PosiProfitSum = RoundMoney(position.PosiProfitSum); + position.TdCloseMtmPnl = RoundMoney(position.TdCloseMtmPnl); + position.TdCloseDividend = RoundMoney(position.TdCloseDividend); + position.TdCloseFee = RoundMoney(position.TdCloseFee); + position.RealizedMtmPnL = RoundMoney(position.RealizedMtmPnL); + position.RealizedDividend = RoundMoney(position.RealizedDividend); + position.RealizedFee = RoundMoney(position.RealizedFee); + position.SwapPositionValue = RoundMoney(position.SwapPositionValue); + } + position.RealizedPnl = RoundMoney(position.RealizedPnl); + } + + /// + /// 浮动腿累计已实现盈亏由盯市、分红和费用三个已实现组成项汇总。 + /// 各组成项已经按本方视角落库,此处不再额外转换方向。 + /// + internal static void SetFloatingRealizedPnl(eod_swap_position position) + { + position.RealizedPnl = position.RealizedMtmPnL + + position.RealizedDividend + + position.RealizedFee; + } + + /// + /// 汇总单条日终腿的我方已实现收益。 + /// 浮动腿及普通利息腿维持数据库记录的方向;初始/追加预付金腿的利息 + /// 则与保证金本金方向相反。这样“收取对手方保证金”产生的利息会作为 + /// 我方支付给对手方的成本计入,而不会错误增加框架合约已实现收益。 + /// 抽为静态纯函数以支持无库单测(marginTypes 等价于 ConsTrade.InterestMarginModels)。 + /// + public static decimal CalculateSwapRealizedPnl(eod_swap_position position) + { + var interestRatio = DirectionRatio.InterestLegPnl(position.InterestDirection, position.InterestMode); + + return position.RealizedMtmPnL + + position.RealizedDividend + + position.RealizedFee + + position.RealizedInterest * interestRatio + + position.RealizedInterestFee; + } + + /// 填充框架合约的持仓腿汇总字段(多空名义本金/市值/浮动盈亏/dv01/平仓量)。 + /// SaveEodSwap 与 UpdateEodSwap 共用,消除 ~10 行重复。 + internal static void FillPositionLegSummary(eod_swap eod_Swap, List positions) + { + eod_Swap.NotionalValueLong = Math.Round(positions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.PosiNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + eod_Swap.NotionalValueShort = Math.Round(-Math.Abs(positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue)), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + 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); + eod_Swap.dv01 = positions.Sum(s => s.dv01 ?? 0); + eod_Swap.TdCloseQty = positions.Sum(s => s.TdCloseQty); + } + + /// 利息腿 PnL 汇总(按方向比例 + 保证金翻转)。原 SaveEodSwap/UpdateEodSwap 各一段 ForEach。 + internal static decimal SumInterestPnL(List interestPositions) + { + decimal interestPnL = 0; + foreach (var x in interestPositions) + interestPnL += x.InterestProfitSum * DirectionRatio.InterestLegPnl(x.InterestDirection, x.InterestMode); + return interestPnL; + } + + /// + /// 风险报表符号归一化:把历史两种符号口径的 TdCloseInterest/RealizedInterest + /// 统一按"绝对金额 × 业务方向"重写。普通利息腿收取为正、支付为负; + /// 预付金腿利息方向与保证金本金方向相反。随后重算 RealizedPnl。 + /// 抽为 public static 纯函数以支持无库单测(见 SwapReportInterestSignNormalizeTest)。 + /// 仅当 InterestDirection > 0 时执行(与原内联逻辑等价)。 + /// + public static void NormalizeInterestSignForReport(eod_swap_position position) + { + if (position.InterestDirection <= 0) return; + + if (position.InterestMode == (int)InterestModeEnum.标的期初全价) + { + return; + } + var interestRatio = DirectionRatio.InterestLegPnl(position.InterestDirection, position.InterestMode); + position.TdCloseInterest = Math.Abs(position.TdCloseInterest) * interestRatio; + position.RealizedInterest = Math.Abs(position.RealizedInterest) * interestRatio; + // 兼容修复前已落库的利息腿:当时只累计了明细字段,未同步写入 RealizedPnl。 + position.RealizedPnl = position.RealizedInterest + position.RealizedInterestFee; + } + + /// + /// 计算预付金利率。多条初始/追加预付金腿按本金绝对值加权, + /// 不按收付方向轧差,避免相反方向本金抵消后放大利率。 + /// + internal 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; + } + + /// + /// 计算预付金利息金额。InterestIncomeSum 已是各腿利息金额, + /// 按收取为正、支付为负直接轧差求和,不做本金加权。 + /// 抽为 public static 纯函数以支持无库单测(见 SwapWeightedMarginInterestTest)。 + /// + public static decimal CalculateWeightedMarginInterest(IEnumerable margins) + { + return margins.Sum(x => + x.InterestIncomeSum * DirectionRatio.ReceivePay(x.InterestDirection)); + } + + /// + /// 固定利息腿的累计已实现盈亏 = 累计已实现利息 + 累计已实现利息费用。 + /// 4 处 SaveAutoEodInterestPosition/SaveEodInterestPosition 路径口径一致, + /// 抽为 public static 纯函数以支持无库单测(见 SwapFixedLegRealizedPnlTest), + /// 并消除复制粘贴带来的笔误风险(如 L1296 历史双分号)。 + /// + public static void SetFixedLegRealizedPnl(eod_swap_position position) + { + position.RealizedPnl = position.RealizedInterest + position.RealizedInterestFee; + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/FundingLegs/ContractNotionalLeg.cs b/YLErpDAL/Modules/SwapModule/FundingLegs/ContractNotionalLeg.cs index f1d62dd0..97e6faea 100644 --- a/YLErpDAL/Modules/SwapModule/FundingLegs/ContractNotionalLeg.cs +++ b/YLErpDAL/Modules/SwapModule/FundingLegs/ContractNotionalLeg.cs @@ -11,6 +11,6 @@ public sealed class ContractNotionalLeg : IFundingLegStrategy { public InterestModeEnum Mode => InterestModeEnum.合约名义本金规模; - public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal posiLong, decimal posiShort, decimal closePercent) + public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal closePercent) => new(posiNotional * closePercent, posiNotional, closePercent); } diff --git a/YLErpDAL/Modules/SwapModule/FundingLegs/FixedAmountLeg.cs b/YLErpDAL/Modules/SwapModule/FundingLegs/FixedAmountLeg.cs index 6e477b07..451384ec 100644 --- a/YLErpDAL/Modules/SwapModule/FundingLegs/FixedAmountLeg.cs +++ b/YLErpDAL/Modules/SwapModule/FundingLegs/FixedAmountLeg.cs @@ -12,6 +12,6 @@ public sealed class FixedAmountLeg : IFundingLegStrategy { public InterestModeEnum Mode => InterestModeEnum.固定值; - public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal posiLong, decimal posiShort, decimal closePercent) + public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal closePercent) => new(fix, fix, 1m); } diff --git a/YLErpDAL/Modules/SwapModule/FundingLegs/IFundingLegStrategy.cs b/YLErpDAL/Modules/SwapModule/FundingLegs/IFundingLegStrategy.cs index 2d29f8c0..9ef23074 100644 --- a/YLErpDAL/Modules/SwapModule/FundingLegs/IFundingLegStrategy.cs +++ b/YLErpDAL/Modules/SwapModule/FundingLegs/IFundingLegStrategy.cs @@ -22,10 +22,8 @@ public interface IFundingLegStrategy /// /// 合约固定本金(固定值/预付金腿用;其余腿忽略)。 /// 当前剩余名义本金(数量 × 全价)。 - /// 多头剩余名义本金(多空存续腿用,当前界面已禁用)。 - /// 空头剩余名义本金。 /// 平仓比例(占剩余,0~1)。 - NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal posiLong, decimal posiShort, decimal closePercent); + NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal closePercent); } /// diff --git a/YLErpDAL/Modules/SwapModule/FundingLegs/UnderlyingEntryFullPriceLeg.cs b/YLErpDAL/Modules/SwapModule/FundingLegs/UnderlyingEntryFullPriceLeg.cs index f78d9b74..6838f7e0 100644 --- a/YLErpDAL/Modules/SwapModule/FundingLegs/UnderlyingEntryFullPriceLeg.cs +++ b/YLErpDAL/Modules/SwapModule/FundingLegs/UnderlyingEntryFullPriceLeg.cs @@ -14,6 +14,6 @@ public sealed class UnderlyingEntryFullPriceLeg : IFundingLegStrategy { public InterestModeEnum Mode => InterestModeEnum.标的期初全价; - public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal posiLong, decimal posiShort, decimal closePercent) + public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal closePercent) => new(posiNotional * closePercent, posiNotional, closePercent); } diff --git a/YLErpDAL/Modules/SwapModule/Margin/MarginAccount.cs b/YLErpDAL/Modules/SwapModule/Margin/MarginAccount.cs index fcdf3f14..33a5474f 100644 --- a/YLErpDAL/Modules/SwapModule/Margin/MarginAccount.cs +++ b/YLErpDAL/Modules/SwapModule/Margin/MarginAccount.cs @@ -4,14 +4,12 @@ using YLErp.Derivatives.Interest; namespace YLErp.Modules.SwapModule.Margin; /// -/// 保证金账户。管理保证金余额的变动(追加/释放/返还),并提供计息入口。 +/// 保证金账户。管理保证金余额的变动(追加/释放/返还),并提供计息入口(预留抽象,尚未接线)。 /// -/// 保证金是独立的资金管理概念(初始保证金/维持保证金/保证金余额/追保), -/// 与融资腿(funding leg)完全无关。现有代码把保证金塞进 InterestMode==5/6 -/// 当计息腿处理是错误的,本类是正确建模的起点。 -/// -/// 利息计算委托 SwapInterest 纯函数(余额×利率×天数/年化), -/// 保证金账户只提供余额和计息入口,不自己实现计息算法。 +/// 保证金是独立的资金管理概念(初始保证金/维持保证金/保证金余额/追保),与融资腿(funding leg)无关。 +/// 生产保证金计息入口为 SwapDealService.CalcMarginInterest(仍以 InterestMode 5/6 标识): +/// EOD 用昨日终本金 preEod.TdInterestPrincipal(无差分);盘中用 accrualBasis 差分(orginPv 经 PreviousBalance)。 +/// 本类尚未被生产代码实例化——其扁平"余额×利率×天数"模型无法表达盘中差分与多行分段,留作未来简化抽象。 /// public sealed class MarginAccount { @@ -31,7 +29,8 @@ public sealed class MarginAccount /// /// 按当前余额计算保证金利息。委托 SwapInterest.AccrueSimple。 - /// 保证金利息是券商对客户保证金存款付息(方向与融资腿相反)。 + /// 注意:当前未被生产代码调用——生产保证金计息入口为 SwapDealService.CalcMarginInterest + /// (处理 EOD 昨日终本金与盘中差分;本方法的扁平余额模型不覆盖盘中差分口径)。 /// /// 保证金利率(年化,如 0.03 = 3%)。 /// 计息开始日。 diff --git a/YLErpDAL/Modules/SwapModule/Margin/MarginBalance.cs b/YLErpDAL/Modules/SwapModule/Margin/MarginBalance.cs index 491de03c..abb50ece 100644 --- a/YLErpDAL/Modules/SwapModule/Margin/MarginBalance.cs +++ b/YLErpDAL/Modules/SwapModule/Margin/MarginBalance.cs @@ -4,7 +4,7 @@ namespace YLErp.Modules.SwapModule.Margin; /// 保证金余额。现金、授信、担保等多种保证金形态的统一表达。 /// /// 保证金就是保证金——有余额、有利率、有利息,不存在"计息基数/Notional"概念。 -/// 余额随追加/释放/盈亏变动,利息由 SwapInterest 纯函数按 余额×利率×天数/年化 计算。 +/// 余额随追加/释放/盈亏变动,利息由计息层(SwapDealService.CalcMarginInterest)按 EOD 昨日终本金 / 盘中差分口径计算。 /// public readonly struct MarginBalance { diff --git a/YLErpDAL/Modules/SwapModule/Margin/MarginModes.cs b/YLErpDAL/Modules/SwapModule/Margin/MarginModes.cs index 7232022f..359e3fc9 100644 --- a/YLErpDAL/Modules/SwapModule/Margin/MarginModes.cs +++ b/YLErpDAL/Modules/SwapModule/Margin/MarginModes.cs @@ -41,4 +41,17 @@ public static class MarginModes /// 判断 mode 是否属于保证金(非 LINQ 场景用)。 public static bool Contains(int interestMode) => All.Contains(interestMode); + + /// 固定值 + 保证金 mode 集合(固定值/初始预付金/追加预付金)。 + /// 用于 EOD 场景判断"计息基数取 InterestPrincipalFix 而非持仓名义本金"的腿。 + /// 替代 SwapEodPositionService 中 3 处内联 new List{固定值, 初始预付金, 追加预付金}。 + public static readonly IReadOnlyCollection FixedAmountAndMargin = new HashSet + { + (int)InterestModeEnum.固定值, + (int)InterestModeEnum.初始预付金, + (int)InterestModeEnum.追加预付金, + }; + + /// 判断 mode 是否为固定值或保证金。 + public static bool IsFixedAmountOrMargin(int interestMode) => FixedAmountAndMargin.Contains(interestMode); } diff --git a/YLErpDAL/Modules/SwapModule/ReturnLegs/DirectionRatio.cs b/YLErpDAL/Modules/SwapModule/ReturnLegs/DirectionRatio.cs index 13808855..33d80a87 100644 --- a/YLErpDAL/Modules/SwapModule/ReturnLegs/DirectionRatio.cs +++ b/YLErpDAL/Modules/SwapModule/ReturnLegs/DirectionRatio.cs @@ -1,5 +1,6 @@ using YLErp.DBModels; using YLErp.DBModels.Enums; +using YLErp.Modules.SwapModule.Margin; namespace YLErp.Modules.SwapModule.ReturnLegs; @@ -19,4 +20,17 @@ public static class DirectionRatio /// 收付方向因子。收取=+1, 支付=-1。 public static int ReceivePay(int direction) => direction == (int)SwapDirectionEnum.收取 ? 1 : -1; + + /// 利息腿 PnL 方向因子。收取=+1/支付=-1;保证金腿翻转(利息现金流与本金方向相反)。 + /// 原 7 处内联 `收取?1:-1; if(MarginModes) ratio=-ratio` 收口到此。 + public static int InterestLegPnl(int interestDirection, int interestMode) + { + var ratio = ReceivePay(interestDirection); + return MarginModes.Contains(interestMode) ? -ratio : ratio; + } + + /// 按收付方向选汇率类型。收取→Buy, 支付→Sell。 + /// 原 7 处内联 `收取 ? Buy : Sell` 收口到此。 + public static CurrencyRateType RateType(int direction) + => direction == (int)SwapDirectionEnum.收取 ? CurrencyRateType.Buy : CurrencyRateType.Sell; } diff --git a/YLErpDAL/Modules/SwapModule/ReturnLegs/InterestIncomeCalc.cs b/YLErpDAL/Modules/SwapModule/ReturnLegs/InterestIncomeCalc.cs new file mode 100644 index 00000000..8f1b2aa4 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/ReturnLegs/InterestIncomeCalc.cs @@ -0,0 +1,21 @@ +namespace YLErp.Modules.SwapModule.ReturnLegs; + +/// +/// EOD 利息腿展示值计算——轻量单日公式(非 SwapDealService 的分段计息引擎)。 +/// +public static class InterestIncomeCalc +{ + /// 日应计利息 = 本金 × (固定利率 + 浮动利差) ÷ 年化天数(若年化)。 + /// 原 3 处内联 `principal*(rate+float); if(annualized) /=annualDays` 收口到此。 + public static decimal DailyAccrual(decimal principal, decimal rate, decimal floatRate, bool isAnnualized, int annualDays) + { + var amount = principal * (rate + floatRate); + return isAnnualized ? amount / annualDays : amount; + } + + /// 已实现利息滚存。(prev + today×ratio, prevFee + todayFee)。 + /// 原 4 处内联 2 行赋值收口到此。 + public static (decimal Interest, decimal Fee) RollRealized( + decimal prevInterest, decimal prevFee, decimal closeInterest, decimal closeInterestFee, int ratio) + => (prevInterest + closeInterest * ratio, prevFee + closeInterestFee); +} diff --git a/YLErpDAL/Modules/SwapModule/ReturnLegs/MtmCalc.cs b/YLErpDAL/Modules/SwapModule/ReturnLegs/MtmCalc.cs index 4bbb5620..7dedab2c 100644 --- a/YLErpDAL/Modules/SwapModule/ReturnLegs/MtmCalc.cs +++ b/YLErpDAL/Modules/SwapModule/ReturnLegs/MtmCalc.cs @@ -19,4 +19,12 @@ public static class MtmCalc /// 收取=1, 支付=-1。 public static decimal UnrealizedPnl(decimal price, decimal costGrossPrice, decimal qty, decimal contractSize, int shortRatio, int ratio) => (price - costGrossPrice) * qty * contractSize * shortRatio * ratio; + + /// 浮动端总未实现盈亏 = 盯市盈亏 + 分红 + 待结费用。原 4 处内联收口到此。 + public static decimal ReturnLegProfitSum(decimal mtmPnl, decimal dividendSum, decimal feePending) + => mtmPnl + dividendSum + feePending; + + /// 加权均价混合:(昨日均价×昨日量 + 今日∑(量×均额)) / 总量。原 4 处内联收口到此。 + public static decimal BlendPrice(decimal prevPrice, decimal prevQty, decimal sumQtyTimesPrice, decimal totalQty) + => (prevPrice * prevQty + sumQtyTimesPrice) / totalQty; } diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 367cef46..13cf78d8 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -46,106 +46,17 @@ namespace YLErp.Modules.SwapModule /// 原 private 改 protected virtual,使测试 stub 可整体 override,规避内部 new SwapEventService 连库。 protected virtual long SaveSwapDeal(UnwindData unwindData, int eventType, int clientCashId, string eventResason = "", bool approve = false) { - NormalizeNotionalValues(unwindData); + UnwindNormalizer.NormalizeNotionalValues(unwindData); return SaveSwapDealInternal(unwindData, eventType, clientCashId, eventResason, approve); } - private static void NormalizeNotionalValues(UnwindData unwindData) - { - unwindData.NotionalValue = Math.Round(unwindData.NotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - unwindData.PosiNotionalValue = Math.Round(unwindData.PosiNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - unwindData.CloseNotionalValue = Math.Round(unwindData.CloseNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - } - - private static bool NormalizeFullCloseRequest(UnwindData unwindData) - { - if (unwindData.CloseMethod != (int)CloseMethodEnum.全部平仓 - && unwindData.ClosePercent < 1 - && !(unwindData.PositionQty > 0 && unwindData.CloseQty >= unwindData.PositionQty) - && !(unwindData.PosiNotionalValue > 0 && unwindData.CloseNotionalValue >= unwindData.PosiNotionalValue)) - { - return false; - } - - var closeQty = unwindData.CloseQty; - var closeNotionalValue = unwindData.CloseNotionalValue; - unwindData.ClosePercent = 1; - if (unwindData.PositionQty > 0) unwindData.CloseQty = unwindData.PositionQty; - if (unwindData.PosiNotionalValue > 0) unwindData.CloseNotionalValue = unwindData.PosiNotionalValue; - return closeQty != unwindData.CloseQty || closeNotionalValue != unwindData.CloseNotionalValue; - } - - private static void RecalculateNormalizedUnwindAmounts(UnwindData unwindData) - { - var floatLeg = unwindData.FlowEvents.FirstOrDefault(x => !string.IsNullOrEmpty(x.UnderlyingCode)); - if (floatLeg == null || floatLeg.PosiGrossPrice == 0) return; - - var input = new UnwindInput - { - Multiplier = ConsGlobal.InstrumentType.IsBond(floatLeg.UnderlyingInstrumentType) ? 100 : 1, - PosiGrossPrice = floatLeg.PosiGrossPrice, - TradingAmountAvg = floatLeg.TradingAmountAvg, - CloseQty = unwindData.CloseQty, - PositionQty = unwindData.PositionQty, - ContractSize = floatLeg.ContractSize, - CloseNotionalValue = unwindData.CloseNotionalValue, - PayDirection = floatLeg.PayDirection, - PositionType = floatLeg.PositionType, - TradingFee = floatLeg.TradingFee.ToString(), - TradingFeePending = floatLeg.TradingFeePending.ToString(), - DividendIn = floatLeg.DividendIn.ToString() - }; - foreach (var leg in unwindData.FlowEvents.Where(x => string.IsNullOrEmpty(x.UnderlyingCode))) - { - var target = MarginModes.Contains(leg.InterestMode) - ? input.MarginLegs - : input.InterestLegs; - target.Add(new LegInput { InterestClosePnL = leg.InterestClosePnL }); - } - - var result = FrontendCalcReference.CalcUnwind(input); - floatLeg.MarkClosePnl = result.MarkClosePnl; - unwindData.SwapCloseAmount = result.SwapCloseAmount; - unwindData.SwapRealizedPnL = result.SwapRealizedPnL; - unwindData.SwapMarginRebatePnl = result.SwapMarginRebatePnl; - } - - private static bool IsFullCloseAfterDeduction(UnwindData unwindData, double remainingNotional, double remainingQuantity) - { - return unwindData.ClosePercent == 1 || (remainingNotional == 0 && remainingQuantity == 0); - } - // 待实现利息会进入 decimal(30,12) 日终快照;精度常量统一引用 SwapInterest.FundingLegPrecision,消除重复定义。 private const int InterestCalculationPrecision = SwapInterest.FundingLegPrecision; - /// - /// 手工平仓、手工互换及收益结算的利息事件按金额两位落库。 - /// 自动平仓保留原有计算与落库口径,不适用本阶段的手工结算规则。 - /// - private static bool NormalizeSettledInterestAmounts(IEnumerable flowEvents, int eventType, string eventReason) - { - if ((eventType != (int)SwapEventTypeEnum.平仓 && eventType != (int)SwapEventTypeEnum.互换) - || eventReason == "系统操作_自动平仓") - { - return false; - } - - foreach (var flowEvent in flowEvents.Where(x => string.IsNullOrEmpty(x.UnderlyingCode))) - { - // 只处理利息腿;浮动腿损益在日终快照入口统一按两位落库。 - flowEvent.InterestPrincipal = Math.Round(flowEvent.InterestPrincipal, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - flowEvent.InterestAmount = Math.Round(flowEvent.InterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - flowEvent.TdInterestAmount = Math.Round(flowEvent.TdInterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - flowEvent.InterestClosePnL = Math.Round(flowEvent.InterestClosePnL, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - flowEvent.InterestFee = Math.Round(flowEvent.InterestFee, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - } - return true; - } - // 客户现金在 SaveSwapDeal 之前创建,手工结算必须先收敛流水并重算汇总金额。 private void NormalizeManualSettlementAmounts(UnwindData unwindData, int eventType, string eventReason) { - if (!NormalizeSettledInterestAmounts(unwindData.FlowEvents, eventType, eventReason)) + if (!UnwindNormalizer.NormalizeSettledInterestAmounts(unwindData.FlowEvents, eventType, eventReason)) { return; } @@ -372,7 +283,7 @@ namespace YLErp.Modules.SwapModule floatEvent.UnderlyingInstrumentType = position.UnderlyingInstrumentType; floatEvent.CloseFee = 0; floatEvent.BeforeCloseFee = oriPosition.PosiTradingFeePending; - floatEvent.TradingFee = CalcInitTradingFee(oriPosition, unwindData); + floatEvent.TradingFee = TradingFeeCalc.CalcInitTradingFee(oriPosition, unwindData); floatEvent.PosiTradingFeeUnit = oriPosition?.PosiTradingFeeUnit ?? 0; floatEvent.PosiFeeType = oriPosition?.PosiFeeType ?? 0; floatEvent.MarkClosePnl = 0; @@ -386,8 +297,8 @@ namespace YLErp.Modules.SwapModule floatEvent.PositionQty = 0; floatEvent.ContractSize = position.ContractSize; floatEvent.TradingAmount = floatEvent.Quantity * floatEvent.ContractSize; - var ratio = position.PosiDirection == (int)SwapDirectionEnum.收取 ? -1m : 1m; - floatEvent.TradingFeePending = CalcInitTradingFeePending(oriPosition, position, unwindData); + var ratio = -DirectionRatio.ReceivePay(position.PosiDirection); + floatEvent.TradingFeePending = TradingFeeCalc.CalcInitTradingFeePending(oriPosition, position, unwindData); floatEvent.DataState = (int)SwapFlowDateStateEnum.完成; floatEvent.InterestMode = position.InterestMode; floatEvent.ClientId = td.ClientId; @@ -397,37 +308,6 @@ namespace YLErp.Modules.SwapModule } return unwindData; } - private static decimal CalcInitTradingFee(swap_position oriPosition, UnwindData unwindData) - { - if (oriPosition == null || unwindData == null) - { - return 0; - } - - if (oriPosition.PosiFeeType == 1) - { - return Math.Round(oriPosition.PosiTradingFeeUnit * unwindData.CloseQty, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - } - - return Math.Round(oriPosition.PosiTradingFeeUnit / 100m * unwindData.CloseNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - } - - private static decimal CalcInitTradingFeePending(swap_position oriPosition, swap_position position, UnwindData unwindData) - { - if (oriPosition == null || unwindData == null || oriPosition.PosiTradingFeeUnit == 0) - { - return position?.PosiTradingFeePending ?? 0; - } - - var closeBase = oriPosition.PosiFeeType == 1 ? unwindData.CloseQty : unwindData.CloseNotionalValue; - var originalBase = oriPosition.PosiFeeType == 1 ? unwindData.NotionalQty : unwindData.NotionalValue; - if (originalBase <= 0) - { - return position?.PosiTradingFeePending ?? 0; - } - - return Math.Round(oriPosition.PosiTradingFeePending * closeBase / originalBase, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - } /// /// 校验上日是否收盘 /// @@ -465,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; - } /// /// 平仓初始化 /// @@ -663,8 +493,8 @@ namespace YLErp.Modules.SwapModule /// 根因(多次部分平仓预付金返还错误):预付金腿(初始/追加)的"当前剩余本金"存于实时持仓 /// realPositions.InterestPrincipalFix,每次平仓由 UpdateInitalPosition 递减;而原始腿 /// origPositions(IsInitial=1)的 InterestPrincipalFix 恒为初始值。GetInterests 算 - /// closePrincipal = Fix × closePercent 与预付金计息基数 orginPv(InitSwapDealInterest) 时都读 - /// position.InterestPrincipalFix,若沿用原始腿,会在多次部分平仓后仍返还/计算初始本金(如始终 99000)。 + /// closePrincipal = Fix × closePercent 时读 position.InterestPrincipalFix,若沿用原始腿, + /// 会在多次部分平仓后仍返还/计算初始本金(如始终 99000)。 /// 修复:迭代源仍用 origPositions(保留 orig.id → eod_swap_position.PositionId 的日终匹配, /// 全库实测 eod 均按 orig.id 归档;若换 realPositions 会破坏 preEod 匹配导致利息重算错误),仅对预付金腿 /// Clone 覆盖其本金值为实时腿的剩余本金。real 与 orig 通过 real.PositionId == orig.id 精确 1:1 关联。 @@ -745,24 +575,7 @@ namespace YLErp.Modules.SwapModule eod_swap lastEod, IEnumerable lastEodPositions, decimal currentNotional) - { - var floatingPositions = lastEodPositions?.Where(x => x.PosiDirection > 0).ToList(); - decimal previousNotional; - if (floatingPositions?.Count > 0) - { - previousNotional = floatingPositions.Sum(x => x.PosiNotionalValue); - } - else - { - previousNotional = lastEod == null - ? currentNotional - : Math.Abs(lastEod.NotionalValueLong) + Math.Abs(lastEod.NotionalValueShort); - } - - return previousNotional == 0m && currentNotional != 0m - ? currentNotional - : previousNotional; - } + => ClosePercentMath.ResolveUnwindPreviousNotional(lastEod, lastEodPositions, currentNotional); /// /// 获取利息腿"已通过历史互换结出的累计利息"(用于复利重算时扣除,类比分红的 CalcConsumedDividend)。 @@ -855,14 +668,22 @@ namespace YLErp.Modules.SwapModule { // 融资腿(1/2/9): 走策略工厂 var r = FundingLegStrategyFactory.Get(mode) - .CalcNotional(position.InterestPrincipalFix, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue, closePrecent); + .CalcNotional(position.InterestPrincipalFix, posiNotionalValue, closePrecent); closePrincipal = r.ClosePrincipal; posiPrincipal = r.PosiPrincipal; newClosePercent = r.ClosePercent; } + // 根因位置:SwapEodPositionService.SaveAutoEodWithCloseInterestPosition 在平仓后收盘时传入 + // “收盘后剩余本金 + closePercent=1”,与盘中“平仓前本金 + 实际关闭比例”不是同一语义。 + // GetInterests 同时被盘中试算和 EOD 平仓后收盘调用:后者传入的 + // posiNotionalValue 是收盘后的剩余本金,closePosiNotionalValue 才是本次实际平掉的本金。 + // 例如平仓前 100、平掉 30、收盘后剩余 70 时,EOD 传入 posi=70、close=30、closePercent=1。 + // 模式2(合约名义本金规模)的本次结息本金必须始终是实际平仓额,因此无条件覆盖, + // 否则会错误地用剩余 70 结算本次平掉的 30。模式9(标的期初全价)的部分平仓 + // 仍保留既有的剩余/复利动态本金承接逻辑;仅最终全平时 posi=0,才覆盖以避免结息本金为 0。 if ((InterestModeEnum)position.InterestMode == InterestModeEnum.合约名义本金规模 - || (InterestModeEnum)position.InterestMode == InterestModeEnum.标的期初全价 - && posiNotionalValue == 0m) + || ((InterestModeEnum)position.InterestMode == InterestModeEnum.标的期初全价 + && posiNotionalValue == 0m)) { closePrincipal = closePosiNotionalValue; } @@ -876,7 +697,13 @@ namespace YLErp.Modules.SwapModule decimal floatRate = GetFloatRate(position, preEodPosition, td.StartDate.Value, endDate, interestPeriod, swap, positionClone); // 根据场景计算利息 - if (settment) + if (MarginModes.Contains(position.InterestMode)) + { + // 保证金腿(5/6):专属计息,notional 直接取保证金余额,无融资腿差分公式与 orginPv 维度 hack + interests.Add(CalcMarginInterest(td, valueDate, endDate, positionClone, rate, closePrincipal, posiPrincipal, + newClosePercent, annualDays, calcFirst, calcLast||newCalcLast, preEodPosition, eventType, add, settment, swap)); + } + else if (settment) { // 收盘归档场景,使用 CalcEodInterest interests.Add(CalcEodInterest(td, valueDate, positionClone, rate, floatRate, closePrincipal, posiPrincipal, annualDays, calcFirst, calcLast, preEodPosition, eventType, add)); @@ -934,32 +761,19 @@ namespace YLErp.Modules.SwapModule /// 分母为 0(无持仓等异常场景)时原样返回,避免除零。 /// public static decimal ToRemainingClosePercent(decimal originalClosePercent, decimal notionalValue, decimal posiNotionalValue) - { - if (posiNotionalValue <= 0) return originalClosePercent; - var remaining = originalClosePercent * notionalValue / posiNotionalValue; - return remaining > 1 ? 1 : remaining; - } + => ClosePercentMath.ToRemainingClosePercent(originalClosePercent, notionalValue, posiNotionalValue); /// /// B(占剩余) → A(占期初),用于落库 / 事件列表展示还原。见 ToRemainingClosePercent。 /// public static decimal ToOriginalClosePercent(decimal remainingClosePercent, decimal notionalValue, decimal posiNotionalValue) - { - if (notionalValue <= 0) return remainingClosePercent; - return remainingClosePercent * posiNotionalValue / notionalValue; - } + => ClosePercentMath.ToOriginalClosePercent(remainingClosePercent, notionalValue, posiNotionalValue); /// - /// 计算 InitUnwind 默认占期初(A)平仓比例 = "平掉剩余全部持仓"对应的占期初比例。 - /// 即:ClosePercent(A) = PosiNotionalValue / NotionalValue。 - /// 未平仓时 PosiNotionalValue==NotionalValue → 1(平100%); - /// 部分平仓后自动变为剩余比例(如已平 30% 则默认 0.7)。 - /// 与互换/提前终止 InitIncome 保持一致。抽出为纯函数以支持无库单测。 + /// 计算 InitUnwind 默认占期初(A)平仓比例 = PosiNotionalValue / NotionalValue。 /// public static decimal CalcDefaultInitClosePercent(decimal notionalValue, decimal posiNotionalValue) - { - return notionalValue > 0 ? posiNotionalValue / notionalValue : 1; - } + => ClosePercentMath.CalcDefaultInitClosePercent(notionalValue, posiNotionalValue); /// /// 读取"上一收盘日"浮动腿的待实现分红(eod_swap_position.PosiDividendSum), @@ -992,16 +806,34 @@ namespace YLErp.Modules.SwapModule /// protected virtual decimal GetPreEodDividendSum(int tradeId, long positionId, DateTime dealDate) { - var lastEod = DbContext.eod_swap - .Where(x => x.ValueDate < dealDate && x.SwapTradeId == tradeId) - .OrderByDescending(o => o.ValueDate).FirstOrDefault(); - var preEodDate = lastEod == null ? dealDate.AddDays(-1) : lastEod.ValueDate; - var preEod = new SwapEodPositionService(this) - .GetPreEodPositions(tradeId, preEodDate) - .FirstOrDefault(x => x.PositionId == positionId); + var preEod = GetPreEodPositionByDate(tradeId, positionId, dealDate); return preEod == null ? 0m : preEod.PosiDividendSum; } + /// + /// 取 dealDate 对应"上一收盘日"持仓的累计分红快照。 + /// GLMS-20260105-0006:登记日当天手动平仓/互换时,当日 EOD 快照已含分红,应取到当日而非 T-1。 + /// 故由 ValueDate 严格小于 dealDate 改为 小于等于:当日 EOD 存在则读当日,否则回退上一收盘日(原口径不变)。 + /// + protected virtual eod_swap_position GetPreEodPositionByDate(int tradeId, long positionId, DateTime dealDate) + { + var lastEod = QueryPreEodSwaps(tradeId) + .Where(x => x.ValueDate <= dealDate) + .OrderByDescending(o => o.ValueDate).FirstOrDefault(); + var preEodDate = lastEod == null ? dealDate.AddDays(-1) : lastEod.ValueDate; + return QueryPreEodPosition(tradeId, positionId, preEodDate); + } + + /// 可测性 seam:返回某交易的全部 eod_swap 行(不做日期过滤)。测试可 override 注入内存数据。 + protected virtual IQueryable QueryPreEodSwaps(int tradeId) + => DbContext.eod_swap.Where(x => x.SwapTradeId == tradeId); + + /// 可测性 seam:取指定收盘日的持仓累计分红快照。测试可 override 注入内存数据。 + protected virtual eod_swap_position QueryPreEodPosition(int tradeId, long positionId, DateTime valueDate) + => new SwapEodPositionService(this) + .GetPreEodPositions(tradeId, valueDate) + .FirstOrDefault(x => x.PositionId == positionId); + /// /// 获取固定利率 /// @@ -1088,12 +920,12 @@ namespace YLErp.Modules.SwapModule if (position.InterestType == (int)InterestTypeEnum.复利) { // 复利计算 - CalcDailyCompoundInterestByEod(preEod, valueDate, td.StartDate.Value, position, closePrincipal, posiPrincipal, interest, annualDays, false, eodFloatRate, 1m, posiPrincipal, ref interestAmount, ref tdInterestAmount); + CalcDailyCompoundInterestByEod(preEod, valueDate, td.StartDate.Value, position, closePrincipal, posiPrincipal, interest, annualDays, eodFloatRate, 1m, ref interestAmount, ref tdInterestAmount); } else { // 单利计算 - CalcDailySimpleInterestByEod(preEod, valueDate, td.StartDate.Value, position, closePrincipal, posiPrincipal, interest, annualDays, false, eodFloatRate, 1m, posiPrincipal, ref interestAmount, ref tdInterestAmount); + CalcDailySimpleInterestByEod(preEod, valueDate, td.StartDate.Value, position, closePrincipal, posiPrincipal, interest, annualDays, eodFloatRate, 1m, ref interestAmount, ref tdInterestAmount); } } @@ -1101,7 +933,125 @@ namespace YLErp.Modules.SwapModule interest.InterestAmount = Math.Round(interestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero); interest.TdInterestAmount = Math.Round(tdInterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero); // 计算InterestClosePnL(方向:收取=1为正,支付=-1为负) - var interestRatio = position.InterestDirection == 1 ? 1m : -1m; + var interestRatio = DirectionRatio.ReceivePay(position.InterestDirection); + interest.InterestClosePnL = interest.InterestAmount * interestRatio; + + if (add) UpdateDbOption(interest); + return interest; + } + + /// + /// 保证金腿(InterestMode 5/6)专属计息——替代 CalcEodInterest/CalcUnwindInterest 对保证金的处理。 + /// + /// 保证金是纯固定利率单利:浮动利率(FR007)/分段利率/复利对其均为死分支(前端无入口、 + /// 确认书不含、FundingLegRate.Build 对空 FloatRateUnderlyingCode 恒返回 Fixed)。故本方法直接用 + /// SimpleInterestAccrual 纯函数计息,本金取保证金余额: + /// EOD = 昨日终本金 preEod.TdInterestPrincipal(与旧 CalcDailySimpleInterestByEod 同源,无差分) + /// 盘中 = accrualBasis(preEod.TdInterestPrincipal + posiPrincipal - orginPv) + /// 盘中保留差分是必要的:posiPrincipal 是否经 ResolveInterestLegPositions 对齐到实时剩余是路径相关的 + /// (生产对齐 / 诊断测试用原始腿),单一本金变量无法覆盖两种状态,差分经 orginPv 自适应。orginPv 在 + /// 本方法内部按保证金维度计算(PreviousBalance),消除原 InitSwapDealInterest 的外部维度 hack + /// (融资腿 orginPv=浮动端名义本金)。保留累计语义(priorAccrued + 增量),满足下游字段契约。 + /// + /// true=收盘归档(EOD),false=盘中平仓/互换。 + /// 互换事件(仅盘中生效,true 时利息归零,同 InitSwapDealInterest)。 + public swap_flow_event CalcMarginInterest( + trade td, DateTime valueDate, DateTime endDate, swap_position position, decimal rate, + decimal closePrincipal, decimal posiPrincipal, decimal closePercent, + int annualDays, bool calcFirst, bool calcLast, + eod_swap_position preEod, int eventType, bool add, bool settment, bool 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 = settment ? valueDate : endDate + }; + + // 互换事件:利息归零(同 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 + { + // 盘中:accrualBasis 自适应"实时剩余本金"——posiPrincipal 已对齐(ResolveInterestLegPositions) + // 时 = posiPrincipal;未对齐的原始腿经 orginPv(=PreviousBalance 昨日终) 修正回昨日终剩余。 + // 单一本金变量无法覆盖两种 position 状态,故保留差分(与 EOD 直接用 preEod.TdInterestPrincipal 不同)。 + // orginPv 在此内部按保证金维度计算,消除原 InitSwapDealInterest 的外部维度 hack。 + var orginPv = MarginCalc.PreviousBalance(preEod, posiPrincipal); + var accrualBasis = preEod.TdInterestPrincipal + posiPrincipal - orginPv; + var segmentRates = new List<(DateTime, decimal)> { (position.PosiStartDate, rate) }; + var r = SimpleInterestAccrual.AccruePeriod( + priorAccrued: preEod.InterestProfitSum * closePercent, + notional: accrualBasis, + unwindFraction: closePercent, + segmentRates: segmentRates, + startDate: position.PosiStartDate, + endDate: endDate, + priorValueDate: preEod.ValueDate, + boundary: AccrualBoundary.Of(calcFirst, calcLast), + annualDays: annualDays, + isAnnualized: position.IsAnnualized); + interestAmount = r.Accrued; + tdInterestAmount = r.AccruedToday; + interest.InterestPrincipal = accrualBasis * closePercent; // 同 CalcDailySimpleInterest:1304 + } + } + + interest.InterestAmount = Math.Round(interestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero); + interest.TdInterestAmount = Math.Round(tdInterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero); + var interestRatio = DirectionRatio.ReceivePay(position.InterestDirection); interest.InterestClosePnL = interest.InterestAmount * interestRatio; if (add) UpdateDbOption(interest); @@ -1130,15 +1080,6 @@ namespace YLErp.Modules.SwapModule orginPv, calcFirst, calcLast, consumedInterest); } /// - /// 保证金腿的 orginPv 维度重映射。 - /// - /// 保证金腿被迫走融资腿的差分公式(dynomicPrincipal = TdInterestPrincipal + posiPrincipal - orginPv), - /// 但 orginPv 对融资腿是"交易名义本金(千万~亿级)",对保证金腿必须是"保证金本金"—— - /// 否则维度不匹配会算出巨负值。本方法把保证金场景的 orginPv 对齐到"上一日保证金本金"。 - /// - /// 待迁入 Margin 模块:保证金独立计息入口建好后,此方法移入 MarginAccount/MarginService。 - /// - /// /// 写入保证金的资金记录:应付预付金(SwapMarginAmount)和预付金返息(SwapMarginRebatePnl)。 /// 依赖实例方法 AddClientCash/AddClientCashInCashOut,暂留此处。 /// @@ -1215,11 +1156,8 @@ namespace YLErp.Modules.SwapModule interest.ClientId = td.ClientId; interest.UnwindDate = endDate; - // 保证金腿的 orginPv 对齐到保证金本金维度,避免差分公式维度不匹配算出巨负值 - if (MarginModes.Contains(position.InterestMode)) - { - orginPv = MarginCalc.PreviousBalance(preEodPosition, position.InterestPrincipalFix); - } + // 保证金腿已走 CalcMarginInterest(不经过本方法),orginPv 维度重映射不再需要; + // orginPv 此处仅对融资腿生效(差分公式 accrualBasis = TdInterestPrincipal + posiPrincipal - orginPv)。 if (swap) { @@ -1232,7 +1170,7 @@ namespace YLErp.Modules.SwapModule { decimal InterestAmount = 0; decimal TdInterestAmount = 0; - var interestRatio = position.InterestDirection == 1 ? 1m : -1m; + var interestRatio = DirectionRatio.ReceivePay(position.InterestDirection); var floateRate = preEodPosition.FloatRate; if (position.InterestType == (int)InterestTypeEnum.复利) { @@ -1263,8 +1201,8 @@ namespace YLErp.Modules.SwapModule // 把上日尚未实现的的利息 按本次平掉的这部分计息基数分给本次平仓 并在重置日并入计息基数 // 它只在当前 endDate 恰好为重置日时使用,避免把同一笔历史利息重复资本化。 var resetCarryInterest = preEodPosition.InterestIncomeSum * remainingPercent; - CalcDailyCompoundInterest(endDate, position, closePosiNotionalValue, interest, annualDays, needPrice, - floateRate, closePrecent, orginPv, calcFirst, calcLast, ref InterestAmount, ref TdInterestAmount, + CalcDailyCompoundInterest(endDate, position, closePosiNotionalValue, interest, annualDays, + floateRate, closePrecent, calcFirst, calcLast, ref InterestAmount, ref TdInterestAmount, consumedInterest, resetCarryInterest); if (preEodPosition.id != 0 && closePrecent == 1m) { @@ -1291,7 +1229,7 @@ namespace YLErp.Modules.SwapModule } // 计算截至本次平仓日的累计利息 amountAtEnd CalcDailyCompoundInterest(replayEndDate, position, closePosiNotionalValue, - interestAtEnd, annualDays, needPrice, floateRate, closePrecent, orginPv, + interestAtEnd, annualDays, floateRate, closePrecent, calcFirst, calcLast, ref amountAtEnd, ref tdAmountAtEnd, consumedInterest); var interestAtPreviousEod = new swap_flow_event { InterestRate = rate }; decimal amountAtPreviousEod = 0m; @@ -1300,7 +1238,7 @@ namespace YLErp.Modules.SwapModule // 因此此处按闭区间包含上一日终当天,避免算头不算尾时重复加入该日利息。 // 计算截至上一日终累积的利息 amountAtPreviousEod CalcDailyCompoundInterest(preEodPosition.ValueDate, position, closePosiNotionalValue, - interestAtPreviousEod, annualDays, needPrice, floateRate, closePrecent, orginPv, + interestAtPreviousEod, annualDays, floateRate, closePrecent, calcFirst, true, ref amountAtPreviousEod, ref tdAmountAtPreviousEod, consumedInterest); // 例如 0004:5/18 待实现 -118631.261797,加 5/19 新增约 -4648.912760, // 得到最终应结 -123280.174557,按金额两位落为 Excel BN 的 -123280.17。 @@ -1312,7 +1250,7 @@ namespace YLErp.Modules.SwapModule } else { - CalcDailySimpleInterest(preEodPosition, endDate, position, posiNotionalValue, interest, annualDays, needPrice, floateRate, closePrecent, orginPv, calcFirst, calcLast, ref InterestAmount, ref TdInterestAmount); + CalcDailySimpleInterest(preEodPosition, endDate, position, posiNotionalValue, interest, annualDays, floateRate, closePrecent, orginPv, calcFirst, calcLast, ref InterestAmount, ref TdInterestAmount); } interest.InterestAmount = Math.Round(InterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero); @@ -1326,6 +1264,19 @@ namespace YLErp.Modules.SwapModule return interest; } + /// + /// 按 interest_rule 取 FR007 定盘价。无浮动标的时返回 fallback;取不到抛异常。 + /// EOD 单日取率 + BuildSegmentRates 多日取率共用此方法,FR007 定盘逻辑收口到一处。 + /// + private decimal ResolveFloatRate(swap_position position, DateTime date, decimal fallback) + { + if (string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) return fallback; + var fixingDate = IndexFixerBase.GetFixingDate(date, position.interest_rule); + if (IndexFixer.TryGetFixing(fixingDate, position.FloatRateUnderlyingCode, out decimal fixing)) + return fixing != 0m ? fixing : fallback; + throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fixingDate:yyyy年MM月dd日}的价格"); + } + /// /// 按重置周期切分利率段,每段记录 all-in 利率(spread+fixing)。返回 (分段列表, 末段浮动利率)。 /// fetchAfterDate: 仅该日期之后的重置日才取 FR007(单利传 ValueDate,复利传 null 全程取)。 @@ -1341,19 +1292,8 @@ namespace YLErp.Modules.SwapModule for (int i = 0; i <= calcDays; i += interestPeriod) { var resetDate = startDate.AddDays(i); - if ((fetchAfterDate == null || resetDate > fetchAfterDate.Value) - && !string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) - { - var fixingDate = IndexFixerBase.GetFixingDate(resetDate, position.interest_rule); - if (IndexFixer.TryGetFixing(fixingDate, position.FloatRateUnderlyingCode, out decimal fixing)) - { - if (fixing != 0m) currentFloat = fixing; - } - else - { - throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fixingDate:yyyy年MM月dd日}的价格"); - } - } + if (fetchAfterDate == null || resetDate > fetchAfterDate.Value) + currentFloat = ResolveFloatRate(position, resetDate, currentFloat); rates.Add((resetDate, spread + currentFloat)); } return (rates, currentFloat); @@ -1371,7 +1311,7 @@ namespace YLErp.Modules.SwapModule /// 年化天数 /// public void CalcDailyCompoundInterest(DateTime endDate, swap_position position, decimal principal, swap_flow_event flowEvent, - int annualDays, bool needPrice, decimal floateRate, decimal closePercent, decimal orginPv, bool calcFirst, bool calcLast, + int annualDays, decimal floateRate, decimal closePercent, bool calcFirst, bool calcLast, ref decimal InterestAmount, ref decimal TdInterestAmount, decimal consumedInterest = 0m, decimal resetCarryInterest = 0m) { var startDate = position.PosiStartDate; @@ -1384,7 +1324,7 @@ namespace YLErp.Modules.SwapModule // 纯函数复利计息:分段重置日并本金 + resetCarryInterest + 扣 consumedInterest var interestTrace = new AccrualTrace(); - var result = FundingLegAccrual.AccrueCompoundPeriod( + var result = CompoundInterestAccrual.AccruePeriod( notional: principal, segmentRates: segmentRates, startDate: startDate, @@ -1410,12 +1350,12 @@ namespace YLErp.Modules.SwapModule /// /// 计算单利 盘中(按重置天数分段,每段使用对应浮动利率) /// - public void CalcDailySimpleInterest(eod_swap_position preEodPosition, DateTime endDate, swap_position position, decimal posiPrincipal, swap_flow_event flowEvent, int annualDays, bool needPrice, decimal floateRate, decimal closePercent, decimal orginPv, bool calcFirst, bool calcLast, ref decimal InterestAmount, ref decimal TdInterestAmount, decimal consumedInterest = 0m) + public void CalcDailySimpleInterest(eod_swap_position preEodPosition, DateTime endDate, swap_position position, decimal posiPrincipal, swap_flow_event flowEvent, int annualDays, decimal floateRate, decimal closePercent, decimal orginPv, bool calcFirst, bool calcLast, ref decimal InterestAmount, ref decimal TdInterestAmount, decimal consumedInterest = 0m) { var startDate = position.PosiStartDate; int interestPeriod = position.interest_rest_days ?? 1; - // orginPv 是路径相关参考本金(资金腿=上一日终浮动端名义本金;保证金腿=上一日终保证金余额)。 + // orginPv 是路径相关参考本金(资金腿=上一日终浮动端名义本金)。保证金腿已走 CalcMarginInterest,不经此方法。 // 单利差分:accrualBasis 全程恒定 = 昨日终滚动基数 + 当日名义本金 - 参考本金。 var accrualBasis = preEodPosition.TdInterestPrincipal + posiPrincipal - orginPv; @@ -1426,7 +1366,7 @@ namespace YLErp.Modules.SwapModule // 纯函数计息:Accrued=缩放累计(InterestAmount),AccruedToday=未缩放累计(TdInterestAmount) var interestTrace = new AccrualTrace(); - var result = FundingLegAccrual.AccrueSimplePeriod( + var result = SimpleInterestAccrual.AccruePeriod( priorAccrued: preEodPosition.InterestProfitSum * closePercent, notional: accrualBasis, unwindFraction: closePercent, @@ -1460,26 +1400,14 @@ namespace YLErp.Modules.SwapModule /// 是否年化 /// 年化天数 /// - public void CalcDailyCompoundInterestByEod(eod_swap_position preEodPosition, DateTime endDate, DateTime tradeDate, swap_position position, decimal principal, decimal posiPrincipal, swap_flow_event flowEvent, int annualDays, bool needPrice, decimal floateRate, decimal closePercent, decimal orginPv, ref decimal InterestAmount, ref decimal TdInterestAmount) + public void CalcDailyCompoundInterestByEod(eod_swap_position preEodPosition, DateTime endDate, DateTime tradeDate, swap_position position, decimal principal, decimal posiPrincipal, swap_flow_event flowEvent, int annualDays, decimal floateRate, decimal closePercent, ref decimal InterestAmount, ref decimal TdInterestAmount) { int interestPeriod = position.interest_rest_days ?? 1; var isResetDay = (endDate - tradeDate).Days % interestPeriod == 0; // 重置日按 interest_rule 重新定盘浮动利率(GLMS-JIATT-20260805 根因—— // 重置日=平仓日必须用新利率,否则沿用旧周期利率并污染后续 EOD)。非重置日沿用 floateRate。 - decimal effectiveFloat = floateRate; - if (isResetDay && !string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) - { - var fixingDate = IndexFixerBase.GetFixingDate(endDate, position.interest_rule); - if (IndexFixer.TryGetFixing(fixingDate, position.FloatRateUnderlyingCode, out decimal fixing)) - { - if (fixing != 0m) effectiveFloat = fixing; - } - else - { - throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fixingDate:yyyy年MM月dd日}的价格"); - } - } + var effectiveFloat = isResetDay ? ResolveFloatRate(position, endDate, floateRate) : floateRate; flowEvent.FloatRate = effectiveFloat; // remainingFraction:重置日把上一日终待实现利息按本次平仓基数分摊(EOD 全量为 1)。 @@ -1487,21 +1415,13 @@ namespace YLErp.Modules.SwapModule ? Math.Max(0m, Math.Min(1m, principal / posiPrincipal)) : 1m; - // 纯数学下沉至 FundingLegAccrual.AccrueCompoundEod(DDD 命名 + 末位生产精度 12 舍入)。 - var isFixedLeg = string.IsNullOrEmpty(position.FloatRateUnderlyingCode); - var legRate = isFixedLeg - ? FundingLegRate.Fixed(flowEvent.InterestRate) - : FundingLegRate.Floating(flowEvent.InterestRate, effectiveFloat); - var accrualPolicy = new AccrualPolicy( - convention: AccrualBoundary.Both, - isCompound: true, - resetPeriodDays: interestPeriod, - annualDays: annualDays, - isAnnualized: position.IsAnnualized); + // 纯数学下沉至 CompoundInterestAccrual.AccrueEod(DDD 命名 + 末位生产精度 12 舍入)。 + var legRate = FundingLegRate.Build(position, flowEvent.InterestRate, effectiveFloat); + var accrualPolicy = AccrualPolicy.BuildEod(position, annualDays, isCompound: true); // 完整计息 trace:前后日期/基数/利率/重置标志全过程,经 SwapCalcTrace 常驻落盘(关键路径日志)。 var interestTrace = new AccrualTrace(); - var result = FundingLegAccrual.AccrueCompoundEod( + var result = CompoundInterestAccrual.AccrueEod( priorAccrued: preEodPosition.InterestProfitSum, priorNotional: preEodPosition.TdInterestPrincipal, notional: posiPrincipal, @@ -1516,7 +1436,7 @@ namespace YLErp.Modules.SwapModule // flowEvent.InterestPrincipal:当日计息基数(已按平仓比例缩放)——下游 EOD 用它播种次日 TdInterestPrincipal。 // 复用 CompoundEodBasis 单一真相源(与 AccrueCompoundEod 内部同一公式)。 - flowEvent.InterestPrincipal = FundingLegAccrual.CompoundEodBasis( + flowEvent.InterestPrincipal = CompoundInterestAccrual.EodBasis( isResetDay, posiPrincipal, preEodPosition.InterestProfitSum, remainingFraction, preEodPosition.TdInterestPrincipal) * closePercent; @@ -1527,7 +1447,7 @@ namespace YLErp.Modules.SwapModule /// /// 计算单利 收盘(按重置天数分段,每段使用对应浮动利率) /// - public void CalcDailySimpleInterestByEod(eod_swap_position preEodPosition, DateTime endDate, DateTime tradeDate, swap_position position, decimal principal, decimal posiPrincipal, swap_flow_event flowEvent, int annualDays, bool needPrice, decimal floateRate, decimal closePercent, decimal orginPv, ref decimal InterestAmount, ref decimal TdInterestAmount) + public void CalcDailySimpleInterestByEod(eod_swap_position preEodPosition, DateTime endDate, DateTime tradeDate, swap_position position, decimal principal, decimal posiPrincipal, swap_flow_event flowEvent, int annualDays, decimal floateRate, decimal closePercent, ref decimal InterestAmount, ref decimal TdInterestAmount) { // 首次操作(preEod.id == 0):计息基数按存量本金初始化——保留旧行为(含对 preEod 的就地修正)。 if (preEodPosition.id == 0) @@ -1537,39 +1457,18 @@ namespace YLErp.Modules.SwapModule // 取率:重置日按 interest_rule 重新定盘浮动利率(GLMS-JIATT-20260805 根因—— // 重置日=平仓日必须用新利率,否则沿用旧周期利率并污染后续 EOD)。 - decimal effectiveFloat = floateRate; int interestPeriod = position.interest_rest_days ?? 1; - if ((endDate - tradeDate).Days % interestPeriod == 0 - && !string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) - { - var fixingDate = IndexFixerBase.GetFixingDate(endDate, position.interest_rule); - if (IndexFixer.TryGetFixing(fixingDate, position.FloatRateUnderlyingCode, out decimal fixing)) - { - if (fixing != 0m) effectiveFloat = fixing; - } - else - { - throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fixingDate:yyyy年MM月dd日}的价格"); - } - } + var isResetDay = (endDate - tradeDate).Days % interestPeriod == 0; + var effectiveFloat = isResetDay ? ResolveFloatRate(position, endDate, floateRate) : floateRate; flowEvent.FloatRate = effectiveFloat; - // 纯数学下沉至 FundingLegAccrual(DDD 命名 + 末位生产精度 12 舍入),行为与上版逐字对齐。 - // 利率构成按腿型封装:固定腿 → FixedRate;浮动腿 → Spread + IndexFixing(沿用旧实现 InterestRate+浮动利率 的口径)。 - var isFixedLeg = string.IsNullOrEmpty(position.FloatRateUnderlyingCode); - var legRate = isFixedLeg - ? FundingLegRate.Fixed(flowEvent.InterestRate) - : FundingLegRate.Floating(flowEvent.InterestRate, effectiveFloat); - var accrualPolicy = new AccrualPolicy( - convention: AccrualBoundary.Both, - isCompound: false, - resetPeriodDays: position.interest_rest_days ?? 1, - annualDays: annualDays, - isAnnualized: position.IsAnnualized); + // 纯数学下沉至 SimpleInterestAccrual(末位生产精度 12 舍入)。 + var legRate = FundingLegRate.Build(position, flowEvent.InterestRate, effectiveFloat); + var accrualPolicy = AccrualPolicy.BuildEod(position, annualDays, isCompound: false); // 完整计息 trace:收集器由适配器创建,随后经 SwapCalcTrace 常驻落盘(关键路径日志,无条件)。 var interestTrace = new AccrualTrace(); - var result = FundingLegAccrual.AccrueSimpleEod( + var result = SimpleInterestAccrual.AccrueEod( priorAccrued: preEodPosition.InterestProfitSum, priorNotional: preEodPosition.TdInterestPrincipal, unwindFraction: closePercent, @@ -1594,16 +1493,16 @@ namespace YLErp.Modules.SwapModule { throw new ServiceException("未找到交易信息"); } - NormalizeEventUnwindDate(unwindData); - NormalizeNotionalValues(unwindData); + UnwindNormalizer.NormalizeEventUnwindDate(unwindData); + UnwindNormalizer.NormalizeNotionalValues(unwindData); NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum.平仓, "系统操作_平仓"); //CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制 // 前端按"占期初(original)"语义传 ClosePercent(A);后端全链路按"占剩余(remaining)"语义(B)消费。 // 入口统一转换为 B,落库展示用的 A 由 SaveSwapDealInternal 还原。 unwindData.ClosePercent = ToRemainingClosePercent(unwindData.ClosePercent, unwindData.NotionalValue, unwindData.PosiNotionalValue); - if (NormalizeFullCloseRequest(unwindData)) + if (UnwindNormalizer.NormalizeFullCloseRequest(unwindData)) { - RecalculateNormalizedUnwindAmounts(unwindData); + UnwindNormalizer.RecalculateNormalizedUnwindAmounts(unwindData); } ValidateFrontendPnL(unwindData, isIncome: false); // 只读校验告警,不阻断交易 bool cofirm = false; @@ -1616,7 +1515,7 @@ namespace YLErp.Modules.SwapModule var eventId = SaveSwapDeal(unwindData, (int)SwapEventTypeEnum.平仓, clientCashId, "系统操作_平仓"); var remainingStockEqvNotional = Math.Round(td.StockEqvNotional - Convert.ToDouble(unwindData.CloseNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); var remainingTradeAmount = td.TradeAmount - Convert.ToDouble(unwindData.CloseQty); - var isFullClose = IsFullCloseAfterDeduction(unwindData, remainingStockEqvNotional, remainingTradeAmount); + var isFullClose = UnwindNormalizer.IsFullCloseAfterDeduction(unwindData, remainingStockEqvNotional, remainingTradeAmount); if (isFullClose) { td.TradeStatus = "已平仓"; @@ -1978,84 +1877,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("未找到交易信息"); - } - 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("未找到交易信息"); - } - 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(); - } - } - /// /// 互换 /// /// @@ -2067,7 +1888,7 @@ namespace YLErp.Modules.SwapModule { throw new ServiceException("未找到交易信息"); } - NormalizeEventUnwindDate(unwindData); + UnwindNormalizer.NormalizeEventUnwindDate(unwindData); ValidateIncomeValueDate(unwindData, td); NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum.互换, "系统操作_互换"); //CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制 @@ -2105,8 +1926,8 @@ namespace YLErp.Modules.SwapModule throw new Exception("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效"); } swapEvent.unwindData = JsonConvert.DeserializeObject(swapEvent.EventData); - NormalizeEventUnwindDate(swapEvent.unwindData); - NormalizeNotionalValues(swapEvent.unwindData); + UnwindNormalizer.NormalizeEventUnwindDate(swapEvent.unwindData); + UnwindNormalizer.NormalizeNotionalValues(swapEvent.unwindData); // Stored events keep display ratio A; approval calculations consume remaining ratio B. swapEvent.unwindData.ClosePercent = ToRemainingClosePercent( swapEvent.unwindData.ClosePercent, @@ -2121,9 +1942,9 @@ namespace YLErp.Modules.SwapModule swapEvent.unwindData.FlowEvents = flowList; if (eventType == (int)SwapEventTypeEnum.平仓) { - if (NormalizeFullCloseRequest(swapEvent.unwindData)) + if (UnwindNormalizer.NormalizeFullCloseRequest(swapEvent.unwindData)) { - RecalculateNormalizedUnwindAmounts(swapEvent.unwindData); + UnwindNormalizer.RecalculateNormalizedUnwindAmounts(swapEvent.unwindData); } } if (eventType == (int)SwapEventTypeEnum.互换) @@ -2152,7 +1973,7 @@ namespace YLErp.Modules.SwapModule { var remainingStockEqvNotional = Math.Round(td.StockEqvNotional - Convert.ToDouble(swapEvent.unwindData.CloseNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); var remainingTradeAmount = td.TradeAmount - Convert.ToDouble(swapEvent.unwindData.CloseQty); - var isFullClose = IsFullCloseAfterDeduction(swapEvent.unwindData, remainingStockEqvNotional, remainingTradeAmount); + var isFullClose = UnwindNormalizer.IsFullCloseAfterDeduction(swapEvent.unwindData, remainingStockEqvNotional, remainingTradeAmount); if (isFullClose) { td.TradeStatus = "已平仓"; @@ -2197,7 +2018,7 @@ namespace YLErp.Modules.SwapModule { throw new ServiceException("未找到交易信息"); } - NormalizeEventUnwindDate(unwindData); + UnwindNormalizer.NormalizeEventUnwindDate(unwindData); if (eventType == (int)SwapEventTypeEnum.互换) { ValidateIncomeValueDate(unwindData, td); @@ -2211,9 +2032,9 @@ namespace YLErp.Modules.SwapModule unwindData.ClosePercent = ToRemainingClosePercent(unwindData.ClosePercent, unwindData.NotionalValue, unwindData.PosiNotionalValue); if (eventType == (int)SwapEventTypeEnum.平仓) { - if (NormalizeFullCloseRequest(unwindData)) + if (UnwindNormalizer.NormalizeFullCloseRequest(unwindData)) { - RecalculateNormalizedUnwindAmounts(unwindData); + UnwindNormalizer.RecalculateNormalizedUnwindAmounts(unwindData); } } string action = eventType == (int)SwapEventTypeEnum.互换 ? ClientCashInCashOut.系统操作_互换 : ClientCashInCashOut.系统操作_平仓费; @@ -2246,11 +2067,6 @@ namespace YLErp.Modules.SwapModule } } - private static void NormalizeEventUnwindDate(UnwindData unwindData) - { - unwindData.UnwindDate = unwindData.ValueDate; - } - /// /// 保存平仓/互换事件 /// @@ -2264,7 +2080,7 @@ namespace YLErp.Modules.SwapModule throw new ServiceException("未找到交易信息"); } var flowList = new List(unwindData.FlowEvents); - NormalizeSettledInterestAmounts(flowList, eventType, eventResason); + UnwindNormalizer.NormalizeSettledInterestAmounts(flowList, eventType, eventResason); unwindData.FlowEvents.Clear(); // 落库展示用"占期初(original)"语义(A);计算链(费用递减/全平判定)用"占剩余(remaining)"语义(B)。 // 序列化前把 ClosePercent 还原为 A,序列化后立即还原回 B 供后续使用。 diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index c70476bb..9bf583aa 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -48,72 +48,13 @@ namespace YLErp.Modules.SwapModule : ConsGlobal.SwapDeliveryPriceRound; } - // 日终利息待实现需跨日累计,按表设计保留 12 位;已实现结算仍按金额两位处理。 - private const int EodInterestStoragePrecision = 12; - - private static decimal RoundMoney(decimal value) - { - return Math.Round(value, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - } - - private static decimal RoundEodInterest(decimal value) - { - return Math.Round(value, EodInterestStoragePrecision, MidpointRounding.AwayFromZero); - } - - /// - /// 仅在写入 eod_swap_position 前统一快照精度。 - /// 浮动腿收益最终以金额两位展示和存储;利息腿的待实现、计息基数及利率保留 12 位, - /// 使部分结算后的尾差可继续参与后续计息。 - /// - private static void NormalizeEodPositionForStorage(eod_swap_position position) - { - if (string.IsNullOrEmpty(position.UnderlyingCode)) - { - // 利息腿没有标的代码:待实现字段保留高精度,已实现结算字段收敛到金额两位。 - position.InterestPrincipalFix = RoundEodInterest(position.InterestPrincipalFix); - position.InterestRateDefault = RoundEodInterest(position.InterestRateDefault); - position.InterestFeePending = RoundEodInterest(position.InterestFeePending); - position.TdInterestPrincipal = RoundEodInterest(position.TdInterestPrincipal); - position.TdInterestRate = RoundEodInterest(position.TdInterestRate); - position.TdInterestIncome = RoundEodInterest(position.TdInterestIncome); - position.TdInterestFee = RoundEodInterest(position.TdInterestFee); - position.InterestIncomeSum = RoundEodInterest(position.InterestIncomeSum); - position.InterestFeeSum = RoundEodInterest(position.InterestFeeSum); - position.InterestProfitSum = RoundEodInterest(position.InterestProfitSum); - position.FloatRate = RoundEodInterest(position.FloatRate); - position.SwapPositionValue = RoundEodInterest(position.SwapPositionValue); - position.TdCloseInterest = RoundMoney(position.TdCloseInterest); - position.TdCloseInterestFee = RoundMoney(position.TdCloseInterestFee); - position.RealizedInterest = RoundMoney(position.RealizedInterest); - position.RealizedInterestFee = RoundMoney(position.RealizedInterestFee); - } - else - { - // 浮动腿有标的代码:其损益作为金额结果落库,统一按两位四舍五入。 - position.TdPosiDividend = RoundMoney(position.TdPosiDividend); - position.PosiMtmPnL = RoundMoney(position.PosiMtmPnL); - position.PosiDividendSum = RoundMoney(position.PosiDividendSum); - position.PosiFeePending = RoundMoney(position.PosiFeePending); - position.PosiProfitSum = RoundMoney(position.PosiProfitSum); - position.TdCloseMtmPnl = RoundMoney(position.TdCloseMtmPnl); - position.TdCloseDividend = RoundMoney(position.TdCloseDividend); - position.TdCloseFee = RoundMoney(position.TdCloseFee); - position.RealizedMtmPnL = RoundMoney(position.RealizedMtmPnL); - position.RealizedDividend = RoundMoney(position.RealizedDividend); - position.RealizedFee = RoundMoney(position.RealizedFee); - position.SwapPositionValue = RoundMoney(position.SwapPositionValue); - } - position.RealizedPnl = RoundMoney(position.RealizedPnl); - } - #region 可测试化接缝(Seams)——override 这些虚方法可在测试中替换 DB/外部调用,生产代码行为不变 /// 持久化 eod 持仓记录(生产: DbContext.Add;测试: 收集到列表) protected virtual void PersistEodSwapPosition(eod_swap_position position) { // 所有新增或更新的日终持仓都经过此入口,避免不同日终分支出现精度差异。 - NormalizeEodPositionForStorage(position); + EodPnlCalculator.NormalizeEodPositionForStorage(position); var storagePriceRound = GetStorageDeliveryPriceRound(position.UnderlyingInstrumentType, position.UnderlyingCode); position.PosiGrossPrice = Math.Round(position.PosiGrossPrice, storagePriceRound, MidpointRounding.AwayFromZero); position.UnderlyingPrice = Math.Round(position.UnderlyingPrice, storagePriceRound, MidpointRounding.AwayFromZero); @@ -193,6 +134,12 @@ namespace YLErp.Modules.SwapModule return new SwapEventService(this).AddSwapEventDate(tradeDate, swapTradeId, eventType, data, clientCashId, save, reason); } + /// 持久化互换流水事件(生产: DbContext.swap_flow_event.Add;测试: 收集到列表) + protected virtual void PersistFlowEvent(swap_flow_event flowEvent) + { + DbContext.swap_flow_event.Add(flowEvent); + } + /// 在事务中执行(生产: BeginTransaction/Commit/Rollback;测试: 直接执行不包事务) protected virtual void ExecuteInTransaction(Action action) { @@ -662,7 +609,7 @@ namespace YLErp.Modules.SwapModule decimal premiumTotal = 0; premiumInterests.ForEach(x => { - var ratio = x.InterestDirection == (int)SwapDirectionEnum.收取 ? -1 : 1; + var ratio = -DirectionRatio.ReceivePay(x.InterestDirection); premiumTotal += x.InterestClosePnL * ratio; }); unwindData.SwapMarginRebatePnl = premiumTotal; @@ -670,7 +617,7 @@ namespace YLErp.Modules.SwapModule decimal interestTotal = 0; interestLegs.ForEach(x => { - var ratio = x.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1; + var ratio = DirectionRatio.ReceivePay(x.InterestDirection); interestTotal += x.InterestClosePnL * ratio; }); unwindData.SwapCloseAmount = interestTotal ; @@ -797,24 +744,26 @@ namespace YLErp.Modules.SwapModule unwindData.ClientCashIds = clientCashIds; string data = JsonConvert.SerializeObject(unwindData); - var swapEvent = new SwapEventService(this).AddSwapEventDate(unwindData.ValueDate, unwindData.SwapTradeId, (int)SwapEventTypeEnum.自动互换, data, clientCashId, true, "系统操作-自动互换");//将互换总额存入事件 + // 走虚方法 AddSwapEvent(与 ComposePage:800 一致),让测试可 override 捕获事件; + // 默认实现仍是 new SwapEventService(this).AddSwapEventDate,生产行为不变。 + var swapEvent = AddSwapEvent(unwindData.ValueDate, unwindData.SwapTradeId, (int)SwapEventTypeEnum.自动互换, data, clientCashId, true, "系统操作-自动互换");//将互换总额存入事件 if (flowEvents!=null) { flowEvents.ForEach(x => { x.EventId = swapEvent.id; - DbContext.swap_flow_event.Add(x); + PersistFlowEvent(x); }); UpdateInitalPostion(flowEvents, td.id); } - + // 保存分红事件 if (dividendEvents != null) { dividendEvents.ForEach(x => { x.EventId = swapEvent.id; - DbContext.swap_flow_event.Add(x); + PersistFlowEvent(x); }); UpdateInitalPostion(dividendEvents, td.id); } @@ -1041,11 +990,7 @@ namespace YLErp.Modules.SwapModule eodPayPosition.PosiMatuirityDate = td.ExerciseDate.Value; } var tradeExtend = td.trade_extend.ExtendObj; - decimal ratio = position.InterestDirection == (int)SwapDirectionEnum.收取 ? 1m : -1m;//收取为正,支付为负 - if (MarginModes.Contains(position.InterestMode)) - { - ratio = -ratio; - } + var ratio = DirectionRatio.InterestLegPnl(position.InterestDirection, position.InterestMode); if (newEodPayPosition == null) { newEodPayPosition = new eod_swap_position(); @@ -1085,18 +1030,16 @@ namespace YLErp.Modules.SwapModule newEodPayPosition.TdCloseInterest = flowEvents.Sum(x => x.InterestAmount); newEodPayPosition.TdCloseInterestFee = newEodPayPosition.TdInterestFee; //持仓内容-利息腿-损益统计(本方视角) - var intersetAcmount = newEodPayPosition.TdInterestPrincipal * (newEodPayPosition.TdInterestRate + newEodPayPosition.FloatRate); - if (position.IsAnnualized) - { - intersetAcmount /= tradeExtend.AnnualDays; - } + var intersetAcmount = InterestIncomeCalc.DailyAccrual( + newEodPayPosition.TdInterestPrincipal, newEodPayPosition.TdInterestRate, newEodPayPosition.FloatRate, + newEodPayPosition.IsAnnualized, tradeExtend.AnnualDays); newEodPayPosition.TdInterestIncome = intersetAcmount;// 要算一下当天产生的利息 var interestIncomeBeforeSettlement = eodPayPosition.InterestIncomeSum + newEodPayPosition.TdInterestIncome; var interestFeeBeforeSettlement = eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee; var isMaturityFinalSettlement = valueDate.Date >= td.ExerciseDate.Value.Date && flowEvents.Any() - && RoundMoney(interestIncomeBeforeSettlement) == RoundMoney(newEodPayPosition.TdCloseInterest) - && RoundMoney(interestFeeBeforeSettlement) == RoundMoney(newEodPayPosition.TdCloseInterestFee); + && EodPnlCalculator.RoundMoney(interestIncomeBeforeSettlement) == EodPnlCalculator.RoundMoney(newEodPayPosition.TdCloseInterest) + && EodPnlCalculator.RoundMoney(interestFeeBeforeSettlement) == EodPnlCalculator.RoundMoney(newEodPayPosition.TdCloseInterestFee); if (isMaturityFinalSettlement) { @@ -1107,19 +1050,20 @@ namespace YLErp.Modules.SwapModule } else { - newEodPayPosition.InterestIncomeSum = RoundEodInterest(interestIncomeBeforeSettlement - newEodPayPosition.TdCloseInterest); - newEodPayPosition.InterestFeeSum = RoundEodInterest(interestFeeBeforeSettlement - newEodPayPosition.TdCloseInterestFee); + newEodPayPosition.InterestIncomeSum = EodPnlCalculator.RoundEodInterest(interestIncomeBeforeSettlement - newEodPayPosition.TdCloseInterest); + newEodPayPosition.InterestFeeSum = EodPnlCalculator.RoundEodInterest(interestFeeBeforeSettlement - newEodPayPosition.TdCloseInterestFee); } newEodPayPosition.InterestProfitSum = newEodPayPosition.InterestIncomeSum + newEodPayPosition.InterestFeeSum; //持仓价值 newEodPayPosition.SwapPositionValue = PositionValueCalc.Calc(newEodPayPosition.InterestProfitSum, newEodPayPosition.PosiProfitSum, (int)ratio); //累计已实现 - newEodPayPosition.RealizedInterest = eodPayPosition.RealizedInterest + newEodPayPosition.TdCloseInterest * ratio; - newEodPayPosition.RealizedInterestFee = eodPayPosition.RealizedInterestFee + newEodPayPosition.TdCloseInterestFee; + var rolled = InterestIncomeCalc.RollRealized(eodPayPosition.RealizedInterest, eodPayPosition.RealizedInterestFee, newEodPayPosition.TdCloseInterest, newEodPayPosition.TdCloseInterestFee, ratio); + newEodPayPosition.RealizedInterest = rolled.Interest; + newEodPayPosition.RealizedInterestFee = rolled.Fee; SetFixedLegRealizedPnl(newEodPayPosition); var currencyRate = GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, valueDate, true, - position.InterestDirection == (int)SwapDirectionEnum.收取 ? CurrencyRateType.Buy : CurrencyRateType.Sell); + DirectionRatio.RateType(position.InterestDirection)); newEodPayPosition.TdCurrency = Convert.ToDecimal(currencyRate); PersistEodSwapPosition(newEodPayPosition); } @@ -1176,11 +1120,7 @@ namespace YLErp.Modules.SwapModule var tradeExtend = td.trade_extend.ExtendObj; decimal posiNotionalValue = posiLongNotional + posiShortNational; decimal closePercent = 1; - decimal ratio = position.InterestDirection == (int)SwapDirectionEnum.收取 ? 1m : -1m;//收取为正,支付为负 - if (MarginModes.Contains(position.InterestMode)) - { - ratio = -ratio; - } + var ratio = DirectionRatio.InterestLegPnl(position.InterestDirection, position.InterestMode); if (eodPayPosition == null) { eodPayPosition = new eod_swap_position(); @@ -1198,7 +1138,7 @@ namespace YLErp.Modules.SwapModule positions.Add(position); List preEodPositions = new List(); preEodPositions.Add(eodPayPosition); - var interestModes = new List() { (int)InterestModeEnum.固定值, (int)InterestModeEnum.初始预付金, (int)InterestModeEnum.追加预付金 }; + var interestModes = MarginModes.FixedAmountAndMargin; if (interestModes.Contains(position.InterestMode)) { orginPv = eodPayPosition.InterestPrincipalFix; @@ -1211,8 +1151,8 @@ namespace YLErp.Modules.SwapModule // 日终快照仍使用上面的高精度应结金额计算待实现尾差,避免把舍入差提前丢掉。 interests.ForEach(x => { - x.InterestAmount = RoundMoney(x.InterestAmount); - x.InterestClosePnL = RoundMoney(x.InterestClosePnL); + x.InterestAmount = EodPnlCalculator.RoundMoney(x.InterestAmount); + x.InterestClosePnL = EodPnlCalculator.RoundMoney(x.InterestClosePnL); }); decimal settledInterestAmount = interests.Sum(x => x.InterestAmount); @@ -1249,20 +1189,21 @@ namespace YLErp.Modules.SwapModule // 到期自动互换是最后一次自动结算:两位实际金额已落流水/资金,待实现不再滚入下一日。 newEodPayPosition.InterestIncomeSum = isMaturityFinalAutoSettlement ? 0 - : RoundEodInterest(interestAmountBeforeSettlement - settledInterestAmount); + : EodPnlCalculator.RoundEodInterest(interestAmountBeforeSettlement - settledInterestAmount); newEodPayPosition.InterestFeeSum = isMaturityFinalAutoSettlement ? 0 - : RoundEodInterest(eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee - newEodPayPosition.TdCloseInterestFee); + : EodPnlCalculator.RoundEodInterest(eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee - newEodPayPosition.TdCloseInterestFee); newEodPayPosition.InterestProfitSum = newEodPayPosition.InterestIncomeSum + newEodPayPosition.InterestFeeSum; //持仓价值 newEodPayPosition.SwapPositionValue = PositionValueCalc.Calc(newEodPayPosition.InterestProfitSum, newEodPayPosition.PosiProfitSum, (int)ratio); //累计已实现 - newEodPayPosition.RealizedInterest = eodPayPosition.RealizedInterest + newEodPayPosition.TdCloseInterest * ratio; - newEodPayPosition.RealizedInterestFee = eodPayPosition.RealizedInterestFee + newEodPayPosition.TdCloseInterestFee; + var rolled = InterestIncomeCalc.RollRealized(eodPayPosition.RealizedInterest, eodPayPosition.RealizedInterestFee, newEodPayPosition.TdCloseInterest, newEodPayPosition.TdCloseInterestFee, ratio); + newEodPayPosition.RealizedInterest = rolled.Interest; + newEodPayPosition.RealizedInterestFee = rolled.Fee; SetFixedLegRealizedPnl(newEodPayPosition); var currencyRate = GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, valueDate, true, - position.InterestDirection == (int)SwapDirectionEnum.收取 ? CurrencyRateType.Buy : CurrencyRateType.Sell); + DirectionRatio.RateType(position.InterestDirection)); newEodPayPosition.TdCurrency = Convert.ToDecimal(currencyRate); PersistEodSwapPosition(newEodPayPosition); Log.Info($"the last newEodPayPosition is {JsonHelper.Serialize(newEodPayPosition, false)}"); @@ -1293,14 +1234,15 @@ namespace YLErp.Modules.SwapModule var tradeExtend = td.trade_extend.ExtendObj; // oriPosiNotionalValue 是平仓前规模,posiNotionalValue 是收盘后剩余规模,closeNational 是本次关闭规模。 // 例如 30% 平仓:303139117.80 = 212197382.46 + 90941735.34。 + // 注意:此处的 posiNotionalValue 与盘中 GetUnwindInterests 传给 GetInterests 的语义不同: + // 盘中传平仓前的当前剩余本金,EOD 此处传平仓后的剩余本金;后面又以 closePercent=1 + // 调用共享计息器。因此策略的 "posiNotional × closePercent" 在本例会得到 212197382.46, + // 而本次实际应结的平仓本金是 closeNational=90941735.34。该语义错位由 + // SwapDealService.GetInterests 的模式2无条件修正、模式9全平零值兜底分流处理,不能删除。 decimal oriPosiNotionalValue = posiLongNotional + posiShortNational + closeNational; decimal posiNotionalValue = posiLongNotional + posiShortNational; // ratio 只负责把腿内原始金额转换为本方盈亏方向,不参与计息金额本身的计算。 - decimal ratio = position.InterestDirection == (int)SwapDirectionEnum.收取 ? 1m : -1m;//收取为正,支付为负 - if (MarginModes.Contains(position.InterestMode)) - { - ratio = -ratio; - } + var ratio = DirectionRatio.InterestLegPnl(position.InterestDirection, position.InterestMode); // 首次日终结算可能包含当日收盘,因此尚无先前的日终利息持仓。 // 部分平仓仍要续接上一日日终:CalcUnwindInterest 会将 InterestProfitSum // 加入本次待实现,已实现字段也必须按日累计,不能从新建的临时对象重新开始。 @@ -1334,7 +1276,7 @@ namespace YLErp.Modules.SwapModule newEodPayPosition = eodPayPosition.Clone(); newEodPayPosition.id = 0; } - var interestModes = new List() { (int)InterestModeEnum.固定值, (int)InterestModeEnum.初始预付金, (int)InterestModeEnum.追加预付金 }; + var interestModes = MarginModes.FixedAmountAndMargin; if (interestModes.Contains(position.InterestMode)) { orginPv = eodPayPosition.InterestPrincipalFix; @@ -1360,6 +1302,8 @@ namespace YLErp.Modules.SwapModule List preEodPositions = new List(); preEodPositions.Add(eodPayPosition); var calcLast = tradeExtend?.InterestCalcMode?.EndsWith("1") ?? true; + // 此处 closePercent=1 表示 EOD 计算本次事件时走全额结息;它不是 closeNational / oriPosiNotionalValue。 + // 与上方“收盘后剩余本金”同时传入会触发共享计息器的模式2/9本金修正,见 GetInterests。 var interests = CalcSwapInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, posiNotionalValue, posiLongNotional, posiShortNational, closeNational, 1, eventType, false, true, grossPrice, orginPv, true, settment: false, newCalcLast: autoSwap || calcLast); // TdInterestAmount:计息器返回的全腿当日/累计参考值,用于拆出 EOD 的当日新增。 // interestAmountBeforeSettlement:本次事件发生前理论应结的高精度利息。 @@ -1370,11 +1314,11 @@ namespace YLErp.Modules.SwapModule decimal autoSettledInterestAmount = 0m; if (autoSwap && interests.Count > 0) { - autoSettledInterestAmount = RoundMoney(interestAmountBeforeSettlement - manualSettledInterestAmount); + autoSettledInterestAmount = EodPnlCalculator.RoundMoney(interestAmountBeforeSettlement - manualSettledInterestAmount); var autoInterest = interests[0]; autoInterest.InterestAmount = autoSettledInterestAmount; autoInterest.InterestClosePnL = autoSettledInterestAmount - * (autoInterest.InterestDirection == (int)SwapDirectionEnum.收取 ? 1m : -1m); + * DirectionRatio.ReceivePay(autoInterest.InterestDirection); } newEodPayPosition.ValueDate = valueDate; newEodPayPosition.PositionId = position.id; @@ -1420,11 +1364,9 @@ namespace YLErp.Modules.SwapModule newEodPayPosition.TdCloseInterest = manualSettledInterestAmount + autoSettledInterestAmount; // intersetAcmount 是收盘后本金的一天应计展示值。算尾部分平仓时,下面的复利分支会改用 // 平仓前全额本金重算当天新增,但跨日携带的 TdInterestPrincipal 仍只能是剩余本金。 - var intersetAcmount = newEodPayPosition.TdInterestPrincipal * (newEodPayPosition.TdInterestRate + newEodPayPosition.FloatRate); - if (position.IsAnnualized) - { - intersetAcmount /= tradeExtend.AnnualDays; - } + var intersetAcmount = InterestIncomeCalc.DailyAccrual( + newEodPayPosition.TdInterestPrincipal, newEodPayPosition.TdInterestRate, newEodPayPosition.FloatRate, + newEodPayPosition.IsAnnualized, tradeExtend.AnnualDays); newEodPayPosition.TdInterestIncome = autoSwap ? intersetAcmount : !hasPreviousEod @@ -1474,12 +1416,9 @@ namespace YLErp.Modules.SwapModule var accrualPrincipal = calcLast ? fullPrincipal : newEodPayPosition.TdInterestPrincipal; - newEodPayPosition.TdInterestIncome = accrualPrincipal - * (newEodPayPosition.TdInterestRate + newEodPayPosition.FloatRate); - if (position.IsAnnualized) - { - newEodPayPosition.TdInterestIncome /= tradeExtend.AnnualDays; - } + newEodPayPosition.TdInterestIncome = InterestIncomeCalc.DailyAccrual( + accrualPrincipal, newEodPayPosition.TdInterestRate, newEodPayPosition.FloatRate, + newEodPayPosition.IsAnnualized, tradeExtend.AnnualDays); } if (!autoSwap && closePercent > 0m && closePercent < 1m @@ -1505,13 +1444,13 @@ namespace YLErp.Modules.SwapModule // InterestIncomeSum 是收盘后仍未结算的尾差/剩余利息。 // 部分平仓:扣款前待实现 - TdCloseInterest;最终全平且两位金额已覆盖时直接清零。 newEodPayPosition.InterestIncomeSum = closePercent == 1 - && RoundMoney(pendingInterestBeforeSettlement) == RoundMoney(newEodPayPosition.TdCloseInterest) + && EodPnlCalculator.RoundMoney(pendingInterestBeforeSettlement) == EodPnlCalculator.RoundMoney(newEodPayPosition.TdCloseInterest) ? 0m - : RoundEodInterest(pendingInterestBeforeSettlement - newEodPayPosition.TdCloseInterest); + : EodPnlCalculator.RoundEodInterest(pendingInterestBeforeSettlement - newEodPayPosition.TdCloseInterest); newEodPayPosition.InterestFeeSum = closePercent == 1 - && RoundMoney(pendingInterestFeeBeforeSettlement) == RoundMoney(newEodPayPosition.TdCloseInterestFee) + && EodPnlCalculator.RoundMoney(pendingInterestFeeBeforeSettlement) == EodPnlCalculator.RoundMoney(newEodPayPosition.TdCloseInterestFee) ? 0m - : RoundEodInterest(pendingInterestFeeBeforeSettlement - newEodPayPosition.TdCloseInterestFee); + : EodPnlCalculator.RoundEodInterest(pendingInterestFeeBeforeSettlement - newEodPayPosition.TdCloseInterestFee); //持仓内容-利息腿-损益统计(本方视角) // InterestProfitSum 是利息腿待实现总额,包含利息和费用;无费用时等于 InterestIncomeSum。 newEodPayPosition.InterestProfitSum = newEodPayPosition.InterestIncomeSum + newEodPayPosition.InterestFeeSum; @@ -1525,11 +1464,12 @@ namespace YLErp.Modules.SwapModule //累计已实现 // RealizedInterest 只增不回滚:上日累计已实现 + 当日结息按方向后的金额。 // 收取腿的 -37119.14 会把累计已实现更新为 -37119.14;后续普通 EOD 保持该值。 - newEodPayPosition.RealizedInterest = eodPayPosition.RealizedInterest + newEodPayPosition.TdCloseInterest * ratio; - newEodPayPosition.RealizedInterestFee = eodPayPosition.RealizedInterestFee + newEodPayPosition.TdCloseInterestFee; + var rolled = InterestIncomeCalc.RollRealized(eodPayPosition.RealizedInterest, eodPayPosition.RealizedInterestFee, newEodPayPosition.TdCloseInterest, newEodPayPosition.TdCloseInterestFee, ratio); + newEodPayPosition.RealizedInterest = rolled.Interest; + newEodPayPosition.RealizedInterestFee = rolled.Fee; SetFixedLegRealizedPnl(newEodPayPosition); var currencyRate = GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, valueDate, true, - position.InterestDirection == (int)SwapDirectionEnum.收取 ? CurrencyRateType.Buy : CurrencyRateType.Sell); + DirectionRatio.RateType(position.InterestDirection)); newEodPayPosition.TdCurrency = Convert.ToDecimal(currencyRate); Log.Info($"即将插入数据库的 newEodPayPosition is {JsonHelper.Serialize(newEodPayPosition, false)}"); PersistEodSwapPosition(newEodPayPosition); @@ -1549,7 +1489,7 @@ namespace YLErp.Modules.SwapModule Log.Info($"eodPayPosition is {JsonHelper.Serialize(eodPayPosition, false)},newEodPayPosition is {JsonHelper.Serialize(newEodPayPosition, false)}"); List intervals = position.SwapIntervalList; var tradeExtend = td.trade_extend.ExtendObj; - var interestModes = new List() { (int)InterestModeEnum.固定值, (int)InterestModeEnum.初始预付金, (int)InterestModeEnum.追加预付金 }; + var interestModes = MarginModes.FixedAmountAndMargin; if (eodPayPosition == null) { //if (position.PosiStartDate > valueDate) @@ -1604,11 +1544,7 @@ namespace YLErp.Modules.SwapModule { closePercent = 1; } - decimal ratio = eodPayPosition.InterestDirection == (int)SwapDirectionEnum.收取 ? 1m : -1m;//收取为正,支付为负 - if (MarginModes.Contains(position.InterestMode)) - { - ratio = -ratio; - } + var ratio = DirectionRatio.InterestLegPnl(eodPayPosition.InterestDirection, position.InterestMode); List positions = new List { position @@ -1647,11 +1583,12 @@ namespace YLErp.Modules.SwapModule newEodPayPosition.SwapPositionValue = PositionValueCalc.Calc(newEodPayPosition.InterestProfitSum, newEodPayPosition.PosiProfitSum, (int)ratio); //累计已实现 - newEodPayPosition.RealizedInterest = eodPayPosition.RealizedInterest + newEodPayPosition.TdCloseInterest * ratio; - newEodPayPosition.RealizedInterestFee = eodPayPosition.RealizedInterestFee + newEodPayPosition.TdCloseInterestFee; + var rolled = InterestIncomeCalc.RollRealized(eodPayPosition.RealizedInterest, eodPayPosition.RealizedInterestFee, newEodPayPosition.TdCloseInterest, newEodPayPosition.TdCloseInterestFee, ratio); + newEodPayPosition.RealizedInterest = rolled.Interest; + newEodPayPosition.RealizedInterestFee = rolled.Fee; SetFixedLegRealizedPnl(newEodPayPosition); var currencyRate = GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, valueDate, true, - eodPayPosition.InterestDirection == (int)SwapDirectionEnum.收取 ? CurrencyRateType.Buy : CurrencyRateType.Sell); + DirectionRatio.RateType(eodPayPosition.InterestDirection)); newEodPayPosition.TdCurrency = Convert.ToDecimal(currencyRate); Log.Info($"the last newEodPayPosition is {JsonHelper.Serialize(newEodPayPosition, false)}"); PersistEodSwapPosition(newEodPayPosition); @@ -1692,7 +1629,7 @@ namespace YLErp.Modules.SwapModule bool open) { payQty = Math.Abs(payQty); - int ratio = eventFlow.PayDirection == (int)SwapDirectionEnum.收取 ? 1 : -1;//收取为正,支付为负 + int ratio = DirectionRatio.ReceivePay(eventFlow.PayDirection);//收取为正,支付为负 int shortRatio = DirectionRatio.LongShort(newEodPayPosition.PositionType); newEodPayPosition.ValueDate = eventFlow.PayDate.Value; newEodPayPosition.PositionId = eventFlow.PositionId; @@ -1740,7 +1677,7 @@ namespace YLErp.Modules.SwapModule newEodPayPosition.TdPosiDividend = Math.Round(dividendIn * ratio, 2); newEodPayPosition.PosiMtmPnL = MtmCalc.UnrealizedPnl(newEodPayPosition.UnderlyingPrice, newEodPayPosition.PosiGrossPrice, newEodPayPosition.PosiQuantity, newEodPayPosition.ContractSize, shortRatio, (int)ratio); newEodPayPosition.PosiDividendSum = Math.Round(newEodPayPosition.TdPosiDividend - newEodPayPosition.TdCloseDividend, 2); - newEodPayPosition.PosiProfitSum = newEodPayPosition.PosiMtmPnL + newEodPayPosition.PosiDividendSum + newEodPayPosition.PosiFeePending; + newEodPayPosition.PosiProfitSum = MtmCalc.ReturnLegProfitSum(newEodPayPosition.PosiMtmPnL, newEodPayPosition.PosiDividendSum, newEodPayPosition.PosiFeePending); //持仓价值 newEodPayPosition.SwapPositionValue = PositionValueCalc.Calc(newEodPayPosition.InterestProfitSum, newEodPayPosition.PosiProfitSum); @@ -1748,7 +1685,7 @@ namespace YLErp.Modules.SwapModule newEodPayPosition.RealizedFee = closeFee; newEodPayPosition.RealizedMtmPnL = newEodPayPosition.TdCloseMtmPnl; newEodPayPosition.RealizedDividend = newEodPayPosition.TdCloseDividend; - SetFloatingRealizedPnl(newEodPayPosition); + EodPnlCalculator.SetFloatingRealizedPnl(newEodPayPosition); newEodPayPosition.PosiStatus = payQty == 0 ? 1 : 0; UpdateDbOption(newEodPayPosition); @@ -1792,7 +1729,7 @@ namespace YLErp.Modules.SwapModule } var dealDate = curretEod.ValueDate; int shortRatio = DirectionRatio.LongShort(eod.PositionType); - int directionRatio = eod.PosiDirection == (int)SwapDirectionEnum.收取 ? 1 : -1; + int directionRatio = DirectionRatio.ReceivePay(eod.PosiDirection); curretEod.PosiStatus = curretEod.PosiQuantity == 0 ? 1 : 0; var price = GetSwapValuationPrice(eod.UnderlyingCode, dealDate, out decimal vobp); curretEod.dv01 = Dv01Helper.CalcDv01(eod.UnderlyingCode, curretEod.PosiQuantity, eod.PosiDirection, eod.PositionType, vobp); @@ -1813,7 +1750,7 @@ namespace YLErp.Modules.SwapModule curretEod.PosiMtmPnL = MtmCalc.UnrealizedPnl(curretEod.UnderlyingPrice, curretEod.PosiGrossPrice, curretEod.PosiQuantity, curretEod.ContractSize, shortRatio, directionRatio); //curretEod.TdPosiDividend = 0; //curretEod.PosiDividendSum = eod.PosiDividendSum + curretEod.TdPosiDividend; - curretEod.PosiProfitSum = curretEod.PosiMtmPnL + curretEod.PosiDividendSum + curretEod.PosiFeePending; + curretEod.PosiProfitSum = MtmCalc.ReturnLegProfitSum(curretEod.PosiMtmPnL, curretEod.PosiDividendSum, curretEod.PosiFeePending); curretEod.TdCloseFee = 0; curretEod.TdCloseQty = 0; curretEod.TdCloseMtmPnl = 0; @@ -1822,9 +1759,9 @@ namespace YLErp.Modules.SwapModule curretEod.RealizedMtmPnL = eod.RealizedMtmPnL + curretEod.TdCloseMtmPnl; curretEod.RealizedDividend = eod.RealizedDividend + curretEod.TdCloseDividend; curretEod.RealizedFee = eod.RealizedFee + curretEod.TdCloseFee; - SetFloatingRealizedPnl(curretEod); + EodPnlCalculator.SetFloatingRealizedPnl(curretEod); var currencyRate = new EodCurrencyRateService(UserInfo).GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, td.StartDate.Value - , seekPreday: true, currencyRateType: curretEod.PosiDirection == (int)SwapDirectionEnum.收取 ? CurrencyRateType.Buy : CurrencyRateType.Sell); + , seekPreday: true, currencyRateType: DirectionRatio.RateType(curretEod.PosiDirection)); curretEod.TdCurrency = Convert.ToDecimal(currencyRate); //持仓价值 curretEod.SwapPositionValue = PositionValueCalc.Calc(curretEod.InterestProfitSum, curretEod.PosiProfitSum); @@ -1837,17 +1774,6 @@ namespace YLErp.Modules.SwapModule return curretEod; } - /// - /// 浮动腿累计已实现盈亏由盯市、分红和费用三个已实现组成项汇总。 - /// 各组成项已经按本方视角落库,此处不再额外转换方向。 - /// - private static void SetFloatingRealizedPnl(eod_swap_position position) - { - position.RealizedPnl = position.RealizedMtmPnL - + position.RealizedDividend - + position.RealizedFee; - } - /// /// 更新虚拟交易费用 /// @@ -1884,7 +1810,7 @@ namespace YLErp.Modules.SwapModule } var dealDate = curretEod.ValueDate; int shortRatio = DirectionRatio.LongShort(eod.PositionType); - int directionRatio = eod.PosiDirection == (int)SwapDirectionEnum.收取 ? 1 : -1; + int directionRatio = DirectionRatio.ReceivePay(eod.PosiDirection); var price = GetSwapValuationPrice(eod.UnderlyingCode, dealDate, out decimal vobp); var todayConsumedDividend = CalcConsumedDividend(curretEod, unwindEvents); var originNotional = (decimal)td.OriginalStockEqvNotional / swapPosition.PosiNetPrice; @@ -1923,16 +1849,16 @@ namespace YLErp.Modules.SwapModule { curretEod.PosiDividendSum = 0; } - SetFloatingRealizedPnl(curretEod); + EodPnlCalculator.SetFloatingRealizedPnl(curretEod); curretEod.SwapPositionValue -= curretEod.TdCloseDividend; - curretEod.PosiProfitSum = curretEod.PosiMtmPnL + curretEod.PosiDividendSum + curretEod.PosiFeePending; + curretEod.PosiProfitSum = MtmCalc.ReturnLegProfitSum(curretEod.PosiMtmPnL, curretEod.PosiDividendSum, curretEod.PosiFeePending); if (curretEod.PosiStatus == 1) { curretEod.PosiNotionalValue = 0; } var currencyRate = new EodCurrencyRateService(UserInfo).GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, td.StartDate.Value - , seekPreday: true, currencyRateType: curretEod.PosiDirection == (int)SwapDirectionEnum.收取 ? CurrencyRateType.Buy : CurrencyRateType.Sell); + , seekPreday: true, currencyRateType: DirectionRatio.RateType(curretEod.PosiDirection)); curretEod.TdCurrency = Convert.ToDecimal(currencyRate); //持仓价值 curretEod.SwapPositionValue = PositionValueCalc.Calc(curretEod.InterestProfitSum, curretEod.PosiProfitSum); @@ -1972,7 +1898,7 @@ namespace YLErp.Modules.SwapModule return; } int shortRatio = DirectionRatio.LongShort(eod.PositionType); - int directionRatio = eod.PosiDirection == (int)SwapDirectionEnum.收取 ? 1 : -1; + int directionRatio = DirectionRatio.ReceivePay(eod.PosiDirection); var unwindFlowEvents = unwindEvents.Where(x => x.EventType == (int)SwapFlowEventTypeEnum.平仓).ToList(); var openFlowEvents = unwindEvents.Where(x => x.EventType == (int)SwapFlowEventTypeEnum.开仓).ToList(); decimal unwindQty = unwindFlowEvents.Sum(s => s.Quantity); @@ -2005,16 +1931,16 @@ namespace YLErp.Modules.SwapModule { posiQty = 0; } - curretEod.PosiGrossPrice = (eod.PosiGrossPrice * eod.PosiQuantity + openFlowEvents.Sum(a => a.Quantity * a.TradingAmountAvg)) / (eod.PosiQuantity + openQty); + curretEod.PosiGrossPrice = MtmCalc.BlendPrice(eod.PosiGrossPrice, eod.PosiQuantity, openFlowEvents.Sum(a => a.Quantity * a.TradingAmountAvg), eod.PosiQuantity + openQty); curretEod.PosiGrossPrice = Math.Round( curretEod.PosiGrossPrice, GetStorageDeliveryPriceRound(curretEod.UnderlyingInstrumentType, curretEod.UnderlyingCode), MidpointRounding.AwayFromZero); - curretEod.PosiNetPrice = (eod.PosiNetPrice * eod.PosiQuantity + openFlowEvents.Sum(a => a.Quantity * a.TradingAmountFeeAvg)) / (eod.PosiQuantity + openQty); + curretEod.PosiNetPrice = MtmCalc.BlendPrice(eod.PosiNetPrice, eod.PosiQuantity, openFlowEvents.Sum(a => a.Quantity * a.TradingAmountFeeAvg), eod.PosiQuantity + openQty); curretEod.PosiNetPrice = Math.Round(curretEod.PosiNetPrice, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); - curretEod.PosiNetNoFeePrice = (eod.PosiNetNoFeePrice * eod.PosiQuantity + openFlowEvents.Sum(a => a.Quantity * a.TradingAmountNetAvg)) / (eod.PosiQuantity + openQty); + curretEod.PosiNetNoFeePrice = MtmCalc.BlendPrice(eod.PosiNetNoFeePrice ?? 0m, eod.PosiQuantity, openFlowEvents.Sum(a => a.Quantity * (a.TradingAmountNetAvg ?? 0m)), eod.PosiQuantity + openQty); curretEod.PosiNetNoFeePrice = Math.Round(curretEod.PosiNetNoFeePrice ?? 0, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); - curretEod.PosiNetFeePrice = (eod.PosiNetFeePrice * eod.PosiQuantity + openFlowEvents.Sum(a => a.Quantity * a.TradingAmountNetFeeAvg)) / (eod.PosiQuantity + openQty); + curretEod.PosiNetFeePrice = MtmCalc.BlendPrice(eod.PosiNetFeePrice ?? 0m, eod.PosiQuantity, openFlowEvents.Sum(a => a.Quantity * (a.TradingAmountNetFeeAvg ?? 0m)), eod.PosiQuantity + openQty); curretEod.PosiNetFeePrice = Math.Round(curretEod.PosiNetFeePrice ?? 0, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); } curretEod.PosiNotionalValue = curretEod.PosiGrossPrice * curretEod.PosiQuantity * curretEod.ContractSize; @@ -2053,7 +1979,7 @@ namespace YLErp.Modules.SwapModule curretEod.PositionId = position.id; curretEod.ClientId = td.ClientId; int shortRatio = DirectionRatio.LongShort(position.PositionType); - int directionRatio = position.PosiDirection == (int)SwapDirectionEnum.收取 ? 1 : -1; + int directionRatio = DirectionRatio.ReceivePay(position.PosiDirection); curretEod.PositionType = position.PositionType; var eod = new eod_swap_position() { @@ -2099,11 +2025,11 @@ namespace YLErp.Modules.SwapModule curretEod.UnderlyingMarketValue = MtmCalc.MarketValue(curretEod.UnderlyingPrice, curretEod.PosiQuantity, curretEod.ContractSize, shortRatio); curretEod.PosiMtmPnL = MtmCalc.UnrealizedPnl(curretEod.UnderlyingPrice, curretEod.PosiGrossPrice, curretEod.PosiQuantity, curretEod.ContractSize, shortRatio, directionRatio); - curretEod.PosiProfitSum = curretEod.PosiMtmPnL + curretEod.PosiDividendSum + curretEod.PosiFeePending; + curretEod.PosiProfitSum = MtmCalc.ReturnLegProfitSum(curretEod.PosiMtmPnL, curretEod.PosiDividendSum, curretEod.PosiFeePending); curretEod.RealizedMtmPnL = curretEod.TdCloseMtmPnl; curretEod.RealizedDividend = curretEod.TdCloseDividend; curretEod.RealizedFee = curretEod.TdCloseFee; - SetFloatingRealizedPnl(curretEod); + EodPnlCalculator.SetFloatingRealizedPnl(curretEod); curretEod.PosiStatus = curretEod.PosiQuantity == 0 ? 1 : 0; if (curretEod.PosiStatus == 1) { @@ -2112,7 +2038,7 @@ namespace YLErp.Modules.SwapModule //持仓价值 curretEod.SwapPositionValue = PositionValueCalc.Calc(curretEod.InterestProfitSum, curretEod.PosiProfitSum); var currencyRate = new EodCurrencyRateService(UserInfo).GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, td.StartDate.Value - , seekPreday: true, currencyRateType: curretEod.PosiDirection == (int)SwapDirectionEnum.收取 ? CurrencyRateType.Buy : CurrencyRateType.Sell); + , seekPreday: true, currencyRateType: DirectionRatio.RateType(curretEod.PosiDirection)); curretEod.TdCurrency = Convert.ToDecimal(currencyRate); UpdateDbOption(curretEod); curretEod.Invalid = false; @@ -2187,8 +2113,6 @@ namespace YLErp.Modules.SwapModule var positions = eodSwapPositions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//持仓腿 // 框架合约的方向约定:多头为正、空头为负;总名义本金取交易原始规模, // 不能直接用多空腿相加,否则会把对冲方向误当成合约规模变化。 - eod_Swap.NotionalValueLong = Math.Round(positions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.PosiNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - eod_Swap.NotionalValueShort = Math.Round(-Math.Abs(positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue)), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); eod_Swap.NotionalValue = Math.Round(Convert.ToDecimal(td.OriginalStockEqvNotional ?? td.StockEqvNotional), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); eod_Swap.SwapTradeId = td.id; eod_Swap.SwapTradeNo = td.TradeNumber; @@ -2196,23 +2120,8 @@ namespace YLErp.Modules.SwapModule eod_Swap.BookId = td.AssetId; eod_Swap.ValueDate = settleDate; eod_Swap.StructureType = td.StructureType; - 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); - eod_Swap.dv01 = positions.Sum(s => s.dv01 ?? 0); - decimal interestPnL = 0; - // 利息腿按我方视角归集。保证金腿的利息现金流方向与普通利息腿相反, - // 因此保证金腿需要额外反转符号,确保 InterestPnL 表示我方的合约利率端收益。 - interestPositions.ForEach(x => - { - decimal ratio = x.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1;//收取为正,支付为负 - if (MarginModes.Contains(x.InterestMode)) - { - ratio = -ratio; - } - interestPnL += x.InterestProfitSum * ratio; - }); - eod_Swap.InterestPnL = interestPnL; + EodPnlCalculator.FillPositionLegSummary(eod_Swap, positions); + eod_Swap.InterestPnL = EodPnlCalculator.SumInterestPnL(interestPositions); eod_Swap.PostionValue = eodSwapPositions.Sum(s => s.SwapPositionValue); // 保证金腿的利息现金流方向与保证金本金方向相反。 // 不能直接汇总 RealizedPnl,否则“收取客户保证金”的腿会把应支付给客户的 @@ -2265,33 +2174,15 @@ namespace YLErp.Modules.SwapModule var interestPositions = eodSwapPositions.Where(x => string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//利息腿 var positions = eodSwapPositions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//持仓腿 eod_Swap.NotionalValue = Math.Round(Convert.ToDecimal(td.OriginalStockEqvNotional ?? td.StockEqvNotional), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - eod_Swap.NotionalValueLong = Math.Round(positions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.PosiNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - eod_Swap.NotionalValueShort = Math.Round(-Math.Abs(positions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue)), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); - 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); - eod_Swap.dv01 = positions.Sum(s => s.dv01 ?? 0); - interestPositions.ForEach(x => - { - decimal ratio = x.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1;//收取为正,支付为负 - if (MarginModes.Contains(x.InterestMode)) - { - ratio = -ratio; - } - eod_Swap.InterestPnL += x.InterestProfitSum * ratio; - }); + EodPnlCalculator.FillPositionLegSummary(eod_Swap, positions); + eod_Swap.InterestPnL += EodPnlCalculator.SumInterestPnL(interestPositions); eodSwapPositions.ForEach(x => { - decimal ratio = x.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1;//收取为正,支付为负 - if (MarginModes.Contains(x.InterestMode)) - { - ratio = -ratio; - } + var ratio = DirectionRatio.InterestLegPnl(x.InterestDirection, x.InterestMode); eod_Swap.TdRealizedPnL += x.TdCloseMtmPnl + x.TdCloseDividend + x.TdCloseFee + x.TdCloseInterest * ratio + x.TdCloseInterestFee; }); eod_Swap.PostionValue = eodSwapPositions.Sum(s => s.SwapPositionValue); eod_Swap.RealizedPnL = eodSwapPositions.Sum(CalculateSwapRealizedPnl); - eod_Swap.TdCloseQty = positions.Sum(s => s.TdCloseQty); var tradeInitMarginObj = DbContext.trade_initial_margin.FirstOrDefault(x => x.TradeId == td.id); var initMarginList = interestPositions.Where(x => x.InterestMode == (int)InterestModeEnum.初始预付金 && x.HappenDate == settleDate).ToList(); var addMarginList = interestPositions.Where(x => x.InterestMode == (int)InterestModeEnum.追加预付金 && x.HappenDate == settleDate).ToList(); @@ -2310,20 +2201,7 @@ namespace YLErp.Modules.SwapModule /// 我方支付给对手方的成本计入,而不会错误增加框架合约已实现收益。 /// 抽为静态纯函数以支持无库单测(marginTypes 等价于 ConsTrade.InterestMarginModels)。 /// - public static decimal CalculateSwapRealizedPnl(eod_swap_position position) - { - var interestRatio = position.InterestDirection == (int)SwapDirectionEnum.收取 ? 1m : -1m; - if (MarginModes.Contains(position.InterestMode)) - { - interestRatio = -interestRatio; - } - - return position.RealizedMtmPnL - + position.RealizedDividend - + position.RealizedFee - + position.RealizedInterest * interestRatio - + position.RealizedInterestFee; - } + public static decimal CalculateSwapRealizedPnl(eod_swap_position position) => EodPnlCalculator.CalculateSwapRealizedPnl(position); /// /// 风险报表符号归一化:把历史两种符号口径的 TdCloseInterest/RealizedInterest @@ -2332,24 +2210,7 @@ namespace YLErp.Modules.SwapModule /// 抽为 public static 纯函数以支持无库单测(见 SwapReportInterestSignNormalizeTest)。 /// 仅当 InterestDirection > 0 时执行(与原内联逻辑等价)。 /// - public static void NormalizeInterestSignForReport(eod_swap_position position) - { - if (position.InterestDirection <= 0) return; - - var interestRatio = position.InterestDirection == (int)SwapDirectionEnum.收取 ? 1m : -1m; - if (MarginModes.Contains(position.InterestMode)) - { - interestRatio = -interestRatio; - } - else if (position.InterestMode == (int)InterestModeEnum.标的期初全价) - { - return; - } - position.TdCloseInterest = Math.Abs(position.TdCloseInterest) * interestRatio; - position.RealizedInterest = Math.Abs(position.RealizedInterest) * interestRatio; - // 兼容修复前已落库的利息腿:当时只累计了明细字段,未同步写入 RealizedPnl。 - position.RealizedPnl = position.RealizedInterest + position.RealizedInterestFee; - } + public static void NormalizeInterestSignForReport(eod_swap_position position) => EodPnlCalculator.NormalizeInterestSignForReport(position); /// /// 获取多空组合 平仓详细 @@ -2838,11 +2699,11 @@ namespace YLErp.Modules.SwapModule var floatRateInterest = eodInterests.Where(x => !string.IsNullOrEmpty(x.FloatRateUnderlyingCode)).FirstOrDefault(); item.position.FloatRateUnderlyingCode = floatRateInterest?.FloatRateUnderlyingCode; item.position.FloatRate = floatRateInterest?.FloatRate ?? 0; - 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.OpenMarginAmount = initialMargins.Sum(s => s.InterestPrincipalFix * DirectionRatio.ReceivePay(s.InterestDirection)); + item.OpenMarginRate = EodPnlCalculator.CalculateWeightedMarginRate(tradeMargins); + item.AdditionalMarginAmount = additionalMargins.Sum(s => s.InterestPrincipalFix * DirectionRatio.ReceivePay(s.InterestDirection)); item.MarginInterestAmount = CalculateWeightedMarginInterest(eodMargins); - item.InterestAmount = eodInterests.Sum(s => s.InterestIncomeSum * (s.InterestDirection == (int)SwapDirectionEnum.收取 ? -1 : 1)); + item.InterestAmount = eodInterests.Sum(s => s.InterestIncomeSum * (-DirectionRatio.ReceivePay(s.InterestDirection))); item.InterestRate = eodInterests.Sum(s => s.InterestRateDefault); // 到期轧差才把期间付息/分红并入净额结算;派息日支付已在现金流层独立结算,不能重复计入估值。 var nettingDividend = (tradeExtend?.ExtendObj?.DividendPayDate ?? 1) == 0 ? pendingDividend : 0m; @@ -2864,29 +2725,12 @@ namespace YLErp.Modules.SwapModule 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; - } - /// /// 计算预付金利息金额。InterestIncomeSum 已是各腿利息金额, /// 按收取为正、支付为负直接轧差求和,不做本金加权。 /// 抽为 public static 纯函数以支持无库单测(见 SwapWeightedMarginInterestTest)。 /// - public static decimal CalculateWeightedMarginInterest(IEnumerable margins) - { - return margins.Sum(x => - x.InterestIncomeSum * (x.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1)); - } + public static decimal CalculateWeightedMarginInterest(IEnumerable margins) => EodPnlCalculator.CalculateWeightedMarginInterest(margins); /// /// 固定利息腿的累计已实现盈亏 = 累计已实现利息 + 累计已实现利息费用。 @@ -2894,10 +2738,7 @@ namespace YLErp.Modules.SwapModule /// 抽为 public static 纯函数以支持无库单测(见 SwapFixedLegRealizedPnlTest), /// 并消除复制粘贴带来的笔误风险(如 L1296 历史双分号)。 /// - public static void SetFixedLegRealizedPnl(eod_swap_position position) - { - position.RealizedPnl = position.RealizedInterest + position.RealizedInterestFee; - } + public static void SetFixedLegRealizedPnl(eod_swap_position position) => EodPnlCalculator.SetFixedLegRealizedPnl(position); /// /// 将数据库中以公司/交易簿记方向保存的日终字段转换为客户视角。 /// 该转换必须在拆分浮动收益、费用和期间付息/分红之前完成, diff --git a/YLErpDAL/Modules/SwapModule/TradingFeeCalc.cs b/YLErpDAL/Modules/SwapModule/TradingFeeCalc.cs new file mode 100644 index 00000000..3c9cea1c --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/TradingFeeCalc.cs @@ -0,0 +1,43 @@ +using YLErp.DBModels; +using YLErp.DBModels.Consts; + +namespace YLErp.Modules.SwapModule; + +/// +/// 平仓手续费计算——纯 static,无 this 依赖。 +/// 从 SwapDealService 提取,零行为变更。 +/// +public static class TradingFeeCalc +{ + public static decimal CalcInitTradingFee(swap_position oriPosition, UnwindData unwindData) + { + if (oriPosition == null || unwindData == null) + { + return 0; + } + + if (oriPosition.PosiFeeType == 1) + { + return Math.Round(oriPosition.PosiTradingFeeUnit * unwindData.CloseQty, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + } + + return Math.Round(oriPosition.PosiTradingFeeUnit / 100m * unwindData.CloseNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + } + + public static decimal CalcInitTradingFeePending(swap_position oriPosition, swap_position position, UnwindData unwindData) + { + if (oriPosition == null || unwindData == null || oriPosition.PosiTradingFeeUnit == 0) + { + return position?.PosiTradingFeePending ?? 0; + } + + var closeBase = oriPosition.PosiFeeType == 1 ? unwindData.CloseQty : unwindData.CloseNotionalValue; + var originalBase = oriPosition.PosiFeeType == 1 ? unwindData.NotionalQty : unwindData.NotionalValue; + if (originalBase <= 0) + { + return position?.PosiTradingFeePending ?? 0; + } + + return Math.Round(oriPosition.PosiTradingFeePending * closeBase / originalBase, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + } +} diff --git a/YLErpDAL/Modules/SwapModule/UnwindNormalizer.cs b/YLErpDAL/Modules/SwapModule/UnwindNormalizer.cs new file mode 100644 index 00000000..63db255c --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/UnwindNormalizer.cs @@ -0,0 +1,104 @@ +using YLErp.Helpers; +using YLErp.Modules.SwapModule.Margin; + +namespace YLErp.Modules.SwapModule; + +/// +/// 平仓数据(UnwindData)规范化——纯 static,无 this 依赖。 +/// 从 SwapDealService 提取,零行为变更。 +/// +internal static class UnwindNormalizer +{ + internal static void NormalizeNotionalValues(UnwindData unwindData) + { + unwindData.NotionalValue = Math.Round(unwindData.NotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + unwindData.PosiNotionalValue = Math.Round(unwindData.PosiNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + unwindData.CloseNotionalValue = Math.Round(unwindData.CloseNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + } + + internal static bool NormalizeFullCloseRequest(UnwindData unwindData) + { + if (unwindData.CloseMethod != (int)CloseMethodEnum.全部平仓 + && unwindData.ClosePercent < 1 + && !(unwindData.PositionQty > 0 && unwindData.CloseQty >= unwindData.PositionQty) + && !(unwindData.PosiNotionalValue > 0 && unwindData.CloseNotionalValue >= unwindData.PosiNotionalValue)) + { + return false; + } + + var closeQty = unwindData.CloseQty; + var closeNotionalValue = unwindData.CloseNotionalValue; + unwindData.ClosePercent = 1; + if (unwindData.PositionQty > 0) unwindData.CloseQty = unwindData.PositionQty; + if (unwindData.PosiNotionalValue > 0) unwindData.CloseNotionalValue = unwindData.PosiNotionalValue; + return closeQty != unwindData.CloseQty || closeNotionalValue != unwindData.CloseNotionalValue; + } + + internal static void RecalculateNormalizedUnwindAmounts(UnwindData unwindData) + { + var floatLeg = unwindData.FlowEvents.FirstOrDefault(x => !string.IsNullOrEmpty(x.UnderlyingCode)); + if (floatLeg == null || floatLeg.PosiGrossPrice == 0) return; + + var input = new UnwindInput + { + Multiplier = ConsGlobal.InstrumentType.IsBond(floatLeg.UnderlyingInstrumentType) ? 100 : 1, + PosiGrossPrice = floatLeg.PosiGrossPrice, + TradingAmountAvg = floatLeg.TradingAmountAvg, + CloseQty = unwindData.CloseQty, + PositionQty = unwindData.PositionQty, + ContractSize = floatLeg.ContractSize, + CloseNotionalValue = unwindData.CloseNotionalValue, + PayDirection = floatLeg.PayDirection, + PositionType = floatLeg.PositionType, + TradingFee = floatLeg.TradingFee.ToString(), + TradingFeePending = floatLeg.TradingFeePending.ToString(), + DividendIn = floatLeg.DividendIn.ToString() + }; + foreach (var leg in unwindData.FlowEvents.Where(x => string.IsNullOrEmpty(x.UnderlyingCode))) + { + var target = MarginModes.Contains(leg.InterestMode) + ? input.MarginLegs + : input.InterestLegs; + target.Add(new LegInput { InterestClosePnL = leg.InterestClosePnL }); + } + + var result = FrontendCalcReference.CalcUnwind(input); + floatLeg.MarkClosePnl = result.MarkClosePnl; + unwindData.SwapCloseAmount = result.SwapCloseAmount; + unwindData.SwapRealizedPnL = result.SwapRealizedPnL; + unwindData.SwapMarginRebatePnl = result.SwapMarginRebatePnl; + } + + internal static bool IsFullCloseAfterDeduction(UnwindData unwindData, double remainingNotional, double remainingQuantity) + { + return unwindData.ClosePercent == 1 || (remainingNotional == 0 && remainingQuantity == 0); + } + + /// + /// 手工平仓、手工互换及收益结算的利息事件按金额两位落库。 + /// 自动平仓保留原有计算与落库口径,不适用本阶段的手工结算规则。 + /// + internal static bool NormalizeSettledInterestAmounts(IEnumerable flowEvents, int eventType, string eventReason) + { + if ((eventType != (int)SwapEventTypeEnum.平仓 && eventType != (int)SwapEventTypeEnum.互换) + || eventReason == "系统操作_自动平仓") + { + return false; + } + + foreach (var flowEvent in flowEvents.Where(x => string.IsNullOrEmpty(x.UnderlyingCode))) + { + flowEvent.InterestPrincipal = Math.Round(flowEvent.InterestPrincipal, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + flowEvent.InterestAmount = Math.Round(flowEvent.InterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + flowEvent.TdInterestAmount = Math.Round(flowEvent.TdInterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + flowEvent.InterestClosePnL = Math.Round(flowEvent.InterestClosePnL, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + flowEvent.InterestFee = Math.Round(flowEvent.InterestFee, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + } + return true; + } + + internal static void NormalizeEventUnwindDate(UnwindData unwindData) + { + unwindData.UnwindDate = unwindData.ValueDate; + } +} diff --git a/YLErpDAL/Modules/SwapModule/docs/eod-continuation-proposal.md b/YLErpDAL/Modules/SwapModule/docs/eod-continuation-proposal.md new file mode 100644 index 00000000..3f31c83c --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/docs/eod-continuation-proposal.md @@ -0,0 +1,66 @@ +# 任务 4:盘中复利从 EOD 续接(而非 PosiStartDate 全程重放) + +## 状态:待立项(高风险,需专项验证) + +## 现状 + +`CalcDailyCompoundInterest` 从 `position.PosiStartDate` 全程重放到 `endDate`,每个重置日把累计利息并入本金(复利),最后扣 `consumedInterest * closePercent`。 + +**调用链**:`InitSwapDealInterest` → `CalcDailyCompoundInterest(endDate, PosiStartDate→endDate 全程重放)` + +**问题**:交易存续期长(数月~数年)时,每次盘中平仓都从起息日重放,计算量随天数线性增长。 + +## 提议 + +改为从上一日终快照(`preEodPosition`)续接: +- 起点 = `preEodPosition.ValueDate + 1` +- 初始本金 = `preEodPosition.TdInterestPrincipal`(已含历史滚入利息) +- 只算 `ValueDate+1` 到 `endDate` 的增量利息 + +## 风险分析(为什么不能直接改) + +### 风险 1:并本金起点不同导致终值不等 + +| | 全程重放(当前) | EOD 续接(提议) | +|---|---|---| +| 起点 | `principal`(原始平仓名义本金) | `preEod.TdInterestPrincipal`(已滚利息) | +| 滚法 | 每段 `basis = principal + accrued` | 每段 `basis = preEod.TdInterestPrincipal + segmentAccrued` | + +两段路径在**中间重置日的四舍五入路径不同**(精度 12 的 Round 作用在不同的中间值上),终值**不一定逐分相等**。 + +### 风险 2:consumedInterest 语义翻转 + +- 全程重放:总利息 - consumedInterest × closePercent = 增量 +- EOD 续接:直接算增量,**不需要**扣 consumedInterest + +如果 EOD 快照的 `InterestIncomeSum` 与 consumedInterest 口径不完全一致,直接去掉扣减会引入误差。 + +### 风险 3:resetCarryInterest 耦合 + +当前逻辑:`resetCarryInterest`(上一日终待实现 × remainingPercent)只在 `endDate` 恰好是重置日时并入本金。EOD 续接模式下,重置日的判定、remainingPercent 的计算、carry 的注入时机都不同。 + +### 风险 4:全平重放逻辑(lines 1293-1304) + +`InitSwapDealInterest` 在 `closePrecent == 1m` 时做 **两次** `CalcDailyCompoundInterest` 重放(截至平仓日 + 截至上一日终),取差值。EOD 续接模式下这段逻辑需要完全重新设计。 + +## 验证方案(立项前提) + +1. 构造测试用例:同一笔复利交易,跨越 ≥2 个重置周期,有 preEod 快照 +2. 用**旧全程重放**算出 `(InterestAmount, TdInterestAmount, finalBasis)` +3. 用**新 EOD 续接**算出同样三个值 +4. 断言差额 < 0.01(到分) +5. 覆盖场景: + - 部分平仓(closePercent < 1) + - 全平(closePercent == 1) + - 平仓日 = 重置日 + - 平仓日 ≠ 重置日 + - 有/无 consumedInterest + - 有/无 resetCarryInterest + +## 建议排期 + +单独 sprint 处理,不混入日常重构。改动范围: +- `CompoundInterestAccrual.AccruePeriod` 新增 `startBasis` 参数(或新方法 `AccrueFromEod`) +- `CalcDailyCompoundInterest` wrapper 改为传 `preEod.TdInterestPrincipal` 作为起点 +- `InitSwapDealInterest` 全平重放逻辑简化(不再需要两次重放取差值) +- `consumedInterest` 扣减逻辑移除或调整 diff --git a/YLErpDAL/Modules/SystemModule/ClientDataModel.cs b/YLErpDAL/Modules/SystemModule/ClientDataModel.cs index 0d00ea90..3252d15c 100644 --- a/YLErpDAL/Modules/SystemModule/ClientDataModel.cs +++ b/YLErpDAL/Modules/SystemModule/ClientDataModel.cs @@ -1536,11 +1536,6 @@ namespace YLErp.Modules.SystemModule Text = SwapTypeEnum.普通.ToString(), Value = SwapTypeEnum.普通.ToString() }, - new SelectItem - { - Text = SwapTypeEnum.多空组合.ToString(), - Value = SwapTypeEnum.多空组合.ToString() - } }; } diff --git a/YLErpWeb/Controllers/SwapTrade2Controller.cs b/YLErpWeb/Controllers/SwapTrade2Controller.cs index 4c8b06e0..2e23a01b 100644 --- a/YLErpWeb/Controllers/SwapTrade2Controller.cs +++ b/YLErpWeb/Controllers/SwapTrade2Controller.cs @@ -352,36 +352,6 @@ namespace YLErp.Web.Controllers return View(model); } /// - /// 收益互换 多空组合平仓 - /// - /// - /// - public ActionResult SwapLongShortUnwind(string enid, bool isUseApproval = false) - { - var intid = DecryptInt(enid); - var model = new SwapDealService(CurUser).InitLongShortUnwind(intid, SwapEventTypeEnum.平仓); - ViewBag.isUseApproval = isUseApproval; - var hasProcess = new SwapDealService(CurUser).HasTradeProcess(); - //需要审批或者复核的交易都会显示行权审核提交按钮 - ViewBag.IsShowReCheckClose = (valuedateBLL.SystemDate.CloseReCheck == 1) || (valuedateBLL.SystemDate.CloseReApprove == 1 && hasProcess); - return View(model); - } - /// - /// 收益互换 多空组合互换 - /// - /// - /// - public ActionResult SwapLongShortSwap(string enid, bool isUseApproval = false) - { - var intid = DecryptInt(enid); - var model = new SwapDealService(CurUser).InitLongShortUnwind(intid, SwapEventTypeEnum.互换); - ViewBag.isUseApproval = isUseApproval; - var hasProcess = new SwapDealService(CurUser).HasTradeProcess(); - //需要审批或者复核的交易都会显示行权审核提交按钮 - ViewBag.IsShowReCheckClose = (valuedateBLL.SystemDate.CloseReCheck == 1) || (valuedateBLL.SystemDate.CloseReApprove == 1 && hasProcess); - return View(model); - } - /// /// 操作历史 /// /// @@ -457,26 +427,6 @@ namespace YLErp.Web.Controllers return JsonSuccess("平仓成功"); } /// - ///多空组合 平仓 - /// - /// - /// - public JsonResult SwapLongShortUnwindJson(UnwindData unwindData) - { - new SwapDealService(CurUser).SwapLongShortUnwind(unwindData); - return JsonSuccess("平仓成功"); - } - /// - ///多空组合 互换 - /// - /// - /// - public JsonResult SwapLongShortJson(UnwindData unwindData) - { - new SwapDealService(CurUser).SwapLongShort(unwindData); - return JsonSuccess("互换成功"); - } - /// /// 互换 /// /// diff --git a/YLErpWeb/Views/SwapTrade2/SwapLongShortSwap.cshtml b/YLErpWeb/Views/SwapTrade2/SwapLongShortSwap.cshtml deleted file mode 100644 index 4df23cf7..00000000 --- a/YLErpWeb/Views/SwapTrade2/SwapLongShortSwap.cshtml +++ /dev/null @@ -1,126 +0,0 @@ -@model UnwindData -@{ - ViewBag.Title = "交易 | 收益结算"; - Layout = "~/Views/Shared/_InfoLayout.cshtml"; - bool isUseApproval = ViewBag.isUseApproval; - bool isShowReCheckClose = ViewBag.IsShowReCheckClose; -} -@section CSS{ - -} -@section JS - { - - - - - - -} -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
-
-
-
-
-
-
- - - - - - - - - - - - - - - -
收支方向利息金额其他费用预付金/预付金平仓盈亏
{{item.InterestDirection==1?"收取":"支付"}} - - - - {{formatAmount(item.InterestClosePnL)}}
-
-
-
-
-
-
-
-
- - - - - - - - - - - - - - - -
收支方向利息金额其他费用利息端平仓盈亏
{{item.InterestDirection==1?"收取":"支付"}} - - - - {{formatAmount(item.InterestClosePnL)}}
-
-
- @if (isUseApproval) - { - - - } - else - { - - - } -
-
-
-
-
diff --git a/YLErpWeb/Views/SwapTrade2/SwapLongShortUnwind.cshtml b/YLErpWeb/Views/SwapTrade2/SwapLongShortUnwind.cshtml deleted file mode 100644 index bdbc1601..00000000 --- a/YLErpWeb/Views/SwapTrade2/SwapLongShortUnwind.cshtml +++ /dev/null @@ -1,138 +0,0 @@ -@model UnwindData -@{ - ViewBag.Title = "交易 | 交易平仓"; - Layout = "~/Views/Shared/_InfoLayout.cshtml"; - bool isUseApproval = ViewBag.isUseApproval; - bool isShowReCheckClose = ViewBag.IsShowReCheckClose; -} -@section CSS{ - -} -@section JS - { - - - - - - -} -
-
-
-
- - -
-
- - -
-
- - - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
-
-
-
-
-
-
- - - - - - - - - - - - - - - - - -
收支方向资金类别应返还本金利息金额预付金/预付金平仓盈亏
{{item.InterestDirection==1?"收取":"支付"}}{{item.InterestModeStr}} - - - - {{formatAmount(item.InterestClosePnL)}}
-
-
-
-
-
-
-
-
- - - - - - - - - - - - - -
收支方向利息金额利息端平仓盈亏
{{item.InterestDirection==1?"收取":"支付"}} - - {{formatAmount(item.InterestClosePnL)}}
-
-
- @if (isUseApproval) - { - - - } - else - { - - - } -
-
-
-
-
diff --git a/YLErpWeb/Views/SwapTrade2/header.cshtml b/YLErpWeb/Views/SwapTrade2/header.cshtml index 54a70d94..227c3f1e 100644 --- a/YLErpWeb/Views/SwapTrade2/header.cshtml +++ b/YLErpWeb/Views/SwapTrade2/header.cshtml @@ -141,10 +141,6 @@ { @MyControls.Btn("平仓", string.Format("unWindSwapTrade('{0}')", tradeModel.EncryptId)) } - if (longShort && !hasPosition && tradeModel.CanSwapTradeUnwind() && CurUser.交易管理_交易平仓) - { - @MyControls.Btn("平仓", string.Format("unWindSwapLongShort('{0}')", tradeModel.EncryptId)) - } if (tradeModel.CanSwapTradeUnwind() && CurUser.交易管理_收益互换) { @MyControls.Btn("展期信息", string.Format("extensionTime('{0}')", tradeModel.EncryptId)) @@ -153,10 +149,6 @@ { @MyControls.Btn("收益结算", string.Format("unWindSwap('{0}')", tradeModel.EncryptId)) } - if (longShort && hasPosition && tradeModel.CanSwapTradeUnwind() && CurUser.交易管理_收益互换) - { - @MyControls.Btn("收益结算", string.Format("unWindLongShortSwap('{0}')", tradeModel.EncryptId)) - } @if (CurUser.交易管理_交易续作) { @MyControls.Btn("续作", string.Format("renewTrade('{0}')", tradeModel.EncryptId)) diff --git a/YLErpWeb/fe-tests/fixtures/historical_swap_events.json b/YLErpWeb/fe-tests/fixtures/historical_swap_events.json index f84e52d5..16cec7a2 100644 --- a/YLErpWeb/fe-tests/fixtures/historical_swap_events.json +++ b/YLErpWeb/fe-tests/fixtures/historical_swap_events.json @@ -43,43 +43,5 @@ "SwapMarginRebatePnl": 200, "SwapMarginAmount": 40000 } - }, - { - "id": "LONGSHORT_UNWIND", - "desc": "多空组合平仓(unwindLongShort):从0起,不含浮动盈亏(FloatPnlSum=0)", - "eventType": 4, - "deal": { "CloseQty": 0 }, - "floatPosition": { "FloatPnlSum": 0, "PositionType": 1, "PayDirection": 1, "TradingFee": 0 }, - "interestList": [], - "marginList": [ - { "InterestClosePnL": 150, "InterestDirection": 1, "InterestPrincipal": 20000 } - ], - "expected": { - "SwapCloseAmount": 150, - "SwapRealizedPnL": 150, - "SwapMarginRebatePnl": 150, - "SwapMarginAmount": -20000 - } - }, - { - "id": "LONGSHORT_SWAP", - "desc": "多空组合互换(swapLongShort):SwapMarginAmount 用裸 InterestPrincipal(无符号) —— 已知差异", - "eventType": 5, - "deal": { "CloseQty": 0 }, - "floatPosition": { "FloatPnlSum": 0, "PositionType": 1, "PayDirection": 1, "TradingFee": 0 }, - "interestList": [], - "marginList": [ - { "InterestClosePnL": 100, "InterestDirection": 1, "InterestPrincipal": 18000 } - ], - "expected": { - "SwapCloseAmount": 100, - "SwapRealizedPnL": 100, - "SwapMarginRebatePnl": 100, - "SwapMarginAmount": 18000 - }, - "knownDiscrepancies": { - "SwapMarginAmount": 36000 - } - } - ] + } ] } diff --git a/YLErpWeb/fe-tests/swapPrecisionConfig.test.js b/YLErpWeb/fe-tests/swapPrecisionConfig.test.js index 05fc793d..ecef4b39 100644 --- a/YLErpWeb/fe-tests/swapPrecisionConfig.test.js +++ b/YLErpWeb/fe-tests/swapPrecisionConfig.test.js @@ -163,8 +163,6 @@ describe('swap price precision common wiring', () => { edit: read('wwwroot/Scripts/app/swaptrade/swapTradeEdit.js'), income: read('wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js'), unwind: read('wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js'), - longShort: read('wwwroot/Scripts/app/swaptrade/swapLongShort.js'), - unwindLongShort: read('wwwroot/Scripts/app/swaptrade/unwindLongShort.js'), view: read('wwwroot/Scripts/app/swaptrade/swapTradeView.js'), flow: read('wwwroot/Scripts/app/swaptrade/SwapflowList.js'), helper: precisionHelperSrc diff --git a/YLErpWeb/wwwroot/Scripts/app/riskHedging/riskIndex2.js b/YLErpWeb/wwwroot/Scripts/app/riskHedging/riskIndex2.js index df78ce5d..b6097e32 100644 --- a/YLErpWeb/wwwroot/Scripts/app/riskHedging/riskIndex2.js +++ b/YLErpWeb/wwwroot/Scripts/app/riskHedging/riskIndex2.js @@ -2000,9 +2000,6 @@ const subView = (function (jqGridMgr) { if (tradeType === "收益互换") { srcurl = "/swaptrade2/SwapUnwind/?enid=" + encryptId; - if (StructureType=="多空组合") { - srcurl = "/swaptrade2/SwapLongShortUnwind/?enid=" + encryptId; - } area = ["1300px", "720px"]; } if (tradeType.indexOf("远期") >= 0) { diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js index eb8e50b9..d160d50e 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js @@ -112,7 +112,7 @@ const vue = new Vue({ this.multiplier, isUseApproval); this.interestList = model.FlowEvents.filter((item) => { - return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9; + return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 9; }); this.marginList = model.FlowEvents.filter((item) => { return item.InterestMode == 5 || item.InterestMode == 6; @@ -255,7 +255,7 @@ const vue = new Vue({ var postData = { ValueDate: thisObj.deal.ValueDate, unwindDate: thisObj.deal.UnwindDate, tradeId: thisObj.deal.SwapTradeId, closePercent: 1, eventType:3 } main.post("/swaptrade2/GetUnwindInterestList", postData, { async: true }).done(function (resp) { thisObj.interestList = resp.obj.filter((item) => { - return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9; + return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 9; }); thisObj.marginList = resp.obj.filter((item) => { return item.InterestMode == 5 || item.InterestMode == 6; diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapLongShort.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapLongShort.js deleted file mode 100644 index 60bcb46b..00000000 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapLongShort.js +++ /dev/null @@ -1,228 +0,0 @@ -//otcformat禁止千分位分组 -window.otcformat.options.disableGrouping = true; -const inputFormatEqvNotional = swapPricePrecision.getCommonInputFormat('amount', { append: '' }); -const swapInstrumentType = (model.FlowEvents || []).find(item => item && item.UnderlyingInstrumentType)?.UnderlyingInstrumentType || model.UnderlyingInstrumentType || ''; -const formatSwapAmount = value => swapPricePrecision.normalizeCommon('amount', value); -const formatSwapQuantity = value => swapPricePrecision.normalizeCommon('quantity', value, swapInstrumentType); -let ValueDate = model.ValueDate; -const vue = new Vue({ - el: '#vueDiv', - data: { - deal: model, - interestList: [], - marginList: [], - }, - computed: { - maxUnwindDate() { - return ValueDate; - }, - minStartDate() { - return this.deal.StartDate; - } - }, - created() { - this.initDeal(); - this.setValueDate(); - }, - methods: { - formatAmount(value) { - return swapPricePrecision.formatCommon('amount', value); - }, - formatQuantity(value) { - return swapPricePrecision.formatCommon('quantity', value, swapInstrumentType); - }, - initDeal() { - this.interestList = model.FlowEvents.filter((item) => { - return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9; - }); - this.marginList = model.FlowEvents.filter((item) => { - return item.InterestMode == 5 || item.InterestMode == 6; - }); - }, - setValueDate(e) {//修改平仓日期 - if (e) { - this.deal.ValueDate = e; - this.deal.UnwindDate = e; - } - if (!isUseApproval) { - this.getInterestList(); - } else { - this.dataFormat(); - } - }, - dataFormat() { - this.deal.NotionalValue = formatSwapAmount(this.deal.NotionalValue); - this.deal.PosiNotionalValue = formatSwapAmount(this.deal.PosiNotionalValue); - this.deal.NotionalQty = formatSwapQuantity(this.deal.NotionalQty); - this.deal.PositionQty = formatSwapQuantity(this.deal.PositionQty); - this.deal.SwapCloseAmount = formatSwapAmount(this.deal.SwapCloseAmount); - this.interestList.forEach(x => { - //x.Principal = formatSwapAmount(x.Principal); - //x.Rate = otcformat.fixed6(x.Rate); - x.InterestFee = formatSwapAmount(x.InterestFee); - x.InterestAmount = formatSwapAmount(x.InterestAmount); - x.InterestClosePnL = formatSwapAmount(x.InterestClosePnL); - //x.InterestStartDate = x.InterestStartDate ? x.InterestStartDate.substr(0, 10) : ""; - //x.InterestEndDate = x.InterestEndDate ? x.InterestEndDate.substr(0, 10) : ""; - }); - this.marginList.forEach(x => { - x.InterestFee = formatSwapAmount(x.InterestFee); - x.InterestAmount = formatSwapAmount(x.InterestAmount); - x.InterestClosePnL = formatSwapAmount(x.InterestClosePnL); - }); - }, - changeInterestAmount(item) {//修改利息金额 - let interestRatio = item.InterestDirection == 1 ? 1 : -1; - item.InterestClosePnL = formatSwapAmount(parseFloat(item.InterestAmount) * interestRatio + parseFloat(item.InterestFee)); - this.calcCloseAmount(); - }, - calcCloseAmount() {//计算平仓总额=浮动收取+利息收取-浮动支付-利息支付 - let thisObj = this; - thisObj.deal.SwapCloseAmount = 0; - thisObj.deal.SwapRealizedPnL = 0; - thisObj.deal.SwapMarginRebatePnl = 0; - thisObj.deal.SwapMarginAmount = 0; - this.interestList.forEach(x => { - /*let interestRatio = x.InterestDirection == 1 ? 1 : -1;*/ - let interestAmount = parseFloat(x.InterestClosePnL); - thisObj.deal.SwapCloseAmount = parseFloat(thisObj.deal.SwapCloseAmount) + interestAmount; - thisObj.deal.SwapRealizedPnL = parseFloat(thisObj.deal.SwapRealizedPnL) + interestAmount; - }); - this.marginList.forEach(x => { - let interestAmount = parseFloat(x.InterestClosePnL); - thisObj.deal.SwapCloseAmount = parseFloat(thisObj.deal.SwapCloseAmount) + interestAmount; - thisObj.deal.SwapMarginRebatePnl = parseFloat(thisObj.deal.SwapMarginRebatePnl) + interestAmount; - thisObj.deal.SwapRealizedPnL = parseFloat(thisObj.deal.SwapRealizedPnL) + interestAmount; - thisObj.deal.SwapMarginAmount = parseFloat(thisObj.deal.SwapMarginAmount) + parseFloat(x.InterestPrincipal); - }); - thisObj.deal.SwapCloseAmount = formatSwapAmount(thisObj.deal.SwapCloseAmount); - thisObj.deal.SwapRealizedPnL = formatSwapAmount(thisObj.deal.SwapRealizedPnL); - thisObj.deal.SwapMarginRebatePnl = formatSwapAmount(thisObj.deal.SwapMarginRebatePnl); - thisObj.deal.SwapMarginAmount = formatSwapAmount(thisObj.deal.SwapMarginAmount); - }, - getInterestList() {//根据平仓日期获取利息腿信息 - var thisObj = this; - var postData = { valueDate: thisObj.deal.ValueDate, unwindDate: thisObj.deal.UnwindDate, tradeId: thisObj.deal.SwapTradeId, closePercent: 1, eventType: 3 } - main.post("/swaptrade2/GetUnwindInterestList", postData, { async: true }).done(function (resp) { - thisObj.interestList = resp.obj.filter((item) => { - return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9; - }); - thisObj.marginList = resp.obj.filter((item) => { - return item.InterestMode == 5 || item.InterestMode == 6; - }); - thisObj.calcCloseAmount(); - thisObj.dataFormat(); - }); - }, - incomeTrade() {//互换 - var thisObj = this; - if (main.isEmpty(thisObj.deal.ValueDate)) { - main.message("请输入平仓日期"); - return; - } - let reqObj = _.cloneDeep(thisObj.deal); - let marginCloneList = _.cloneDeep(thisObj.marginList); - - reqObj.FlowEvents = _.cloneDeep(thisObj.interestList); - marginCloneList.forEach((item) => { - reqObj.FlowEvents.push(item); - }) - var postData = { unwindData: reqObj }; - var msg = "确认提交收益结算?"; - var postUrl = "/swaptrade2/SwapLongShortJson"; - if (g_isShowReCheckClose) { - msg = "确认提交收益结算审核?"; - postUrl = "/swaptrade2/ApplyUnwind"; - postData.eventType = 3;//互换3,平仓2 - } - main.confirm(msg, - function () { - //重新计算百分比 - var thisObj2 = thisObj; - main.post(postUrl, { unwindData: reqObj }).done(function (res) { - if (res.success) { - thisObj2.closetrade_cashWindow(); - } - else { - try { - thisObj2.closetrade_cashWindow(); - } catch (e) { - } - } - }); - }); - }, - getSumbitText: function () { - return g_isShowReCheckClose ? "审核提交" : "保存"; - }, - submitApproval(status) { - var pop = ''; - if (status === 'pass') { - pop = "确认通过审批?"; - } - if (status === 'reject') { - pop = "确认拒绝?"; - } - var confirmFunc = function (additionalProcessing) { - var pData = { tradeId: trade.id, status: status, text: "" }; - if (!main.isEmpty(additionalProcessing)) { - pData.additionalProcessing = additionalProcessing; - } - var thisObj2 = thisObj; - main.post("/processtradelog/UpdateTradeProcessLog", pData).done( - function (data) { - if (data.obj && data.obj.proccessType == "AdditionalProcessing") { - if (data.obj.type == "LackOfMoney") { - var htmlContent = `
${data.obj.message}
`; - var lackMoneyConfirmLayer = main.open2("提示", - htmlContent, - { - area: ["430px", "175px"], - btn: ['交易特批', '取消'], - yes: function (index, layero) { - var layerIndex = lackMoneyConfirmLayer; - main.confirm("客户资金或授信不足,强制成交会导致本机构产生风险!要继续审批通过?", function () { - layer.close(layerIndex); - confirmFunc("LackOfMoney"); - }); - }, - cancel: function (index, layero) { - if (window.parent && window.parent.reloadtrade) { - thisObj2.closetrade_cashWindow(); - } - (parent || window).layer.closeAll(); - } - }); - } - return; - } - (parent || window).main.message(data.msg); - try { thisObj2.closetrade_cashWindow(); } - catch (e) { } - if (parent) { - parent.layer.closeAll(); - } - }); - } - main.confirm(pop, confirmFunc); - }, - closetrade_cashWindow: function () { - layer.closeMe('reloadData'); - }, - closeCurrentWindow: function () { - try { - if (window.parent && window.parent.reload) window.parent.reload(); - } catch (e) { - } - try { - var layer = window.parent.layer; - layer.close(layer.getFrameIndex(window.name)); - } catch (e) { - } - } - }, - components: { - 'vue-datepicker': FastVue.vueDatePicker(), - 'vue-number-input': FastVue.vueNumberInput(), - } -}); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js index bf8d7285..d73ea0ab 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js @@ -868,9 +868,6 @@ const vue = new Vue({ } }, changeInterestMode(item) { - if (item.InterestMode == 7 || item.InterestMode == 8) { - item.InterestType = 0; - } }, changeInterestType(item) { if (!item.interest_rest_days) { @@ -1462,28 +1459,7 @@ const vue = new Vue({ }, // 多空组合利息腿校验 checkSwapRateList() { - var thisObj = this; - var longInterestModelCount = 0; - var shortInterestModelCount = 0; var check = true; - if (thisObj.getSwapList != null) { - thisObj.getSwapList.forEach((val, num, arr) => { - if (val.InterestMode==7) { - longInterestModelCount++; - } - if (val.InterestMode == 8) { - shortInterestModelCount++; - } - }); - } - if (longInterestModelCount > 1) { - check = false; - main.message("计息基本类型为多头存续名义本金的利息腿只能有一条"); - } - if (shortInterestModelCount > 1) { - check = false; - main.message("计息基本类型为空头存续名义本金的利息腿只能有一条"); - } return check; }, //观察日起始日期跟交易起始日期检查 @@ -1619,7 +1595,7 @@ const vue = new Vue({ //初始化利息端列表 initSwapRateList() { var thisObj = this; - thisObj.getSwapList = thisObj.trade.swap_positions.filter(x => { if ((x.UnderlyingCode == null || x.UnderlyingCode.length == 0) && x.IsInitial && (x.InterestMode == 1 || x.InterestMode == 2 || x.InterestMode == 7 || x.InterestMode == 8 || x.InterestMode == 9)) return x; }); + thisObj.getSwapList = thisObj.trade.swap_positions.filter(x => { if ((x.UnderlyingCode == null || x.UnderlyingCode.length == 0) && x.IsInitial && (x.InterestMode == 1 || x.InterestMode == 2 || x.InterestMode == 9)) return x; }); thisObj.getSwapList.forEach((val, num, arr) => { arr[num].index = num; arr[num].category_tag = arr[num].category_tag || '互换利率'; diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js index 0cd7bc04..2f4b1d8b 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js @@ -330,46 +330,6 @@ function unWindSwap(id) { } }); } -function unWindSwapLongShort(id) { - var title = "交易平仓"; - var srcurl = "/swaptrade2/SwapLongShortUnwind/?enid=" + id; - main.post("/swaptrade2/CheckEodTrade?enid=" + id).done(function (res) { - if (res.success) { - main.open(title, - srcurl, - { - area: ["1300px", "780px"], - end: function () { - if (window.parent && window.parent.reloadtrade) { - window.parent.reloadtrade(); - } - } - }); - } else { - main.message(res.message); - } - }); -} -function unWindLongShortSwap(id) { - var title = "期间互换"; - var srcurl = "/swaptrade2/SwapLongShortSwap/?enid=" + id; - main.post("/swaptrade2/CheckEodTrade?enid=" + id).done(function (res) { - if (res.success) { - main.open(title, - srcurl, - { - area: ["1300px", "780px"], - end: function () { - if (window.parent && window.parent.reloadtrade) { - window.parent.reloadtrade(); - } - } - }); - } else { - main.message(res.message); - } - }); -} function extensionTime(id) { var title = "展期信息"; var srcurl = "/swaptrade2/ExtensionTime/?enid=" + id; diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindLongShort.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindLongShort.js deleted file mode 100644 index ed32fced..00000000 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindLongShort.js +++ /dev/null @@ -1,197 +0,0 @@ -//otcformat禁止千分位分组 -window.otcformat.options.disableGrouping = true; -const inputFormatEqvNotional = swapPricePrecision.getCommonInputFormat('amount', { append: '' }); -const swapInstrumentType = (model.FlowEvents || []).find(item => item && item.UnderlyingInstrumentType)?.UnderlyingInstrumentType || model.UnderlyingInstrumentType || ''; -const formatSwapAmount = value => swapPricePrecision.normalizeCommon('amount', value); -const formatSwapQuantity = value => swapPricePrecision.normalizeCommon('quantity', value, swapInstrumentType); -let dealDate = model.DealDate; -const vue = new Vue({ - el: '#vueDiv', - data: { - deal: model, - interestList: [], - marginList: [], - oriClosePercent: model.ClosePercent - }, - computed: { - minStartDate() { - return dealDate; - } - }, - created() { - this.initDeal(); - this.calcCloseAmount(); - this.dataFormat(); - }, - methods: { - formatAmount(value) { - return swapPricePrecision.formatCommon('amount', value); - }, - formatQuantity(value) { - return swapPricePrecision.formatCommon('quantity', value, swapInstrumentType); - }, - initDeal() { - this.interestList = model.FlowEvents.filter((item) => { - return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9; - }); - this.marginList = model.FlowEvents.filter((item) => { - return item.InterestMode == 5 || item.InterestMode == 6; - }); - }, - dataFormat() { - this.deal.NotionalValue = formatSwapAmount(this.deal.NotionalValue); - this.deal.PosiNotionalValue = formatSwapAmount(this.deal.PosiNotionalValue); - this.deal.NotionalQty = formatSwapQuantity(this.deal.NotionalQty); - this.deal.SwapCloseAmount = formatSwapAmount(this.deal.SwapCloseAmount); - this.deal.PositionQty = formatSwapQuantity(this.deal.PositionQty); - this.interestList.forEach(x => { - //x.Principal = formatSwapAmount(x.Principal); - //x.Rate = otcformat.fixed6(x.Rate); - x.InterestAmount = formatSwapAmount(x.InterestAmount); - x.InterestClosePnL = formatSwapAmount(x.InterestClosePnL); - //x.InterestStartDate = x.InterestStartDate ? x.InterestStartDate.substr(0, 10) : ""; - //x.InterestEndDate = x.InterestEndDate ? x.InterestEndDate.substr(0, 10) : ""; - }); - this.marginList.forEach(x => { - x.InterestAmount = formatSwapAmount(x.InterestAmount); - x.InterestClosePnL = formatSwapAmount(x.InterestClosePnL); - }); - }, - changeInterestAmount(item) {//修改利息金额 - let interestRatio = item.InterestDirection == 1 ? 1 : -1; - item.InterestClosePnL = formatSwapAmount(parseFloat(item.InterestAmount) * interestRatio+ parseFloat(item.InterestFee)); - this.calcCloseAmount(); - }, - calcCloseAmount() {//计算平仓总额=浮动收取+利息收取-浮动支付-利息支付 - let thisObj = this; - thisObj.deal.SwapCloseAmount = 0; - thisObj.deal.SwapRealizedPnL = 0; - thisObj.deal.SwapMarginRebatePnl = 0; - thisObj.deal.SwapMarginAmount = 0; - this.interestList.forEach(x => { - /*let interestRatio = x.InterestDirection == 1 ? 1 : -1;*/ - let interestAmount = parseFloat(x.InterestClosePnL); - thisObj.deal.SwapCloseAmount = parseFloat(thisObj.deal.SwapCloseAmount) + interestAmount; - thisObj.deal.SwapRealizedPnL = parseFloat(thisObj.deal.SwapRealizedPnL) + interestAmount; - }); - this.marginList.forEach(x => { - let interestAmount = parseFloat(x.InterestClosePnL); - let interestRatio = x.InterestDirection == 1 ? -1 : 1; - thisObj.deal.SwapCloseAmount = parseFloat(thisObj.deal.SwapCloseAmount) + interestAmount; - thisObj.deal.SwapMarginRebatePnl = parseFloat(thisObj.deal.SwapMarginRebatePnl) + interestAmount; - thisObj.deal.SwapRealizedPnL = parseFloat(thisObj.deal.SwapRealizedPnL) + interestAmount; - thisObj.deal.SwapMarginAmount = parseFloat(thisObj.deal.SwapMarginAmount) + parseFloat(x.InterestPrincipal) * interestRatio; - }); - thisObj.deal.SwapCloseAmount = formatSwapAmount(thisObj.deal.SwapCloseAmount); - thisObj.deal.SwapRealizedPnL = formatSwapAmount(thisObj.deal.SwapRealizedPnL); - thisObj.deal.SwapMarginRebatePnl = formatSwapAmount(thisObj.deal.SwapMarginRebatePnl); - thisObj.deal.SwapMarginAmount = formatSwapAmount(thisObj.deal.SwapMarginAmount); - }, - closeTrade() {//平仓 - var thisObj = this; - let reqObj = _.cloneDeep(thisObj.deal); - let marginCloneList = _.cloneDeep(thisObj.marginList); - reqObj.FlowEvents = _.cloneDeep(thisObj.interestList); - marginCloneList.forEach((item) => { - reqObj.FlowEvents.push(item); - }) - var postData = { unwindData: reqObj }; - var msg = "确认提交平仓?"; - var postUrl = "/swaptrade2/SwapLongShortUnwindJson"; - if (g_isShowReCheckClose) { - msg = "确认提交平仓审核?"; - postUrl = "/swaptrade2/ApplyUnwind"; - postData.eventType = 2;//互换3,平仓2 - } - main.confirm(msg, - function () { - //重新计算百分比 - var thisObj2 = thisObj; - main.post(postUrl, postData).done(function (res) { - if (res.success) { - thisObj2.closetrade_cashWindow(); - } - else { - try { - thisObj2.closetrade_cashWindow(); - } catch (e) { - } - } - }); - }); - }, - getSumbitText: function () { - return g_isShowReCheckClose ? "审核提交" : "保存"; - }, - submitApproval(status) { - var pop = ''; - if (status === 'pass') { - pop = "确认通过审批?"; - } - if (status === 'reject') { - pop = "确认拒绝?"; - } - let thisObj = this; - var confirmFunc = function (additionalProcessing) { - var pData = { tradeId: thisObj.deal.SwapTradeId, status: status, text: "" }; - if (!main.isEmpty(additionalProcessing)) { - pData.additionalProcessing = additionalProcessing; - } - var thisObj2 = thisObj; - main.post("/processtradelog/UpdateTradeProcessLog", pData).done( - function (data) { - if (data.obj && data.obj.proccessType == "AdditionalProcessing") { - if (data.obj.type == "LackOfMoney") { - var htmlContent = `
${data.obj.message}
`; - var lackMoneyConfirmLayer = main.open2("提示", - htmlContent, - { - area: ["430px", "175px"], - btn: ['交易特批', '取消'], - yes: function (index, layero) { - var layerIndex = lackMoneyConfirmLayer; - main.confirm("客户资金或授信不足,强制成交会导致本机构产生风险!要继续审批通过?", function () { - layer.close(layerIndex); - confirmFunc("LackOfMoney"); - }); - }, - cancel: function (index, layero) { - if (window.parent && window.parent.reloadtrade) { - thisObj2.closetrade_cashWindow(); - } - (parent || window).layer.closeAll(); - } - }); - } - return; - } - (parent || window).main.message(data.msg); - try { thisObj2.closetrade_cashWindow(); } - catch (e) { } - if (parent) { - parent.layer.closeAll(); - } - }); - } - main.confirm(pop, confirmFunc); - }, - closetrade_cashWindow: function () { - layer.closeMe('reloadData'); - }, - closeCurrentWindow: function () { - try { - if (window.parent && window.parent.reload) window.parent.reload(); - } catch (e) { - } - try { - var layer = window.parent.layer; - layer.close(layer.getFrameIndex(window.name)); - } catch (e) { - } - } - }, - components: { - 'vue-datepicker': FastVue.vueDatePicker(), - 'vue-number-input': FastVue.vueNumberInput(), - } -}); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js index 4655829d..b19a4c95 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js @@ -110,7 +110,7 @@ const vue = new Vue({ this.floatPosition = positions[0]; this.initPosiNetPrice = this.floatPosition.PosiGrossPrice; this.interestList = model.FlowEvents.filter((item) => { - return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9; + return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 9; }); this.marginList = model.FlowEvents.filter((item) => { return item.InterestMode == 5 || item.InterestMode == 6; @@ -375,7 +375,7 @@ const vue = new Vue({ ' closeNotionalValue=', thisObj.deal.CloseNotionalValue, ' posiNotionalValue=', thisObj.deal.PosiNotionalValue); main.post("/swaptrade2/GetUnwindInterestList", postData, { async: true }).done(function (resp) { thisObj.interestList = resp.obj.filter((item) => { - return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9; + return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 9; }); thisObj.marginList = resp.obj.filter((item) => { return item.InterestMode == 5 || item.InterestMode == 6; diff --git a/YLErpWeb/wwwroot/Scripts/app/trade/tradeApproval.js b/YLErpWeb/wwwroot/Scripts/app/trade/tradeApproval.js index ecbd7a7d..21bd8878 100644 --- a/YLErpWeb/wwwroot/Scripts/app/trade/tradeApproval.js +++ b/YLErpWeb/wwwroot/Scripts/app/trade/tradeApproval.js @@ -731,9 +731,6 @@ function passorreinfo(eid, tradeType, status, isGroup, StructureType) { else if (tradeType === "收益互换") { weight = "95%"; url = "/swaptrade2/SwapUnwind/?enid=" + eid + "&isUseApproval=" + true; - if (StructureType == "多空组合") { - url = "/swaptrade2/SwapLongShortUnwind/?enid=" + eid + "&isUseApproval=" + true; - } height = "800px"; } else { @@ -744,9 +741,6 @@ function passorreinfo(eid, tradeType, status, isGroup, StructureType) { weight = "95%"; title = "互换审批"; url = "/swaptrade2/SwapIncome/?enid=" + eid + "&isUseApproval=" + true; - if (StructureType == "多空组合") { - url = "/swaptrade2/SwapLongShortSwap/?enid=" + eid + "&isUseApproval=" + true; - } height = "800px"; } else if (status === "行权待复核") { @@ -883,9 +877,6 @@ function unWindSelect(id, tradeType, isReCheck, StructureType) { }); } else if (tradeType == "收益互换") { var url = "/swaptrade2/SwapUnwind/?enid=" + id + "&isUseApproval=" + true;; - if (StructureType == "多空组合") { - url = "/swaptrade2/SwapLongShortUnwind/?enid=" + id + "&isUseApproval=" + true;; - } main.open(title, url, { area: ["1300px", "720px"], end: function () { diff --git a/项目文档/缺陷分析-利息部分平仓尾差20260807.md b/项目文档/缺陷分析-利息部分平仓尾差20260807.md deleted file mode 100644 index 5e88e9bc..00000000 --- a/项目文档/缺陷分析-利息部分平仓尾差20260807.md +++ /dev/null @@ -1,103 +0,0 @@ -# 缺陷分析:利息部分平仓尾差(业务场景3 / 业务场景4 浮动利率) - -> 数据来源:`缺陷测试-利息20260807晚.xlsx`(独立手算 oracle,非代码 re-baseline) -> 分析日期:2026-08-08 | 关联分支:`glms/feature/1.4.2` - -## 1. 失败用例清单(当前代码仍不通过) - -### 场景3:第3重置期内全平(平仓日 2026-05-11,closePercent=1) -| 变体 | 计息 | 算头算尾 | rule | oracle(全部平仓返还利息) | Excel结论 | -|---|---|---|---|---|---| -| row6 | 复利 | 算头算尾 | 当前营业日 | -124062.54 | **不通过** | -| row7 | 复利 | 算头算尾 | 当前营业日 | 280303.16 | **不通过** | -| row8 | 复利 | 算头不算尾 | 当前营业日 | -117918.47 | 通过 | -| row9 | 复利 | 算头不算尾 | 当前营业日 | 266674.35 | 通过 | -| row10 | 复利 | 算头算尾 | 前一营业日 | -123730.45 | **不通过** | -| row11 | 复利 | 算头算尾 | 前一营业日 | 279733.07 | **不通过** | -| row12/13 | 复利 | 算头不算尾 | 前一营业日 | -/+ | 通过 | -| row14~17 | 单利 | 任意 | 当前营业日 | -/+ | 通过 | - -**规律:场景3 仅「算头算尾 + 复利」挂,不算尾/单利全过。** - -### 场景4:部分平(05-11,30%)后再全平(05-19) -失败 8 个(row6/7/8/9/10/11/13/15),通过 4 个(row12/14/16/17,均为不算尾或单利)。 -Excel 备注(row6)原文: -> 不通过,部分平仓时,利息端平仓金额没有跟随平仓比例变化。全部平仓时,居然没有考虑已经过大支付了利息。 - -场景4 row6 量化:oracle 最终全平 = -124093.74,系统 = -124122.96,**系统多算 29.22**; -部分用例偏差更大(row8 多算 3685.96,row15 多算 3961.58)。说明部分平仓后再全平的尾差链路在复利下整体脆弱。 - -## 2. 根因(代码实证) - -### Bug A:部分平仓利息端未随平仓比例缩放 -`SwapDealService.cs` 盘中平仓(复利分支,`CalcDailyCompoundInterest` 内 `daysFromPreEod==1` 早退分支): -``` -InterestAmount = preEodPosition.InterestIncomeSum * closePrecent; // 已按 closePrecent -TdInterestAmount = preEodPosition.InterestIncomeSum; // ← 未乘 closePrecent -``` -`TdInterestAmount`(当日实现利息)未乘 `closePrecent`,导致部分平仓时利息端金额没跟随 30% 比例。 -对应场景4 备注第一条「利息端平仓金额没有跟随平仓比例变化」。 - -### Bug B:全部平仓未扣减已部分平仓已付利息 -`SwapEodPositionService.cs` EOD 平仓结算: -``` -TdCloseInterest = flowEvents.Sum(x => x.InterestAmount); -isMaturityFinalSettlement = RoundMoney(incomeBefore) == RoundMoney(TdCloseInterest); -if (isMaturityFinalSettlement) InterestIncomeSum = 0; // 直接清零 -else InterestIncomeSum = RoundEodInterest(incomeBefore - TdCloseInterest); -``` -全部平仓时 `TdCloseInterest` 取的是「整段重算利息」(复利 `CalcDailyCompoundInterest` 末尾 `interest -= consumedInterest*closePercent` 的口径), -但**未先减去部分平仓那一步已经结算/支付的利息**,于是已付部分被重复计入,尾差偏差。 -对应场景4 备注第二条「全部平仓时没考虑已大支付了利息」。 - -### Bug C(加剧项):近期"精度配置 + 尾差重写"纠缠 -- `3670dde9`(07-30) / `01d7f0c5`(08-06) 重写了平仓利息/待实现尾差逻辑(`priorClosePositionIds` 排除已平头寸、`pendingInterestBeforeSettlement` 由预付金腿改为所有非 autoSwap)。 -- 同期 `bff3e920`(07-29) `swappriceprecision.js`:`yield 6→4`、`price 11→9`;`a4906010` 净价/全价精度分开。 -- 尾差 = 高精度应结 − 结算(2位)。精度配置改变 → 舍入残差落点变 → 与重写后的尾差逻辑在"部分平后再全平"长链路(场景4)上交互出错。固定利率 4-2 路径短未触发,浮动 4-2 路径长直接爆。 - -## 3. 为什么现有测试没护住好代码 -1. **测试被 re-baseline 到代码**:`01d7f0c5` 把期望常量从 `0.006383561644` 改成 `-0.010438356164`,拿新代码输出当期望值 → 测试只是复述代码行为。 -2. **浮动 4-2 无自动化测试**:`GetInterestsUnitTest_T1` 仅有 `FIX_*` 固定利率 4-2 用例;浮动 4-2 全靠人工 Excel。 -3. **断言容差太松**:既有 `AssertInterestEqual` 用 `ConsGlobal.PriceRound-2` 容差(约 0.01),尾差差在 4~6 位小数全被放过。 -4. **真 oracle 躺在 Excel 未自动化**:「善洁方法二」30%/70% 守恒检查是极佳 golden,但人肉比对,CI 不响。 - -## 4. 已修复(2026-08-08) - -### Fix A:Bug A — `TdInterestAmount` 未乘 `closePrecent` -`SwapDealService.cs` `CalcDailyCompoundInterest` 内 `daysFromPreEod==1` 早退分支: -```csharp -// 修复前(Bug A): -TdInterestAmount = preEodPosition.InterestIncomeSum; -// 修复后: -TdInterestAmount = preEodPosition.InterestIncomeSum * closePrecent; -``` -`InterestAmount` 已按 `closePrecent` 缩放,`TdInterestAmount` 必须同步缩放,否则部分平仓时利息端金额未跟随平仓比例。 - -### Fix B:根因 — `resetCarryInterest` 使用 EOD 舍入快照导致精度偏差 -`SwapDealService.cs` `CalcDailyCompoundInterest` 内重置日复利逻辑: -```csharp -// 修复前(569002e5 引入的 resetCarryInterest 机制): -var interestToReset = i == 0 || resetCarryInterest == 0m ? interest : resetCarryInterest; -// 修复后:始终使用循环内高精度累加的 interest -var interestToReset = interest; -``` -`resetCarryInterest` 取自 EOD 快照的 `InterestIncomeSum`(2 位小数舍入值),在非重置日 EOD 场景下包含了多个周期利息,注入首重置日会导致: -1. 精度损失(舍入值 vs 循环高精度累加值) -2. 多周期利息错误注入(EOD 的 InterestIncomeSum 是整段累计,不是当前周期利息) - -此修复与 `253a89b7` 对 `CalcDailyCompoundInterestByEod`(EOD 路径)的修复逻辑一致。 - -### Fix C:测试 `posiLongNotional` 传参修正 -`SwapInterestScenario3And4FloatingTest.cs` 中 `ExecuteClose` 调用的 `posiLongNotional` 应为**平仓后剩余**名义本金(非平仓前): -- 场景3全平:`posiLongNotional = 0`(全平后无剩余) -- 场景4部分平:`posiLongNotional = Notional - partialCloseNotional`(70% 剩余) -- 场景4全平:`posiLongNotional = 0`(全平后无剩余) - -这使得 `oriPosiNotionalValue = remaining_after + close = original`,`closePercent` 计算正确。 - -## 5. 验证手段 -- **C# 测试**:`UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs` - — 24 个 Excel oracle 用例,通过 EOD 结算路径(`SaveAutoEodWithCloseInterestPosition`)复现, - 断言 `TdCloseInterest`(容差 0.01)。修复前 → RED(偏差 29~3961 元),修复后 → 预期 GREEN。 - ⚠️ 需在 Windows + VS 运行验证。 -- **禁止 re-baseline**:今后任何 fix 改测试期望值常量,必须附注来源(本 Excel 手算 or 文档公式),否则评审红线。