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 : 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 _price; private readonly decimal _vobp; // 输出别名(转发到基类捕获属性) public List CreatedEodPositions => PersistedPositions; public List LastInterestCalculationPositions { get; private set; } public TestableSwapEodService( List trades, List positions, List eodPositions, List eodSwaps, List extends, List flowEvents, decimal price = 100m, decimal vobp = 0m) : base(nameof(SwapPositionComposeScenarioTest)) { _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 FindCompletedFlowEvents(List tradeIds) => _flowEvents; public override DateTime? GetPreDealDate(int tradeId, DateTime valueDate, List eventTypes) => null; 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(PersistEodSwapPosition/SaveAllChanges/GetCurrencyRate/AddClientCash 由基类提供) 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 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 closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, List closeList = null) { LastInterestCalculationPositions = positions; return base.CalcSwapInterests(td, tradeExtend, valueDate, unwindDate, eodPositions, positions, posiNotionalValue, closePosiNotionalValue, closePrecent, eventType, tdClose, orginPv, add, settment, newCalcLast, closeList); } public void ExecuteSwapPositionCompose(DateTime settleDate, DateTime preSettleDate) => SwapPositionCompose(settleDate, preSettleDate, null); public void ExecuteFundCorporateActions( IReadOnlyCollection positions, IReadOnlyCollection dividendInfos) { ApplyCorporateActions( positions, dividendInfos.ToDictionary(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase), SettleDate); } } #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", UnderlyingPrice = grossPrice, 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.完成 }; } private static ex_dividend_info CreateFundCorporateAction( decimal cashAmount = 0m, decimal shareAmount = 0m) { return new ex_dividend_info { UnderlyingCode = "FUND.TEST", ExDividendDate = SettleDate, EffectiveDate = SettleDate, GiveCashAmount = cashAmount, GiveShareAmount = shareAmount, ValidStatus = true }; } private static void SetFundLeg(swap_position position, eod_swap_position previousEod) { position.UnderlyingCode = "FUND.TEST"; position.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund; position.PosiGrossPrice = 100m; position.PosiNetPrice = 102m; position.PosiNetFeePrice = 104m; position.PosiNetNoFeePrice = 106m; previousEod.UnderlyingCode = position.UnderlyingCode; previousEod.UnderlyingInstrumentType = position.UnderlyingInstrumentType; previousEod.PosiGrossPrice = position.PosiGrossPrice; previousEod.PosiNetPrice = position.PosiNetPrice; previousEod.PosiNetFeePrice = position.PosiNetFeePrice; previousEod.PosiNetNoFeePrice = position.PosiNetNoFeePrice; previousEod.PosiNotionalValue = previousEod.PosiGrossPrice * previousEod.PosiQuantity * previousEod.ContractSize; } #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} ✅"); } [TestMethod] public void SPC_FUND_001_送股除权_调整价格数量并重算持仓结果() { var td = CreateTrade(); var position = CreateFloatPosition(1, 1000m); var previousEod = CreateFloatEodPosition(1, 1000m, 100m); SetFundLeg(position, previousEod); var service = new TestableSwapEodService( new List { td }, new List { position }, new List { previousEod }, new List(), new List { CreateExtend() }, new List(), price: 100m); service.ExDividendInfos.Add(CreateFundCorporateAction(shareAmount: 10m)); var actual = previousEod.Clone(); actual.ValueDate = SettleDate; actual.UnderlyingPrice = 100m; service.ExecuteFundCorporateActions( new[] { actual }, service.ExDividendInfos); Assert.AreEqual(2000m, actual.PosiQuantity); Assert.AreEqual(1000m, actual.TdChangedQty); Assert.AreEqual(50m, actual.PosiGrossPrice); Assert.AreEqual(51m, actual.PosiNetPrice); Assert.AreEqual(52m, actual.PosiNetFeePrice); Assert.AreEqual(53m, actual.PosiNetNoFeePrice); Assert.AreEqual(100000m, actual.PosiNotionalValue); Assert.AreEqual(200000m, actual.UnderlyingMarketValue); Assert.AreEqual(100000m, actual.PosiMtmPnL); Assert.AreEqual(100000m, actual.PosiProfitSum); } [TestMethod] public void SPC_FUND_002_现金分红_登记日不直接入账() { var td = CreateTrade(); var position = CreateFloatPosition(1, 1000m); var previousEod = CreateFloatEodPosition(1, 1000m, 100m); SetFundLeg(position, previousEod); var service = new TestableSwapEodService( new List { td }, new List { position }, new List { previousEod }, new List(), new List { CreateExtend() }, new List(), price: 100m); service.ExDividendInfos.Add(CreateFundCorporateAction(cashAmount: 10m)); service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate); var actual = service.CreatedEodPositions.Single(x => x.PositionId == 1); // 现金分红改由同步任务写入 bond_payment_info,并以 EffectiveDate 进入债券付息 // 链路;登记日 EOD 不直接读取 ex_dividend_info,因此此处不应提前产生现金。 Assert.AreEqual(1000m, actual.PosiQuantity); Assert.AreEqual(0m, actual.TdChangedQty); Assert.AreEqual(100m, actual.PosiGrossPrice); Assert.AreEqual(0m, actual.TdPosiDividend); Assert.AreEqual(0m, actual.PosiDividendSum); Assert.AreEqual(100000m, actual.PosiNotionalValue); Assert.AreEqual(0m, actual.PosiMtmPnL); Assert.AreEqual(0m, actual.PosiProfitSum); Assert.AreEqual(0m, actual.RealizedDividend); Assert.AreEqual(0m, actual.RealizedPnl); } [TestMethod] public void SPC_FUND_003_同日重跑_从前日基线重算不重复除权() { var td = CreateTrade(); var position = CreateFloatPosition(1, 1000m); var previousEod = CreateFloatEodPosition(1, 1000m, 100m); SetFundLeg(position, previousEod); var service = new TestableSwapEodService( new List { td }, new List { position }, new List { previousEod }, new List(), new List { CreateExtend() }, new List(), price: 100m); service.ExDividendInfos.Add(CreateFundCorporateAction(shareAmount: 10m)); // 生产重收盘每次都会从上一日 EOD clone 出新的当日基线,再应用一次公司行为; // 底层 ApplyCorporateActions 只负责处理调用方提供的未调整基线,不再承担恢复旧基线的测试兼容职责。 var firstRunEod = previousEod.Clone(); firstRunEod.ValueDate = SettleDate; firstRunEod.UnderlyingPrice = 100m; service.ExecuteFundCorporateActions(new[] { firstRunEod }, service.ExDividendInfos); var rerunEod = previousEod.Clone(); rerunEod.ValueDate = SettleDate; rerunEod.UnderlyingPrice = 100m; service.ExecuteFundCorporateActions(new[] { rerunEod }, service.ExDividendInfos); Assert.AreEqual(2000m, firstRunEod.PosiQuantity); Assert.AreEqual(1000m, firstRunEod.TdChangedQty); Assert.AreEqual(50m, firstRunEod.PosiGrossPrice); Assert.AreEqual(100000m, firstRunEod.PosiNotionalValue); Assert.AreEqual(firstRunEod.PosiQuantity, rerunEod.PosiQuantity); Assert.AreEqual(firstRunEod.PosiGrossPrice, rerunEod.PosiGrossPrice); } [TestMethod] public void SPC_FUND_004_非Fund标的_即使命中公司行为也不调整() { var td = CreateTrade(); var position = CreateFloatPosition(1, 1000m); position.UnderlyingCode = "FUND.TEST"; position.PosiGrossPrice = 100m; var previousEod = CreateFloatEodPosition(1, 1000m, 100m); previousEod.UnderlyingCode = position.UnderlyingCode; previousEod.UnderlyingInstrumentType = "TBonds"; var service = new TestableSwapEodService( new List { td }, new List { position }, new List { previousEod }, new List(), new List { CreateExtend() }, new List(), price: 100m); service.ExDividendInfos.Add(CreateFundCorporateAction(shareAmount: 10m)); var actual = previousEod.Clone(); actual.ValueDate = SettleDate; actual.UnderlyingPrice = 100m; service.ExecuteFundCorporateActions( new[] { actual }, service.ExDividendInfos); Assert.AreEqual(1000m, actual.PosiQuantity); Assert.AreEqual(100m, actual.PosiGrossPrice); Assert.AreEqual(0m, actual.TdChangedQty); } [TestMethod] public void SPC_FUND_005_同日同代码多条有效记录_明确失败() { var service = new TestableSwapEodService( new List { CreateTrade() }, new List(), new List(), new List(), new List { CreateExtend() }, new List()); service.ExDividendInfos.Add(CreateFundCorporateAction(cashAmount: 1m)); service.ExDividendInfos.Add(CreateFundCorporateAction(shareAmount: 1m)); var exception = Assert.ThrowsException(() => service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate)); StringAssert.Contains(exception.Message, "存在多条有效除权记录"); } [TestMethod] public void SPC_FUND_006_登记日Eod保持除权前数量价格_生效日才调整() { var recordDate = SettleDate; var effectiveDate = recordDate.AddDays(3); var td = CreateTrade(); var position = CreateFloatPosition(1, 1000m); var previousEod = CreateFloatEodPosition(1, 1000m, 100m); SetFundLeg(position, previousEod); var service = new TestableSwapEodService( new List { td }, new List { position }, new List { previousEod }, new List(), new List { CreateExtend() }, new List(), price: 100m); service.ExDividendInfos.Add(new ex_dividend_info { UnderlyingCode = "FUND.TEST", ExDividendDate = recordDate, EffectiveDate = effectiveDate, GiveShareAmount = 10m, ValidStatus = true }); service.ExecuteSwapPositionCompose(recordDate, PreSettleDate); var recordEod = service.CreatedEodPositions.First(x => x.PositionId == 1); Assert.AreEqual(1000m, recordEod.PosiQuantity, "登记日 EOD 仍展示除权前数量,不能提前变成 2000"); Assert.AreEqual(100m, recordEod.PosiGrossPrice, "登记日 EOD 仍展示除权前价格,不能提前变成 50"); } [TestMethod] public void SPC_FUND_007_生效日先以除权后基线处理平仓_1000平300得到1700份50元() { var recordDate = SettleDate; var effectiveDate = recordDate.AddDays(3); var td = CreateTrade(); var initialPosition = CreateFloatPosition(1, 1000m); var realtimePosition = initialPosition.Clone(); realtimePosition.id = 2; realtimePosition.IsInitial = false; realtimePosition.PositionId = initialPosition.id; var previousEod = CreateFloatEodPosition(1, 1000m, 100m); previousEod.ValueDate = recordDate; SetFundLeg(initialPosition, previousEod); SetFundLeg(realtimePosition, previousEod); var closeFlow = CreateCloseFlowEvent(initialPosition.id, 300m); closeFlow.UnderlyingCode = "FUND.TEST"; closeFlow.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund; closeFlow.DividendIn = 0m; var service = new TestableSwapEodService( new List { td }, new List { initialPosition, realtimePosition }, new List { previousEod }, new List { new eod_swap { SwapTradeId = SwapTradeId, ValueDate = recordDate } }, new List { CreateExtend() }, new List { closeFlow }, price: 100m); service.ExDividendInfos.Add(new ex_dividend_info { UnderlyingCode = "FUND.TEST", ExDividendDate = recordDate, EffectiveDate = effectiveDate, GiveShareAmount = 10m, ValidStatus = true }); service.ExecuteSwapPositionCompose(effectiveDate, recordDate); var effectiveEod = service.CreatedEodPositions.First(x => x.PositionId == 1); Assert.AreEqual(1700m, effectiveEod.PosiQuantity, "生效日先把 1000 份变为 2000 份,再平仓 300 份,应剩 1700 而非 1400"); Assert.AreEqual(50m, effectiveEod.PosiGrossPrice, "10 送 10 后期初价格应为 50"); } [TestMethod] public void SPC_FUND_008_上游splitratio零点零一映射GiveShareAmount负九点九_Eod数量价格调整() { var td = CreateTrade(); var position = CreateFloatPosition(1, 1000m); var previousEod = CreateFloatEodPosition(1, 1000m, 100m); SetFundLeg(position, previousEod); var service = new TestableSwapEodService( new List { td }, new List { position }, new List { previousEod }, new List(), new List { CreateExtend() }, new List(), price: 100m); // 上游 splitratio=sharesafter/sharesbefore=0.01,落库前按 // GiveShareAmount=10*(splitratio-1) 转换为 -9.9;现有公式因此得到 0.01 倍。 service.ExDividendInfos.Add(CreateFundCorporateAction(shareAmount: -9.9m)); var actual = previousEod.Clone(); actual.ValueDate = SettleDate; actual.UnderlyingPrice = 100m; service.ExecuteFundCorporateActions( new[] { actual }, service.ExDividendInfos); Assert.AreEqual(10m, actual.PosiQuantity, "上游 splitratio=0.01 映射为 GiveShareAmount=-9.9,1000 份应调整为 10 份"); Assert.AreEqual(10000m, actual.PosiGrossPrice, "上游 splitratio=0.01 映射为 GiveShareAmount=-9.9,期初价格应反向放大 100 倍"); } // ================================================================ // 场景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}' ✅"); } [TestMethod] public void SPC_005_部分平仓后_预付金日终按实时剩余本金计息() { const long initialPrepayId = 2; var td = CreateTrade(); var initialPrepay = new swap_position { id = initialPrepayId, SwapTradeId = SwapTradeId, PosiDirection = 0, InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)InterestModeEnum.初始预付金, InterestPrincipalFix = 1000m, IsInitial = true, Invalid = false, PosiStartDate = SettleDate.AddDays(-1), PosiMatuirityDate = td.ExerciseDate.Value, InterestSwapInterval = "[]" }; var realPrepay = new swap_position { id = 3, PositionId = initialPrepayId, SwapTradeId = SwapTradeId, PosiDirection = 0, InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)InterestModeEnum.初始预付金, InterestPrincipalFix = 700m, IsInitial = false, Invalid = false }; var prepayEod = new eod_swap_position { id = 200, SwapTradeId = SwapTradeId, PositionId = initialPrepayId, ValueDate = PreSettleDate, PosiDirection = 0, InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)InterestModeEnum.初始预付金, InterestPrincipalFix = 700m, TdInterestPrincipal = 700m }; var service = new TestableSwapEodService( new List { td }, new List { CreateFloatPosition(1, 1000), initialPrepay, realPrepay }, new List { CreateFloatEodPosition(1, 1000, 1.0020m), prepayEod }, new List { new eod_swap { SwapTradeId = SwapTradeId, ValueDate = PreSettleDate } }, new List { CreateExtend() }, new List { CreateCloseFlowEvent(1, 300), new swap_flow_event { SwapTradeId = SwapTradeId, PositionId = initialPrepayId, EventType = (int)SwapEventTypeEnum.平仓, EventDate = SettleDate, DataState = (int)SwapFlowDateStateEnum.完成 } }); service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate); var calculatedPrepay = service.LastInterestCalculationPositions .Single(x => x.id == initialPrepayId); Assert.AreEqual(700m, calculatedPrepay.InterestPrincipalFix); Assert.AreEqual(initialPrepayId, calculatedPrepay.id); } [TestMethod] public void SPC_006_平仓日_预付金日终不得重复扣减实时剩余本金() { const long initialPrepayId = 2; var td = CreateTrade(); var initialPrepay = new swap_position { id = initialPrepayId, SwapTradeId = SwapTradeId, PosiDirection = 0, InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)InterestModeEnum.初始预付金, InterestPrincipalFix = 1000m, InterestRateDefault = 0.01m, IsInitial = true, Invalid = false, IsAnnualized = true, PosiStartDate = SettleDate.AddDays(-1), PosiMatuirityDate = td.ExerciseDate.Value, InterestSwapInterval = "[]" }; var realPrepay = new swap_position { id = 3, PositionId = initialPrepayId, SwapTradeId = SwapTradeId, PosiDirection = 0, InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)InterestModeEnum.初始预付金, InterestPrincipalFix = 700m, IsInitial = false, Invalid = false }; var prepayEod = new eod_swap_position { id = 200, SwapTradeId = SwapTradeId, PositionId = initialPrepayId, ValueDate = PreSettleDate, PosiDirection = 0, InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)InterestModeEnum.初始预付金, InterestPrincipalFix = 1000m, TdInterestPrincipal = 1000m }; var closeFlow = CreateCloseFlowEvent(1, 300); closeFlow.InterestRate = 0.01m; var prepayCloseFlow = new swap_flow_event { SwapTradeId = SwapTradeId, PositionId = initialPrepayId, EventType = (int)SwapEventTypeEnum.平仓, EventDate = SettleDate, DataState = (int)SwapFlowDateStateEnum.完成, InterestMode = (int)InterestModeEnum.初始预付金, InterestPrincipal = 300m, InterestRate = 0.01m }; var service = new TestableSwapEodService( new List { td }, new List { CreateFloatPosition(1, 1000), initialPrepay, realPrepay }, new List { CreateFloatEodPosition(1, 1000, 1.0020m), prepayEod }, new List { new eod_swap { SwapTradeId = SwapTradeId, ValueDate = PreSettleDate } }, new List { CreateExtend() }, new List { closeFlow, prepayCloseFlow }); service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate); var persistedPrepay = service.CreatedEodPositions .Single(x => x.PositionId == initialPrepayId); Assert.AreEqual(700m, persistedPrepay.InterestPrincipalFix, "实时腿已经扣减到700,日终不得再次按平仓比例扣减"); Assert.AreEqual(700m, persistedPrepay.TdInterestPrincipal, "平仓日预付金计息本金应立即切换为实时剩余本金"); var expectedDailyInterest = Math.Round(700m * 0.01m / 365m, 12, MidpointRounding.AwayFromZero); Assert.AreEqual(expectedDailyInterest, persistedPrepay.TdInterestIncome, "平仓日新增利息应按实时剩余本金计算"); } [TestMethod] public void SPC_007_HistoricalReplayUsesAsOfPrincipal() { const long originalPositionId = 2; var original = new swap_position { id = originalPositionId, PosiDirection = 0, InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)InterestModeEnum.初始预付金, InterestPrincipalFix = 10000m }; var realtime = new swap_position { PositionId = originalPositionId, InterestMode = (int)InterestModeEnum.初始预付金, InterestPrincipalFix = 7000m }; var close = new swap_flow_event { PositionId = originalPositionId, PositionType = 0, EventType = (int)SwapEventTypeEnum.平仓, EventDate = new DateTime(2026, 7, 9), UnwindDate = new DateTime(2026, 7, 10), InterestMode = (int)InterestModeEnum.初始预付金, InterestPrincipal = 3000m }; var floatClose = new swap_flow_event { PositionId = 1, PositionType = 1, EventType = (int)SwapEventTypeEnum.平仓, EventDate = new DateTime(2026, 7, 9), UnwindDate = new DateTime(2026, 7, 10), TradingAmount = 3000000m }; var originalWithFloat = new List { original, new swap_position { id = 1, PosiDirection = 1, PosiNotionalValue = 10000000m } }; var beforeClose = SwapDealService.ResolveInterestLegPositionsAsOf( originalWithFloat, new List { realtime }, new[] { close, floatClose }, new DateTime(2026, 7, 8)) .Single(x => x.id == originalPositionId); var onCloseDate = SwapDealService.ResolveInterestLegPositionsAsOf( originalWithFloat, new List { realtime }, new[] { close, floatClose }, new DateTime(2026, 7, 9)) .Single(x => x.id == originalPositionId); Assert.AreEqual(10000m, beforeClose.InterestPrincipalFix); Assert.AreEqual(7000m, onCloseDate.InterestPrincipalFix); } /// /// [SPC_008] EventDate ≠ UnwindDate 时,ResolveInterestLegPositionsAsOf 按 EventDate(事件日期)分桶。 /// ---------------------------------------------------------------------------- /// 锁定事件日期作为历史重放的生效边界: /// - settleDate < EventDate → 平仓"未发生",as-of=原始本金 /// - settleDate >= EventDate → 平仓"已生效",as-of=实时剩余本金 /// 本测试构造 EventDate=7/9、UnwindDate=7/10,验证 settleDate=7/9 时已按 EventDate 生效。 /// [TestMethod] public void SPC_008_EventDateDiffersFromUnwindDate_BucketsByEventDate() { const long originalPositionId = 2; var original = new swap_position { id = originalPositionId, PosiDirection = 0, InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)InterestModeEnum.初始预付金, InterestPrincipalFix = 10000m }; var realtime = new swap_position { PositionId = originalPositionId, InterestMode = (int)InterestModeEnum.初始预付金, InterestPrincipalFix = 7000m }; // 关键:EventDate 为 7/9,as-of 应按事件日期判断。 var close = new swap_flow_event { PositionId = originalPositionId, PositionType = 0, EventType = (int)SwapEventTypeEnum.平仓, EventDate = new DateTime(2026, 7, 9), UnwindDate = new DateTime(2026, 7, 10), InterestMode = (int)InterestModeEnum.初始预付金, InterestPrincipal = 3000m }; var floatClose = new swap_flow_event { PositionId = 1, PositionType = 1, EventType = (int)SwapEventTypeEnum.平仓, EventDate = new DateTime(2026, 7, 9), UnwindDate = new DateTime(2026, 7, 10), TradingAmount = 3000000m }; var originalWithFloat = new List { original, new swap_position { id = 1, PosiDirection = 1, PosiNotionalValue = 10000000m } }; var flows = new[] { close, floatClose }; // settleDate=7/8(事件日期前)→ as-of=原始 10000 var beforeEffective = SwapDealService.ResolveInterestLegPositionsAsOf( originalWithFloat, new List { realtime }, flows, new DateTime(2026, 7, 8)) .Single(x => x.id == originalPositionId); Assert.AreEqual(10000m, beforeEffective.InterestPrincipalFix, "7/8(事件日期前):平仓未发生,as-of 本金应=原始 10000"); // settleDate=7/9(事件日期当天)→ as-of=实时剩余 7000 var onEffectiveDate = SwapDealService.ResolveInterestLegPositionsAsOf( originalWithFloat, new List { realtime }, flows, new DateTime(2026, 7, 9)) .Single(x => x.id == originalPositionId); Assert.AreEqual(7000m, onEffectiveDate.InterestPrincipalFix, "7/9(事件日期):平仓已生效,as-of 本金应=实时剩余 7000"); // settleDate=7/10(事件日期后)→ 仍为实时剩余 7000 var afterEffectiveBeforeBook = SwapDealService.ResolveInterestLegPositionsAsOf( originalWithFloat, new List { realtime }, flows, new DateTime(2026, 7, 10)) .Single(x => x.id == originalPositionId); Assert.AreEqual(7000m, afterEffectiveBeforeBook.InterestPrincipalFix, "7/10(事件日期后):必须按 EventDate 判已生效 → 7000。"); } } }