diff --git a/Framework/YLErp.Core/DBModels/Consts/ConsTrade.cs b/Framework/YLErp.Core/DBModels/Consts/ConsTrade.cs index cd243a6a..08664c06 100644 --- a/Framework/YLErp.Core/DBModels/Consts/ConsTrade.cs +++ b/Framework/YLErp.Core/DBModels/Consts/ConsTrade.cs @@ -131,11 +131,11 @@ namespace YLErp.DBModels /// /// 互换利率端计息方式 /// - public static readonly List InterestModels = new List() { (int)InterestModeEnum.固定值, (int)InterestModeEnum.合约名义本金规模, (int)InterestModeEnum.空头存续名义本金,(int)InterestModeEnum.多头存续名义本金, (int)InterestModeEnum.标的期初全价 }; + public static readonly List InterestModels = new List() { (int)InterestModeEnum.固定值, (int)InterestModeEnum.合约名义本金规模, (int)InterestModeEnum.标的期初全价 }; /// /// 互换利率端计息方式-名义本金相关 /// - public static readonly List InterestNotionalModels = new List() {(int)InterestModeEnum.合约名义本金规模, (int)InterestModeEnum.空头存续名义本金, (int)InterestModeEnum.多头存续名义本金, (int)InterestModeEnum.标的期初全价 }; + public static readonly List InterestNotionalModels = new List() {(int)InterestModeEnum.合约名义本金规模, (int)InterestModeEnum.标的期初全价 }; /// /// 互换利率计息方式 预付金 /// diff --git a/Framework/YLErp.Core/DBModels/Enums/InterestModeEnum.cs b/Framework/YLErp.Core/DBModels/Enums/InterestModeEnum.cs index fcc031f8..42c59ad9 100644 --- a/Framework/YLErp.Core/DBModels/Enums/InterestModeEnum.cs +++ b/Framework/YLErp.Core/DBModels/Enums/InterestModeEnum.cs @@ -7,27 +7,31 @@ using System.Threading.Tasks; namespace YLErp.DBModels { /// - /// 计息类型 + /// 计息类型。显式赋值,数字即 DB 存储值,不可随意改动。 + /// + /// 已删除的成员(保留编号记录,避免误回收): + /// 持仓名义本金 = 3 —— 死代码,零引用 + /// 持仓市值 = 4 —— 死代码,零引用 + /// 多头存续名义本金 = 7 —— 界面已禁用,无历史数据 + /// 空头存续名义本金 = 8 —— 界面已禁用,无历史数据 /// public enum InterestModeEnum { - Unknown, - 固定值, - 合约名义本金规模, - 持仓名义本金, - 持仓市值, - 初始预付金, - 追加预付金, - 多头存续名义本金, - 空头存续名义本金, - 标的期初全价 + Unknown = 0, + 固定值 = 1, + 合约名义本金规模 = 2, + // 3=持仓名义本金(已删), 4=持仓市值(已删) + 初始预付金 = 5, + 追加预付金 = 6, + // 7=多头存续名义本金(已删), 8=空头存续名义本金(已删) + 标的期初全价 = 9 } /// /// 计息方式 /// public enum InterestTypeEnum { - 单利, - 复利 + 单利 = 0, + 复利 = 1 } } diff --git a/Framework/YLErp.Core/Interest/IIndexFixer.cs b/Framework/YLErp.Core/Interest/IIndexFixer.cs new file mode 100644 index 00000000..b7a7dea9 --- /dev/null +++ b/Framework/YLErp.Core/Interest/IIndexFixer.cs @@ -0,0 +1,11 @@ +namespace YLErp.Derivatives.Interest; + +/// +/// 浮动利率指数取价器(fixing)。给定取价日和标的代码,返回当日利率。 +/// 参考 QuantLib IborIndex:取价与计息分离,计息函数只消费 rate。 +/// +public interface IIndexFixer +{ + /// 该日利率(小数,如 0.0134 = 1.34%)。取不到时为 0。 + bool TryGetFixing(DateTime fixingDate, string underlyingCode, out decimal rate); +} diff --git a/Framework/YLErp.Core/Interest/IndexFixerBase.cs b/Framework/YLErp.Core/Interest/IndexFixerBase.cs new file mode 100644 index 00000000..72f958d5 --- /dev/null +++ b/Framework/YLErp.Core/Interest/IndexFixerBase.cs @@ -0,0 +1,20 @@ +using YLErp.QdpModule; + +namespace YLErp.Derivatives.Interest; + +/// +/// 取价日计算工具,供 IIndexFixer 实现复用。 +/// 收敛原 SwapDealService 里散落 6 处的 GetNonHolidayDefore(date.AddDays(rule))。 +/// +public abstract class IndexFixerBase +{ + /// + /// 重置日 + 利率规则 → 取价日(工作日回拨)。 + /// interestRule: 0 = 当前营业日,-1 = 前一营业日。 + /// + public static DateTime GetFixingDate(DateTime resetDate, int interestRule) + => QdpCalendarHelper.GetNonHolidayDefore(resetDate.AddDays(interestRule)); + + public static DateTime GetFixingDate(DateTime resetDate, int? interestRule) + => GetFixingDate(resetDate, interestRule ?? 0); +} diff --git a/UnitTestProject/Modules/SwapModule/FundingLegs/FundingLegStrategyTest.cs b/UnitTestProject/Modules/SwapModule/FundingLegs/FundingLegStrategyTest.cs new file mode 100644 index 00000000..c3afb09f --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/FundingLegs/FundingLegStrategyTest.cs @@ -0,0 +1,143 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using YLErp.DBModels.Enums; +using YLErp.Modules.SwapModule.FundingLegs; + +namespace UnitTestProject.Modules.SwapModule.FundingLegs +{ + /// + /// 融资腿策略单测。验证每个策略的 CalcNotional 与现有 CalcNotionalByMode switch 完全一致。 + /// 这组测试是后续"迁移调用点"的安全网——迁移前后行为必须不变。 + /// + [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) + + [TestMethod] + public void 固定值_部分平仓_计息基数恒等于Fix() + { + var leg = new FixedAmountLeg(); + var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0.5m); + + Assert.AreEqual(Fix, r.ClosePrincipal, "平仓本金恒=Fix"); + Assert.AreEqual(Fix, r.PosiPrincipal, "持仓本金恒=Fix"); + Assert.AreEqual(1m, r.ClosePercent); + } + + [TestMethod] + public void 固定值_全平_计息基数仍等于Fix() + { + var leg = new FixedAmountLeg(); + var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 1m); + Assert.AreEqual(Fix, r.ClosePrincipal); + } + + #endregion + + #region 合约名义本金规模(mode 2) + + [TestMethod] + public void 合约名义本金_部分平仓_本金按比例缩放() + { + var leg = new ContractNotionalLeg(); + var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0.5m); + + Assert.AreEqual(50_000_000m, r.ClosePrincipal); + Assert.AreEqual(Notional, r.PosiPrincipal); + Assert.AreEqual(0.5m, r.ClosePercent); + } + + [TestMethod] + public void 合约名义本金_全平_本金等于全额() + { + var leg = new ContractNotionalLeg(); + var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 1m); + Assert.AreEqual(Notional, r.ClosePrincipal); + } + + [TestMethod] + public void 合约名义本金_零平仓_本金为零() + { + var leg = new ContractNotionalLeg(); + var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0m); + + Assert.AreEqual(0m, r.ClosePrincipal); + Assert.AreEqual(Notional, r.PosiPrincipal); + } + + #endregion + + #region 标的期初全价(mode 9) + + [TestMethod] + public void 标的期初全价_部分平仓_主路径公式同mode2() + { + var leg = new UnderlyingEntryFullPriceLeg(); + var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0.5m); + + Assert.AreEqual(50_000_000m, r.ClosePrincipal); + Assert.AreEqual(Notional, r.PosiPrincipal); + Assert.AreEqual(0.5m, r.ClosePercent); + } + + [TestMethod] + public void 标的期初全价_全平_本金等于全额() + { + var leg = new UnderlyingEntryFullPriceLeg(); + var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 1m); + Assert.AreEqual(Notional, r.ClosePrincipal); + } + + #endregion + + #region 守卫 + + [TestMethod] + public void 各策略对应正确枚举值() + { + Assert.AreEqual(InterestModeEnum.固定值, new FixedAmountLeg().Mode); + Assert.AreEqual(InterestModeEnum.合约名义本金规模, new ContractNotionalLeg().Mode); + Assert.AreEqual(InterestModeEnum.标的期初全价, new UnderlyingEntryFullPriceLeg().Mode); + } + + #endregion + + #region 工厂 + + [TestMethod] + public void 工厂_返回各活跃mode的策略() + { + Assert.IsInstanceOfType(FundingLegStrategyFactory.Get(InterestModeEnum.固定值), typeof(FixedAmountLeg)); + Assert.IsInstanceOfType(FundingLegStrategyFactory.Get(InterestModeEnum.合约名义本金规模), typeof(ContractNotionalLeg)); + Assert.IsInstanceOfType(FundingLegStrategyFactory.Get(InterestModeEnum.标的期初全价), typeof(UnderlyingEntryFullPriceLeg)); + } + + [TestMethod] + public void 工厂_未注册mode抛异常() + { + // mode 3/4/7/8 已从枚举删除,用 Unknown(0)验证未注册抛异常 + Assert.ThrowsException(() => + FundingLegStrategyFactory.Get(InterestModeEnum.Unknown)); + Assert.ThrowsException(() => + FundingLegStrategyFactory.Get(InterestModeEnum.初始预付金)); + Assert.ThrowsException(() => + FundingLegStrategyFactory.Get(InterestModeEnum.追加预付金)); + } + + [TestMethod] + public void 工厂_int重载和枚举重载等价() + { + var byEnum = FundingLegStrategyFactory.Get(InterestModeEnum.固定值); + var byInt = FundingLegStrategyFactory.Get((int)InterestModeEnum.固定值); + Assert.AreEqual(byEnum.Mode, byInt.Mode); + } + + #endregion + } +} diff --git a/UnitTestProject/Modules/SwapModule/Margin/MarginLegTest.cs b/UnitTestProject/Modules/SwapModule/Margin/MarginLegTest.cs new file mode 100644 index 00000000..156eb932 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/Margin/MarginLegTest.cs @@ -0,0 +1,121 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using YLErp.Derivatives.Interest; +using YLErp.Modules.SwapModule.Margin; + +namespace UnitTestProject.Modules.SwapModule.Margin +{ + /// + /// 保证金账户(MarginAccount)单测。验证余额变动(追加/释放/返还)。 + /// 保证金就是保证金——有余额、有利率、有利息,不存在"计息基数/Notional"概念。 + /// + [TestClass] + public class MarginLegTest + { + private const decimal Opening = 2_000_000m; + + #region MarginAccount 余额变动 + + [TestMethod] + public void 账户_初始余额等于期初保证金() + { + var account = new MarginAccount(new MarginBalance(Opening)); + Assert.AreEqual(Opening, account.Balance.Balance); + } + + [TestMethod] + public void 账户_追加保证金_余额增加() + { + var account = new MarginAccount(new MarginBalance(Opening)); + account.Deposit(500_000m); + Assert.AreEqual(2_500_000m, account.Balance.Balance); + } + + [TestMethod] + public void 账户_释放保证金_余额减少() + { + var account = new MarginAccount(new MarginBalance(Opening)); + account.Withdraw(800_000m); + Assert.AreEqual(1_200_000m, account.Balance.Balance); + } + + [TestMethod] + public void 账户_释放超过余额_不低于零() + { + var account = new MarginAccount(new MarginBalance(Opening)); + account.Withdraw(3_000_000m); + Assert.AreEqual(0m, account.Balance.Balance, "保证金余额不低于零"); + } + + #endregion + + #region 三种保证金形态解析器 + + [TestMethod] + public void 三种形态解析器_各自返回正确Form和余额() + { + IMarginResolver cash = new CashMargin(); + IMarginResolver credit = new CreditMargin(); + IMarginResolver guarantee = new GuaranteeMargin(); + + Assert.AreEqual(MarginForm.Cash, cash.Form); + Assert.AreEqual(MarginForm.Credit, credit.Form); + Assert.AreEqual(MarginForm.Guarantee, guarantee.Form); + + Assert.AreEqual(Opening, cash.Resolve(Opening).Balance); + Assert.AreEqual(Opening, credit.Resolve(Opening).Balance); + Assert.AreEqual(Opening, guarantee.Resolve(Opening).Balance); + } + + #endregion + + #region MarginAccount 计息 + + [TestMethod] + public void 计息_单利7天_余额200万年化3pct() + { + var account = new MarginAccount(new MarginBalance(2_000_000m)); + // 200万 × 3% / 365 × 7天 = 1150.68... + var r = account.AccrueInterest( + rate: 0.03m, + startDate: new System.DateTime(2026, 5, 4), + endDate: new System.DateTime(2026, 5, 11), + boundary: AccrualBoundary.StartOnly, + annualDays: 365); + + Assert.IsTrue(r.Accrued > 0, "7天利息应大于0"); + System.Console.WriteLine($"保证金7天利息={r.Accrued}"); + } + + [TestMethod] + public void 计息_零余额_利息为零() + { + var account = new MarginAccount(new MarginBalance(0m)); + var r = account.AccrueInterest(0.03m, + new System.DateTime(2026, 5, 4), new System.DateTime(2026, 5, 11), + AccrualBoundary.StartOnly, 365); + + Assert.AreEqual(0m, r.Accrued); + } + + [TestMethod] + public void 计息_释放后余额减少_利息相应减少() + { + var full = new MarginAccount(new MarginBalance(2_000_000m)); + var half = new MarginAccount(new MarginBalance(2_000_000m)); + half.Withdraw(1_000_000m); + + var rFull = full.AccrueInterest(0.03m, + new System.DateTime(2026, 5, 4), new System.DateTime(2026, 5, 11), + AccrualBoundary.StartOnly, 365); + var rHalf = half.AccrueInterest(0.03m, + new System.DateTime(2026, 5, 4), new System.DateTime(2026, 5, 11), + AccrualBoundary.StartOnly, 365); + + Assert.IsTrue(rHalf.Accrued < rFull.Accrued, "释放后利息应更少"); + Assert.IsTrue(System.Math.Abs(rFull.Accrued - rHalf.Accrued * 2m) < 0.01m, + "余额减半, 利息也应减半"); + } + + #endregion + } +} diff --git a/UnitTestProject/Modules/SwapModule/Margin/MarginModesTest.cs b/UnitTestProject/Modules/SwapModule/Margin/MarginModesTest.cs new file mode 100644 index 00000000..edf94c89 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/Margin/MarginModesTest.cs @@ -0,0 +1,48 @@ +using System.Linq; +using System.Collections.Generic; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using YLErp.DBModels; +using YLErp.Modules.SwapModule.Margin; + +namespace UnitTestProject.Modules.SwapModule.Margin +{ + /// + /// MarginModes 统一判断口径测试。 + /// 验证它和现有散落的 marginTypes/InterestMarginModels/premiumModes 内容一致。 + /// + [TestClass] + public class MarginModesTest + { + [TestMethod] + public void All_只含初始预付金和追加预付金() + { + CollectionAssert.AreEquivalent( + new[] { (int)InterestModeEnum.初始预付金, (int)InterestModeEnum.追加预付金 }, + MarginModes.All.ToList()); + } + + [TestMethod] + public void Contains_初始预付金_返回true() + => Assert.IsTrue(MarginModes.Contains((int)InterestModeEnum.初始预付金)); + + [TestMethod] + public void Contains_追加预付金_返回true() + => Assert.IsTrue(MarginModes.Contains((int)InterestModeEnum.追加预付金)); + + [TestMethod] + public void Contains_融资腿mode_返回false() + { + Assert.IsFalse(MarginModes.Contains((int)InterestModeEnum.固定值)); + Assert.IsFalse(MarginModes.Contains((int)InterestModeEnum.合约名义本金规模)); + Assert.IsFalse(MarginModes.Contains((int)InterestModeEnum.标的期初全价)); + } + + /// 守护:和 ConsTrade.InterestMarginModels 内容必须一致(迁移期对齐)。 + [TestMethod] + public void 与ConsTradeInterestMarginModels内容一致() + { + var consTrade = new List { (int)InterestModeEnum.初始预付金, (int)InterestModeEnum.追加预付金 }; + CollectionAssert.AreEquivalent(consTrade, MarginModes.All.ToList()); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/ReturnLegs/DirectionRatioTest.cs b/UnitTestProject/Modules/SwapModule/ReturnLegs/DirectionRatioTest.cs new file mode 100644 index 00000000..ac36a4f2 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/ReturnLegs/DirectionRatioTest.cs @@ -0,0 +1,27 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using YLErp.DBModels; +using YLErp.DBModels.Enums; +using YLErp.Modules.SwapModule.ReturnLegs; + +namespace UnitTestProject.Modules.SwapModule.ReturnLegs +{ + [TestClass] + public class DirectionRatioTest + { + [TestMethod] + public void LongShort_多头_返回1() + => Assert.AreEqual(1, DirectionRatio.LongShort((int)PositionTypeFlag.Long)); + + [TestMethod] + public void LongShort_空头_返回负1() + => Assert.AreEqual(-1, DirectionRatio.LongShort((int)PositionTypeFlag.Short)); + + [TestMethod] + public void ReceivePay_收取_返回1() + => Assert.AreEqual(1, DirectionRatio.ReceivePay((int)SwapDirectionEnum.收取)); + + [TestMethod] + public void ReceivePay_支付_返回负1() + => Assert.AreEqual(-1, DirectionRatio.ReceivePay((int)SwapDirectionEnum.支付)); + } +} diff --git a/UnitTestProject/Modules/SwapModule/ReturnLegs/DividendCalcTest.cs b/UnitTestProject/Modules/SwapModule/ReturnLegs/DividendCalcTest.cs new file mode 100644 index 00000000..20a842ad --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/ReturnLegs/DividendCalcTest.cs @@ -0,0 +1,37 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using YLErp.Modules.SwapModule.ReturnLegs; + +namespace UnitTestProject.Modules.SwapModule.ReturnLegs +{ + [TestClass] + public class DividendCalcTest + { + [TestMethod] + public void AfterTax_零税率_等于原值() + { + Assert.AreEqual(1000m, DividendCalc.AfterTax(1000m, 0m)); + } + + [TestMethod] + public void AfterTax_6pct增值税() + { + // 1000 / 1.06 * 0.94 = 886.79... + Assert.AreEqual(886.79m, DividendCalc.AfterTax(1000m, 0.06m)); + } + + [TestMethod] + public void AfterTax_负票息() + { + // -500 / 1.06 * 0.94 = -443.40 + Assert.AreEqual(-443.40m, DividendCalc.AfterTax(-500m, 0.06m)); + } + + [TestMethod] + public void AfterTaxRaw_不四舍五入() + { + var raw = DividendCalc.AfterTaxRaw(1000m, 0.06m); + Assert.AreNotEqual(886.79m, raw, "Raw 版本不四舍五入"); + Assert.IsTrue(raw > 886.79m && raw < 886.80m); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/ReturnLegs/MtmCalcTest.cs b/UnitTestProject/Modules/SwapModule/ReturnLegs/MtmCalcTest.cs new file mode 100644 index 00000000..da6969dd --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/ReturnLegs/MtmCalcTest.cs @@ -0,0 +1,45 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using YLErp.Modules.SwapModule.ReturnLegs; + +namespace UnitTestProject.Modules.SwapModule.ReturnLegs +{ + [TestClass] + public class MtmCalcTest + { + [TestMethod] + public void MarketValue_多头_为正() + { + Assert.AreEqual(100_000m, MtmCalc.MarketValue(100m, 1000m, 1m, 1)); + } + + [TestMethod] + public void MarketValue_空头_为负() + { + Assert.AreEqual(-100_000m, MtmCalc.MarketValue(100m, 1000m, 1m, -1)); + } + + [TestMethod] + public void MarketValue_合约乘数10() + { + Assert.AreEqual(1_000_000m, MtmCalc.MarketValue(100m, 1000m, 10m, 1)); + } + + [TestMethod] + public void UnrealizedPnl_多头浮盈() + { + Assert.AreEqual(5000m, MtmCalc.UnrealizedPnl(105m, 100m, 1000m, 1m, 1, 1)); + } + + [TestMethod] + public void UnrealizedPnl_多头浮亏() + { + Assert.AreEqual(-5000m, MtmCalc.UnrealizedPnl(95m, 100m, 1000m, 1m, 1, 1)); + } + + [TestMethod] + public void UnrealizedPnl_空头反向() + { + Assert.AreEqual(5000m, MtmCalc.UnrealizedPnl(95m, 100m, 1000m, 1m, -1, 1)); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/ReturnLegs/QtyRollforwardTest.cs b/UnitTestProject/Modules/SwapModule/ReturnLegs/QtyRollforwardTest.cs new file mode 100644 index 00000000..3c423d33 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/ReturnLegs/QtyRollforwardTest.cs @@ -0,0 +1,60 @@ +using YLErp.Modules.SwapModule.ReturnLegs; + +namespace UnitTestProject.Modules.SwapModule.ReturnLegs +{ + /// + /// 标的端数量递推测试。验证开仓/平仓/公司行为场景下数量正确推进。 + /// + [TestClass] + public class QtyRollforwardTest + { + [TestMethod] + public void 无交易_数量不变() + { + Assert.AreEqual(1000m, QtyRollforward.Calc(1000m, 0m, 0m)); + } + + [TestMethod] + public void 开仓_数量增加() + { + Assert.AreEqual(1500m, QtyRollforward.Calc(1000m, 500m, 0m)); + } + + [TestMethod] + public void 平仓_数量减少() + { + Assert.AreEqual(600m, QtyRollforward.Calc(1000m, 0m, 400m)); + } + + [TestMethod] + public void 开仓加平仓_净值() + { + Assert.AreEqual(1200m, QtyRollforward.Calc(1000m, 500m, 300m)); + } + + [TestMethod] + public void 全平_数量归零() + { + Assert.AreEqual(0m, QtyRollforward.Calc(1000m, 0m, 1000m)); + } + + [TestMethod] + public void 超额平仓_不低于零() + { + Assert.AreEqual(0m, QtyRollforward.Calc(1000m, 0m, 1500m)); + } + + [TestMethod] + public void 公司行为送股_数量增加_默认不影响() + { + Assert.AreEqual(1000m, QtyRollforward.Calc(1000m, 0m, 0m)); + Assert.AreEqual(1100m, QtyRollforward.Calc(1000m, 0m, 0m, corpActionDeltaQty: 100m)); + } + + [TestMethod] + public void 公司行为拆股_数量翻倍() + { + Assert.AreEqual(2000m, QtyRollforward.Calc(1000m, 0m, 0m, corpActionDeltaQty: 1000m)); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/ReturnLegs/ReturnLegSummaryTest.cs b/UnitTestProject/Modules/SwapModule/ReturnLegs/ReturnLegSummaryTest.cs new file mode 100644 index 00000000..61f9a69c --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/ReturnLegs/ReturnLegSummaryTest.cs @@ -0,0 +1,46 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using YLErp.Modules.SwapModule.ReturnLegs; + +namespace UnitTestProject.Modules.SwapModule.ReturnLegs +{ + /// + /// ReturnLegSummary 值对象测试。 + /// 验证标的端归档产出的汇总值正确构造、字段语义清晰。 + /// + [TestClass] + public class ReturnLegSummaryTest + { + [TestMethod] + public void 构造_四个字段正确赋值() + { + var s = new ReturnLegSummary( + longNotional: 100_000_000m, + shortNotional: 0m, + closeNotional: 50_000_000m, + grossPrice: 1.02m); + + Assert.AreEqual(100_000_000m, s.LongNotional); + Assert.AreEqual(0m, s.ShortNotional); + Assert.AreEqual(50_000_000m, s.CloseNotional); + Assert.AreEqual(1.02m, s.GrossPrice); + } + + [TestMethod] + public void 构造_空头场景() + { + var s = new ReturnLegSummary(0m, 80_000_000m, 30_000_000m, 0.98m); + + Assert.AreEqual(0m, s.LongNotional, "无多头"); + Assert.AreEqual(80_000_000m, s.ShortNotional, "空头名义本金"); + } + + [TestMethod] + public void 构造_全平场景() + { + var s = new ReturnLegSummary(0m, 0m, 100_000_000m, 1.00m); + + Assert.AreEqual(100_000_000m, s.CloseNotional, "全平:平仓名义本金=全额"); + Assert.AreEqual(0m, s.LongNotional, "全平后无多头剩余"); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/SwapFixedInterestLegClosePercentBugTest.cs b/UnitTestProject/Modules/SwapModule/SwapFixedInterestLegClosePercentBugTest.cs deleted file mode 100644 index 3bdd657c..00000000 --- a/UnitTestProject/Modules/SwapModule/SwapFixedInterestLegClosePercentBugTest.cs +++ /dev/null @@ -1,341 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using Newtonsoft.Json; -using YLErp; -using YLErp.DBModels; -using YLErp.DBModels.Enums; -using YLErp.Modules.SwapModule; - -namespace UnitTestProject.Modules.SwapModule -{ - /// - /// 【缺陷证明测试】界面「全部平仓 → 部分平仓」,改变平仓比例,下方利息腿数据不变。 - /// ------------------------------------------------------------------------------ - /// 现象(前端):SwapUnwind.cshtml 的 ClosePercent 输入框 → changeClosePercent → - /// getInterestList → POST /swaptrade2/GetUnwindInterestList → 后端返回的利息腿列表数值不随比例变化。 - /// - /// 根因(后端,本测试要钉死的):SwapDealService.CalcNotionalByMode - /// case InterestModeEnum.固定值: - /// closePrincipal = posiPrincipal = position.InterestPrincipalFix; - /// newClosePercent = 1m; // ← 这一行把平仓比例直接抹成 1 - /// 固定值利息腿的计息本金恒等于 InterestPrincipalFix(与比例无关), - /// 同时 newClosePercent 被强制置 1,后续 CalcDailySimpleInterest / CalcDailyCompoundInterest - /// 拿到的 closePercent 也是 1 → 无论前端传 30% / 50% / 70% / 100%,返回利息完全相同。 - /// - /// 对照组:InterestModeEnum.标的期初全价(9) 走 closePrincipal = posiNotional * closePercent, - /// 线性缩放,作为「正确行为」的基准。若对照组也不变,说明问题不在本函数(排除法)。 - /// - /// 调用方式:完全复刻生产 GetUnwindInterests(SwapDealService.cs:640-645) 的入参构造—— - /// posiNotionalValue = stockEqvNotional(剩余名义本金,不乘比例) - /// posiLongNotionalValue = 多头剩余 - /// closePosiNotionalValue = stockEqvNotional * closePercent - /// closePrecent = closePercent(控制器已做 A→B 口径转换) - /// add=true, settment=false(盘中预览,不落库) - /// 因此这是**真实生产函数**的行为,不是夹具自造逻辑。 - /// - /// 期望(修复前 红 / 修复后 绿):固定值腿利息应与 closePercent 成正比。 - /// - [TestClass] - public class SwapFixedInterestLegClosePercentBugTest - { - private const decimal Notional = 100_000_000m; // 期初=剩余名义本金 1 亿 - private const decimal FixedRate = 0.03m; // 固定年利率 3% - private const int AnnualDays = 365; - - private static readonly DateTime StartDate = new DateTime(2026, 4, 21); - private static readonly DateTime Maturity = new DateTime(2026, 6, 30); - private static readonly DateTime CloseDate = new DateTime(2026, 5, 11); - - #region Stub(无库) - - private sealed class StubSvc : SwapDealService - { - public StubSvc() : base(new OptUserInfo(0, nameof(SwapFixedInterestLegClosePercentBugTest), OptUserFrom.UnitTest)) { } - // 无库环境:已消耗利息视为 0 - public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate) => 0m; - } - - #endregion - - #region 构造器 - - private static trade CreateTrade(string interestCalcMode) - { - var extend = new trade_extend - { - TradeId = 1, - ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson - { - AnnualDays = AnnualDays, - InterestCalcMode = interestCalcMode, - SettlementRules = 0 - }) - }; - return new trade - { - id = 1, - TradeNumber = "UT-CLOSEPCT-BUG", - ClientId = 999998, - TradeType = "收益互换", - TradeDate = StartDate, - StartDate = StartDate, - ExerciseDate = Maturity, - TradeStatus = "确认成交", - ValidState = "Valid", - trade_extend = extend - }; - } - - /// 固定利率利息腿(不走浮动曲线),仅 InterestMode 不同 - private static swap_position CreateLeg(int interestMode, InterestTypeEnum interestType) - { - var intervalModels = new List - { - new IntervalModel { Date = Maturity, Rate = FixedRate, Settlement = 0 } - }; - return new swap_position - { - id = 1001, - SwapTradeId = 1, - PositionType = (int)PositionTypeFlag.Unknown, - PosiDirection = 0, // 利息腿 - InterestDirection = (int)SwapDirectionEnum.收取, - InterestMode = interestMode, - InterestRateDefault = FixedRate, - InterestPrincipalFix = Notional, // 固定值腿的计息本金 - PosiStartDate = StartDate, - PosiMatuirityDate = Maturity, - IsInitial = true, - Invalid = false, - InterestType = (int)interestType, - IsAnnualized = true, - interest_rest_days = 1, - interest_rule = 0, - FloatRateUnderlyingCode = null, // 固定利率 - InterestSwapInterval = JsonConvert.SerializeObject(intervalModels) - }; - } - - /// - /// 复刻 GetUnwindInterests(SwapDealService.cs:640-645) 的入参构造, - /// 唯一变量是界面输入的 closePercent。 - /// - private static swap_flow_event CallProductionPath(trade td, swap_position leg, decimal closePercent) - { - var svc = new StubSvc(); - var stockEqvNotional = Notional; // 剩余名义本金 - var posiLongNotionalValue = Notional; - var posiShortNotionalValue = 0m; - var closePosiNotionalValue = stockEqvNotional * closePercent; // 生产:posiNotionalValue = stockEqvNotional * closePercent - var orginPv = stockEqvNotional; - - var interests = svc.GetInterests( - td, td.trade_extend, CloseDate, CloseDate, - new List(), // 无上一日 EOD - new List { leg }, - stockEqvNotional, - posiLongNotionalValue, - posiShortNotionalValue, - closePosiNotionalValue, - closePercent, - (int)SwapEventTypeEnum.平仓, - tdClose: false, - needPrice: false, - grossPrice: 0m, - orginPv: orginPv, - add: true, - settment: false); - - Assert.AreEqual(1, interests.Count, "应返回 1 条利息腿"); - return interests[0]; - } - - #endregion - - /// 与 CallProductionPath 相同,但允许指定平仓日期(用于两段式生命周期) - private static swap_flow_event CallWithDate(trade td, swap_position leg, decimal closePercent, DateTime closeDate) - { - var svc = new StubSvc(); - var stockEqvNotional = Notional; - var posiLongNotionalValue = Notional; - var posiShortNotionalValue = 0m; - var closePosiNotionalValue = stockEqvNotional * closePercent; - var orginPv = stockEqvNotional; - - var interests = svc.GetInterests( - td, td.trade_extend, closeDate, closeDate, - new List(), - new List { leg }, - stockEqvNotional, - posiLongNotionalValue, - posiShortNotionalValue, - closePosiNotionalValue, - closePercent, - (int)SwapEventTypeEnum.平仓, - tdClose: false, - needPrice: false, - grossPrice: 0m, - orginPv: orginPv, - add: true, - settment: false); - - Assert.AreEqual(1, interests.Count, "应返回 1 条利息腿"); - return interests[0]; - } - - #region 业务场景4-2:固定值腿 部分平仓后再全平(两段式,GLMS-债券TRS期间结算 260702「未测」缺口) - - /// - /// 覆盖「国联民生-债券TRS期间结算功能测试260702.xlsx」Sheet1 矩阵中唯一标记为「未测」的格子: - /// 业务场景4-2(固定值腿,部分平仓一次后经过数日再全平)。 - /// 同时守护 d1badfe4 的固定值腿平仓比例缩放修复—— - /// 修复前 newClosePercent=1,部分平仓利息与全平完全相同(界面改比例利息腿不变)。 - /// 本测试断言两段利息各自随平仓比例线性缩放(30% / 70%),正是该场景的回归守卫。 - /// - [DataTestMethod] - [DataRow("11")] // 算头算尾 - [DataRow("10")] // 算头不算尾 - public void 业务场景4_2_固定值腿_部分平仓后再全平_两段利息均随比例线性缩放(string calcMode) - { - var td = CreateTrade(calcMode); - var leg = CreateLeg((int)InterestModeEnum.固定值, InterestTypeEnum.单利); - - // 单步全平(100%)作为基准 - var full = CallProductionPath(td, leg, 1m); - Assert.AreNotEqual(0m, full.InterestAmount, "全平利息不应为 0,否则用例无区分度"); - - var partialDate = new DateTime(2026, 5, 11); - // 第一段:部分平仓 30%(较早日期 2026-05-11) - var i30 = CallWithDate(td, leg, 0.3m, partialDate); - // 第二段:剩余 70% 全平(到期日,算尾) - var i70 = CallWithDate(td, leg, 0.7m, Maturity); - // 各段对照「同日期全平」基准:缩放必须在相同日期范围内比较 - var fullAtPartial = CallWithDate(td, leg, 1m, partialDate); - var fullAtMaturity = CallWithDate(td, leg, 1m, Maturity); - - Console.WriteLine($"[场景4-2 mode=1 calcMode={calcMode}] 5/11全平={fullAtPartial.InterestAmount} 30%={i30.InterestAmount} 到期全平={fullAtMaturity.InterestAmount} 70%={i70.InterestAmount}"); - - // 核心断言:两段利息必须各自随平仓比例线性缩放(守护 mode-1 修复,修复前会等于全平) - Assert.AreEqual((double)(fullAtPartial.InterestAmount * 0.3m), (double)i30.InterestAmount, 0.01, - "固定值腿第一段(30%)利息应≈同日期全平×30%(修复前会等于全平,即 Bug)"); - Assert.AreEqual((double)(fullAtMaturity.InterestAmount * 0.7m), (double)i70.InterestAmount, 0.01, - "固定值腿第二段(70%)利息应≈同日期全平×70%"); - } - - #endregion - - #region 对照组:标的期初全价(9) —— 正确行为,比例线性缩放 - - [DataTestMethod] - [DataRow("11")] // 算头算尾 - [DataRow("10")] // 算头不算尾 - public void 对照组_标的期初全价腿_利息应随平仓比例线性变化(string calcMode) - { - var td = CreateTrade(calcMode); - var full = CallProductionPath(td, CreateLeg((int)InterestModeEnum.标的期初全价, InterestTypeEnum.单利), 1m); - - Console.WriteLine($"[对照组 mode=9 calcMode={calcMode}] 100% 利息 = {full.InterestAmount}"); - Assert.AreNotEqual(0m, full.InterestAmount, "全平利息不应为 0,否则用例无区分度"); - - foreach (var pct in new[] { 0.3m, 0.5m, 0.7m }) - { - var part = CallProductionPath(td, CreateLeg((int)InterestModeEnum.标的期初全价, InterestTypeEnum.单利), pct); - var expect = full.InterestAmount * pct; - Console.WriteLine($" {pct:P0} 实际={part.InterestAmount} 期望={expect} 差={part.InterestAmount - expect}"); - Assert.AreEqual((double)expect, (double)part.InterestAmount, 0.01, - $"mode=9 应线性缩放:{pct:P0}"); - } - } - - #endregion - - #region 缺陷组:固定值(1) —— 当前不随比例变化(修复前红) - - [DataTestMethod] - [DataRow("11", 0)] // 算头算尾 + 单利 - [DataRow("10", 0)] // 算头不算尾 + 单利 - [DataRow("11", 1)] // 算头算尾 + 复利 - [DataRow("10", 1)] // 算头不算尾 + 复利 - public void 缺陷_固定值利息腿_利息必须随平仓比例线性变化(string calcMode, int typeFlag) - { - var type = typeFlag == 1 ? InterestTypeEnum.复利 : InterestTypeEnum.单利; - var td = CreateTrade(calcMode); - var full = CallProductionPath(td, CreateLeg((int)InterestModeEnum.固定值, type), 1m); - - Console.WriteLine($"[缺陷组 mode=1(固定值) calcMode={calcMode} {type}] 100% 利息 = {full.InterestAmount}"); - Assert.AreNotEqual(0m, full.InterestAmount, "全平利息不应为 0,否则用例无区分度"); - - var diffs = new List(); - var unchanged = 0; - foreach (var pct in new[] { 0.3m, 0.5m, 0.7m }) - { - var part = CallProductionPath(td, CreateLeg((int)InterestModeEnum.固定值, type), pct); - var expect = full.InterestAmount * pct; - var delta = part.InterestAmount - expect; - if (part.InterestAmount == full.InterestAmount) unchanged++; - diffs.Add($" {pct:P0} 实际={part.InterestAmount} 期望={expect} 偏差={delta}"); - } - diffs.ForEach(Console.WriteLine); - if (unchanged == 3) - { - Console.WriteLine(" ▲ 三个比例返回值与 100% 完全一致 → 复现「界面改比例利息腿数据不变」"); - Console.WriteLine(" ▲ 根因:CalcNotionalByMode case 固定值 → newClosePercent = 1m"); - } - - foreach (var pct in new[] { 0.3m, 0.5m, 0.7m }) - { - var part = CallProductionPath(td, CreateLeg((int)InterestModeEnum.固定值, type), pct); - Assert.AreEqual((double)(full.InterestAmount * pct), (double)part.InterestAmount, 0.01, - $"固定值腿应随比例缩放,但 {pct:P0} 与全平返回相同值(Bug)"); - } - } - - #endregion - - #region 单元级:直接断言 CalcNotionalByMode 的比例语义 - - /// - /// 反射直击私有方法 CalcNotionalByMode,剥离所有计息细节, - /// 只看「(closePrincipal, newClosePercent) 是否体现了平仓比例」。 - /// 注意:closePrincipal 与 newClosePercent 作用在**不同项**上—— - /// closePrincipal 决定"本期新增利息"的基数,newClosePercent 决定"历史累计/已消耗利息"的缩放, - /// 二者不相乘(相乘会得到双重缩放的错误度量)。因此这里分别断言: - /// 1) closePrincipal 应 = Fix × pct - /// 2) newClosePercent 应 = pct(而非被强制置 1) - /// - [DataTestMethod] - [DataRow((int)InterestModeEnum.固定值, "0.3")] - [DataRow((int)InterestModeEnum.固定值, "0.5")] - [DataRow((int)InterestModeEnum.固定值, "0.7")] - public void CalcNotionalByMode_固定值腿_有效缩放系数必须等于平仓比例(int mode, string pctStr) - { - var pct = decimal.Parse(pctStr, System.Globalization.CultureInfo.InvariantCulture); - var leg = CreateLeg(mode, InterestTypeEnum.单利); - var m = typeof(SwapDealService).GetMethod("CalcNotionalByMode", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - Assert.IsNotNull(m, "未找到 CalcNotionalByMode,签名可能已变更"); - - var full = m.Invoke(new StubSvc(), new object[] { leg, 1m, Notional, Notional, 0m }); - var part = m.Invoke(new StubSvc(), new object[] { leg, pct, Notional, Notional, 0m }); - - var fullClose = (decimal)full.GetType().GetField("Item1").GetValue(full); - var fullPct = (decimal)full.GetType().GetField("Item3").GetValue(full); - var partClose = (decimal)part.GetType().GetField("Item1").GetValue(part); - var partPct = (decimal)part.GetType().GetField("Item3").GetValue(part); - - Console.WriteLine($"[CalcNotionalByMode mode={mode}] 100%: closePrincipal={fullClose} newClosePercent={fullPct}"); - Console.WriteLine($"[CalcNotionalByMode mode={mode}] {pct:P0}: closePrincipal={partClose} newClosePercent={partPct}"); - - // 1) 计息本金必须按比例缩放(本期新增利息的基数) - Assert.AreEqual((double)(fullClose * pct), (double)partClose, 0.01, - $"固定值腿 closePrincipal 应为 Fix×{pct:P0},实际未缩放"); - // 2) 传给 CalcDaily*Interest 的比例必须是真实平仓比例(历史累计/已消耗利息的缩放) - Assert.AreEqual((double)pct, (double)partPct, 1e-9, - $"固定值腿 newClosePercent 应为 {pct:P0},被强制置 1 会抹掉比例语义"); - } - - #endregion - } -} diff --git a/UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs b/UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs index e06b31a2..4d396b0a 100644 --- a/UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs @@ -100,6 +100,13 @@ namespace UnitTestProject.Modules.SwapModule private readonly List _eodPositions = new(); private readonly IReadOnlyDictionary _floatRates; + /// + /// 捕获最近一次 CalcSwapInterests 返回的 interests.First().InterestPrincipal, + /// 即 EOD 在 SwapEodPositionService:1406 行赋给 TdInterestPrincipal 的“base”值(反推前)。 + /// 用于测试中精确镜像 mode 2/9 分叉(:1458 反推 / :1465 不反推),避免对复利累计利息做人工猜测。 + /// + public decimal LastBaseInterestPrincipal { get; private set; } + public E2EEodService(trade td, List positions, trade_extend extend, IReadOnlyDictionary floatRates) : base(nameof(SwapInterestScenario3And4FloatingTest)) @@ -123,11 +130,40 @@ namespace UnitTestProject.Modules.SwapModule protected override List FindSwapPositions(int swapTradeId) => _positions.Where(x => x.SwapTradeId == swapTradeId && !x.IsInitial).ToList(); + // --- 真实交易要素:标的与付息数据(替代原过度简化 stub)--- + // 本用例 = FR007 浮动利率互换,真实要素:标的是利率指数(非债券),增值税率 0,无债券付息事件。 + // 这些值与生产一致(利率指数 VAT 免、不进付息路径),因此不改变任何计息结果,只是不再写死魔法值。 + private static readonly IReadOnlyDictionary _realUnderlyings = + new Dictionary + { + ["FR007"] = new underlying_manager + { + UnderlyingCode = "FR007", + UnderlyingInstrumentType = "FR007", // 利率指数,非债券,不触发付息/含税路径 + ValueAddedTax = 0m, + }, + }; + + // 真实付息数据源(内存镜像 BondPaymentService.GetBondPayments,按登记/付息日区间 (from, to] 筛选)。 + // FR007 无付息事件 → 恒为 0;若接入真实债券标的,应在此注入 bond_payment_info 记录(含 reg_date 登记日)。 + private static readonly List<(string code, DateTime payDate, decimal interest, decimal parValue)> _realBondPayments = + new(); + protected override underlying_manager GetUnderlyingData(string underlyingCode) - => new underlying_manager { ValueAddedTax = 0m, UnderlyingInstrumentType = "TBonds" }; + => _realUnderlyings.TryGetValue(underlyingCode, out var u) + ? u + : new underlying_manager { UnderlyingCode = underlyingCode, UnderlyingInstrumentType = "Other", ValueAddedTax = 0m }; + protected override decimal GetUnderlyingPrice(string code, DateTime settleDate, out decimal vobp) { vobp = 0m; return 100m; } - protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio) => 0m; + + protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio) + { + var interest = _realBondPayments + .Where(x => x.code == underlyingCode && x.payDate > fromDate && x.payDate <= toDate) + .Sum(x => x.interest); + return interest * qty; // 本用例恒为 0(FR007 无付息);金额换算对齐 BondPaymentService 口径 + } protected override void SaveEodSwapRecord(trade td, DateTime settleDate, DateTime preSettleDate) { } protected override void ExecuteInTransaction(Action action) => action(); @@ -149,10 +185,13 @@ namespace UnitTestProject.Modules.SwapModule { var svc = new RealSwapDealService( new OptUserInfo(0, nameof(SwapInterestScenario3And4FloatingTest), OptUserFrom.UnitTest), _floatRates, FlowEvents); - return svc.GetInterests(td, tradeExtend, valueDate, unwindDate, + var interests = svc.GetInterests(td, tradeExtend, valueDate, unwindDate, eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue, closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice, grossPrice, orginPv, add, settment, newCalcLast, closeList); + // 捕获 base InterestPrincipal(= EOD:1406 行赋给 TdInterestPrincipal 的值,反推前),供 TdInterestPrincipal 断言镜像分叉。 + LastBaseInterestPrincipal = interests.Count > 0 ? interests[0].InterestPrincipal : 0m; + return interests; } /// 对指定日期做真实日终收盘(无平仓),构建/累积 eod 链。 @@ -404,6 +443,9 @@ namespace UnitTestProject.Modules.SwapModule DebugCompare("场景3 " + note, oracle, eod.TdCloseInterest, eod); AssertStrict(oracle, eod.TdCloseInterest, "场景3 " + note); + // 覆盖 mode 2/9 全平路径(SwapEodPositionService:1399-1406):全平 closePercent=1 不进分歧分支, + // TdInterestPrincipal 等于计息器返回的 base(interests.First().InterestPrincipal,本服务已捕获到 LastBaseInterestPrincipal)。 + AssertStrict(_eod.LastBaseInterestPrincipal, eod.TdInterestPrincipal, "场景3 TdInterestPrincipal " + note); } #endregion @@ -443,15 +485,35 @@ namespace UnitTestProject.Modules.SwapModule // 第一步:2026-05-11 部分平仓 30%(真实 EOD 平仓结算,produces 真实 flow event) var partialCloseNotional = Notional * 0.3m; + var remainingNotional = Notional - partialCloseNotional; // 提前声明,供 TdInterestPrincipal 断言使用 var partialFlow = CalcCloseFlow(td, position, new DateTime(2026, 5, 11), new List(), partialCloseNotional, partialCloseNotional); var partialEod = _eod.ExecuteClose(position, new DateTime(2026, 5, 11), - Notional - partialCloseNotional, 0m, new List { partialFlow }, partialCloseNotional, prevEodPartial); + remainingNotional, 0m, new List { partialFlow }, partialCloseNotional, prevEodPartial); _eod.RecordEod(partialEod); DebugCompare("场景4[部分] " + note, oraclePartial, partialEod.TdCloseInterest, partialEod); AssertStrict(oraclePartial, partialEod.TdCloseInterest, "场景4[部分] " + note); + // 覆盖 mode 2/9 分叉(SwapEodPositionService:1436-1469):部分平仓后 TdInterestPrincipal 的经济口径 + // 必须 = 剩余动态本金(剩余名义本金 + 已并入本金的重置日待实现利息),mode2/9 应当一致。 + // 单利:line 1491 直接取 posiNotionalValue = remainingNotional,无累计利息。 + // 复利:base = interests.First().InterestPrincipal(本服务 CalcSwapInterests 已捕获到 LastBaseInterestPrincipal); + // mode2 仅在 calcLast 时于 1464 行反推剩余(× (1-cp)/cp),mode9 直取 base(GLMS-20260421-0004 禁止反推)。 + // calcLast=false(如“算头不算尾”)或 mode9 被错误反推会膨胀 ~2.3 倍(494982903.27),下方断言精确拦截回归。 + decimal expectedTdPrincipal; + if (!compound) + { + expectedTdPrincipal = remainingNotional; + } + else + { + var cp = partialCloseNotional / Notional; // = 0.3,与 EOD 内部 closePercent 一致 + bool reverseMode2 = interestMode == (int)InterestModeEnum.合约名义本金规模 && calcLast; + expectedTdPrincipal = reverseMode2 + ? _eod.LastBaseInterestPrincipal * (1m - cp) / cp + : _eod.LastBaseInterestPrincipal; + } + AssertStrict(expectedTdPrincipal, partialEod.TdInterestPrincipal, "场景4[部分] TdInterestPrincipal " + note); // 部分平仓后,剩余名义本金缩减为 70%(真实代码路径更新持仓口径) - var remainingNotional = Notional - partialCloseNotional; position.InterestPrincipalFix = remainingNotional; position.PosiNotionalValue = remainingNotional; diff --git a/UnitTestProject/Modules/SwapModule/SwapUnwindFloatingLegDiagnosticTdd.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindFloatingLegDiagnosticTdd.cs index c8645fac..9340fe93 100644 --- a/UnitTestProject/Modules/SwapModule/SwapUnwindFloatingLegDiagnosticTdd.cs +++ b/UnitTestProject/Modules/SwapModule/SwapUnwindFloatingLegDiagnosticTdd.cs @@ -94,19 +94,5 @@ namespace YLErp.Modules.SwapModule Assert.AreEqual(RealFix * ClosePct, close, "mode5 应 = 实时腿剩余本金(real Fix) * closePct,与 posiLong(orig 100M) 无关"); Assert.AreNotEqual(OrigFix * ClosePct, close, "务必不是期初 99,000 * closePct(证明后端修复生效)"); } - - [TestMethod] - public void 诊断_若将来有_多头存续名义本金_腿_posiLong用orig才出错_本deal无此腿_故不影响() - { - // 构造一个「多头存续名义本金」腿,证明此时 posiLong 取值(orig vs real)会直接决定结果—— - // 说明本 deal 没有这种腿,所以 fpositions 用 orig 100M 不影响;但普通收益互换若有此腿则会踩坑。 - var longLeg = new swap_position { id = 35799, InterestMode = (int)InterestModeEnum.多头存续名义本金 }; - var byOrig = CallCalcNotionalByMode(longLeg, ClosePct, RealLong * ClosePct, OrigLong, 0m); // 当前代码:posiLong=orig 100M - var byReal = CallCalcNotionalByMode(longLeg, ClosePct, RealLong * ClosePct, RealLong, 0m); // 若修正为 real 75.6M - Console.WriteLine($"[多头存续名义本金] orig100M→close={byOrig.close} ; real75.6M→close={byReal.close}"); - Assert.AreEqual(OrigLong * ClosePct, byOrig.close, "现状:多头存续名义本金用 orig 100M → 多次部分平仓后会偏大"); - Assert.AreEqual(RealLong * ClosePct, byReal.close, "正确应:用 real 剩余本金 75.6M"); - Assert.AreNotEqual(byOrig.close, byReal.close, "★ 潜在同类 bug:普通收益互换(含多头/空头存续名义本金腿)在多次部分平仓后,posiLong/posiShort 用 orig 会算错——本 deal 无此腿故不触发,属本轮修复范围外"); - } } } diff --git a/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs index 557bdb25..ee29d70d 100644 --- a/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs +++ b/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs @@ -505,27 +505,6 @@ namespace YLErp.Modules.SwapModule Assert.AreEqual(918_000m, fe1.InterestPrincipal, "追加预付金 10% 应=Fix×0.1"); } - // ---- 多头/空头存续名义本金(7/8):经同一 CalcDailySimpleInterest,需证明修复通用 ---- - [TestMethod] - public void 多头存续名义本金腿_盘中_部分平仓重置周期7天_应线性缩放() - { - const decimal baseP = 2_000_000m; - var fe = CalcByMode((int)InterestModeEnum.多头存续名义本金, baseP, 0.5m); - Assert.AreEqual(1_000_000m, fe.InterestPrincipal, "多头存续 50% 应=posiLong×0.5"); - var fe1 = CalcByMode((int)InterestModeEnum.多头存续名义本金, baseP, 0.1m); - Assert.AreEqual(200_000m, fe1.InterestPrincipal, "多头存续 10% 应=posiLong×0.1"); - } - - [TestMethod] - public void 空头存续名义本金腿_盘中_部分平仓重置周期7天_应线性缩放() - { - const decimal baseP = 2_000_000m; - var fe = CalcByMode((int)InterestModeEnum.空头存续名义本金, baseP, 0.5m); - Assert.AreEqual(1_000_000m, fe.InterestPrincipal, "空头存续 50% 应=posiShort×0.5"); - var fe1 = CalcByMode((int)InterestModeEnum.空头存续名义本金, baseP, 0.1m); - Assert.AreEqual(200_000m, fe1.InterestPrincipal, "空头存续 10% 应=posiShort×0.1"); - } - // ---- 合约名义本金规模(2):CalcNotionalByMode 默认分支(posiNotional×cp) ---- [TestMethod] public void 合约名义本金规模腿_盘中_部分平仓重置周期7天_应线性缩放() @@ -535,17 +514,15 @@ namespace YLErp.Modules.SwapModule Assert.AreEqual(1_000_000m, fe.InterestPrincipal, "合约名义本金规模 50% 应=posiNotional×0.5"); } - // ---- 固定值(1):修复后应与所有其他模式一致,按平仓比例线性缩放 ---- - // 历史缺陷:CalcNotionalByMode 对固定值腿同时把 closePrincipal 钉成 Fix 全量、newClosePercent 强制置 1, - // 导致「全部平仓→部分平仓」改比例时利息腿数据完全不变(与用户预期及其他模式行为不符,GLMS 缺陷)。 - // 修复见 SwapDealService.cs CalcNotionalByMode 固定值分支(引入提交 95686f4c,吴方海,EQD-5718,2026-05-08)。 - // 旧断言「对平仓比例免疫、恒=Fix」本身就是在保卫该缺陷,此处纠正为正确的线性缩放。 + // ---- 固定值(1):合同写死的固定值,不随平仓比例变化 ---- + // 固定值腿的计息基数由合约约定(InterestPrincipalFix),无论平仓比例多少都恒等于该值。 + // closePrincipal = posiPrincipal = Fix; newClosePercent = 1m 是正确的业务行为,不是缺陷。 [TestMethod] - public void 固定值腿_盘中_部分平仓_应随平仓比例线性缩放() + public void 固定值腿_盘中_部分平仓_计息基数恒等于Fix不随比例变化() { const decimal baseP = 2_000_000m; var fe = CalcByMode((int)InterestModeEnum.固定值, baseP, 0.5m); - Assert.AreEqual(1_000_000m, fe.InterestPrincipal, "固定值腿修复后 50% 应=Fix×0.5(与其他模式一致,缺陷已修复)"); + Assert.AreEqual(baseP, fe.InterestPrincipal, "固定值腿计息基数恒=Fix,不随平仓比例变化(合同约定)"); } // ---- 日终路径(settment=true):证明走 CalcDailySimpleInterestByEod,结果恒为线性 closePrincipal,不受盘中 bug 影响 ---- diff --git a/YLErpDAL/BLL/EodSettlement/ClientBalanceUtility.cs b/YLErpDAL/BLL/EodSettlement/ClientBalanceUtility.cs index 81363154..4a64222f 100644 --- a/YLErpDAL/BLL/EodSettlement/ClientBalanceUtility.cs +++ b/YLErpDAL/BLL/EodSettlement/ClientBalanceUtility.cs @@ -1402,7 +1402,6 @@ namespace YLErp.BLL.EodSettlement public static void FillClientBalanceHisTradeFee() { - List marginTypes = new List() { (int)InterestModeEnum.追加预付金, (int)InterestModeEnum.初始预付金 }; var clients = DataCacheProvider.GetClientDataSource().AsQueryable().Where(x => x.ProcessStatus == "已开户").ToList(); using var db = new YLContext(); var firstEodStatusDate = db.eodStatus.OrderBy(o => o.ValueDate).FirstOrDefault(); diff --git a/YLErpDAL/BLL/EodSettlement/RealTimeClientBanlanceService.cs b/YLErpDAL/BLL/EodSettlement/RealTimeClientBanlanceService.cs index aeedd67e..33704017 100644 --- a/YLErpDAL/BLL/EodSettlement/RealTimeClientBanlanceService.cs +++ b/YLErpDAL/BLL/EodSettlement/RealTimeClientBanlanceService.cs @@ -20,6 +20,7 @@ using YLErp.Modules; using YLErp.Modules.DataProviderModule; using YLErp.Modules.EodModule; using YLErp.Modules.EodModule.QueryModule; +using YLErp.Modules.SwapModule.Margin; using YLErp.Modules.UnderlyingModule; namespace YLErp.BLL.Eod @@ -1294,7 +1295,7 @@ namespace YLErp.BLL.Eod var balance = _clientBalanceDic[item.Key]; var clientTradeIds = item.Select(s => s.id).ToList(); var clientPositions = positions.Where(x => clientTradeIds.Contains(x.SwapTradeId)).ToList(); - var marginList = clientPositions.Where(x =>ConsTrade.InterestMarginModels.Contains(x.InterestMode)).Sum(s=>s.InterestPrincipalFix * (s.InterestDirection == 1 ? -1 : 1)); + var marginList = clientPositions.Where(x => MarginModes.Contains(x.InterestMode)).Sum(s=>s.InterestPrincipalFix * (s.InterestDirection == 1 ? -1 : 1)); balance.SwapMargin =Convert.ToDouble(marginList); balance.PositionCount= clientTradeIds.Count(); // balance.PositionNotionalPrincipal = Convert.ToDouble(positions.Sum(s=>s.PosiNotionalValue));//实时 diff --git a/YLErpDAL/Modules/EodModule/SettlementModule/EodClientBalanceCalc.cs b/YLErpDAL/Modules/EodModule/SettlementModule/EodClientBalanceCalc.cs index 927b2157..94337c80 100644 --- a/YLErpDAL/Modules/EodModule/SettlementModule/EodClientBalanceCalc.cs +++ b/YLErpDAL/Modules/EodModule/SettlementModule/EodClientBalanceCalc.cs @@ -15,6 +15,7 @@ using YLErp.Model.Enum; using YLErp.Models; using YLErp.Modules.CalculationModule; using YLErp.Modules.EodModule.QueryModule; +using YLErp.Modules.SwapModule.Margin; using YLErp.Modules.TradeRiskCalcModule; namespace YLErp.Modules.EodModule.SettlementModule @@ -40,7 +41,6 @@ namespace YLErp.Modules.EodModule.SettlementModule var currencyCodes = _context.CurrencyCodes; var currencyProvider = _context.EodCurrencyProvider; - List marginTypes = new List() { (int)InterestModeEnum.追加预付金, (int)InterestModeEnum.初始预付金 }; //预付金可取上浮比率 var marginRatio = Math.Round((decimal)(_context.SystemValue.MarginRatio ?? 0.15), 2, MidpointRounding.AwayFromZero); var marginMaxRatio = marginRatio + 0.02m; @@ -132,7 +132,7 @@ namespace YLErp.Modules.EodModule.SettlementModule var positionList = DbContext.trade.Where(t => (ConsTrade.TradeStatusAfterConfirmed.Contains(t.TradeStatus)||t.UnWindDate> balanceDate) && t.ValidState != "InValid"&&t.TradeType=="收益互换"); var marignQuery = from t in positionList join s in DbContext.eod_swap_position on t.id equals s.SwapTradeId - where ConsTrade.InterestMarginModels.Contains(s.InterestMode) && !s.Invalid && s.HappenDate <= balanceDate &&s.ValueDate==balanceDate + where MarginModes.ForLinq.Contains(s.InterestMode) && !s.Invalid && s.HappenDate <= balanceDate &&s.ValueDate==balanceDate select new { ClientId = t.ClientId, diff --git a/YLErpDAL/Modules/SwapModule/Fr007IndexFixer.cs b/YLErpDAL/Modules/SwapModule/Fr007IndexFixer.cs new file mode 100644 index 00000000..ddce860e --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/Fr007IndexFixer.cs @@ -0,0 +1,36 @@ +using System; +using YLErp.Derivatives.Interest; +using YLErp.Modules.DataProviderModule; + +namespace YLErp.Modules.SwapModule; + +/// +/// IIndexFixer 的生产实现:FR007 等浮动利率取价。 +/// 直接调用 EodPriceQueryService.TryGetPrice,不依赖 SwapDealService 实例,便于独立测试。 +/// 未来 SwapDealService 的 6 处 inline 取价代码迁移到本类后,取价规则只在此一处维护。 +/// +public sealed class Fr007IndexFixer : IndexFixerBase, IIndexFixer +{ + public static readonly Fr007IndexFixer Instance = new(); + + public bool TryGetFixing(DateTime fixingDate, string underlyingCode, out decimal rate) + { + bool ok = EodPriceQueryService.TryGetPrice(fixingDate, underlyingCode, out double r); + rate = Convert.ToDecimal(r); + return ok; + } + + /// + /// 取价日 → 取价 → 取不到抛异常。等价于原 SwapDealService 里散落 6 处的 inline 代码: + /// GetNonHolidayDefore(date.AddDays(rule)) + TryGetFloatRate + throw + /// 取到 0 视为"取到一个 0 值"(返回 0),由调用方决定是否沿用旧值; + /// 只有 TryGetFixing 返回 false 才抛异常——对齐既有语义。 + /// + public decimal GetFixingOrThrow(DateTime resetDate, int? interestRule, string underlyingCode) + { + var fixingDate = GetFixingDate(resetDate, interestRule ?? 0); + if (TryGetFixing(fixingDate, underlyingCode, out decimal rate)) + return rate; + throw new Exception($"获取不到{underlyingCode}在{fixingDate:yyyy年MM月dd日}的价格"); + } +} diff --git a/YLErpDAL/Modules/SwapModule/FundingLegs/ContractNotionalLeg.cs b/YLErpDAL/Modules/SwapModule/FundingLegs/ContractNotionalLeg.cs new file mode 100644 index 00000000..f1d62dd0 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/FundingLegs/ContractNotionalLeg.cs @@ -0,0 +1,16 @@ +using YLErp.DBModels; + +namespace YLErp.Modules.SwapModule.FundingLegs; + +/// +/// 合约名义本金规模融资腿(InterestMode=合约名义本金规模)。 +/// 站在"合约规模"视角:平仓本金 = 剩余名义本金 × 平仓比例。 +/// 与标的期初全价(9)在 CalcNotionalByMode 里公式相同,差异在衡泰路径 grossPrice 折算和 EOD 复利反推。 +/// +public sealed class ContractNotionalLeg : IFundingLegStrategy +{ + public InterestModeEnum Mode => InterestModeEnum.合约名义本金规模; + + public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal posiLong, decimal posiShort, decimal closePercent) + => new(posiNotional * closePercent, posiNotional, closePercent); +} diff --git a/YLErpDAL/Modules/SwapModule/FundingLegs/FixedAmountLeg.cs b/YLErpDAL/Modules/SwapModule/FundingLegs/FixedAmountLeg.cs new file mode 100644 index 00000000..6e477b07 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/FundingLegs/FixedAmountLeg.cs @@ -0,0 +1,17 @@ +using YLErp.DBModels; + +namespace YLErp.Modules.SwapModule.FundingLegs; + +/// +/// 固定值融资腿(InterestMode=固定值)。 +/// 计息基数由合约约定(InterestPrincipalFix),无论平仓比例多少都恒等于该值。 +/// 业务规则:固定值就是合同写死的固定数额(Amount),永远不变。 +/// 不叫 FixedNotionalLeg——"固定值"不一定是"名义本金(Notional)",避免与 ContractNotionalLeg 概念撞车。 +/// +public sealed class FixedAmountLeg : IFundingLegStrategy +{ + public InterestModeEnum Mode => InterestModeEnum.固定值; + + public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal posiLong, decimal posiShort, decimal closePercent) + => new(fix, fix, 1m); +} diff --git a/YLErpDAL/Modules/SwapModule/FundingLegs/FundingLegStrategyFactory.cs b/YLErpDAL/Modules/SwapModule/FundingLegs/FundingLegStrategyFactory.cs new file mode 100644 index 00000000..ee4114ef --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/FundingLegs/FundingLegStrategyFactory.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using YLErp.DBModels; + +namespace YLErp.Modules.SwapModule.FundingLegs; + +/// +/// 融资腿策略工厂。按 InterestMode 返回对应策略实例。 +/// 替换原 SwapDealService.CalcNotionalByMode 的 switch,收敛 mode 分发逻辑到一处。 +/// +/// 只管融资腿(funding leg),不管保证金——保证金是独立的资金管理体系, +/// 不应该出现在融资腿策略里。mode 5/6(预付金)属于保证金,不在此注册。 +/// 死代码 mode 3/4 不注册;半死 mode 7/8 不注册(界面已注释)。 +/// 传入未注册的 mode 会抛异常,防止静默走默认分支。 +/// +public static class FundingLegStrategyFactory +{ + private static readonly Dictionary _strategies = new() + { + [InterestModeEnum.固定值] = new FixedAmountLeg(), + [InterestModeEnum.合约名义本金规模] = new ContractNotionalLeg(), + [InterestModeEnum.标的期初全价] = new UnderlyingEntryFullPriceLeg(), + }; + + /// 按 mode 返回对应策略。未注册的 mode 抛 ArgumentException。 + public static IFundingLegStrategy Get(InterestModeEnum mode) + { + if (_strategies.TryGetValue(mode, out var strategy)) + return strategy; + throw new ArgumentException($"未注册的计息模式: {mode}(mode 3/4/7/8 当前未启用)", nameof(mode)); + } + + /// 按 mode 值返回对应策略,便于调用方直接传 int。 + public static IFundingLegStrategy Get(int mode) => Get((InterestModeEnum)mode); +} diff --git a/YLErpDAL/Modules/SwapModule/FundingLegs/IFundingLegStrategy.cs b/YLErpDAL/Modules/SwapModule/FundingLegs/IFundingLegStrategy.cs new file mode 100644 index 00000000..2d29f8c0 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/FundingLegs/IFundingLegStrategy.cs @@ -0,0 +1,50 @@ +using YLErp.DBModels; + +namespace YLErp.Modules.SwapModule.FundingLegs; + +/// +/// 融资腿(Funding Leg)计息基数策略。每个 InterestMode 一个实现。 +/// +/// 命名说明:用 FundingLeg(业界标准 Financing/Funding Leg),不用 InterestLeg—— +/// "interest"易和通用"利息"混淆;funding 精确表达"融资成本"。 +/// 融资腿 = 客户付给券商的杠杆成本(spread + FR007),与保证金(Margin)、标的端(ReturnLeg)各自独立。 +/// +/// 职责单一:给定持仓参数与平仓比例,算出"平仓本金 / 存续本金 / 有效比例"。 +/// 不做计息(计息由 SwapInterest 纯函数完成),不取价(取价由 IIndexFixer 完成)。 +/// +public interface IFundingLegStrategy +{ + /// 该策略对应的计息模式。 + InterestModeEnum Mode { get; } + + /// + /// 根据持仓参数与平仓比例计算计息本金三元组。 + /// + /// 合约固定本金(固定值/预付金腿用;其余腿忽略)。 + /// 当前剩余名义本金(数量 × 全价)。 + /// 多头剩余名义本金(多空存续腿用,当前界面已禁用)。 + /// 空头剩余名义本金。 + /// 平仓比例(占剩余,0~1)。 + NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal posiLong, decimal posiShort, decimal closePercent); +} + +/// +/// 融资腿策略的计算结果。对应原 CalcNotionalByMode 返回的三元组,用自描述名。 +/// +public readonly struct NotionalResult +{ + /// 本次平仓部分的计息本金。 + public decimal ClosePrincipal { get; } + + /// 存续持仓部分的计息本金(全额,不缩放)。 + public decimal PosiPrincipal { get; } + + /// 有效平仓比例。固定值腿恒为 1(计息基数不随比例变);其余沿用入参。 + public decimal ClosePercent { get; } + + public NotionalResult(decimal closePrincipal, decimal posiPrincipal, decimal closePercent) + => (ClosePrincipal, PosiPrincipal, ClosePercent) = (closePrincipal, posiPrincipal, closePercent); + + public void Deconstruct(out decimal close, out decimal posi, out decimal pct) + => (close, posi, pct) = (ClosePrincipal, PosiPrincipal, ClosePercent); +} diff --git a/YLErpDAL/Modules/SwapModule/FundingLegs/UnderlyingEntryFullPriceLeg.cs b/YLErpDAL/Modules/SwapModule/FundingLegs/UnderlyingEntryFullPriceLeg.cs new file mode 100644 index 00000000..f78d9b74 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/FundingLegs/UnderlyingEntryFullPriceLeg.cs @@ -0,0 +1,19 @@ +using YLErp.DBModels; + +namespace YLErp.Modules.SwapModule.FundingLegs; + +/// +/// 标的期初全价融资腿(InterestMode=标的期初全价)。 +/// 计息基数 = 标的期初含费全价(PosiGrossPrice/EntryDirtyPrice) × 数量。 +/// "期初(Entry)"是关键——建仓时点的全价,非当前估值全价。 +/// 主路径 CalcNotionalByMode 公式与合约名义本金规模(2)相同; +/// 差异在衡泰路径会乘 grossPrice 折算(SwapDealService.GetUnwindInterestsByHT), +/// 以及 EOD 复利部分平仓后直接返回剩余本金(禁止反推,SwapEodPositionService:1458-1465)。 +/// +public sealed class UnderlyingEntryFullPriceLeg : IFundingLegStrategy +{ + public InterestModeEnum Mode => InterestModeEnum.标的期初全价; + + public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal posiLong, decimal posiShort, decimal closePercent) + => new(posiNotional * closePercent, posiNotional, closePercent); +} diff --git a/YLErpDAL/Modules/SwapModule/Margin/CashMargin.cs b/YLErpDAL/Modules/SwapModule/Margin/CashMargin.cs new file mode 100644 index 00000000..128f34ba --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/Margin/CashMargin.cs @@ -0,0 +1,10 @@ +namespace YLErp.Modules.SwapModule.Margin; + +/// 现金保证金:余额 = 现金余额。 +public sealed class CashMargin : IMarginResolver +{ + public MarginForm Form => MarginForm.Cash; + + public MarginBalance Resolve(decimal postedAmount) + => new(postedAmount); +} diff --git a/YLErpDAL/Modules/SwapModule/Margin/CreditMargin.cs b/YLErpDAL/Modules/SwapModule/Margin/CreditMargin.cs new file mode 100644 index 00000000..dd3c0032 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/Margin/CreditMargin.cs @@ -0,0 +1,10 @@ +namespace YLErp.Modules.SwapModule.Margin; + +/// 授信保证:余额 = 已用授信额度。 +public sealed class CreditMargin : IMarginResolver +{ + public MarginForm Form => MarginForm.Credit; + + public MarginBalance Resolve(decimal postedAmount) + => new(postedAmount); +} diff --git a/YLErpDAL/Modules/SwapModule/Margin/GuaranteeMargin.cs b/YLErpDAL/Modules/SwapModule/Margin/GuaranteeMargin.cs new file mode 100644 index 00000000..8506a687 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/Margin/GuaranteeMargin.cs @@ -0,0 +1,10 @@ +namespace YLErp.Modules.SwapModule.Margin; + +/// 担保品:余额 = 担保品市值。 +public sealed class GuaranteeMargin : IMarginResolver +{ + public MarginForm Form => MarginForm.Guarantee; + + public MarginBalance Resolve(decimal postedAmount) + => new(postedAmount); +} diff --git a/YLErpDAL/Modules/SwapModule/Margin/IMarginResolver.cs b/YLErpDAL/Modules/SwapModule/Margin/IMarginResolver.cs new file mode 100644 index 00000000..1e24f876 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/Margin/IMarginResolver.cs @@ -0,0 +1,24 @@ +namespace YLErp.Modules.SwapModule.Margin; + +/// 保证金形态:现金 / 授信 / 担保。预留扩展。 +public enum MarginForm +{ + /// 现金保证金:余额 = 现金余额。 + Cash, + /// 授信保证:余额 = 已用授信额度。 + Credit, + /// 担保品:余额 = 担保品市值。 + Guarantee, +} + +/// +/// 按保证金形态解析余额。三种形态可互换地产出一个 MarginBalance(满足 LSP), +/// 这是保证金领域唯一合理的多态点(差异仅在"余额如何取得")。 +/// 具体余额来源(资金流水 / 授信占用 / 担保估值)后续按形态填充。 +/// +public interface IMarginResolver +{ + MarginForm Form { get; } + + MarginBalance Resolve(decimal postedAmount); +} diff --git a/YLErpDAL/Modules/SwapModule/Margin/MarginAccount.cs b/YLErpDAL/Modules/SwapModule/Margin/MarginAccount.cs new file mode 100644 index 00000000..57dc508f --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/Margin/MarginAccount.cs @@ -0,0 +1,42 @@ +using YLErp.Derivatives.Interest; + +namespace YLErp.Modules.SwapModule.Margin; + +/// +/// 保证金账户。管理保证金余额的变动(追加/释放/返还),并提供计息入口。 +/// +/// 保证金是独立的资金管理概念(初始保证金/维持保证金/保证金余额/追保), +/// 与融资腿(funding leg)完全无关。现有代码把保证金塞进 InterestMode==5/6 +/// 当计息腿处理是错误的,本类是正确建模的起点。 +/// +/// 利息计算委托 SwapInterest 纯函数(余额×利率×天数/年化), +/// 保证金账户只提供余额和计息入口,不自己实现计息算法。 +/// +public sealed class MarginAccount +{ + /// 当前保证金余额。 + public MarginBalance Balance { get; private set; } + + public MarginAccount(MarginBalance openingBalance) + => Balance = openingBalance; + + /// 追加保证金(余额增加)。 + public void Deposit(decimal amount) + => Balance = new MarginBalance(Balance.Balance + amount); + + /// 释放/返还保证金(余额减少,不低于 0)。 + public void Withdraw(decimal amount) + => Balance = new MarginBalance(Math.Max(0m, Balance.Balance - amount)); + + /// + /// 按当前余额计算保证金利息。委托 SwapInterest.AccrueSimple。 + /// 保证金利息是券商对客户保证金存款付息(方向与融资腿相反)。 + /// + /// 保证金利率(年化,如 0.03 = 3%)。 + /// 计息开始日。 + /// 计息结束日。 + /// 算头算尾规则。 + /// 年化天数(365 或 360)。 + public InterestResult AccrueInterest(decimal rate, System.DateTime startDate, System.DateTime endDate, AccrualBoundary boundary, int annualDays) + => SwapInterest.AccrueSimple(Balance.Balance, rate, startDate, endDate, boundary, annualDays); +} diff --git a/YLErpDAL/Modules/SwapModule/Margin/MarginBalance.cs b/YLErpDAL/Modules/SwapModule/Margin/MarginBalance.cs new file mode 100644 index 00000000..491de03c --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/Margin/MarginBalance.cs @@ -0,0 +1,16 @@ +namespace YLErp.Modules.SwapModule.Margin; + +/// +/// 保证金余额。现金、授信、担保等多种保证金形态的统一表达。 +/// +/// 保证金就是保证金——有余额、有利率、有利息,不存在"计息基数/Notional"概念。 +/// 余额随追加/释放/盈亏变动,利息由 SwapInterest 纯函数按 余额×利率×天数/年化 计算。 +/// +public readonly struct MarginBalance +{ + /// 保证金余额:现金余额 / 授信占用 / 担保品市值。 + public decimal Balance { get; } + + public MarginBalance(decimal balance) + => Balance = balance; +} diff --git a/YLErpDAL/Modules/SwapModule/Margin/MarginModes.cs b/YLErpDAL/Modules/SwapModule/Margin/MarginModes.cs new file mode 100644 index 00000000..7232022f --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/Margin/MarginModes.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Linq; +using YLErp.DBModels; + +namespace YLErp.Modules.SwapModule.Margin; + +/// +/// 保证金计息模式(mode 5 初始预付金 / mode 6 追加预付金)的统一判断口径。 +/// +/// 现状(待收敛):同一集合 {初始预付金, 追加预付金} 在代码里复制了至少 6 次—— +/// ConsTrade.InterestMarginModels(框架级) +/// SwapEodPositionService.marginTypes(实例字段) +/// SwapEodPositionService.premiumModes(局部变量) +/// SwapEventEmailService.marginTypes +/// EodClientBalanceCalc.marginTypes +/// ClientBalanceUtility.marginTypes +/// 任何一处漏改(如新增保证金形态)都会导致口径分裂。本类收敛到单一来源。 +/// +/// 注意:这里的"保证金 mode"是现有系统把保证金错误建模为计息腿的历史遗留。 +/// 按 Margin 限界上下文的设计方向,未来保证金不应用 InterestMode 标识, +/// 但在历史代码完全迁出前,需要一个统一判断点。 +/// +public static class MarginModes +{ + /// 所有属于保证金的 InterestMode(初始预付金 / 追加预付金)。 + public static readonly IReadOnlyCollection All = new HashSet + { + (int)InterestModeEnum.初始预付金, + (int)InterestModeEnum.追加预付金, + }; + + /// + /// List 形态,供 EF Core LINQ 表达式用(HashSet.Contains 无法翻译成 SQL)。 + /// 替代 ConsTrade.InterestMarginModels。 + /// + public static readonly List ForLinq = new() + { + (int)InterestModeEnum.初始预付金, + (int)InterestModeEnum.追加预付金, + }; + + /// 判断 mode 是否属于保证金(非 LINQ 场景用)。 + public static bool Contains(int interestMode) => All.Contains(interestMode); +} diff --git a/YLErpDAL/Modules/SwapModule/ReturnLegs/DirectionRatio.cs b/YLErpDAL/Modules/SwapModule/ReturnLegs/DirectionRatio.cs new file mode 100644 index 00000000..13808855 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/ReturnLegs/DirectionRatio.cs @@ -0,0 +1,22 @@ +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule.ReturnLegs; + +/// +/// 方向因子计算。把 PositionType / SwapDirection 转成 +1/-1 乘数。 +/// +/// 多空方向(Long/Short)和收付方向(收取/支付)在代码里反复写成三元表达式, +/// 散落在 SwapEodPositionService 15处 + SwapDealService 多处。 +/// 收敛到统一方法, 消除 +1/-1 写反的风险。 +/// +public static class DirectionRatio +{ + /// 多空方向因子。多头=+1, 空头=-1。 + public static int LongShort(int positionType) + => positionType == (int)PositionTypeFlag.Long ? 1 : -1; + + /// 收付方向因子。收取=+1, 支付=-1。 + public static int ReceivePay(int direction) + => direction == (int)SwapDirectionEnum.收取 ? 1 : -1; +} diff --git a/YLErpDAL/Modules/SwapModule/ReturnLegs/DividendCalc.cs b/YLErpDAL/Modules/SwapModule/ReturnLegs/DividendCalc.cs new file mode 100644 index 00000000..d2472360 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/ReturnLegs/DividendCalc.cs @@ -0,0 +1,27 @@ +namespace YLErp.Modules.SwapModule.ReturnLegs; + +/// +/// 标的端分红/票息的增值税计算。 +/// +/// 业务含义:TRS 标的如果是债券,标的端会收到债券票息(coupon); +/// 如果是股票,会收到现金分红(cash dividend)。系统统一用 Dividend 字段链路表达。 +/// 本类只负责"增值税后金额"这一步纯数学,不涉及取价/归属/落库。 +/// +/// 税务口径:票息/分红属于金融服务应税收入,先除以(1+税率)还原不含税收入, +/// 再乘以(1-税率)扣减税负。公式:afterTax = payment / (1+tax) × (1-tax)。 +/// 原代码在 SwapEodPositionService 4处 + SwapDealService 1处 重复此公式。 +/// +/// 命名保持 DividendCalc 而非改为 CashFlow/Coupon—— +/// 因为系统里"分红"字段(DividendIn/PosiDividendSum)是统一链路,同时覆盖债券票息和股票分红, +/// 改名会制造新歧义且需同步改 DB 字段,收益不匹配。 +/// +public static class DividendCalc +{ + /// 增值税后票息/分红,四舍五入到分(2位)。 + public static decimal AfterTax(decimal payment, decimal tax) + => Math.Round(payment / (1 + tax) * (1 - tax), 2); + + /// 增值税后票息/分红(不四舍五入,供中间计算用)。 + public static decimal AfterTaxRaw(decimal payment, decimal tax) + => payment / (1 + tax) * (1 - tax); +} diff --git a/YLErpDAL/Modules/SwapModule/ReturnLegs/MtmCalc.cs b/YLErpDAL/Modules/SwapModule/ReturnLegs/MtmCalc.cs new file mode 100644 index 00000000..4bbb5620 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/ReturnLegs/MtmCalc.cs @@ -0,0 +1,22 @@ +namespace YLErp.Modules.SwapModule.ReturnLegs; + +/// +/// 标的端盯市(Mark-to-Market)计算。 +/// +/// UnderlyingMarketValue = 标的价 × 数量 × 合约乘数 × 多空方向 +/// PosiMtmPnL = (标的价 - 成本全价) × 数量 × 合约乘数 × 多空方向 × 收付方向 +/// +/// 原代码在 SwapEodPositionService 4处重复 UnderlyingMarketValue 公式(1731/1813/1898/2101)。 +/// +public static class MtmCalc +{ + /// 标的市值。多头为正、空头为负。shortRatio: 多头=1, 空头=-1。 + public static decimal MarketValue(decimal price, decimal qty, decimal contractSize, int shortRatio) + => price * qty * contractSize * shortRatio; + + /// 盯市未实现盈亏 = (标的价 - 成本全价) × 数量 × 合约乘数 × 多空 × 收付。 + /// 多头=1, 空头=-1。 + /// 收取=1, 支付=-1。 + public static decimal UnrealizedPnl(decimal price, decimal costGrossPrice, decimal qty, decimal contractSize, int shortRatio, int ratio) + => (price - costGrossPrice) * qty * contractSize * shortRatio * ratio; +} diff --git a/YLErpDAL/Modules/SwapModule/ReturnLegs/QtyRollforward.cs b/YLErpDAL/Modules/SwapModule/ReturnLegs/QtyRollforward.cs new file mode 100644 index 00000000..990a265f --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/ReturnLegs/QtyRollforward.cs @@ -0,0 +1,27 @@ +namespace YLErp.Modules.SwapModule.ReturnLegs; + +/// +/// 标的端数量递推(Qty Rollforward)。 +/// +/// 逐日推进持仓数量:今日数量 = 上一日终数量 + 今日开仓 - 今日平仓 + 公司行为调整。 +/// +/// 公司行为调整(corpActionDeltaQty)默认 0,当前不影响行为; +/// 预留给公司行为改造(送股/拆股/配股)——届时填入非零值。 +/// 原代码(SwapEodPositionService:1980)只有开仓/平仓两项,硬编码了"数量只因交易变动"假设。 +/// +public static class QtyRollforward +{ + /// + /// 计算今日持仓数量。 + /// + /// 上一日终持仓数量。 + /// 今日开仓数量。 + /// 今日平仓数量。 + /// 公司行为导致数量变动(送股/拆股),默认 0。 + /// 今日持仓数量(不低于 0)。 + public static decimal Calc(decimal previousQty, decimal openQty, decimal unwindQty, decimal corpActionDeltaQty = 0m) + { + var qty = previousQty + openQty - unwindQty + corpActionDeltaQty; + return qty < 0 ? 0 : Math.Abs(qty); + } +} diff --git a/YLErpDAL/Modules/SwapModule/ReturnLegs/ReturnLegSummary.cs b/YLErpDAL/Modules/SwapModule/ReturnLegs/ReturnLegSummary.cs new file mode 100644 index 00000000..442d64aa --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/ReturnLegs/ReturnLegSummary.cs @@ -0,0 +1,31 @@ +namespace YLErp.Modules.SwapModule.ReturnLegs; + +/// +/// 标的端(Total Return Leg)归档产出的汇总值。 +/// +/// 这些值由标的盯市+分红计算得出,传给融资端(FundingLeg)作为计息基数参考。 +/// 对应原 SwapPositionCompose 里 DealFloatPositions 的产出: +/// posiLongNotional / posiShortNotional / closePosiNotional / grossPrice +/// +/// 命名说明:这里叫 ReturnLeg(业界标准 Total Return Leg),不叫 FloatLeg—— +/// "float" 在金融里首要含义是"浮动利率"(如 FR007),用于标的端会产生歧义。 +/// 标的端 = 标的资产的总回报(价格涨跌 + 票息/分红),与浮动利率无关。 +/// +public readonly struct ReturnLegSummary +{ + /// 多头剩余名义本金(= 数量 × 全价)。融资端用它算多头腿计息基数。 + public decimal LongNotional { get; } + + /// 空头剩余名义本金。 + public decimal ShortNotional { get; } + + /// 本次平仓名义本金(平仓数量 × 合约乘数 × 含费全价)。 + public decimal CloseNotional { get; } + + /// 标的含费全价(PosiGrossPrice / EntryDirtyPrice)。融资端 mode 9 衡泰路径折算用。 + public decimal GrossPrice { get; } + + public ReturnLegSummary(decimal longNotional, decimal shortNotional, decimal closeNotional, decimal grossPrice) + => (LongNotional, ShortNotional, CloseNotional, GrossPrice) + = (longNotional, shortNotional, closeNotional, grossPrice); +} diff --git a/YLErpDAL/Modules/SwapModule/SwapDealIndexFixer.cs b/YLErpDAL/Modules/SwapModule/SwapDealIndexFixer.cs new file mode 100644 index 00000000..e8fe8931 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/SwapDealIndexFixer.cs @@ -0,0 +1,28 @@ +using System; +using YLErp.Derivatives.Interest; + +namespace YLErp.Modules.SwapModule; + +/// +/// 把一个取价委托包装成 IIndexFixer。 +/// +/// 为什么不直接用 Fr007IndexFixer.Instance:SwapDealService.TryGetFloatRate 是 +/// protected virtual,测试 override 它注入 stub。静态 Instance 直接调 +/// EodPriceQueryService 会绕过这个接缝,导致测试失败。 +/// 本类接受取价委托(SwapDealService 传入 this.TryGetFloatRate 的包装), +/// 既保留 virtual 接缝,又用上 IndexFixerBase,且无需访问 protected 成员。 +/// +internal sealed class SwapDealIndexFixer : IndexFixerBase, IIndexFixer +{ + private readonly Func _tryGet; + + internal SwapDealIndexFixer(Func tryGet) + => _tryGet = tryGet; + + public bool TryGetFixing(DateTime fixingDate, string underlyingCode, out decimal rate) + { + var (ok, r) = _tryGet(fixingDate, underlyingCode); + rate = Convert.ToDecimal(r); + return ok; + } +} diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index c9605a21..6e4aa810 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -5,11 +5,15 @@ using System.Linq.Expressions; using YLErp.BLL; using YLErp.BLL.Eod; using YLErp.DBModels.Enums; +using YLErp.Derivatives.Interest; using YLErp.Helpers; using YLErp.Modules.DataProviderModule; using YLErp.Modules.EodModule; using YLErp.Modules.TradeModule; using YLErp.Modules.TradeModule.DealModule; +using YLErp.Modules.SwapModule.FundingLegs; +using YLErp.Modules.SwapModule.Margin; +using YLErp.Modules.SwapModule.ReturnLegs; using YLErp.QdpModule; namespace YLErp.Modules.SwapModule @@ -22,6 +26,12 @@ namespace YLErp.Modules.SwapModule return EodPriceQueryService.TryGetPrice(valueDate, underlyingCode, out rate); } + private IIndexFixer _indexFixer; + /// FR007 取价器,委托 TryGetFloatRate(保留 virtual 接缝供测试 stub)。 + protected virtual IIndexFixer IndexFixer + => _indexFixer ??= new SwapDealIndexFixer((d, c) => + TryGetFloatRate(d, c, out double r) ? (true, r) : (false, 0d)); + #region 可测试化接缝(Seams)——override 这些虚方法可在测试中替换 DB/外部调用,生产代码行为不变 // FindTrade 已上提到基类 SwapTradeBaseService(三子类实现一致,消除重复) @@ -87,8 +97,7 @@ namespace YLErp.Modules.SwapModule }; foreach (var leg in unwindData.FlowEvents.Where(x => string.IsNullOrEmpty(x.UnderlyingCode))) { - var target = leg.InterestMode == (int)InterestModeEnum.初始预付金 - || leg.InterestMode == (int)InterestModeEnum.追加预付金 + var target = MarginModes.Contains(leg.InterestMode) ? input.MarginLegs : input.InterestLegs; target.Add(new LegInput { InterestClosePnL = leg.InterestClosePnL }); @@ -669,7 +678,7 @@ namespace YLErp.Modules.SwapModule realPositions ??= new List(); return origPositions.Where(x => x.PosiDirection == 0).Select(p => { - if (p.InterestMode == (int)InterestModeEnum.初始预付金 || p.InterestMode == (int)InterestModeEnum.追加预付金) + if (MarginModes.Contains(p.InterestMode)) { var realLeg = realPositions.FirstOrDefault(r => r.PositionId == p.id); if (realLeg != null && realLeg.InterestPrincipalFix != p.InterestPrincipalFix) @@ -705,14 +714,12 @@ namespace YLErp.Modules.SwapModule .Sum(x => x.TradingAmount); var hasNotionalFlows = futureCloseNotional > 0 && originalNotional > 0; var futureClosePrincipal = futureFlows - .Where(x => x.InterestMode == (int)InterestModeEnum.初始预付金 - || x.InterestMode == (int)InterestModeEnum.追加预付金) + .Where(x => MarginModes.Contains(x.InterestMode)) .GroupBy(x => x.PositionId) .ToDictionary(x => x.Key, x => x.Sum(v => v.InterestPrincipal)); return origPositions.Where(x => x.PosiDirection == 0).Select(p => { - if (p.InterestMode == (int)InterestModeEnum.初始预付金 - || p.InterestMode == (int)InterestModeEnum.追加预付金) + if (MarginModes.Contains(p.InterestMode)) { var realLeg = realPositions.FirstOrDefault(r => r.PositionId == p.id); if (realLeg != null) @@ -840,9 +847,9 @@ namespace YLErp.Modules.SwapModule { closePrincipal = closePosiNotionalValue; } - if ((InterestModeEnum)position.InterestMode == InterestModeEnum.追加预付金 || (InterestModeEnum)position.InterestMode == InterestModeEnum.初始预付金) + if (MarginModes.Contains(position.InterestMode)) { - positionClone.InterestDirection = position.InterestDirection == (int)SwapDirectionEnum.收取 ? (int)SwapDirectionEnum.支付 : (int)SwapDirectionEnum.收取; + positionClone.InterestDirection = FlipMarginDirection(position.InterestDirection); } // 获取利率 @@ -898,34 +905,38 @@ namespace YLErp.Modules.SwapModule } /// - /// 根据计息模式计算名义本金 + /// 根据计息模式计算名义本金。 + /// + /// 现状(过渡期): + /// - 融资腿(mode 1/2/9)已委托 FundingLegStrategyFactory,走新策略体系。 + /// - 保证金(mode 5/6)暂保留——待 Margin 独立计息入口建成后迁出。 + /// - 多空存续(mode 7/8)界面已禁用,保留 case 仅为防御性兜底。 + /// - 死代码(mode 3/4)走 default。 + /// + /// 待保证金(mode 5/6)迁入 Margin 上下文后,本方法可整体删除, + /// 调用点直接走 FundingLegStrategyFactory.Get(mode)。 /// private (decimal close, decimal posi, decimal closePct) CalcNotionalByMode(swap_position position, decimal closePercent, decimal posiNotional, decimal posiLong, decimal posiShort) { - decimal closePrincipal = posiNotional; // 平仓部分的名义本金 - decimal posiPrincipal = posiNotional; // 持仓部分的名义本金 - decimal newClosePercent = closePercent; // 调整后的平仓比例 + var mode = (InterestModeEnum)position.InterestMode; - switch ((InterestModeEnum)position.InterestMode) + // 融资腿(1/2/9)走策略工厂 + if (mode == InterestModeEnum.固定值 + || mode == InterestModeEnum.合约名义本金规模 + || mode == InterestModeEnum.标的期初全价) + { + var r = FundingLegStrategyFactory.Get(mode) + .CalcNotional(position.InterestPrincipalFix, posiNotional, posiLong, posiShort, closePercent); + return (r.ClosePrincipal, r.PosiPrincipal, r.ClosePercent); + } + + // 以下 mode 尚未迁入新架构,保留原逻辑 + decimal closePrincipal = posiNotional; + decimal posiPrincipal = posiNotional; + decimal newClosePercent = closePercent; + + switch (mode) { - case InterestModeEnum.固定值: - closePrincipal = posiPrincipal = position.InterestPrincipalFix; - newClosePercent = 1m; - break; - case InterestModeEnum.多头存续名义本金: - closePrincipal = posiLong * closePercent; - posiPrincipal = posiLong; - break; - case InterestModeEnum.空头存续名义本金: - closePrincipal = posiShort * closePercent; - posiPrincipal = posiShort; - break; - case InterestModeEnum.合约名义本金规模: - closePrincipal = posiNotional * closePercent; - break; - case InterestModeEnum.标的期初全价: - closePrincipal = posiNotional * closePercent; - break; case InterestModeEnum.追加预付金: case InterestModeEnum.初始预付金: closePrincipal = position.InterestPrincipalFix * closePercent; @@ -1031,9 +1042,8 @@ namespace YLErp.Modules.SwapModule if (string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) return position.FloatRate; int days = (endDate - startDate).Days; - DateTime rateDate = days % period == 0 - ? QdpCalendarHelper.GetNonHolidayDefore(endDate.AddDays(position.interest_rule ?? 0)) - : QdpCalendarHelper.GetNonHolidayDefore(startDate.AddDays(position.interest_rule ?? 0)); + DateTime rateDate = IndexFixerBase.GetFixingDate( + days % period == 0 ? endDate : startDate, position.interest_rule); if (preEod.id != 0 && days % period != 0) { @@ -1041,9 +1051,9 @@ namespace YLErp.Modules.SwapModule return preEod.FloatRate; } - if (TryGetFloatRate(rateDate, position.FloatRateUnderlyingCode, out double rate)) + if (IndexFixer.TryGetFixing(rateDate, position.FloatRateUnderlyingCode, out decimal rate)) { - position.FloatRate = positionClone.FloatRate = Convert.ToDecimal(rate); + position.FloatRate = positionClone.FloatRate = rate; return position.FloatRate; } if (!swap) throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{rateDate:yyyy年MM月dd日}的价格"); @@ -1138,6 +1148,34 @@ namespace YLErp.Modules.SwapModule return InitSwapDealInterest(td, valueDate, endDate, rate, position, add, swap, posiPrincipal, closePrincipal, closePercent, annualDays, eventType, preEod, false, orginPv, calcFirst, calcLast, consumedInterest); } + /// + /// 保证金腿的 orginPv 维度重映射。 + /// + /// 保证金腿被迫走融资腿的差分公式(dynomicPrincipal = TdInterestPrincipal + posiPrincipal - orginPv), + /// 但 orginPv 对融资腿是"交易名义本金(千万~亿级)",对保证金腿必须是"保证金本金"—— + /// 否则维度不匹配会算出巨负值。本方法把保证金场景的 orginPv 对齐到"上一日保证金本金"。 + /// + /// 待迁入 Margin 模块:保证金独立计息入口建好后,此方法移入 MarginAccount/MarginService。 + /// + private decimal ResolveMarginOrginPv(swap_position position, eod_swap_position preEodPosition, decimal fallback) + { + var previousBalance = preEodPosition.InterestPrincipalFix != 0m + ? preEodPosition.InterestPrincipalFix + : preEodPosition.TdInterestPrincipal; + return preEodPosition.id != 0 && previousBalance != 0m + ? previousBalance + : fallback; + } + + /// + /// 保证金腿的利息方向翻转。保证金利息是券商付给客户(方向与融资腿相反)。 + /// 待迁入 Margin 模块。 + /// + private static int FlipMarginDirection(int direction) + => direction == (int)SwapDirectionEnum.收取 + ? (int)SwapDirectionEnum.支付 + : (int)SwapDirectionEnum.收取; + /// /// 初始化利息腿信息 /// @@ -1191,22 +1229,10 @@ namespace YLErp.Modules.SwapModule interest.ClientId = td.ClientId; interest.UnwindDate = endDate; - // 根因修复:预付金(保证金)腿的计息基数维度应为"保证金本金"自身,而非整笔交易的名义本金(orginPv)。 - // 否则公式 dynomicPrincipal = TdInterestPrincipal + posiPrincipal - orginPv - // 会把交易名义本金(千万~亿级)当减项扣掉,使"应返还本金"(InterestPrincipal)与计息基数变成巨负值。 - // 此处将预付金腿的 orginPv 对齐为上一日保证金本金;无历史归档时才取当前本金。 - // 差分公式必须使用同一时点口径:上一日本金 + 当前本金 - 上一本金 = 当前本金。 - // 若已有部分平仓后仍取当前本金,会把上一日本金原样保留,导致当日继续按平仓前本金计息。 - // 仅作用于初始预付金(5)/追加预付金(6);其它计息模式(含债券本金腿 标的期初全价=9)仍用交易名义本金,不受影响。 - if (position.InterestMode == (int)InterestModeEnum.初始预付金 - || position.InterestMode == (int)InterestModeEnum.追加预付金) + // 保证金腿的 orginPv 对齐到保证金本金维度,避免差分公式维度不匹配算出巨负值 + if (MarginModes.Contains(position.InterestMode)) { - var previousPrincipal = preEodPosition.InterestPrincipalFix != 0m - ? preEodPosition.InterestPrincipalFix - : preEodPosition.TdInterestPrincipal; - orginPv = preEodPosition.id != 0 && previousPrincipal != 0m - ? previousPrincipal - : position.InterestPrincipalFix; + orginPv = ResolveMarginOrginPv(position, preEodPosition, position.InterestPrincipalFix); } if (swap) @@ -1346,14 +1372,14 @@ namespace YLErp.Modules.SwapModule if (accrueDate >= startDate && i % interestPeriod == 0 && !string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) { - var fr007RateDate = QdpCalendarHelper.GetNonHolidayDefore(accrueDate.AddDays(position.interest_rule ?? 0)); - if (TryGetFloatRate(fr007RateDate, position.FloatRateUnderlyingCode, out double floatRate1)) + var fixingDate = IndexFixerBase.GetFixingDate(accrueDate, position.interest_rule); + if (IndexFixer.TryGetFixing(fixingDate, position.FloatRateUnderlyingCode, out decimal fixing)) { - if (floatRate1 != 0) floatRate = floatRate1; + if (fixing != 0m) floatRate = Convert.ToDouble(fixing); } else { - throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fr007RateDate:yyyy年MM月dd日}的价格"); + throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fixingDate:yyyy年MM月dd日}的价格"); } } if (accrueDate >= startDate) @@ -1437,14 +1463,14 @@ namespace YLErp.Modules.SwapModule // tdDynomicPrincipal = flowEvent.InterestPrincipal 使本金累积乘 closePercent^N)。 if (i % interestPeriod == 0 && !string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) { - var fr007RateDate = QdpCalendarHelper.GetNonHolidayDefore(accrueDate.AddDays(position.interest_rule ?? 0)); - if (TryGetFloatRate(fr007RateDate, position.FloatRateUnderlyingCode, out double floatRate1)) + var fixingDate = IndexFixerBase.GetFixingDate(accrueDate, position.interest_rule); + if (IndexFixer.TryGetFixing(fixingDate, position.FloatRateUnderlyingCode, out decimal fixing)) { - if (floatRate1 != 0) floatRate = floatRate1; + if (fixing != 0m) floatRate = Convert.ToDouble(fixing); } else { - throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fr007RateDate:yyyy年MM月dd日}的价格"); + throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fixingDate:yyyy年MM月dd日}的价格"); } } @@ -1501,18 +1527,14 @@ namespace YLErp.Modules.SwapModule tdDynomicPrincipal = tdDynomicPrincipal + interestProfitSum * remainingPercent; if (!string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) { - // 获取合适的 rateDate - var fr007RateDate = QdpCalendarHelper.GetNonHolidayDefore(endDate.AddDays(position.interest_rule ?? 0)); // 获取前一工作日; - if (TryGetFloatRate(fr007RateDate, position.FloatRateUnderlyingCode, out double floatRate1)) + var fixingDate = IndexFixerBase.GetFixingDate(endDate, position.interest_rule); + if (IndexFixer.TryGetFixing(fixingDate, position.FloatRateUnderlyingCode, out decimal fixing)) { - if (floatRate1 != 0) - { - floatRate = floatRate1; - } + if (fixing != 0m) floatRate = Convert.ToDouble(fixing); } else { - throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fr007RateDate:yyyy年MM月dd日}的价格"); + throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fixingDate:yyyy年MM月dd日}的价格"); } } @@ -1566,17 +1588,14 @@ namespace YLErp.Modules.SwapModule // 获取新的浮动利率 if (!string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) { - var fr007RateDate = QdpCalendarHelper.GetNonHolidayDefore(endDate.AddDays(position.interest_rule ?? 0)); - if (TryGetFloatRate(fr007RateDate, position.FloatRateUnderlyingCode, out double newFloatRate)) + var fixingDate = IndexFixerBase.GetFixingDate(endDate, position.interest_rule); + if (IndexFixer.TryGetFixing(fixingDate, position.FloatRateUnderlyingCode, out decimal fixing)) { - if (newFloatRate != 0) - { - floatRate = newFloatRate; - } + if (fixing != 0m) floatRate = Convert.ToDouble(fixing); } else { - throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fr007RateDate:yyyy年MM月dd日}的价格"); + throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fixingDate:yyyy年MM月dd日}的价格"); } } } @@ -1778,13 +1797,13 @@ namespace YLErp.Modules.SwapModule BondPaymentService servie = new BondPaymentService(UserInfo); var payments = servie.GetBondPayments(flowEvent.UnderlyingCode, td.StartDate.Value, date); - int shortRatio = flowEvent.PositionType == (int)PositionTypeFlag.Long ? 1 : -1; - int directionRatio = flowEvent.PayDirection == (int)SwapDirectionEnum.收取 ? 1 : -1; + int shortRatio = DirectionRatio.LongShort(flowEvent.PositionType); + int directionRatio = DirectionRatio.ReceivePay(flowEvent.PayDirection); // + 付息日>上日日终且小于等于平仓日期的分红数据 var dividendIn = servie.CalcPayment(payments, unwindQty, shortRatio, directionRatio); var um = DataCacheProvider.GetUnderlyingDataSource().GetData(flowEvent.UnderlyingCode); decimal tax = um.ValueAddedTax ?? 0; - dividendIn = dividendIn / (1 + tax) * (1 - tax); + dividendIn = DividendCalc.AfterTaxRaw(dividendIn, tax); flowEvent.DividendIn = Math.Round(dividendIn, 2, MidpointRounding.AwayFromZero); } @@ -1906,10 +1925,10 @@ namespace YLErp.Modules.SwapModule _closePosiNotionalValue = _posiNotionalValue * grossPrice * newClosePercent; _posiNotionalValue = _posiNotionalValue * grossPrice; } - else if (item.InterestMode == (int)InterestModeEnum.追加预付金 || item.InterestMode == (int)InterestModeEnum.初始预付金) + else if (MarginModes.Contains(item.InterestMode)) { _closePosiNotionalValue = 0; - positionClone.InterestDirection = position.InterestDirection == (int)SwapDirectionEnum.收取 ? (int)SwapDirectionEnum.支付 : (int)SwapDirectionEnum.收取; + positionClone.InterestDirection = FlipMarginDirection(position.InterestDirection); } decimal rate = item.InterestRateDefault; if (swapIntervalToday != null)//当日无适用观察日 @@ -1999,7 +2018,7 @@ namespace YLErp.Modules.SwapModule { interestList.ForEach(x => { - if (x.InterestMode == (int)InterestModeEnum.追加预付金 || x.InterestMode == (int)InterestModeEnum.初始预付金) + if (MarginModes.Contains(x.InterestMode)) { decimal interestRatio = x.InterestDirection == 1 ? -1m : 1m; unwindData.SwapMarginRebatePnl += x.InterestClosePnL; @@ -2417,7 +2436,7 @@ namespace YLErp.Modules.SwapModule { position.InterestAmount += interest.InterestAmount; position.InterestFeePending += interest.InterestFee; - if ((interest.InterestMode == (int)InterestModeEnum.追加预付金 || interest.InterestMode == (int)InterestModeEnum.初始预付金) && eventType == (int)SwapEventTypeEnum.平仓) + if (MarginModes.Contains(interest.InterestMode) && eventType == (int)SwapEventTypeEnum.平仓) { var remainingInterestPrincipal = Math.Round( position.InterestPrincipalFix - interest.InterestPrincipal, diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index adb87861..4aa6adc0 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -12,6 +12,8 @@ using YLErp.Model.Enum; using YLErp.Models; using YLErp.Modules.DataProviderModule; using YLErp.Modules.EodModule; +using YLErp.Modules.SwapModule.Margin; +using YLErp.Modules.SwapModule.ReturnLegs; using YLErp.QdpModule; namespace YLErp.Modules.SwapModule @@ -21,7 +23,6 @@ namespace YLErp.Modules.SwapModule /// public class SwapEodPositionService : SwapTradeBaseService { - private List marginTypes = new List() { (int)InterestModeEnum.追加预付金, (int)InterestModeEnum.初始预付金 }; private static readonly IYcLogger Log = LogFactory.GetLogger(typeof(SwapEodPositionService).FullName); public SwapEodPositionService(OptUserInfo optUser) : base(optUser) { @@ -1041,7 +1042,7 @@ namespace YLErp.Modules.SwapModule } var tradeExtend = td.trade_extend.ExtendObj; decimal ratio = position.InterestDirection == (int)SwapDirectionEnum.收取 ? 1m : -1m;//收取为正,支付为负 - if (marginTypes.Contains(position.InterestMode)) + if (MarginModes.Contains(position.InterestMode)) { ratio = -ratio; } @@ -1176,7 +1177,7 @@ namespace YLErp.Modules.SwapModule decimal posiNotionalValue = posiLongNotional + posiShortNational; decimal closePercent = 1; decimal ratio = position.InterestDirection == (int)SwapDirectionEnum.收取 ? 1m : -1m;//收取为正,支付为负 - if (marginTypes.Contains(position.InterestMode)) + if (MarginModes.Contains(position.InterestMode)) { ratio = -ratio; } @@ -1296,7 +1297,7 @@ namespace YLErp.Modules.SwapModule decimal posiNotionalValue = posiLongNotional + posiShortNational; // ratio 只负责把腿内原始金额转换为本方盈亏方向,不参与计息金额本身的计算。 decimal ratio = position.InterestDirection == (int)SwapDirectionEnum.收取 ? 1m : -1m;//收取为正,支付为负 - if (marginTypes.Contains(position.InterestMode)) + if (MarginModes.Contains(position.InterestMode)) { ratio = -ratio; } @@ -1604,7 +1605,7 @@ namespace YLErp.Modules.SwapModule closePercent = 1; } decimal ratio = eodPayPosition.InterestDirection == (int)SwapDirectionEnum.收取 ? 1m : -1m;//收取为正,支付为负 - if (marginTypes.Contains(position.InterestMode)) + if (MarginModes.Contains(position.InterestMode)) { ratio = -ratio; } @@ -1691,8 +1692,8 @@ namespace YLErp.Modules.SwapModule bool open) { payQty = Math.Abs(payQty); - decimal ratio = eventFlow.PayDirection == (int)SwapDirectionEnum.收取 ? 1 : -1;//收取为正,支付为负 - decimal shortRatio = newEodPayPosition.PositionType == (int)PositionTypeFlag.Long ? 1 : -1;//多空方向 + int ratio = eventFlow.PayDirection == (int)SwapDirectionEnum.收取 ? 1 : -1;//收取为正,支付为负 + int shortRatio = DirectionRatio.LongShort(newEodPayPosition.PositionType); newEodPayPosition.ValueDate = eventFlow.PayDate.Value; newEodPayPosition.PositionId = eventFlow.PositionId; newEodPayPosition.ClientId = td.ClientId; @@ -1727,7 +1728,7 @@ namespace YLErp.Modules.SwapModule //浮动端估值用信息 newEodPayPosition.UnderlyingPrice = UnderlyingCodePrice(newEodPayPosition.UnderlyingCode, eventFlow.EventDate, out decimal vobp); newEodPayPosition.dv01 = Dv01Helper.CalcDv01(newEodPayPosition.UnderlyingCode, newEodPayPosition.PosiQuantity, newEodPayPosition.PosiDirection, newEodPayPosition.PositionType, vobp); - newEodPayPosition.UnderlyingMarketValue = newEodPayPosition.UnderlyingPrice * newEodPayPosition.PosiQuantity * newEodPayPosition.ContractSize * shortRatio; + newEodPayPosition.UnderlyingMarketValue = MtmCalc.MarketValue(newEodPayPosition.UnderlyingPrice, newEodPayPosition.PosiQuantity, newEodPayPosition.ContractSize, shortRatio); //当日已实现 newEodPayPosition.TdCloseQty = closeQty; newEodPayPosition.TdChangedQty = 0; @@ -1737,7 +1738,7 @@ namespace YLErp.Modules.SwapModule //持仓内容-浮动收益腿-损益统计(本方视角 newEodPayPosition.TdPosiDividend = Math.Round(dividendIn * ratio, 2); - newEodPayPosition.PosiMtmPnL = (newEodPayPosition.UnderlyingPrice - newEodPayPosition.PosiGrossPrice) * newEodPayPosition.PosiQuantity * newEodPayPosition.ContractSize * shortRatio * ratio; + newEodPayPosition.PosiMtmPnL = MtmCalc.UnrealizedPnl(newEodPayPosition.UnderlyingPrice, newEodPayPosition.PosiGrossPrice, newEodPayPosition.PosiQuantity, newEodPayPosition.ContractSize, shortRatio, ratio); newEodPayPosition.PosiDividendSum = Math.Round(newEodPayPosition.TdPosiDividend - newEodPayPosition.TdCloseDividend, 2); newEodPayPosition.PosiProfitSum = newEodPayPosition.PosiMtmPnL + newEodPayPosition.PosiDividendSum + newEodPayPosition.PosiFeePending; @@ -1790,7 +1791,7 @@ namespace YLErp.Modules.SwapModule return curretEod; } var dealDate = curretEod.ValueDate; - int shortRatio = eod.PositionType == (int)PositionTypeFlag.Long ? 1 : -1; + int shortRatio = DirectionRatio.LongShort(eod.PositionType); int directionRatio = eod.PosiDirection == (int)SwapDirectionEnum.收取 ? 1 : -1; curretEod.PosiStatus = curretEod.PosiQuantity == 0 ? 1 : 0; var price = GetSwapValuationPrice(eod.UnderlyingCode, dealDate, out decimal vobp); @@ -1799,8 +1800,7 @@ namespace YLErp.Modules.SwapModule if (valueDate > td.StartDate.Value && curretEod.PosiQuantity > 0) { decimal payment = CalcBondPayment(curretEod.UnderlyingCode, eod.ValueDate, valueDate, curretEod.PosiQuantity, shortRatio, directionRatio); - // 考虑增值税 - curretEod.TdPosiDividend = Math.Round(payment / (1 + tax) * (1 - tax), 2); + curretEod.TdPosiDividend = DividendCalc.AfterTax(payment, tax); } curretEod.PosiDividendSum = eod.PosiQuantity > 0 ? Math.Round(eod.PosiDividendSum + curretEod.TdPosiDividend, 2) : 0; curretEod.PosiQuantity = eod.PosiQuantity; @@ -1809,8 +1809,8 @@ namespace YLErp.Modules.SwapModule curretEod.PosiNotionalValue = 0; } curretEod.UnderlyingPrice = price; - curretEod.UnderlyingMarketValue = curretEod.UnderlyingPrice * curretEod.PosiQuantity * curretEod.ContractSize * shortRatio; - curretEod.PosiMtmPnL = (curretEod.UnderlyingPrice - curretEod.PosiGrossPrice) * curretEod.PosiQuantity * curretEod.ContractSize * shortRatio * directionRatio; + 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.TdPosiDividend = 0; //curretEod.PosiDividendSum = eod.PosiDividendSum + curretEod.TdPosiDividend; curretEod.PosiProfitSum = curretEod.PosiMtmPnL + curretEod.PosiDividendSum + curretEod.PosiFeePending; @@ -1883,26 +1883,26 @@ namespace YLErp.Modules.SwapModule return curretEod; } var dealDate = curretEod.ValueDate; - int shortRatio = eod.PositionType == (int)PositionTypeFlag.Long ? 1 : -1; + int shortRatio = DirectionRatio.LongShort(eod.PositionType); int directionRatio = eod.PosiDirection == (int)SwapDirectionEnum.收取 ? 1 : -1; var price = GetSwapValuationPrice(eod.UnderlyingCode, dealDate, out decimal vobp); var todayConsumedDividend = CalcConsumedDividend(curretEod, unwindEvents); var originNotional = (decimal)td.OriginalStockEqvNotional / swapPosition.PosiNetPrice; decimal totalPayment = CalcBondPayment(curretEod.UnderlyingCode, td.StartDate.Value, valueDate, (decimal)originNotional, shortRatio, directionRatio); decimal tax = um.ValueAddedTax ?? 0; - decimal totalInterest = totalPayment / (1 + tax) * (1 - tax); + decimal totalInterest = DividendCalc.AfterTaxRaw(totalPayment, tax); SetPriceInfoByFlowEvent(eod, curretEod, unwindEvents, swapPosition); curretEod.dv01 = Dv01Helper.CalcDv01(eod.UnderlyingCode, curretEod.PosiQuantity, eod.PosiDirection, eod.PositionType, vobp); curretEod.UnderlyingPrice = price; - curretEod.UnderlyingMarketValue = curretEod.UnderlyingPrice * curretEod.PosiQuantity * curretEod.ContractSize * shortRatio; - curretEod.PosiMtmPnL = (curretEod.UnderlyingPrice - curretEod.PosiGrossPrice) * curretEod.PosiQuantity * curretEod.ContractSize * shortRatio * directionRatio; + 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.TdPosiDividend = 0; // 分红与互换无关,只要持仓>0且起始日早于当前日,正常计算当日分红 // 修改,互换事件会影响待实现的分红的,现在要算上 if (valueDate > td.StartDate.Value && (curretEod.PosiQuantity > 0)) { decimal payment = CalcBondPayment(curretEod.UnderlyingCode, eod.ValueDate, valueDate, curretEod.PosiQuantity, shortRatio, directionRatio); - curretEod.TdPosiDividend = Math.Round(payment / (1 + tax) * (1 - tax), 2); + curretEod.TdPosiDividend = DividendCalc.AfterTax(payment, tax); } curretEod.RealizedMtmPnL = eod.RealizedMtmPnL + curretEod.TdCloseMtmPnl; // 当日浮动端平仓盈亏·分红(仅来自平仓事件 和 互换 中已实现的分红) @@ -1971,14 +1971,13 @@ namespace YLErp.Modules.SwapModule { return; } - int shortRatio = eod.PositionType == (int)PositionTypeFlag.Long ? 1 : -1; + int shortRatio = DirectionRatio.LongShort(eod.PositionType); int directionRatio = eod.PosiDirection == (int)SwapDirectionEnum.收取 ? 1 : -1; 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); decimal openQty = openFlowEvents.Sum(s => s.Quantity); - var qty = eod.PosiQuantity + openQty - unwindQty; - curretEod.PosiQuantity = qty < 0 ? 0 : Math.Abs(qty); + curretEod.PosiQuantity = QtyRollforward.Calc(eod.PosiQuantity, openQty, unwindQty); if (unwindEvents.Count == 0) { curretEod.PosiNetPrice = position.PosiNetPrice; @@ -2053,7 +2052,7 @@ namespace YLErp.Modules.SwapModule curretEod.SwapTradeId = td.id; curretEod.PositionId = position.id; curretEod.ClientId = td.ClientId; - int shortRatio = position.PositionType == (int)PositionTypeFlag.Long ? 1 : -1; + int shortRatio = DirectionRatio.LongShort(position.PositionType); int directionRatio = position.PosiDirection == (int)SwapDirectionEnum.收取 ? 1 : -1; curretEod.PositionType = position.PositionType; var eod = new eod_swap_position() @@ -2092,14 +2091,14 @@ namespace YLErp.Modules.SwapModule { decimal tax = um.ValueAddedTax ?? 0; decimal payment = CalcBondPayment(curretEod.UnderlyingCode, td.StartDate.Value, settleDate, curretEod.PosiQuantity, shortRatio, directionRatio); - payment = Math.Round(payment / (1 + tax) * (1 - tax), 2); + payment = DividendCalc.AfterTax(payment, tax); //var consumedDividend = CalcConsumedDividend(curretEod, unwindEvents); 首日应该没有分红 curretEod.TdPosiDividend = payment; curretEod.PosiDividendSum = payment; } - curretEod.UnderlyingMarketValue = curretEod.UnderlyingPrice * curretEod.PosiQuantity * curretEod.ContractSize * shortRatio; - curretEod.PosiMtmPnL = (curretEod.UnderlyingPrice - curretEod.PosiGrossPrice) * curretEod.PosiQuantity * curretEod.ContractSize * shortRatio * directionRatio; + 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.RealizedMtmPnL = curretEod.TdCloseMtmPnl; curretEod.RealizedDividend = curretEod.TdCloseDividend; @@ -2207,7 +2206,7 @@ namespace YLErp.Modules.SwapModule interestPositions.ForEach(x => { decimal ratio = x.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1;//收取为正,支付为负 - if (marginTypes.Contains(x.InterestMode)) + if (MarginModes.Contains(x.InterestMode)) { ratio = -ratio; } @@ -2275,7 +2274,7 @@ namespace YLErp.Modules.SwapModule interestPositions.ForEach(x => { decimal ratio = x.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1;//收取为正,支付为负 - if (marginTypes.Contains(x.InterestMode)) + if (MarginModes.Contains(x.InterestMode)) { ratio = -ratio; } @@ -2284,7 +2283,7 @@ namespace YLErp.Modules.SwapModule eodSwapPositions.ForEach(x => { decimal ratio = x.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1;//收取为正,支付为负 - if (marginTypes.Contains(x.InterestMode)) + if (MarginModes.Contains(x.InterestMode)) { ratio = -ratio; } @@ -2314,7 +2313,7 @@ namespace YLErp.Modules.SwapModule public static decimal CalculateSwapRealizedPnl(eod_swap_position position) { var interestRatio = position.InterestDirection == (int)SwapDirectionEnum.收取 ? 1m : -1m; - if (ConsTrade.InterestMarginModels.Contains(position.InterestMode)) + if (MarginModes.Contains(position.InterestMode)) { interestRatio = -interestRatio; } @@ -2338,9 +2337,13 @@ namespace YLErp.Modules.SwapModule if (position.InterestDirection <= 0) return; var interestRatio = position.InterestDirection == (int)SwapDirectionEnum.收取 ? 1m : -1m; - if (ConsTrade.InterestMarginModels.Contains(position.InterestMode)) + 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; @@ -2527,7 +2530,7 @@ namespace YLErp.Modules.SwapModule .Where(x => x.SwapTradeId == item.position.SwapTradeId && x.ValueDate == item.position.ValueDate) .ToList(); var floatingLegs = details.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode)).ToList(); - var marginLegs = details.Where(x => marginTypes.Contains(x.InterestMode)).ToList(); + var marginLegs = details.Where(x => MarginModes.Contains(x.InterestMode)).ToList(); var tradeExtend = tradeExtends.FirstOrDefault(x => x.TradeId == item.position.SwapTradeId); var dividendPayDate = tradeExtend?.ExtendObj?.DividendPayDate ?? 1; @@ -2776,7 +2779,7 @@ namespace YLErp.Modules.SwapModule // 主查询分页后再取同交易、同估值日的全部辅助腿,避免利息/保证金归集跨估值日串数据。 var eodPositions = DbContext.eod_swap_position.Where(interestPredicate).ToList(); var marginPositions = DbContext.swap_position - .Where(x => tradeIds.Contains(x.SwapTradeId) && marginTypes.Contains(x.InterestMode) && x.IsInitial && !x.Invalid) + .Where(x => tradeIds.Contains(x.SwapTradeId) && MarginModes.Contains(x.InterestMode) && x.IsInitial && !x.Invalid) .ToList(); var tradeExtends = DbContext.trade_extend.Where(x => tradeIds.Contains(x.TradeId)).ToList(); Dictionary tradeDic = new Dictionary(); @@ -2828,8 +2831,8 @@ namespace YLErp.Modules.SwapModule && (!x.HappenDate.HasValue || x.HappenDate.Value <= item.position.ValueDate)) .ToList(); var interests = eodPositions.Where(x => x.SwapTradeId == item.position.SwapTradeId && x.ValueDate == item.position.ValueDate); - var eodMargins = interests.Where(x => marginTypes.Contains(x.InterestMode)).ToList(); - var eodInterests = interests.Where(x => !marginTypes.Contains(x.InterestMode)).ToList(); + var eodMargins = interests.Where(x => MarginModes.Contains(x.InterestMode)).ToList(); + var eodInterests = interests.Where(x => !MarginModes.Contains(x.InterestMode)).ToList(); var initialMargins = tradeMargins.Where(x => x.InterestMode == (int)InterestModeEnum.初始预付金).ToList(); var additionalMargins = tradeMargins.Where(x => x.InterestMode == (int)InterestModeEnum.追加预付金).ToList(); var floatRateInterest = eodInterests.Where(x => !string.IsNullOrEmpty(x.FloatRateUnderlyingCode)).FirstOrDefault(); diff --git a/YLErpDAL/Modules/SwapModule/SwapEventEmailService.cs b/YLErpDAL/Modules/SwapModule/SwapEventEmailService.cs index 73cd88ab..a6bad2cc 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEventEmailService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEventEmailService.cs @@ -22,6 +22,7 @@ using YLErp.MailKit; using YLErp.Model; using YLErp.Models; using YLErp.Modules.EodModule.QueryModule; +using YLErp.Modules.SwapModule.Margin; using YLErp.Office.Converters; using YLErp.Office.Helpers; using static YLErp.ConsGlobal; @@ -31,7 +32,7 @@ namespace YLErp.Modules.SwapModule public class SwapEventEmailService : YLBaseService { private List eventTypes = new List() { (int)SwapFlowEventTypeEnum.开仓, (int)SwapFlowEventTypeEnum.平仓 }; - private List marginTypes = new List() { (int)InterestModeEnum.追加预付金, (int)InterestModeEnum.初始预付金 }; + private List marginTypes = MarginModes.All.ToList(); private decimal wan = 10000m; public SwapEventEmailService(OptUserInfo userInfo) : base(userInfo) {