diff --git a/UnitTestProject/Modules/SwapModule/GLMS20260805ClosePercentDiffDiagnoseTest.cs b/UnitTestProject/Modules/SwapModule/GLMS20260805ClosePercentDiffDiagnoseTest.cs
index 9bb7ea3e..8d34bc48 100644
--- a/UnitTestProject/Modules/SwapModule/GLMS20260805ClosePercentDiffDiagnoseTest.cs
+++ b/UnitTestProject/Modules/SwapModule/GLMS20260805ClosePercentDiffDiagnoseTest.cs
@@ -96,6 +96,97 @@ namespace YLErp.Modules.SwapModule
#endregion
+ #region 1.5) 离线回放:从已录快照重跑 EOD 基数诊断(不连库)
+
+ ///
+ /// 去 DB 化回放:从 落盘的 snapshot_*.json 反序列化
+ /// eod_swap_position / swap_position,离线重跑「EOD 预付金基数是否=初始本金」诊断。
+ ///
+ /// 目的:原 Diagnose_100vs40_InterestDiff 直接连 96 库跑 GetUnwindInterests,
+ /// 依赖数据库可用性、且每次重跑都重新查库。本方法把「一次录制、内存多次回放」
+ /// 落地——录制一次(连库)后,后续诊断完全在内存完成,确定性、可重复、不依赖库。
+ ///
+ /// 语义保持为 bug 护栏:若快照录制时 EOD 基数用了初始本金而非实时剩余,本测试
+ /// 仍会 Assert.Fail(不掩盖生产 bug)。录制一份「修复后」的快照即可转绿。
+ /// 无快照时 Inconclusive(须先连库跑一次 Record_RealSnapshot)。
+ ///
+ [TestMethod]
+ [TestCategory("DbDiagnose")]
+ public void Replay_100vs40_FromSnapshot()
+ {
+ var dir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Resources", "DbDiagnose", "GLMS20260805");
+ if (!Directory.Exists(dir))
+ {
+ Assert.Inconclusive($"未找到快照目录 {dir},请先连库跑一次 Record_RealSnapshot 录制真实数据快照");
+ return;
+ }
+ var files = Directory.GetFiles(dir, "snapshot_*.json").OrderByDescending(f => f).ToArray();
+ if (files.Length == 0)
+ {
+ Assert.Inconclusive($"目录 {dir} 下无 snapshot_*.json,请先连库跑一次 Record_RealSnapshot");
+ return;
+ }
+
+ var snapshotPath = files[0];
+ Console.WriteLine($"✅ 载入快照(离线回放): {snapshotPath}");
+
+ var snapshot = JObject.Parse(File.ReadAllText(snapshotPath));
+ var tradeNumber = snapshot.Value("TradeNumber");
+ Console.WriteLine($"===== 离线回放 交易 {tradeNumber} =====");
+
+ var eodPrepay = JsonConvert.DeserializeObject>(snapshot["EodPositions"].ToString())
+ .Where(e => !e.Invalid
+ && (e.InterestMode == (int)InterestModeEnum.初始预付金
+ || e.InterestMode == (int)InterestModeEnum.追加预付金))
+ .OrderBy(e => e.ValueDate).ThenBy(e => e.PositionId).ToList();
+
+ var origPrepay = JsonConvert.DeserializeObject>(snapshot["Positions"].ToString())
+ .Where(p => !p.Invalid && p.IsInitial
+ && (p.InterestMode == (int)InterestModeEnum.初始预付金 || p.InterestMode == (int)InterestModeEnum.追加预付金)).ToList();
+ var realPrepay = JsonConvert.DeserializeObject>(snapshot["Positions"].ToString())
+ .Where(p => !p.Invalid && !p.IsInitial
+ && (p.InterestMode == (int)InterestModeEnum.初始预付金 || p.InterestMode == (int)InterestModeEnum.追加预付金)).ToList();
+
+ bool bugDetected = false;
+ if (eodPrepay.Count == 0)
+ {
+ Console.WriteLine(" (快照无预付金腿 EOD 记录 → 无可诊断的基数 bug)");
+ }
+ else
+ {
+ Console.WriteLine($" {"ValueDate",-12}{"PosId",-8}{"Mode",-6}{"TdInterestPrincipal",-20}{"InterestProfitSum",-20}");
+ foreach (var e in eodPrepay)
+ {
+ Console.WriteLine($" {e.ValueDate:yyyy-MM-dd} {e.PositionId,-8}{e.InterestMode,-6}{e.TdInterestPrincipal,-20}{e.InterestProfitSum,-20}");
+ }
+
+ Console.WriteLine("\n ---- 预付金本金基数三方对比(离线)----");
+ foreach (var orig in origPrepay)
+ {
+ var real = realPrepay.FirstOrDefault(r => r.PositionId == orig.id);
+ var latestEod = eodPrepay.Where(e => e.PositionId == orig.id).OrderByDescending(e => e.ValueDate).FirstOrDefault();
+ var realFix = real?.InterestPrincipalFix ?? 0;
+ var eodTd = latestEod?.TdInterestPrincipal ?? 0;
+ Console.WriteLine($" PosId={orig.id} origFix(初始)={orig.InterestPrincipalFix} realFix(剩余)={realFix} EOD.TdInterestPrincipal(最新)={eodTd}");
+
+ bool eodMatchesOrig = Math.Abs((double)(eodTd - orig.InterestPrincipalFix)) < 0.01;
+ bool eodMatchesReal = Math.Abs((double)(eodTd - realFix)) < 0.01;
+ if (eodMatchesOrig && !eodMatchesReal && orig.InterestPrincipalFix != realFix)
+ {
+ bugDetected = true;
+ Console.WriteLine($" ⚠⚠ EOD 基数=初始本金(≠剩余)→ 坐实:日终用了初始预付金本金而非实时剩余,后续利息计算基数错误!");
+ }
+ }
+ }
+
+ // bug 护栏:快照若录制到基数 bug,离线回放仍须红,不掩盖生产事故。
+ // 修复生产并重新录制快照后,此断言自然转绿。
+ Assert.IsFalse(bugDetected,
+ "离线回放复现 8/5 基数 bug:EOD 预付金基数用了初始本金而非实时剩余。需先修复生产、再录制新快照让本测试转绿。");
+ }
+
+ #endregion
+
#region 2) 诊断:100% vs 40% 利息差异根因定位(连库跑)
[TestMethod]
diff --git a/UnitTestProject/Modules/SwapModule/SwapUnwindSameDayDoublePartialTest.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindSameDayDoublePartialTest.cs
new file mode 100644
index 00000000..e6442d6c
--- /dev/null
+++ b/UnitTestProject/Modules/SwapModule/SwapUnwindSameDayDoublePartialTest.cs
@@ -0,0 +1,152 @@
+using Newtonsoft.Json;
+using YLErp.DBModels;
+using YLErp.DBModels.Enums;
+
+namespace YLErp.Modules.SwapModule
+{
+ ///
+ /// 【同日多次部分平仓 · unwind 基数滚动表征测试】
+ /// ============================================================================
+ /// 背景:unwind 计息基数公式 basis = priorNotional + notional - baseNotional
+ /// (FundingLegAccrual / 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, currentNotional, currentNotional * closePercent, closePercent,
+ (int)SwapEventTypeEnum.平仓,
+ false, false, 0, 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(线性拆分,无重复/遗漏)");
+ }
+ }
+}