diff --git a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs new file mode 100644 index 00000000..86c3dac1 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs @@ -0,0 +1,259 @@ +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// SwapPositionCompose 日终归档端到端测试 + /// ============================================================================ + /// 借鉴 testable 分支 SwapPositionComposeScenarioTest,基于当前分支 seam 重写。 + /// 覆盖 DealFloatPositions 的首次归档/Copy/Update/异常路径。 + /// 利息腿场景(自动互换)因 CalcSwapInterests 参数适配复杂留后续。 + /// ============================================================================ + [TestClass] + public class SwapPositionComposeScenarioTest + { + private const int SwapTradeId = 100; + private static readonly DateTime SettleDate = new(2025, 4, 24); + private static readonly DateTime PreSettleDate = new(2025, 4, 23); + + #region 可测试化子类 + + /// + /// 继承 SwapEodPositionService,override SwapPositionCompose 路径上的 seam。 + /// 适配当前分支 seam 签名(GetUnderlyingPrice 带 out、GetCurrencyRate 返回 double 等)。 + /// + private sealed class TestableSwapEodService : SwapEodPositionService + { + 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 _price; + private readonly decimal _vobp; + + public List CreatedEodPositions { get; } = new(); + public List<(double amount, string action)> ClientCashCalls { get; } = new(); + + public TestableSwapEodService( + List trades, List positions, + List eodPositions, List eodSwaps, + List extends, List flowEvents, + decimal price = 100m, decimal vobp = 0m) + : base(new OptUserInfo(0, nameof(SwapPositionComposeScenarioTest), OptUserFrom.UnitTest)) + { + _trades = trades; _positions = positions; _eodPositions = eodPositions; + _eodSwaps = eodSwaps; _extends = extends; _flowEvents = flowEvents; + _price = price; _vobp = vobp; + } + + // SwapPositionCompose 路径 seam override + 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 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(); + + // DealFloatPositions 路径 seam override + protected override underlying_manager GetUnderlyingData(string underlyingCode) + => new underlying_manager { ValueAddedTax = 0m, UnderlyingInstrumentType = "TBonds" }; + protected override decimal GetUnderlyingPrice(string code, DateTime settleDate, out decimal vobp) + { vobp = _vobp; return _price; } + protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio) => 0m; + + // 持久化/事务 seam override + protected override void PersistEodSwapPosition(eod_swap_position position) { CreatedEodPositions.Add(position); } + protected override void SaveEodSwapRecord(trade td, DateTime settleDate, DateTime preSettleDate) { } + protected override void SaveAllChanges() { } + protected override void ExecuteInTransaction(Action action) => action(); + protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate) + { ClientCashCalls.Add((amount, action)); return ClientCashCalls.Count; } + protected override void ClearSwapPositionsForCompose(trade td, DateTime tradeDate, List eventTypes) { } + public override void ClearSwapPositions(trade td, DateTime valueDate, List eventTypes, bool delAfter) { } + protected override swap_event AddSwapEvent(DateTime tradeDate, int swapTradeId, int eventType, string data, int clientCashId, bool save, string reason) + { return new swap_event { id = 1 }; } + 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) => new List(); + protected override double GetCurrencyRate(string quoteCurrency, string settlementCurrency, DateTime valueDate, bool seekPreday, CurrencyRateType currencyRateType) => 1.0; + + public void ExecuteSwapPositionCompose(DateTime settleDate, DateTime preSettleDate) + => SwapPositionCompose(settleDate, preSettleDate, null); + } + + #endregion + + #region 工厂方法 + + private static trade CreateTrade(DateTime? startDate = null) + { + var date = startDate ?? SettleDate; + return new trade + { + id = SwapTradeId, TradeNumber = "TEST-COMPOSE-001", ClientId = 10, + TradeType = "收益互换", TradeDate = date, StartDate = date, + ExerciseDate = SettleDate.AddYears(1), TradeStatus = "确认成交", ValidState = "Valid", + QuoteCurrency = "CNY", SettlementCurrency = "CNY", StructureType = "普通债券类收益互换", + OriginalStockEqvNotional = 100000, TradePrice = 0 + }; + } + + private static trade_extend CreateExtend() + { + return new trade_extend + { + TradeId = SwapTradeId, + ExtendJson = @"{""NeedOpenFee"":false,""AnnualDays"":365,""SettlementRules"":0,""Direction"":1,""FlowBookMode"":0}" + }; + } + + private static swap_position CreateFloatPosition(long positionId, decimal qty) + { + return new swap_position + { + id = positionId, SwapTradeId = SwapTradeId, PositionId = positionId, + PosiDirection = 1, PositionType = (int)PositionTypeFlag.Long, + UnderlyingCode = "220205.IB", UnderlyingInstrumentType = "TBonds", + ContractSize = 1m, CountRatio = 1m, IsInitial = true, Invalid = false, + PosiQuantity = qty, PosiNotionalValue = qty, + PosiNetPrice = 1.0050m, PosiGrossPrice = 1.0020m, + PosiNetFeePrice = 1.0000m, PosiNetNoFeePrice = 0.9970m, + InterestDirection = 0 + }; + } + + private static eod_swap_position CreateFloatEodPosition(long positionId, decimal qty, decimal grossPrice) + { + return new eod_swap_position + { + SwapTradeId = SwapTradeId, PositionId = positionId, ValueDate = PreSettleDate, + PosiDirection = 1, PositionType = (int)PositionTypeFlag.Long, Invalid = false, + PosiQuantity = qty, PosiGrossPrice = grossPrice, PosiNetPrice = 1.0050m, + PosiNetFeePrice = 1.0030m, PosiNetNoFeePrice = 1.0000m, + UnderlyingCode = "220205.IB", ContractSize = 1m, + InterestIncomeSum = 0m, InterestProfitSum = 0m, PosiNotionalValue = qty + }; + } + + private static swap_flow_event CreateCloseFlowEvent(long positionId, decimal qty) + { + return new swap_flow_event + { + SwapTradeId = SwapTradeId, PositionId = positionId, + EventType = (int)SwapFlowEventTypeEnum.平仓, + Quantity = qty, EventDate = SettleDate, UnwindDate = SettleDate, + MarkClosePnl = 500m, CloseFee = 10m, DividendIn = 5m, + TradingAmountAvg = 1.0030m, DataState = (int)SwapFlowDateStateEnum.完成 + }; + } + + #endregion + + // ================================================================ + // 场景1:首次归档(无前日eod,交易首日) + // ================================================================ + + [TestMethod] + public void SPC_001_首次归档_无前日Eod_直接取初始持仓() + { + var td = CreateTrade(); + var extend = CreateExtend(); + var positions = new List { CreateFloatPosition(1, 1000) }; + var service = new TestableSwapEodService( + new List { td }, positions, + new List(), new List(), + new List { extend }, new List()); + + service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate); + + Assert.IsTrue(service.CreatedEodPositions.Count >= 1, "应创建至少1条eod"); + var floatEod = service.CreatedEodPositions.FirstOrDefault(x => x.PositionId == 1); + Assert.IsNotNull(floatEod, "应创建浮动腿持仓"); + Assert.AreEqual(1000m, floatEod.PosiQuantity, "首次归档 PosiQuantity=初始持仓数量"); + Console.WriteLine($"SPC_001: PosiQuantity={floatEod.PosiQuantity} ✅"); + } + + // ================================================================ + // 场景2:有前日eod无事件 → Copy + // ================================================================ + + [TestMethod] + public void SPC_002_Copy分支_有前日Eod无事件_价格原样复制() + { + var td = CreateTrade(); + var extend = CreateExtend(); + var positions = new List { CreateFloatPosition(1, 1000) }; + var prevEod = new List { CreateFloatEodPosition(1, 1000, 1.0020m) }; + var service = new TestableSwapEodService( + new List { td }, positions, + prevEod, new List(), + new List { extend }, new List()); + + service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate); + + var floatEod = service.CreatedEodPositions.FirstOrDefault(x => x.PositionId == 1); + Assert.IsNotNull(floatEod); + Assert.AreEqual(1000m, floatEod.PosiQuantity, "Copy分支 PosiQuantity不变"); + Assert.AreEqual(1.0020m, floatEod.PosiGrossPrice, "Copy分支 PosiGrossPrice从前日eod复制"); + Console.WriteLine($"SPC_002: PosiQuantity={floatEod.PosiQuantity}, PosiGrossPrice={floatEod.PosiGrossPrice} ✅"); + } + + // ================================================================ + // 场景3:有平仓事件 → Update(持仓扣减) + // ================================================================ + + [TestMethod] + public void SPC_003_Update分支_有平仓事件_持仓扣减() + { + var td = CreateTrade(); + var extend = CreateExtend(); + var positions = new List { CreateFloatPosition(1, 1000) }; + var prevEod = new List { CreateFloatEodPosition(1, 1000, 1.0020m) }; + var flowEvents = new List { CreateCloseFlowEvent(1, 400) }; + var service = new TestableSwapEodService( + new List { td }, positions, + prevEod, new List(), + new List { extend }, flowEvents); + + service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate); + + var floatEod = service.CreatedEodPositions.FirstOrDefault(x => x.PositionId == 1); + Assert.IsNotNull(floatEod); + Assert.AreEqual(600m, floatEod.PosiQuantity, "Update分支 PosiQuantity=1000-400=600"); + Assert.AreEqual(400m, floatEod.TdCloseQty, "TdCloseQty=平仓数量400"); + Console.WriteLine($"SPC_003: PosiQuantity={floatEod.PosiQuantity}, TdCloseQty={floatEod.TdCloseQty} ✅"); + } + + // ================================================================ + // 场景4:未收盘抛异常 + // ================================================================ + + [TestMethod] + public void SPC_004_未收盘_非交易首日无前日Eod_抛异常() + { + // 交易起始日早于收盘日(非交易首日),且无前日eod + var td = CreateTrade(startDate: SettleDate.AddDays(-10)); + var extend = CreateExtend(); + var positions = new List { CreateFloatPosition(1, 1000) }; + var service = new TestableSwapEodService( + new List { td }, positions, + new List(), new List(), + new List { extend }, new List()); + + var ex = Assert.ThrowsException(() => + service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate)); + Assert.IsTrue(ex.Message.Contains("未收盘"), $"异常消息应含'未收盘',实际:{ex.Message}"); + Console.WriteLine($"SPC_004: 抛异常'{ex.Message}' ✅"); + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index 4f885bb4..b313e99c 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -1563,7 +1563,7 @@ namespace YLErp.Modules.SwapModule curretEod.Invalid = false; if (curretEod.id == 0) { - DbContext.eod_swap_position.Add(curretEod); + PersistEodSwapPosition(curretEod); } return curretEod; } @@ -1660,7 +1660,7 @@ namespace YLErp.Modules.SwapModule curretEod.Invalid = false; if (curretEod.id == 0) { - DbContext.eod_swap_position.Add(curretEod); + PersistEodSwapPosition(curretEod); } return curretEod; } @@ -1834,7 +1834,7 @@ namespace YLErp.Modules.SwapModule curretEod.TdCurrency = Convert.ToDecimal(currencyRate); UpdateDbOption(curretEod); curretEod.Invalid = false; - DbContext.eod_swap_position.Add(curretEod); + PersistEodSwapPosition(curretEod); return curretEod; } ///