using Newtonsoft.Json; using YLErp.DBModels; using YLErp.DBModels.Enums; namespace YLErp.Modules.SwapModule { /// /// 【同日多次部分平仓 · unwind 基数滚动表征测试】 /// ============================================================================ /// 背景:unwind 计息基数公式 basis = priorNotional + notional - baseNotional /// (CompoundInterestAccrual / CalcDailyCompoundInterestByEod 同源),其中 /// - priorNotional = 上一日终归档 eod_swap_position.TdInterestPrincipal /// - baseNotional = orginPv = ResolveUnwindPreviousNotional(lastEod)(上一日终浮动端名义本金) /// - notional = 当前持仓名义本金(posiNotionalValue,来自实时持仓) /// 既有测试(AS_* / SwapUnwindPrepay*Tdd)全是「单事件」场景,没有覆盖 /// 「同一天第 2 次部分平仓」:第 1 次平仓后持仓已缩减,第 2 次平仓传入的 /// notional 应是缩减后的实时值。本文件用内存对象驱动真实 GetInterests 两次, /// 定性验证「同日多次部分平仓」的应返还本金/计息基数是否按线性拆分。 /// /// 建模:标的期初全价腿(mode=9),初始名义本金 N=1,000,000;上一日终归档 /// eod.TdInterestPrincipal=N、PosiNotionalValue=N(lastEod)。 /// 第1次平仓 30%(closePercent=0.3,传入 notional=N) /// 第2次平仓剩余 50%(closePercent=0.5,传入 notional=0.7N=实时缩减后) /// 预期(领域线性):IP1=0.3N、IP2=0.5×0.7N=0.35N,合计 0.65N。 /// 若公式在 notional 正确传入时仍非线性 → 暴露 unwind 基数滚动缺陷。 /// 注:本测试同时是「前置条件护栏」——它证明"只要调用方传入实时缩减后的 /// notional,公式即线性正确";若生产在第2次平仓时传入的是未缩减的陈旧 notional, /// 则结果会偏离,需另查调用方(GetUnwindInterests 的 notional 来源)。 /// ============================================================================ /// [TestClass] public class SwapUnwindSameDayDoublePartialTest { private sealed class StubSwapDealService : SwapDealService { public StubSwapDealService(OptUserInfo optUser) : base(optUser) { } protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate) { rate = 0; return false; // 标的期初全价腿无浮动标的,不查库 } } private const decimal N = 1_000_000m; // 初始名义本金(标的期初全价维度) private const int AnnualDays = 365; private static readonly DateTime StartDate = new(2026, 8, 1); private static readonly DateTime LastEodDate = new(2026, 8, 4); private static readonly DateTime UnwindDate = new(2026, 8, 5); private SwapDealService _svc; [TestInitialize] public void Init() => _svc = new StubSwapDealService(new OptUserInfo(0, nameof(SwapUnwindSameDayDoublePartialTest), OptUserFrom.UnitTest)); private static trade MakeTrade() { var extend = new trade_extend { TradeId = 1, ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson { AnnualDays = AnnualDays, InterestCalcMode = "10", // 算头不算尾 SettlementRules = 0 }) }; return new trade { id = 1, TradeNumber = "UT-SAMEDAY-2UNWIND", ClientId = 999997, TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate, ExerciseDate = new DateTime(2027, 8, 1), TradeStatus = "确认成交", ValidState = "Valid", StockEqvNotional = (double)N, Notional = (double)N, trade_extend = extend }; } /// 标的期初全价腿(mode=9),单利、重置周期1天(无重置日分支,隔离基数滚动行为)。 private static swap_position MakePosition(decimal posiNotionalValue) { return new swap_position { id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown, InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)InterestModeEnum.标的期初全价, InterestRateDefault = 0.01m, InterestPrincipalFix = 0m, PosiStartDate = StartDate, PosiMatuirityDate = new DateTime(2027, 8, 1), IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.单利, IsAnnualized = true, interest_rest_days = 1, interest_rule = 0, FloatRateUnderlyingCode = null, InterestSwapInterval = "[]", PosiNotionalValue = posiNotionalValue }; } /// 上一日终归档:basis 锚点。TdInterestPrincipal=N、PosiNotionalValue=N(lastEod 尚未缩减)。 private static List MakeLastEod() { return new List { new eod_swap_position { id = 1, SwapTradeId = 1, PositionId = 1001, ValueDate = LastEodDate, TdInterestPrincipal = N, PosiNotionalValue = N, InterestProfitSum = 0m, FloatRate = 0m } }; } /// /// 驱动一次盘中平仓(与前端平仓页相同路径,仅用内存对象、不查库)。 /// = 本次平仓时实时持仓名义本金; /// = 占剩余比例(前端 ToRemainingClosePercent 转换后的值)。 /// orginPv 取 lastEod 名义本金 N(与 GetUnwindInterests 真实传参 ResolveUnwindPreviousNotional(lastEod) 一致)。 /// private swap_flow_event CalcUnwind(decimal currentNotional, decimal closePercent) { var td = MakeTrade(); var position = MakePosition(currentNotional); var interests = _svc.GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, MakeLastEod(), new List { position }, currentNotional, currentNotional * closePercent, closePercent, (int)SwapEventTypeEnum.平仓, false, N, false, settment: false, newCalcLast: false, closeList: null); Assert.AreEqual(1, interests.Count, "标的期初全价腿应生成 1 条 flow_event"); return interests[0]; } [TestMethod] public void 同日两次部分平仓_应返还本金应线性拆分且合计等于65pct() { // 第1次:平仓 30%(持仓仍满 N) var fe1 = CalcUnwind(N, 0.3m); // 第2次:同日再平剩余 50%(持仓已缩减为 0.7N,传入实时 notional) var fe2 = CalcUnwind(0.7m * N, 0.5m); Console.WriteLine($"[表征] 第1次(30%) InterestPrincipal={fe1.InterestPrincipal} InterestAmount={fe1.InterestAmount}"); Console.WriteLine($"[表征] 第2次(剩余50%) InterestPrincipal={fe2.InterestPrincipal} InterestAmount={fe2.InterestAmount}"); Console.WriteLine($"[表征] 合计 InterestPrincipal={fe1.InterestPrincipal + fe2.InterestPrincipal} (期望=0.65N={(0.65m * N)})"); // 领域预期(线性):第1次返 0.3N,第2次返 0.5×0.7N=0.35N,合计 0.65N Assert.AreEqual(0.3m * N, fe1.InterestPrincipal, "第1次平仓30%: 应返还本金应=0.3N(线性)"); Assert.AreEqual(0.35m * N, fe2.InterestPrincipal, "第2次平仓剩余50%: 应返还本金应=0.5×0.7N=0.35N(基于实时缩减后的 notional,线性)"); Assert.AreEqual(0.65m * N, fe1.InterestPrincipal + fe2.InterestPrincipal, "同日两次部分平仓合计应返还本金应=0.65N(线性拆分,无重复/遗漏)"); } } }