diff --git a/UnitTestProject/Modules/SwapModule/ComposePageScenarioTest.cs b/UnitTestProject/Modules/SwapModule/ComposePageScenarioTest.cs new file mode 100644 index 00000000..ccbb7672 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/ComposePageScenarioTest.cs @@ -0,0 +1,270 @@ +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// ComposePage 流水合成持仓 - 合成单元测试 + /// ============================================================================ + /// 验证 swap_flow_event(开仓/平仓事件)→ eod_swap_position(持仓)的转换。 + /// ComposePage 是每笔开仓/平仓/互换都要经过的核心逻辑。 + /// + /// 场景参考 testable 分支 ComposePageScenarioTest,简化为最核心的 3 个: + /// ① 空事件直接返回 + /// ② 单条开仓 → 创建1条持仓,均价=开仓价 + /// ③ 两条开仓(同标的) → 加权均价 + /// ============================================================================ + [TestClass] + public class ComposePageScenarioTest + { + private const int SwapTradeId = 100; + private static readonly DateTime TradeDate = new(2026, 4, 27); + + #region Stub + + private sealed class StubEodService : SwapEodPositionService + { + public List CreatedEodPositions { get; } = new(); + public int ClientCashCallCount { get; private set; } + private int _nextId = 1; + + public StubEodService() : base(new OptUserInfo(0, nameof(ComposePageScenarioTest), OptUserFrom.UnitTest)) + { + } + + // 内存数据 + public Dictionary Trades { get; set; } = new(); + public Dictionary Extends { get; set; } = new(); + public List Positions { get; set; } = new(); + public List EodPositions { get; set; } = new(); + public eod_swap LastEodSwap { get; set; } + + protected override trade FindTrade(int swapTradeId) + => Trades.TryGetValue(swapTradeId, out var t) ? t : null; + + protected override trade_extend FindTradeExtend(int tradeId) + => Extends.TryGetValue(tradeId, out var e) ? e : null; + + protected override List FindEodSwapPositions(int swapTradeId, DateTime preSettleDate) + => EodPositions.Where(x => x.SwapTradeId == swapTradeId && !x.Invalid && x.ValueDate >= preSettleDate).ToList(); + + protected override List FindSwapPositions(int swapTradeId) + => Positions.Where(x => x.SwapTradeId == swapTradeId && !x.Invalid).ToList(); + + protected override eod_swap FindEodSwap(int swapTradeId, DateTime valueDate) + => LastEodSwap?.SwapTradeId == swapTradeId ? LastEodSwap : null; + + protected override swap_event AddSwapEvent(DateTime tradeDate, int swapTradeId, int eventType, string data, int clientCashId, bool save, string reason) + { + return new swap_event { id = _nextId++, SwapTradeId = swapTradeId, EventType = eventType, ValueDate = tradeDate, EventData = data }; + } + + protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate) + { + ClientCashCallCount++; + return _nextId++; + } + + protected override void SaveEodSwapRecord(trade td, DateTime settleDate, DateTime preSettleDate) + { + // 不做任何事(测试不验证框架合约汇总) + } + + protected override void ClearSwapPositionsForCompose(trade td, DateTime tradeDate, List eventTypes) + { + // 不做任何事(测试无历史事件需清理) + } + + protected override void PersistEodSwapPosition(eod_swap_position position) + { + if (position.id == 0) position.id = _nextId++; + CreatedEodPositions.Add(position); + } + + protected override void SaveAllChanges() { } + + protected override double GetCurrencyRate(string quoteCurrency, string settlementCurrency, DateTime valueDate, bool seekPreday, CurrencyRateType currencyRateType) + => 1.0; + + // override SaveEodPosition:捕获生成的 eod,绕过 UpdateSwapPosition 连库 + protected override decimal SaveEodPosition(eod_swap_position newEodPayPosition, + trade td, swap_flow_event eventFlow, + decimal netPrice, decimal grossPrice, decimal netFeePrice, decimal netNoFeePrice, + decimal payQty, decimal tradingFee, decimal posiNotionalValue, + decimal dividendIn, decimal tdDividendIn, + decimal closeQty, decimal closeFee, decimal closeMtmPnl, + int posiType, bool isNewPosition) + { + // 设置关键字段(模拟生产逻辑的输出) + newEodPayPosition.PosiNetPrice = netPrice; + newEodPayPosition.PosiGrossPrice = grossPrice; + newEodPayPosition.PosiQuantity = payQty; + newEodPayPosition.PosiNotionalValue = posiNotionalValue; + newEodPayPosition.SwapTradeId = td.id; + newEodPayPosition.ClientId = td.ClientId; + PersistEodSwapPosition(newEodPayPosition); + return 0m; // 开仓费(测试不关心) + } + + public void ExecuteComposePage(int swapTradeId, List flowEvents, DateTime tradeDate) + { + // needTrans=false 跳过事务 + ComposePage(swapTradeId, flowEvents, tradeDate, needTrans: false); + } + } + + #endregion + + #region 数据构建 + + private static trade CreateTrade() + { + return new trade + { + id = SwapTradeId, TradeNumber = "UT-COMPOSE-001", ClientId = 999998, + TradeType = "收益互换", TradeDate = TradeDate, StartDate = TradeDate, + ExerciseDate = new DateTime(2027, 4, 27), TradeStatus = "确认成交", + ValidState = "Valid", StructureType = "单标的", + QuoteCurrency = "CNY", SettlementCurrency = "CNY", + trade_extend = new trade_extend { TradeId = SwapTradeId } + }; + } + + private static swap_position CreateFloatPosition(int positionId = 1, int positionType = 1) + { + return new swap_position + { + id = positionId, SwapTradeId = SwapTradeId, + PosiDirection = 2, PositionType = positionType, + UnderlyingCode = "210210.IB", ContractSize = 1m, + PosiQuantity = 0, PosiNotionalValue = 0, + PosiNetPrice = 0, PosiGrossPrice = 0, + IsInitial = true, Invalid = false + }; + } + + private static swap_flow_event CreateOpenEvent(int positionId, decimal qty, decimal feeAvg, decimal avg, int positionType = 1) + { + return new swap_flow_event + { + SwapTradeId = SwapTradeId, EventType = (int)SwapFlowEventTypeEnum.开仓, + PositionId = positionId, Quantity = qty, + TradingAmountFeeAvg = feeAvg, TradingAmountAvg = avg, + TradingAmountNetFeeAvg = feeAvg, TradingAmountNetAvg = avg, + ContractSize = 1m, PositionType = positionType, + MarkClosePnl = 0, DividendIn = 0, CloseFee = 0, TradingFeePending = 0, + UnwindDate = TradeDate, EventDate = TradeDate, PayDate = TradeDate, + DataState = (int)SwapFlowDateStateEnum.等待完成 + }; + } + + private static void AssertDecimalEqual(decimal expected, decimal actual, decimal tolerance, string message = "") + { + Assert.IsTrue(Math.Abs(expected - actual) <= tolerance, + $"{message} Expected: {expected}, Actual: {actual}, Diff: {expected - actual}"); + } + + private static StubEodService CreateService() + { + var svc = new StubEodService(); + svc.Trades[SwapTradeId] = CreateTrade(); + svc.Extends[SwapTradeId] = CreateTrade().trade_extend; + svc.Positions.Add(CreateFloatPosition()); + return svc; + } + + #endregion + + // ================================================================ + // 场景1:空事件 → 直接返回,不创建任何持仓 + // ================================================================ + + [TestMethod] + public void CP_001_空事件不创建持仓() + { + var service = CreateService(); + service.ExecuteComposePage(SwapTradeId, new List(), TradeDate); + Assert.AreEqual(0, service.CreatedEodPositions.Count, "无事件不应创建持仓"); + } + + // ================================================================ + // 场景2:单条开仓 → 创建1条持仓,均价=开仓价 + // ================================================================ + + [TestMethod] + public void CP_002_单条开仓创建一条持仓() + { + var service = CreateService(); + var events = new List + { + CreateOpenEvent(positionId: 1, qty: 1000, feeAvg: 1.0050m, avg: 1.0020m) + }; + + service.ExecuteComposePage(SwapTradeId, events, TradeDate); + + Assert.AreEqual(1, service.CreatedEodPositions.Count, "应创建1条持仓"); + var pos = service.CreatedEodPositions[0]; + Assert.AreEqual(1000m, pos.PosiQuantity, "持仓数量=1000"); + AssertDecimalEqual(1.0050m, pos.PosiNetPrice, 0.0001m, "含费均价"); + AssertDecimalEqual(1.0020m, pos.PosiGrossPrice, 0.0001m, "不含费均价"); + Assert.AreEqual((int)SwapFlowDateStateEnum.完成, events[0].DataState, "事件应标记完成"); + } + + // ================================================================ + // 场景3:两条开仓(同标的) → 加权均价 + // ================================================================ + + [TestMethod] + public void CP_003_两条开仓加权均价() + { + var service = CreateService(); + var events = new List + { + CreateOpenEvent(positionId: 1, qty: 600, feeAvg: 1.0040m, avg: 1.0010m), + CreateOpenEvent(positionId: 1, qty: 400, feeAvg: 1.0060m, avg: 1.0030m) + }; + + service.ExecuteComposePage(SwapTradeId, events, TradeDate); + + Assert.AreEqual(1, service.CreatedEodPositions.Count); + var pos = service.CreatedEodPositions[0]; + + // 加权均价: netPrice = (1.0040*600 + 1.0060*400) / 1000 = 1.0048 + AssertDecimalEqual(1.0048m, pos.PosiNetPrice, 0.0001m, "加权含费均价"); + // grossPrice = (1.0010*600 + 1.0030*400) / 1000 = 1.0018 + AssertDecimalEqual(1.0018m, pos.PosiGrossPrice, 0.0001m, "加权不含费均价"); + } + + // ================================================================ + // 场景4:一条开仓+一条平仓 → 验证平仓扣减数量 + // ================================================================ + + [TestMethod] + public void CP_004_开仓后平仓扣减数量() + { + var service = CreateService(); + var events = new List + { + CreateOpenEvent(positionId: 1, qty: 1000, feeAvg: 1.0050m, avg: 1.0020m), + new swap_flow_event + { + SwapTradeId = SwapTradeId, EventType = (int)SwapFlowEventTypeEnum.平仓, + PositionId = 1, Quantity = 400, + TradingAmountFeeAvg = 1.0050m, TradingAmountAvg = 1.0020m, + ContractSize = 1m, PositionType = 1, + MarkClosePnl = 100m, DividendIn = 0, CloseFee = 5m, TradingFeePending = 0, + UnwindDate = TradeDate, EventDate = TradeDate, PayDate = TradeDate, + DataState = (int)SwapFlowDateStateEnum.等待完成 + } + }; + + service.ExecuteComposePage(SwapTradeId, events, TradeDate); + + Assert.AreEqual(1, service.CreatedEodPositions.Count); + var pos = service.CreatedEodPositions[0]; + // 开仓1000 - 平仓400 = 剩余600 + Assert.AreEqual(600m, pos.PosiQuantity, "开仓1000-平仓400=剩余600"); + Assert.IsTrue(service.ClientCashCallCount > 0, "平仓应产生资金记录"); + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index 406ab09a..f719194d 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -1379,7 +1379,7 @@ namespace YLErp.Modules.SwapModule /// 当日平仓数量 /// 当日平仓费用 /// 当日浮动盈亏 - private decimal SaveEodPosition(eod_swap_position newEodPayPosition, + protected virtual decimal SaveEodPosition(eod_swap_position newEodPayPosition, trade td, swap_flow_event eventFlow, decimal netPrice, @@ -1470,10 +1470,7 @@ namespace YLErp.Modules.SwapModule UpdateSwapPosition(newEodPayPosition, td.TradeNumber); } UpdateSwapPositionWithRealTime(newEodPayPosition); - if (newEodPayPosition.id == 0) - { - DbContext.eod_swap_position.Add(newEodPayPosition); - } + PersistEodSwapPosition(newEodPayPosition); return posiTradingFee; } ///