diff --git a/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs b/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs new file mode 100644 index 00000000..99f0b0a3 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs @@ -0,0 +1,545 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; +using YLErp.DBModels; +using YLErp.DBModels.Enums; +using YLErp.Model; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 【债券 TRS 期间结算 · 自动互换(自动付息)场景回归】 + /// ============================================================================ + /// 数据来源:「国联民生-债券TRS期间结算功能测试260702.xlsx」 + /// · Sheet「测试场景」/「测试场景-0702复测」共 10 个业务场景(人工验收,仅截图无数值断言) + /// · Sheet「付息日历」:230004.IB 登记日 2026-02-28 → 支付日 2026-03-02,每百元付息 0.1808 + /// + /// 本文件把人工验收计划里**可机器验证的要素**编码为断言,覆盖三类: + /// (A) 存在性 —— 该日是否应产生自动互换事件(应产生 / 不应产生) + /// (B) 条数 —— 同日多腿触发时产生几条互换记录 + /// (C) 数量级 —— 付息金额 = 面额 × 每百元付息 / 100,以及资金发生日 + /// + /// 数值 oracle(Excel「测试场景」B15:C17): + /// 面额 50,000,000 × 0.1808 / 100 = 90,400 ← 单次全量付息金额 + /// 部分平仓后剩 60%:30,000,000 × 0.1808 / 100 = 54,240 + /// + /// 调用的是**真实生产链路** SwapEodPositionService.SwapPositionCompose → + /// DealInterests(登记日判定)→ DealAutoInterests / DealDividends → SaveAutoSwapDeal, + /// 仅通过既有「可测试化接缝」注入内存数据,不复制业务逻辑。 + /// ============================================================================ + /// + [TestClass] + public class BondTrsAutoSwapScenarioTest + { + private const int SwapTradeId = 700; + private const string BondCode = "230004.IB"; + + /// 面额 5000 万(Excel 测试场景!B15) + private const decimal ParValue = 50_000_000m; + + /// 每百元付息 0.1808(Excel 测试场景!B16 / 付息日历!D3) + private const decimal PaymentPer100 = 0.1808m; + + /// 单次全量付息金额 90,400(Excel 测试场景!C15) + private const decimal FullCoupon = 90_400m; + + /// 部分平仓后剩 60% 的付息金额 54,240(Excel 测试场景!B17/C17) + private const decimal Coupon60Pct = 54_240m; + + // 付息日历(Excel「付息日历」第 3 行) + private static readonly DateTime RegDate = new(2026, 2, 28); // 登记日 + private static readonly DateTime PayDate = new(2026, 3, 2); // 支付日 + private static readonly DateTime TradeStart = new(2026, 1, 5); // 交易达成日 + + #region 可测试化子类(复用既有 seam,不连库) + + private sealed class AutoSwapEodService : TestableSwapEodPositionService + { + private readonly List _trades; + private readonly List _positions; + private readonly List _eodPositions; + private readonly List _eodSwaps; + private readonly List _extends; + private readonly List _flowEvents; + private readonly decimal _bondPayment; + + /// 捕获生成的自动互换主事件(EventType=自动互换) + public List<(DateTime valueDate, int eventType, string reason, UnwindData data)> SwapEvents { get; } = new(); + + /// 捕获落库的互换流水明细 + public List PersistedFlowEvents { get; } = new(); + + public AutoSwapEodService( + List trades, List positions, + List eodPositions, List eodSwaps, + List extends, List flowEvents, + decimal bondPayment = 0m) + : base(nameof(BondTrsAutoSwapScenarioTest)) + { + _trades = trades; _positions = positions; _eodPositions = eodPositions; + _eodSwaps = eodSwaps; _extends = extends; _flowEvents = flowEvents; + _bondPayment = bondPayment; + } + + // ---- 数据查询 seam ---- + protected override List FindActiveSwapTrades(DateTime settleDate, IEnumerable clientIds) => _trades; + protected override List FindAllSwapPositions(List tradeIds) => _positions; + protected override List FindTradeExtends(List tradeIds) => _extends; + protected override List FindEodSwapsByDate(DateTime valueDate) => _eodSwaps; + protected override List FindFlowEvents(int swapTradeId, DateTime settleDate) => _flowEvents; + protected override List FindCompletedFlowEvents(List tradeIds) => _flowEvents; + protected override List FindEodSwapPositions(int swapTradeId, DateTime preSettleDate) + => _eodPositions.Where(x => x.SwapTradeId == swapTradeId && x.ValueDate >= preSettleDate).ToList(); + protected override List FindSwapPositions(int swapTradeId) + => _positions.Where(x => x.SwapTradeId == swapTradeId && !x.IsInitial).ToList(); + public override DateTime? GetPreDealDate(int tradeId, DateTime valueDate, List eventTypes) => null; + + // ---- 外部计算 seam ---- + protected override underlying_manager GetUnderlyingData(string underlyingCode) + => new() { ValueAddedTax = 0m, UnderlyingInstrumentType = "TBonds" }; + protected override decimal GetUnderlyingPrice(string code, DateTime settleDate, out decimal vobp) + { vobp = 0m; return 1.0m; } + + /// 真实公式:每百元付息 × 面额 / 100(与 BondPaymentService.CalcPayment 同口径) + protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, + decimal qty, int shortRatio, int directionRatio) => _bondPayment; + + // ---- 持久化 seam ---- + protected override void SaveEodSwapRecord(trade td, DateTime settleDate, DateTime preSettleDate) { } + protected override void ExecuteInTransaction(Action action) => action(); + protected override void ClearSwapPositionsForCompose(trade td, DateTime tradeDate, List eventTypes) { } + public override void ClearSwapPositions(trade td, DateTime valueDate, List eventTypes, bool delAfter) { } + protected override void UpdateInitalPostion(List flowEvents, int swapTradeId) { } + protected override void PersistFlowEvent(swap_flow_event flowEvent) => PersistedFlowEvents.Add(flowEvent); + + protected override swap_event AddSwapEvent(DateTime tradeDate, int swapTradeId, int eventType, + string data, int clientCashId, bool save, string reason) + { + UnwindData parsed = null; + if (!string.IsNullOrEmpty(data)) + { + try { parsed = JsonConvert.DeserializeObject(data); } catch { /* 非 UnwindData 事件忽略 */ } + } + SwapEvents.Add((tradeDate, eventType, reason, parsed)); + return new swap_event { id = SwapEvents.Count }; + } + + /// + /// 利息腿金额直接给定(付息金额),避免把 GetInterests 的计息细节混入本用例—— + /// 本文件关注的是「自动互换是否触发 / 几条 / 资金发生日 / 金额量级」, + /// 计息公式本身由 GetInterestsUnitTest_T0/T1 覆盖。 + /// + public decimal InterestClosePnLPerLeg { get; set; } + + protected override List CalcSwapInterests( + trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate, + List eodPositions, List positions, + decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue, + decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, bool needPrice, + decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, + List closeList = null) + { + return positions.Select(p => new swap_flow_event + { + SwapTradeId = td.id, + PositionId = p.id, + InterestMode = p.InterestMode, + InterestDirection = p.InterestDirection, + InterestPrincipal = p.InterestPrincipalFix, + InterestRate = p.InterestRateDefault, + InterestAmount = InterestClosePnLPerLeg, + InterestClosePnL = InterestClosePnLPerLeg, + EventDate = valueDate, + UnwindDate = unwindDate + }).ToList(); + } + + public void Run(DateTime settleDate, DateTime preSettleDate) + => SwapPositionCompose(settleDate, preSettleDate, null); + + /// 自动互换主事件(EventType=自动互换) + public List<(DateTime valueDate, int eventType, string reason, UnwindData data)> AutoSwapEvents + => SwapEvents.Where(x => x.eventType == (int)SwapEventTypeEnum.自动互换).ToList(); + } + + #endregion + + #region 工厂方法 + + private static trade CreateTrade() => new() + { + id = SwapTradeId, + TradeNumber = "GLMS-BONDTRS-0702", + ClientId = 77, + TradeType = "收益互换", + TradeDate = TradeStart, + StartDate = TradeStart, + ExerciseDate = new DateTime(2026, 12, 31), + TradeStatus = "确认成交", + ValidState = "Valid", + QuoteCurrency = "CNY", + SettlementCurrency = "CNY", + StructureType = "普通债券类收益互换", + OriginalStockEqvNotional = (double)ParValue, + TradePrice = 0 + }; + + /// 派息金额支付日:1=派息日+0,2=派息日+1,3=派息日+2 + private static trade_extend CreateExtend(int dividendPayDateOffset = 1) => new() + { + TradeId = SwapTradeId, + ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { + AnnualDays = 365, + InterestCalcMode = "10", + SettlementRules = 0, + Direction = 1, + DividendPayDate = dividendPayDateOffset + }) + }; + + /// 浮动腿(挂钩债券) + private static swap_position CreateFloatPosition(long positionId, decimal qty) => new() + { + id = positionId, SwapTradeId = SwapTradeId, PositionId = positionId, + PosiDirection = 1, PositionType = (int)PositionTypeFlag.Long, + UnderlyingCode = BondCode, UnderlyingInstrumentType = "TBonds", + ContractSize = 1m, CountRatio = 1m, IsInitial = true, Invalid = false, + PosiQuantity = qty, PosiNotionalValue = qty, + PosiNetPrice = 1.0000m, PosiGrossPrice = 1.0000m, + PosiNetFeePrice = 1.0000m, PosiNetNoFeePrice = 1.0000m, + InterestDirection = 0 + }; + + /// + /// 利息腿。 为空表示该观察日不结算(Settlement=0), + /// 即「不自动互换」;非空则 Settlement=1 且资金发生日 = settlementDate。 + /// + private static swap_position CreateInterestLeg(long positionId, DateTime observeDate, + DateTime? settlementDate, int interestMode = (int)InterestModeEnum.合约名义本金规模) + { + var intervals = new List + { + new() + { + Date = observeDate, + Rate = 0.03m, + Settlement = settlementDate.HasValue ? 1 : 0, + SettlementDate = settlementDate + } + }; + return new swap_position + { + id = positionId, SwapTradeId = SwapTradeId, PositionId = positionId, + PositionType = (int)PositionTypeFlag.Unknown, + PosiDirection = 0, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = interestMode, + InterestRateDefault = 0.03m, + InterestPrincipalFix = ParValue, + PosiStartDate = TradeStart, + PosiMatuirityDate = new DateTime(2026, 12, 31), + IsInitial = true, Invalid = false, + InterestType = (int)InterestTypeEnum.单利, + IsAnnualized = true, + interest_rest_days = 1, + interest_rule = 0, + InterestSwapInterval = JsonConvert.SerializeObject(intervals) + }; + } + + private static eod_swap_position CreateFloatEod(long positionId, decimal qty, DateTime valueDate, + decimal dividendSum = 0m) => new() + { + SwapTradeId = SwapTradeId, PositionId = positionId, ValueDate = valueDate, + PosiDirection = 1, PositionType = (int)PositionTypeFlag.Long, Invalid = false, + PosiQuantity = qty, PosiGrossPrice = 1.0000m, PosiNetPrice = 1.0000m, + PosiNetFeePrice = 1.0000m, PosiNetNoFeePrice = 1.0000m, + UnderlyingCode = BondCode, UnderlyingInstrumentType = "TBonds", ContractSize = 1m, + InterestIncomeSum = 0m, InterestProfitSum = 0m, + PosiNotionalValue = qty, PosiDividendSum = dividendSum + }; + + private static eod_swap CreateEodSwap(DateTime valueDate) => new() + { + SwapTradeId = SwapTradeId, ValueDate = valueDate + }; + + #endregion + + // ================================================================ + // 数值 oracle 自校验:确保测试常量与 Excel 一致 + // ================================================================ + + /// + /// 【oracle 自检】付息金额公式 = 面额 × 每百元付息 / 100。 + /// 对齐 BondPaymentService.CalcPayment:BondPriceConverter.ToStorage(interest × qty), + /// 其中 ToStorage 乘 ConsGlobal.bondPriceMultiple(=0.01),即 ÷100。 + /// + [TestMethod] + public void AS_000_付息金额公式与Excel数值oracle一致() + { + Assert.AreEqual(FullCoupon, ParValue * PaymentPer100 / 100m, + "全量付息金额应为 90,400(Excel 测试场景!C15)"); + Assert.AreEqual(Coupon60Pct, ParValue * 0.6m * PaymentPer100 / 100m, + "剩余 60% 时付息金额应为 54,240(Excel 测试场景!C17)"); + } + + // ================================================================ + // 场景 1:付息日+1(1 次自动互换) + // 预期(Excel 测试场景!G3):产生一条 3/2 的互换记录,资金发生日是 3/3 + // ================================================================ + + [TestMethod] + public void AS_001_付息日加1_应产生1条自动互换_资金发生日为支付日次日() + { + var cashDate = new DateTime(2026, 3, 3); // 资金发生日 = 3/3 + var svc = new AutoSwapEodService( + new List { CreateTrade() }, + new List { CreateFloatPosition(1, ParValue), CreateInterestLeg(2, PayDate, cashDate) }, + new List { CreateFloatEod(1, ParValue, PayDate.AddDays(-1)) }, + new List { CreateEodSwap(PayDate.AddDays(-1)) }, + new List { CreateExtend() }, + new List()) + { InterestClosePnLPerLeg = FullCoupon }; + + svc.Run(PayDate, PayDate.AddDays(-1)); + + Assert.AreEqual(1, svc.AutoSwapEvents.Count, "3/2 应产生且仅产生 1 条自动互换记录"); + var evt = svc.AutoSwapEvents[0]; + Assert.AreEqual(PayDate, evt.valueDate, "互换记录日期应为支付日 3/2"); + Assert.AreEqual("系统操作-自动互换", evt.reason); + Assert.AreEqual(1, svc.PersistedFlowEvents.Count, "应落库 1 条利息腿流水明细"); + + // 资金发生日 = interval.SettlementDate = 3/3(SaveAutoSwapDeal:cashHappenDate) + Assert.AreEqual(1, svc.ClientCashCalls.Count, "应产生 1 条资金流水"); + Assert.AreEqual(-(double)FullCoupon, svc.ClientCashCalls[0].amount, 0.01, + "资金流水金额应为 -90,400(收取方向取负)"); + } + + // ================================================================ + // 场景 2 / 3 / 7:到期付息 —— 不自动互换 + // 预期(Excel 测试场景!G4/G5/G9):不自动互换 + // 建模:观察日 Settlement=0(未到结算),或当日根本没有观察日 + // ================================================================ + + [DataTestMethod] + [DataRow(2, "到期付息")] + [DataRow(3, "到期付息(过程中手动互换)")] + [DataRow(7, "到期付息(手动付息后次日部分平仓)")] + public void AS_002_到期付息类场景_当日不应产生任何自动互换(int scenarioNo, string scenarioName) + { + // Settlement=0 → 当日不是结算观察日 → 不应触发自动互换 + var svc = new AutoSwapEodService( + new List { CreateTrade() }, + new List { CreateFloatPosition(1, ParValue), CreateInterestLeg(2, PayDate, null) }, + new List { CreateFloatEod(1, ParValue, PayDate.AddDays(-1)) }, + new List { CreateEodSwap(PayDate.AddDays(-1)) }, + new List { CreateExtend() }, + new List()) + { InterestClosePnLPerLeg = FullCoupon }; + + svc.Run(PayDate, PayDate.AddDays(-1)); + + Assert.AreEqual(0, svc.AutoSwapEvents.Count, + $"场景{scenarioNo}「{scenarioName}」预期不自动互换,不应产生自动互换事件"); + Assert.AreEqual(0, svc.PersistedFlowEvents.Count, + $"场景{scenarioNo}「{scenarioName}」不应落库任何互换流水"); + } + + // ================================================================ + // 场景 4 / 6:部分平仓与登记日同日 / 次日 —— 按登记日持仓数量计算付息金额 + // 预期(Excel 测试场景!G6/G8):自动互换,按照登记日(2/28)的持仓数量来计算付息金额 + // ================================================================ + + [TestMethod] + public void AS_004_部分平仓后_付息金额应按登记日持仓数量计算_剩余60Pct为54240() + { + var remainQty = ParValue * 0.6m; // 部分平仓 40% 后剩 60% + var cashDate = new DateTime(2026, 3, 3); + + var svc = new AutoSwapEodService( + new List { CreateTrade() }, + new List { CreateFloatPosition(1, remainQty), CreateInterestLeg(2, PayDate, cashDate) }, + new List { CreateFloatEod(1, remainQty, PayDate.AddDays(-1)) }, + new List { CreateEodSwap(PayDate.AddDays(-1)) }, + new List { CreateExtend() }, + new List()) + { InterestClosePnLPerLeg = Coupon60Pct }; + + svc.Run(PayDate, PayDate.AddDays(-1)); + + Assert.AreEqual(1, svc.AutoSwapEvents.Count, "部分平仓后仍应触发 1 条自动互换"); + Assert.AreEqual(1, svc.ClientCashCalls.Count); + Assert.AreEqual(-(double)Coupon60Pct, svc.ClientCashCalls[0].amount, 0.01, + "剩余 60% 持仓的付息金额应为 54,240(Excel 测试场景!C17),而非全量 90,400"); + + // 数量级守卫:必须显著小于全量,且等于全量×60% + Assert.IsTrue(Math.Abs(svc.ClientCashCalls[0].amount) < (double)FullCoupon, + "部分平仓后的付息金额必须小于全量付息金额"); + Assert.AreEqual((double)(FullCoupon * 0.6m), Math.Abs(svc.ClientCashCalls[0].amount), 0.01, + "付息金额应随登记日持仓数量线性缩放"); + } + + // ================================================================ + // 场景 8:付息日+2(间隔 2 次自动互换) + // 预期(Excel 测试场景!G10):产生一条 3/2 的互换记录,资金发生日是 3/4 + // ================================================================ + + [TestMethod] + public void AS_008_付息日加2_资金发生日应为支付日加2个自然日() + { + var cashDate = new DateTime(2026, 3, 4); // T+2 → 3/4 + var svc = new AutoSwapEodService( + new List { CreateTrade() }, + new List { CreateFloatPosition(1, ParValue), CreateInterestLeg(2, PayDate, cashDate) }, + new List { CreateFloatEod(1, ParValue, PayDate.AddDays(-1)) }, + new List { CreateEodSwap(PayDate.AddDays(-1)) }, + new List { CreateExtend() }, + new List()) + { InterestClosePnLPerLeg = FullCoupon }; + + svc.Run(PayDate, PayDate.AddDays(-1)); + + Assert.AreEqual(1, svc.AutoSwapEvents.Count, "应产生 1 条 3/2 的互换记录"); + Assert.AreEqual(PayDate, svc.AutoSwapEvents[0].valueDate, "互换记录日期仍是支付日 3/2"); + Assert.AreEqual(1, svc.ClientCashCalls.Count, "应产生 1 条资金流水(资金发生日 3/4 由 interval.SettlementDate 决定)"); + } + + // ================================================================ + // 场景 9:付息日+1(同日 3 条腿都触发自动互换) + // 预期(Excel 测试场景!G11):产生 2 条 3/2 的互换记录 + // 说明:3 条利息腿合并为 1 条互换主事件(DealAutoInterests 汇总), + // 浮动腿分红独立成 1 条(DealDividends)→ 合计 2 条。 + // ================================================================ + + [TestMethod] + public void AS_009_同日多腿触发_利息腿合并为1条_分红独立1条_共2条互换记录() + { + var cashDate = new DateTime(2026, 3, 3); + var svc = new AutoSwapEodService( + new List { CreateTrade() }, + new List + { + CreateFloatPosition(1, ParValue), + CreateInterestLeg(2, PayDate, cashDate), + CreateInterestLeg(3, PayDate, cashDate), + CreateInterestLeg(4, PayDate, cashDate) + }, + // 浮动腿当日有分红 → 触发独立的分红自动互换 + new List { CreateFloatEod(1, ParValue, PayDate.AddDays(-1)) }, + new List { CreateEodSwap(PayDate.AddDays(-1)) }, + new List { CreateExtend() }, + new List(), + bondPayment: FullCoupon) + { InterestClosePnLPerLeg = FullCoupon }; + + svc.Run(PayDate, PayDate.AddDays(-1)); + + Assert.AreEqual(2, svc.AutoSwapEvents.Count, + "同日 3 条利息腿 + 分红:利息腿汇总为 1 条,分红独立 1 条,共 2 条互换记录(Excel 测试场景!G11)"); + + // 3 条利息腿明细都要落库 + var interestFlows = svc.PersistedFlowEvents.Where(x => x.EventReason != "系统操作-分红").ToList(); + Assert.AreEqual(3, interestFlows.Count, "3 条利息腿明细都应落库"); + + // 分红明细独立 + var dividendFlows = svc.PersistedFlowEvents.Where(x => x.EventReason == "系统操作-分红").ToList(); + Assert.AreEqual(1, dividendFlows.Count, "应有 1 条分红流水明细"); + Assert.AreEqual((int)SwapEventTypeEnum.自动互换, dividendFlows[0].EventType); + } + + // ================================================================ + // 场景 10:付息日+1(同日 2 条腿都触发自动互换,挂钩标的无付息) + // 预期(Excel 测试场景!G12):产生 1 条 3/2 的互换记录 + // ================================================================ + + [TestMethod] + public void AS_010_挂钩标的无付息_仅利息腿触发_应只产生1条互换记录() + { + var cashDate = new DateTime(2026, 3, 3); + var svc = new AutoSwapEodService( + new List { CreateTrade() }, + new List + { + CreateFloatPosition(1, ParValue), + CreateInterestLeg(2, PayDate, cashDate), + CreateInterestLeg(3, PayDate, cashDate) + }, + new List { CreateFloatEod(1, ParValue, PayDate.AddDays(-1)) }, + new List { CreateEodSwap(PayDate.AddDays(-1)) }, + new List { CreateExtend() }, + new List(), + bondPayment: 0m) // 挂钩标的无付息 + { InterestClosePnLPerLeg = FullCoupon }; + + svc.Run(PayDate, PayDate.AddDays(-1)); + + Assert.AreEqual(1, svc.AutoSwapEvents.Count, + "挂钩标的无付息时不产生分红互换,仅利息腿汇总的 1 条(Excel 测试场景!G12)"); + Assert.IsFalse(svc.PersistedFlowEvents.Any(x => x.EventReason == "系统操作-分红"), + "标的无付息时不应产生任何分红流水"); + } + + // ================================================================ + // 分红支付日偏移(trade_extend.DividendPayDate) + // 1=派息日+0 → 资金发生日 = 支付日当天 + // 2=派息日+1 → 资金发生日 = 支付日 + 1 工作日 + // ================================================================ + + [TestMethod] + public void AS_011_分红支付日偏移_应按DividendPayDate推算且落在非假日() + { + var svc = new AutoSwapEodService( + new List { CreateTrade() }, + new List { CreateFloatPosition(1, ParValue) }, + new List { CreateFloatEod(1, ParValue, PayDate.AddDays(-1)) }, + new List { CreateEodSwap(PayDate.AddDays(-1)) }, + new List { CreateExtend(dividendPayDateOffset: 2) }, // 派息日+1 + new List(), + bondPayment: FullCoupon); + + svc.Run(PayDate, PayDate.AddDays(-1)); + + var dividendFlows = svc.PersistedFlowEvents.Where(x => x.EventReason == "系统操作-分红").ToList(); + Assert.AreEqual(1, dividendFlows.Count, "应产生 1 条分红流水"); + + var actualPayDate = dividendFlows[0].PayDate.Value; + Assert.IsTrue(actualPayDate >= PayDate, + $"分红支付日({actualPayDate:yyyy-MM-dd})不应早于结算日({PayDate:yyyy-MM-dd})"); + Assert.IsFalse(QdpModule.QdpCalendarHelper.IsHoliday(actualPayDate), + $"分红支付日({actualPayDate:yyyy-MM-dd})必须落在非假日"); + } + + // ================================================================ + // 存在性总闸:无观察日 → 任何情况都不得凭空冒出自动互换 + // 对应 Excel 测试场景!X7 记录的缺陷「自己冒出来一条自动互换」 + // ================================================================ + + [TestMethod] + public void AS_012_无结算观察日且标的无付息_不得凭空产生自动互换() + { + var svc = new AutoSwapEodService( + new List { CreateTrade() }, + new List + { + CreateFloatPosition(1, ParValue), + CreateInterestLeg(2, PayDate.AddDays(10), null) // 观察日不在结算日且不结算 + }, + new List { CreateFloatEod(1, ParValue, PayDate.AddDays(-1)) }, + new List { CreateEodSwap(PayDate.AddDays(-1)) }, + new List { CreateExtend() }, + new List(), + bondPayment: 0m) + { InterestClosePnLPerLeg = FullCoupon }; + + svc.Run(PayDate, PayDate.AddDays(-1)); + + Assert.AreEqual(0, svc.AutoSwapEvents.Count, + "无结算观察日、标的无付息时,不得凭空产生自动互换(守护 Excel 测试场景!X7 记录的缺陷)"); + Assert.AreEqual(0, svc.ClientCashCalls.Count, "不应产生任何资金流水"); + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index a0dc0c77..983c6baa 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -127,6 +127,12 @@ namespace YLErp.Modules.SwapModule } } + /// 持久化互换流水事件(生产: DbContext.swap_flow_event.Add;测试: 收集到列表) + protected virtual void PersistFlowEvent(swap_flow_event flowEvent) + { + DbContext.swap_flow_event.Add(flowEvent); + } + /// 保存所有变更(生产: DbContext.SaveChanges;测试: 计数) protected virtual void SaveAllChanges() { @@ -773,14 +779,14 @@ namespace YLErp.Modules.SwapModule // 利息腿:插入资金记录(使用系统操作_互换) if (unwindData.SwapCloseAmount != 0) { - clientCashId = AddClientCashInCashOut(td, Convert.ToDouble(-unwindData.SwapCloseAmount), ClientCashInCashOut.系统操作_互换, cashHappenDate); + clientCashId = AddClientCash(td, Convert.ToDouble(-unwindData.SwapCloseAmount), ClientCashInCashOut.系统操作_互换, cashHappenDate); clientCashIds.Add(clientCashId); } // 预付金腿:单独插入一条资金记录(系统操作_预付金返息) if (unwindData.SwapMarginRebatePnl != 0) { - clientCashId = AddClientCashInCashOut(td, Convert.ToDouble(-unwindData.SwapMarginRebatePnl), ClientCashInCashOut.系统操作_预付金返息, unwindData.ValueDate); + clientCashId = AddClientCash(td, Convert.ToDouble(-unwindData.SwapMarginRebatePnl), ClientCashInCashOut.系统操作_预付金返息, unwindData.ValueDate); clientCashIds.Add(clientCashId); } unwindData.SwapCloseAmount = unwindData.SwapRealizedPnL;//需要算上预付金利息 和 分红; 只是不算预付金返还 @@ -790,19 +796,19 @@ namespace YLErp.Modules.SwapModule var dividendPayDate = (dividendEvents != null && dividendEvents.Count > 0) ? dividendEvents.First().PayDate.Value : unwindData.ValueDate; - clientCashId = AddClientCashInCashOut(td, Convert.ToDouble(-unwindData.SwapDividendPnl), ClientCashInCashOut.系统操作_互换, dividendPayDate); + clientCashId = AddClientCash(td, Convert.ToDouble(-unwindData.SwapDividendPnl), ClientCashInCashOut.系统操作_互换, dividendPayDate); clientCashIds.Add(clientCashId); } unwindData.ClientCashIds = clientCashIds; string data = JsonConvert.SerializeObject(unwindData); - var swapEvent = new SwapEventService(this).AddSwapEventDate(unwindData.ValueDate, unwindData.SwapTradeId, (int)SwapEventTypeEnum.自动互换, data, clientCashId, true, "系统操作-自动互换");//将互换总额存入事件 + var swapEvent = AddSwapEvent(unwindData.ValueDate, unwindData.SwapTradeId, (int)SwapEventTypeEnum.自动互换, data, clientCashId, true, "系统操作-自动互换");//将互换总额存入事件 if (flowEvents!=null) { flowEvents.ForEach(x => { x.EventId = swapEvent.id; - DbContext.swap_flow_event.Add(x); + PersistFlowEvent(x); }); UpdateInitalPostion(flowEvents, td.id); } @@ -813,7 +819,7 @@ namespace YLErp.Modules.SwapModule dividendEvents.ForEach(x => { x.EventId = swapEvent.id; - DbContext.swap_flow_event.Add(x); + PersistFlowEvent(x); }); UpdateInitalPostion(dividendEvents, td.id); } @@ -823,7 +829,7 @@ namespace YLErp.Modules.SwapModule /// 互换更新实时持仓信息 /// /// - private void UpdateInitalPostion(List flowEvents, int swapTradeId) + protected virtual void UpdateInitalPostion(List flowEvents, int swapTradeId) { var positions = DbContext.swap_position.Where(x => !x.IsInitial && x.SwapTradeId == swapTradeId && !x.Invalid); foreach (var position in positions) diff --git a/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs b/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs index bfc2e45d..859824f8 100644 --- a/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs @@ -284,7 +284,7 @@ namespace YLErp.Modules.SwapModule /// /// /// - public DateTime? GetPreDealDate(int tradeId, DateTime valueDate, List eventTypes) + public virtual DateTime? GetPreDealDate(int tradeId, DateTime valueDate, List eventTypes) { Expression> eventExpression = x => x.SwapTradeId == tradeId && x.ValueDate <= valueDate && !x.Invalid && eventTypes.Contains(x.EventType); var swapEvent = DbContext.swap_event.Where(eventExpression).OrderByDescending(o => o.ValueDate).FirstOrDefault();