diff --git a/UnitTestProject/Modules/EodModule/GLMS20260805FR007UnderlyingIdDiagnoseTest.cs b/UnitTestProject/Modules/EodModule/GLMS20260805FR007UnderlyingIdDiagnoseTest.cs
new file mode 100644
index 00000000..1a8020cf
--- /dev/null
+++ b/UnitTestProject/Modules/EodModule/GLMS20260805FR007UnderlyingIdDiagnoseTest.cs
@@ -0,0 +1,581 @@
+using System.Diagnostics;
+using YLErp.BLL;
+using YLErp.DBModels;
+
+namespace YLErp.Modules.EodModule
+{
+ ///
+ /// FR007 UnderlyingId 错挂诊断(连 96 测试库)—— GLMS-20260701 同类事故复发排查
+ /// ============================================================================
+ /// 背景:EodPriceUnderlyingIdGuardTest 记录的事故——
+ /// FR007 价格行 UnderlyingCode='FR007' 但 UnderlyingId 被错写成
+ /// 511160.SH(2173889)/159111.SZ(2173890),正确应为 FR007 的 2170838。
+ /// 网页端按 UnderlyingId(int) JOIN underlying_manager 把 FR007 行误挂到别的标的(显示正常);
+ /// EOD 结算按 UnderlyingCode(string 'FR007') JOIN 查不到 → "结算价格缺失 / 没用上"。
+ ///
+ /// 本测试连真实库,回答用户问题:"是不是又关联到错误标的了?"
+ /// 1) 查 underlying_manager 里 FR007 的正确 id
+ /// 2) 查 eod_commodity_future_price 里所有 UnderlyingCode='FR007' 的行,看 UnderlyingId 是否=正确 id
+ /// 3) 对比"网页端查询(UnderlyingId JOIN)" vs "EOD 查询(UnderlyingCode JOIN)" 是否一致
+ /// 4) 核对最近 N 天的 FR007 行是否错挂(复发判定)
+ ///
+ /// 用法:本地连 96 库跑 Diagnose_FR007_UnderlyingIdMismatch;连不上库自动 Inconclusive。
+ ///
+ [TestClass]
+ public class GLMS20260805FR007UnderlyingIdDiagnoseTest
+ {
+ ///
+ /// 诊断 FR007 价格行的 UnderlyingId 是否错挂(GLMS-20260701 同类复发判定)
+ ///
+ [TestMethod]
+ [TestCategory("DbDiagnose")]
+ public void Diagnose_FR007_UnderlyingIdMismatch()
+ {
+ YLContext db;
+ try { db = DbContextFactory.GetYLDbContext(); }
+ catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
+
+ // ---- 1. 查 underlying_manager 里 FR007 的正确 id(权威定义)----
+ var fr007Underlying = db.underlying_manager
+ .Where(a => a.UnderlyingCode == "FR007")
+ .Select(a => new { a.id, a.UnderlyingCode, a.UnderlyingName })
+ .ToList();
+
+ Console.WriteLine("===== 1. underlying_manager 里 FR007 的定义 =====");
+ if (fr007Underlying.Count == 0)
+ {
+ Console.WriteLine(" ⚠⚠ underlying_manager 无 UnderlyingCode='FR007' 的记录!");
+ Console.WriteLine(" → 这是致命问题:EOD 按 UnderlyingCode 查 FR007 必然查不到(结算价格缺失)");
+ }
+ foreach (var u in fr007Underlying)
+ {
+ Console.WriteLine($" id={u.id} Code={u.UnderlyingCode} Name={u.UnderlyingName} ← 这是 FR007 的正确 UnderlyingId");
+ }
+ int? fr007CorrectId = fr007Underlying.FirstOrDefault()?.id;
+ Console.WriteLine();
+
+ // ---- 2. 查 eod_commodity_future_price 里所有 FR007 行,看 UnderlyingId 是否错挂 ----
+ var fr007PriceRows = db.eod_commodity_future_price
+ .Where(a => a.UnderlyingCode == "FR007")
+ .OrderByDescending(a => a.ValueDate)
+ .Take(30)
+ .Select(a => new { a.ValueDate, a.UnderlyingCode, a.UnderlyingId, a.ReferencePrice, a.DataSource })
+ .ToList();
+
+ Console.WriteLine($"===== 2. eod_commodity_future_price 里 FR007 行(最近{fr007PriceRows.Count}条,新→旧)=====");
+ Console.WriteLine($" {"ValueDate",-12}{"UnderlyingCode",-16}{"UnderlyingId",-14}{"是否错挂?",-12}{"ReferencePrice",-16}{"DataSource"}");
+
+ int mismatchCount = 0;
+ foreach (var r in fr007PriceRows)
+ {
+ bool mismatched = fr007CorrectId.HasValue && r.UnderlyingId.HasValue && r.UnderlyingId.Value != fr007CorrectId.Value;
+ if (mismatched) mismatchCount++;
+ string flag = mismatched ? "⚠错挂!" : (r.UnderlyingId == null ? "空" : "✓正确");
+ Console.WriteLine($" {r.ValueDate:yyyy-MM-dd} {r.UnderlyingCode,-16}{r.UnderlyingId?.ToString() ?? "NULL",-14}{flag,-12}{r.ReferencePrice,-16}{r.DataSource}");
+ }
+
+ Console.WriteLine($"\n 小结:{mismatchCount}/{fr007PriceRows.Count} 条 FR007 行 UnderlyingId 错挂");
+ if (mismatchCount > 0)
+ {
+ Console.WriteLine(" ⚠⚠ 确认复发:FR007 行的 UnderlyingId 被错写成别的标的 id!");
+ Console.WriteLine(" 网页端按 UnderlyingId JOIN 能查到(误挂到 511160.SH/159111.SZ 等),但 EOD 按 UnderlyingCode='FR007' 查反而正常");
+ Console.WriteLine(" → 若用户看到'网页有、EOD 没用上',需进一步看 EOD 查询路径(见下方第4步)");
+ }
+ Console.WriteLine();
+
+ // ---- 3. 反向查:UnderlyingId=FR007正确id 的行里,有没有 UnderlyingCode 不是 FR007 的(错误传染方向2)----
+ if (fr007CorrectId.HasValue)
+ {
+ var crossContaminated = db.eod_commodity_future_price
+ .Where(a => a.UnderlyingId == fr007CorrectId.Value && a.UnderlyingCode != "FR007")
+ .OrderByDescending(a => a.ValueDate)
+ .Take(10)
+ .Select(a => new { a.ValueDate, a.UnderlyingCode, a.UnderlyingId, a.ReferencePrice })
+ .ToList();
+
+ Console.WriteLine($"===== 3. 反向查:UnderlyingId=FR007({fr007CorrectId}) 但 Code≠FR007 的行(错误传染方向2)=====");
+ if (crossContaminated.Count == 0)
+ {
+ Console.WriteLine(" (无)FR007 的 id 没有被别的标的发生的行误用");
+ }
+ else
+ {
+ Console.WriteLine($" ⚠ 发现 {crossContaminated.Count} 条:这些行占了 FR007 的 id 但 Code 是别的标的");
+ foreach (var c in crossContaminated)
+ {
+ Console.WriteLine($" {c.ValueDate:yyyy-MM-dd} Code={c.UnderlyingCode} UnderlyingId={c.UnderlyingId} Price={c.ReferencePrice}");
+ }
+ }
+ }
+ Console.WriteLine();
+
+ // ---- 4. EOD 查询路径验证:EodPriceQueryService.TryGetPrice 的查询能否命中 ----
+ Console.WriteLine("===== 4. EOD 查询路径验证(EodPriceQueryService.TryGetPrice 的实际命中情况)=====");
+ Console.WriteLine(" EOD 按 UnderlyingCode(string) 精确匹配 + ValueDate 精确匹配,不依赖 UnderlyingId。");
+ Console.WriteLine(" 即:即使 UnderlyingId 错挂,只要 UnderlyingCode='FR007' 且 ValueDate 对得上,EOD 仍能查到。");
+ Console.WriteLine(" → UnderlyingId 错挂主要影响【网页端展示/JOIN】,不一定影响【EOD 取价】。");
+ Console.WriteLine(" → 若 EOD 仍取不到价,根因更可能是:日期错位/非重置日/未上传当日值,而非 UnderlyingId 错挂。");
+ Console.WriteLine();
+
+ // ---- 5. 近 7 天 FR007 上传覆盖情况(判断 EOD 取不到是不是因为没上传)----
+ var recentDates = db.eod_commodity_future_price
+ .Where(a => a.UnderlyingCode == "FR007" && a.ValueDate >= DateTime.Today.AddDays(-10))
+ .OrderBy(a => a.ValueDate)
+ .Select(a => new { a.ValueDate, a.ReferencePrice })
+ .ToList();
+
+ Console.WriteLine($"===== 5. 近 10 天 FR007 上传覆盖(判断是否漏传导致 EOD 取不到)=====");
+ if (recentDates.Count == 0)
+ {
+ Console.WriteLine(" ⚠⚠ 近 10 天无任何 FR007 上传记录!EOD 复利取价必然失败(或用历史快照)");
+ }
+ foreach (var d in recentDates)
+ {
+ var weekday = d.ValueDate.DayOfWeek;
+ string wd = weekday == DayOfWeek.Saturday || weekday == DayOfWeek.Sunday ? "周末" : "工作日";
+ Console.WriteLine($" {d.ValueDate:yyyy-MM-dd}({wd}) FR007={d.ReferencePrice}");
+ }
+
+ // ---- 结论判定 ----
+ Console.WriteLine("\n===== 诊断结论 =====");
+ if (mismatchCount > 0)
+ {
+ Console.WriteLine(" [确认] FR007 行存在 UnderlyingId 错挂(GLMS-20260701 同类复发)");
+ Console.WriteLine(" 影响:网页端按 UnderlyingId JOIN 会把 FR007 误挂到别的标的显示");
+ Console.WriteLine(" 但 EOD 取价走 UnderlyingCode,错挂不直接导致 EOD 取不到价");
+ }
+ else
+ {
+ Console.WriteLine(" [排除] FR007 行 UnderlyingId 均正确,未复发 GLMS-20260701 事故");
+ Console.WriteLine(" → 'EOD 没用上 FR007' 更可能是:非重置日(设计)/日期错位/未上传当日值");
+ }
+
+ // 断言:错挂数应为 0(若 >0 说明复发)
+ Assert.IsTrue(mismatchCount == 0,
+ $"FR007 有 {mismatchCount} 条价格行 UnderlyingId 错挂(应为 {fr007CorrectId}),GLMS-20260701 事故复发");
+ }
+
+ ///
+ /// 诊断 GLMS-JIATT-20260805 复利 EOD 取价日:算出哪些天是重置日、实际查哪天的 FR007
+ /// 回答"是不是只需要 8/3 一天的价格即可"
+ ///
+ [TestMethod]
+ [TestCategory("DbDiagnose")]
+ public void Diagnose_Trade_FR007_ResetDays()
+ {
+ const string TradeNumber = "GLMS-JIATT-20260805-FICC-01-2180120IB";
+ YLContext db;
+ try { db = DbContextFactory.GetYLDbContext(); }
+ catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
+
+ var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber);
+ if (td == null) { Assert.Inconclusive($"测试库无 {TradeNumber}"); return; }
+
+ Console.WriteLine($"===== 交易 {TradeNumber} (id={td.id}) =====");
+ Console.WriteLine($" StartDate(起息日/tradeDate) = {td.StartDate:yyyy-MM-dd}");
+ Console.WriteLine();
+
+ // 复利腿配置(InterestType=1 复利)
+ var compoundLegs = db.swap_position
+ .Where(p => p.SwapTradeId == td.id && !p.Invalid && p.InterestType == 1)
+ .ToList();
+
+ if (compoundLegs.Count == 0)
+ {
+ Console.WriteLine(" ⚠ 该交易无复利腿(InterestType=1),FR007 取价逻辑不适用");
+ Assert.Inconclusive("无复利腿");
+ return;
+ }
+
+ foreach (var leg in compoundLegs)
+ {
+ Console.WriteLine($" 复利腿 PositionId={leg.id} Mode={leg.InterestMode}");
+ Console.WriteLine($" PosiStartDate={leg.PosiStartDate:yyyy-MM-dd}");
+ Console.WriteLine($" interest_rest_days(重置周期)={leg.interest_rest_days}");
+ Console.WriteLine($" interest_rule(日期偏移)={leg.interest_rule}");
+ Console.WriteLine($" FloatRateUnderlyingCode={leg.FloatRateUnderlyingCode}");
+ Console.WriteLine($" InterestRateDefault(加点固定利率)={leg.InterestRateDefault}");
+ Console.WriteLine();
+ }
+
+ // 用第一条复利腿的配置算重置日(EOD 用 td.StartDate 算 days,见 SwapDealService.cs:1085,1385)
+ var leg0 = compoundLegs[0];
+ int interestPeriod = leg0.interest_rest_days ?? 1;
+ int interestRule = leg0.interest_rule ?? 0;
+ DateTime tradeDate = td.StartDate.Value;
+ string floatCode = leg0.FloatRateUnderlyingCode;
+
+ Console.WriteLine($"===== EOD 复利取价日推算(days=(收盘日-StartDate)%{interestPeriod}==0 才取价)=====");
+ Console.WriteLine($" 公式:fr007RateDate = GetNonHolidayDefore(收盘日 + interest_rule({interestRule}))");
+ Console.WriteLine($" 法定节假日会回退到前一工作日(GetNonHolidayDefore)");
+ Console.WriteLine();
+
+ // 推算 7/27 ~ 8/7 每天是不是重置日,以及重置日实际查哪天的 FR007
+ Console.WriteLine($" {"收盘日",-12}{"days",-8}{"重置日?",-10}{"查询日(raw)",-14}{"查询日(节假日回退)",-20}{"FR007有值?"}");
+ var fr007Dates = db.eod_commodity_future_price
+ .Where(a => a.UnderlyingCode == "FR007" && a.ReferencePrice != null && a.ReferencePrice != 0)
+ .Select(a => a.ValueDate)
+ .ToList();
+ var fr007Set = new HashSet(fr007Dates);
+
+ // 简单节假日表(周末;法定节假日用 GetNonHolidayDefore 实际逻辑,这里近似用周末判断)
+ DateTime CalcNonHoliday(DateTime d)
+ {
+ while (d.DayOfWeek == DayOfWeek.Saturday || d.DayOfWeek == DayOfWeek.Sunday)
+ d = d.AddDays(-1);
+ return d;
+ }
+
+ int resetDayCount = 0;
+ for (var d = new DateTime(2026, 7, 27); d <= new DateTime(2026, 8, 7); d = d.AddDays(1))
+ {
+ int days = (d - tradeDate).Days;
+ bool isReset = days % interestPeriod == 0;
+ if (d.DayOfWeek == DayOfWeek.Saturday || d.DayOfWeek == DayOfWeek.Sunday) continue; // EOD 不跑周末
+
+ string resetFlag = isReset ? "✓重置日" : "非重置";
+ if (isReset) resetDayCount++;
+
+ DateTime rawQueryDate = d.AddDays(interestRule);
+ DateTime actualQueryDate = CalcNonHoliday(rawQueryDate);
+ bool hasFr007 = fr007Set.Contains(actualQueryDate);
+
+ Console.WriteLine($" {d:yyyy-MM-dd} {days,-8}{resetFlag,-10}{rawQueryDate:yyyy-MM-dd} {actualQueryDate:yyyy-MM-dd} {(hasFr007 ? "✓有值" : "✗缺失")}");
+ }
+
+ Console.WriteLine($"\n 小结:7/27~8/7 期间共 {resetDayCount} 个重置日(EOD 只有这些天才查 FR007)");
+ Console.WriteLine(" → 非重置日根本不查 FR007,沿用上一重置周期的利率,无需每天都有值");
+ Console.WriteLine(" → 只要【重置日实际查到的那天】有 FR007 值即可,其它天空值不影响 EOD 复利");
+ }
+
+ ///
+ /// 逐日对比:EOD 累计 InterestProfitSum(增量)vs 平仓从头重放(全段)—— 定位哪天开始偏差
+ ///
+ [TestMethod]
+ [TestCategory("DbDiagnose")]
+ public void Diagnose_EOD_vs_Unwind_Compound_DailyCompare()
+ {
+ const string TradeNumber = "GLMS-JIATT-20260805-FICC-01-2180120IB";
+ YLContext db;
+ try { db = DbContextFactory.GetYLDbContext(); }
+ catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
+
+ var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber);
+ if (td == null) { Assert.Inconclusive($"测试库无 {TradeNumber}"); return; }
+
+ Console.WriteLine($"===== 交易 {TradeNumber} (id={td.id}) EOD累计 vs 平仓重放 逐日对比 =====");
+ Console.WriteLine($" StartDate={td.StartDate:yyyy-MM-dd}");
+ Console.WriteLine();
+
+ // 复利腿(取 IsInitial=true 的,其 id 才是 EOD/平仓 PositionId 匹配的 key)
+ var compoundLeg = db.swap_position.FirstOrDefault(p => p.SwapTradeId == td.id && !p.Invalid && p.InterestType == 1 && p.IsInitial);
+ if (compoundLeg == null) { Assert.Inconclusive("无 IsInitial 复利腿"); return; }
+ Console.WriteLine($" 复利腿(初始) id={compoundLeg.id} Mode={compoundLeg.InterestMode} PosiStartDate={compoundLeg.PosiStartDate:yyyy-MM-dd}");
+ Console.WriteLine($" interest_rest_days={compoundLeg.interest_rest_days} interest_rule={compoundLeg.interest_rule} Rate={compoundLeg.InterestRateDefault}");
+ Console.WriteLine();
+
+ // 读取 EOD 逐日快照(复利腿,按初始腿 id 匹配 PositionId)
+ var eodSeq = db.eod_swap_position
+ .Where(e => e.SwapTradeId == td.id && !e.Invalid && e.PositionId == compoundLeg.id)
+ .OrderBy(e => e.ValueDate)
+ .Select(e => new { e.ValueDate, e.InterestProfitSum, e.TdInterestPrincipal, e.FloatRate, e.InterestIncomeSum, e.TdInterestIncome })
+ .ToList();
+
+ Console.WriteLine($" EOD 快照共 {eodSeq.Count} 天");
+ Console.WriteLine();
+
+ // 平仓从头重放:逐日调 GetUnwindInterests(closePercent=1, 全平)
+ // 注意:平仓返回值 = 从头重放全段利息 - consumedInterest*1(扣历史已结)
+ // EOD InterestProfitSum 是逐日增量累计(不扣 consumedInterest)
+ // 所以两者差 = consumedInterest(历史已结)。重点看"差"是否稳定。
+ var user = new OptUserInfo(0, nameof(GLMS20260805FR007UnderlyingIdDiagnoseTest), OptUserFrom.UnitTest);
+ var svc = new YLErp.Modules.SwapModule.SwapDealService(user);
+
+ int interestPeriod = compoundLeg.interest_rest_days ?? 1;
+ int interestRule = compoundLeg.interest_rule ?? 0;
+ DateTime tradeDate0 = td.StartDate.Value;
+
+ Console.WriteLine($" {"日期",-12}{"days",-6}{"重置?",-8}{"EOD.FloatRate",-14}{"EOD.ProfitSum",-18}{"EOD.TdIntPrin",-16}{"平仓重放",-18}{"差",-14}{"说明"}");
+ Console.WriteLine($" {new string('-', 118)}");
+
+ decimal prevEodSum = 0;
+ decimal prevUnwind = 0;
+ decimal prevEodFloat = 0;
+ for (var d = td.StartDate.Value; d <= new DateTime(2026, 8, 7); d = d.AddDays(1))
+ {
+ if (d.DayOfWeek == DayOfWeek.Saturday || d.DayOfWeek == DayOfWeek.Sunday) continue;
+
+ int days = (d - tradeDate0).Days;
+ bool isReset = days % interestPeriod == 0;
+
+ var eod = eodSeq.FirstOrDefault(e => e.ValueDate == d);
+ decimal eodProfitSum = eod?.InterestProfitSum ?? 0;
+ decimal eodTdPrin = eod?.TdInterestPrincipal ?? 0;
+ decimal eodFloat = eod?.FloatRate ?? 0;
+
+ // 重置日 FR007 切换检查
+ string floatNote = "";
+ if (isReset && days != 0 && prevEodFloat != 0 && eodFloat == prevEodFloat)
+ {
+ floatNote = "⚠重置日FR007未切换!";
+ }
+
+ // 平仓从头重放(全平 closePercent=1)
+ decimal unwindInterest = 0;
+ string note = "";
+ try
+ {
+ var interests = svc.GetUnwindInterests(d, d, td.id, 1m, (int)SwapEventTypeEnum.平仓);
+ var compoundResult = interests.FirstOrDefault(x => x.PositionId == compoundLeg.id);
+ unwindInterest = compoundResult?.InterestAmount ?? 0;
+ // 平仓路径取的 FR007(看是否切换)
+ if (isReset && compoundResult != null)
+ {
+ note = $"平仓FloatRate={compoundResult.FloatRate}";
+ }
+ }
+ catch (Exception ex)
+ {
+ note = $"⚠平仓失败:{ex.Message}";
+ }
+
+ decimal diff = unwindInterest - eodProfitSum;
+ string diffNote = Math.Abs(diff) < 0.01m ? "一致" : (Math.Abs(diff) < 1m ? "微小差" : "偏差");
+ string resetFlag = isReset ? "✓重置" : "";
+
+ Console.WriteLine($" {d:yyyy-MM-dd} {days,-6}{resetFlag,-8}{eodFloat,12:F6} {eodProfitSum,16:F6} {eodTdPrin,14:F4} {unwindInterest,16:F6} {diff,12:F6} {diffNote} {floatNote} {note}");
+
+ prevEodSum = eodProfitSum;
+ prevUnwind = unwindInterest;
+ prevEodFloat = eodFloat;
+ }
+
+ Console.WriteLine();
+ Console.WriteLine($" ===== 解读 =====");
+ Console.WriteLine($" · 平仓重放 = 从 PosiStartDate 到当日全段复利利息 - consumedInterest(历史互换已结)");
+ Console.WriteLine($" · EOD ProfitSum = 逐日增量累计(preEod.ProfitSum + 当天新计)");
+ Console.WriteLine($" · 两者差应≈consumedInterest(若有历史互换)。若差值不稳定/突变 → 某天 EOD 增量算错");
+ Console.WriteLine($" · 重点看 FloatRate 列:EOD 用的浮动利率是否在重置日正确切换、非重置日是否正确沿用");
+ }
+
+ ///
+ /// 深挖复利腿(38122)在 8/4 互换前后发生了什么:flow_event + EOD 全字段
+ ///
+ [TestMethod]
+ [TestCategory("DbDiagnose")]
+ public void Diagnose_Trade_CompoundLeg_AroundSwap()
+ {
+ const string TradeNumber = "GLMS-JIATT-20260805-FICC-01-2180120IB";
+ const long CompoundPositionId = 38122;
+ YLContext db;
+ try { db = DbContextFactory.GetYLDbContext(); }
+ catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
+
+ var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber);
+ if (td == null) { Assert.Inconclusive($"测试库无 {TradeNumber}"); return; }
+
+ // 1. 复利腿所有 flow_event(看 8/4 互换对它做了什么)
+ Console.WriteLine($"===== 复利腿 PositionId={CompoundPositionId} 所有 flow_event =====");
+ var flows = db.swap_flow_event
+ .Where(f => f.SwapTradeId == td.id && f.PositionId == CompoundPositionId)
+ .OrderBy(f => f.EventDate).ThenBy(f => f.id)
+ .Select(f => new { f.EventDate, f.UnwindDate, f.EventType, f.InterestMode, f.InterestAmount, f.InterestPrincipal, f.FloatRate, f.InterestRate, f.DataState, f.Quantity, f.PositionQty })
+ .Take(30)
+ .ToList();
+ Console.WriteLine($" 共 {flows.Count} 条");
+ Console.WriteLine($" {"EventDate",-12}{"UnwindDate",-12}{"Type",-6}{"DataState",-10}{"I.Amount",-16}{"I.Principal",-18}{"FloatRate",-12}{"Rate"}");
+ foreach (var f in flows)
+ {
+ string typeStr = f.EventType == 2 ? "平仓" : f.EventType == 3 ? "互换" : f.EventType == 4 ? "自动" : f.EventType.ToString();
+ Console.WriteLine($" {f.EventDate:yyyy-MM-dd} {f.UnwindDate:yyyy-MM-dd} {typeStr,-6}{f.DataState,-10}{f.InterestAmount,14:F6} {f.InterestPrincipal,16:F4} {f.FloatRate,10:F6} {f.InterestRate}");
+ }
+ Console.WriteLine();
+
+ // 2. 8/3~8/6 EOD 全字段(看 8/4 互换后状态怎么变的)
+ Console.WriteLine($"===== PositionId={CompoundPositionId} 8/3~8/6 EOD 全字段 =====");
+ var eods = db.eod_swap_position
+ .Where(e => e.SwapTradeId == td.id && e.PositionId == CompoundPositionId
+ && e.ValueDate >= new DateTime(2026, 8, 3) && e.ValueDate <= new DateTime(2026, 8, 6))
+ .OrderBy(e => e.ValueDate)
+ .Select(e => new { e.ValueDate, e.InterestProfitSum, e.TdInterestPrincipal, e.FloatRate, e.InterestIncomeSum, e.TdInterestIncome, e.TdCloseInterest, e.RealizedInterest, e.InterestRateDefault, e.PosiStatus })
+ .ToList();
+ Console.WriteLine($" {"日期",-12}{"ProfitSum",-16}{"TdIntPrin",-16}{"FloatRate",-12}{"IncomeSum",-16}{"TdIncome",-14}{"TdCloseInt",-14}{"RealizedInt",-14}{"PosiStatus"}");
+ foreach (var e in eods)
+ {
+ Console.WriteLine($" {e.ValueDate:yyyy-MM-dd} {e.InterestProfitSum,14:F6} {e.TdInterestPrincipal,14:F4} {e.FloatRate,10:F6} {e.InterestIncomeSum,14:F6} {e.TdInterestIncome,12:F6} {e.TdCloseInterest,12:F6} {e.RealizedInterest,12:F6} {e.PosiStatus}");
+ }
+ Console.WriteLine();
+
+ // 3. 看 8/4 是否有 swap_event(互换事件记录)
+ Console.WriteLine($"===== 8/3~8/5 的 swap_event(看有无互换操作)=====");
+ var events = db.swap_event
+ .Where(s => s.SwapTradeId == td.id && !s.Invalid
+ && s.ValueDate >= new DateTime(2026, 8, 3) && s.ValueDate <= new DateTime(2026, 8, 5))
+ .OrderBy(s => s.ValueDate)
+ .Select(s => new { s.id, s.ValueDate, s.EventType, s.EventReason, s.Invalid })
+ .ToList();
+ foreach (var s in events)
+ {
+ string typeStr = s.EventType == 2 ? "平仓" : s.EventType == 3 ? "互换" : s.EventType.ToString();
+ Console.WriteLine($" id={s.id} {s.ValueDate:yyyy-MM-dd} Type={typeStr} Reason={s.EventReason} Invalid={s.Invalid}");
+ }
+ if (events.Count == 0) Console.WriteLine(" (8/3~8/5 无 swap_event)");
+ }
+
+ ///
+ /// 精确诊断:8/4 重置日为何取到 FR007=0.0123 而非 0.0213
+ /// 复刻 CalcDailyCompoundInterest 循环 + GetFloatRate 逻辑,逐 i 打印 floatRate 演变
+ ///
+ [TestMethod]
+ [TestCategory("DbDiagnose")]
+ public void Diagnose_Trade_804_ResetDay_FloatRate_Trace()
+ {
+ const string TradeNumber = "GLMS-JIATT-20260805-FICC-01-2180120IB";
+ YLContext db;
+ try { db = DbContextFactory.GetYLDbContext(); }
+ catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
+
+ var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber);
+ if (td == null) { Assert.Inconclusive($"测试库无 {TradeNumber}"); return; }
+ var te = db.trade_extend.FirstOrDefault(x => x.TradeId == td.id);
+
+ // InterestCalcMode
+ string calcMode = "?";
+ int annualDays = 365;
+ if (te != null && !string.IsNullOrEmpty(te.ExtendJson))
+ {
+ try
+ {
+ dynamic ext = Newtonsoft.Json.JsonConvert.DeserializeObject(te.ExtendJson);
+ calcMode = (string)ext.InterestCalcMode ?? "?";
+ annualDays = (int?)ext.AnnualDays ?? 365;
+ }
+ catch { }
+ }
+ bool calcFirst = calcMode.StartsWith("1");
+ bool calcLast = calcMode.EndsWith("1");
+ Console.WriteLine($"===== InterestCalcMode 诊断 =====");
+ Console.WriteLine($" InterestCalcMode = '{calcMode}' → calcFirst={calcFirst} calcLast={calcLast}");
+ Console.WriteLine();
+
+ var compoundLeg = db.swap_position.FirstOrDefault(p => p.SwapTradeId == td.id && !p.Invalid && p.InterestType == 1 && p.IsInitial);
+ if (compoundLeg == null) { Assert.Inconclusive("无复利腿"); return; }
+ int period = compoundLeg.interest_rest_days ?? 1;
+ int rule = compoundLeg.interest_rule ?? 0;
+ DateTime startDate = compoundLeg.PosiStartDate;
+
+ // 直接调生产代码 GetUnwindInterests(8/4 全平),看返回的 FloatRate
+ DateTime endDate = new DateTime(2026, 8, 4);
+ Console.WriteLine($"===== 1. 调 GetUnwindInterests(8/4) 看复利腿返回的 FloatRate =====");
+ Console.WriteLine($" PosiStartDate={startDate:yyyy-MM-dd} endDate={endDate:yyyy-MM-dd} period={period} rule={rule}");
+ Console.WriteLine($" (endDate - PosiStartDate).Days = {(endDate - startDate).Days}, %period = {(endDate - startDate).Days % period}(==0 即重置日)");
+ Console.WriteLine($" (endDate - td.StartDate).Days = {(endDate - td.StartDate.Value).Days}(GetFloatRate 用这个判重置日)");
+ Console.WriteLine();
+
+ var user = new OptUserInfo(0, nameof(GLMS20260805FR007UnderlyingIdDiagnoseTest), OptUserFrom.UnitTest);
+ var svc = new YLErp.Modules.SwapModule.SwapDealService(user);
+ try
+ {
+ var interests = svc.GetUnwindInterests(endDate, endDate, td.id, 1m, (int)SwapEventTypeEnum.平仓);
+ var compoundResult = interests.FirstOrDefault(x => x.PositionId == compoundLeg.id);
+ if (compoundResult != null)
+ {
+ Console.WriteLine($" ✓ 平仓返回:InterestAmount={compoundResult.InterestAmount:F6} FloatRate={compoundResult.FloatRate:F6} InterestRate={compoundResult.InterestRate}");
+ Console.WriteLine($" 若 FloatRate≈0.0123 → 取到的是 7/27 旧值(重置日未生效)");
+ Console.WriteLine($" 若 FloatRate≈0.0213 → 取到的是 8/3 新值(重置日生效,正常)");
+ }
+ }
+ catch (Exception ex) { Console.WriteLine($" ⚠ 平仓调用失败:{ex.Message}"); }
+ Console.WriteLine();
+
+ // 2. 直接验证 FR007 在关键日期的值(EodPriceQueryService.TryGetPrice)
+ Console.WriteLine($"===== 2. FR007 在关键日期的实际值(EodPriceQueryService.TryGetPrice)=====");
+ var checkDates = new[] {
+ ("7/27(首重置日查询日)", new DateTime(2026,7,27)),
+ ("8/3(8/4重置日应查的日期, interest_rule=-1)", new DateTime(2026,8,3)),
+ ("8/4(直接查)", new DateTime(2026,8,4)),
+ };
+ foreach (var (label, dt2) in checkDates)
+ {
+ bool ok = YLErp.Modules.DataProviderModule.EodPriceQueryService.TryGetPrice(dt2, "FR007", out double v);
+ Console.WriteLine($" {label} {dt2:yyyy-MM-dd}: {(ok ? $"{v:F6}" : "✗查不到")}");
+ }
+ Console.WriteLine();
+
+ // 3. 结论判定
+ Console.WriteLine($"===== 3. 结论判定 =====");
+ Console.WriteLine($" calcLast={calcLast}(InterestCalcMode='{calcMode}' EndsWith('1'))");
+ Console.WriteLine($" 8/4 平仓:endDate=8/4 是重置日((8/4-7/28).Days=7, 7%7=0)");
+ if (!calcLast)
+ {
+ Console.WriteLine($" ⚠ calcLast=false:CalcDailyCompoundInterest 循环里 i=7(accrueDate=8/4=endDate) 命中");
+ Console.WriteLine($" 'if(!calcLast && accrueDate==endDate) continue' → 被跳过,不进重置日取价分支");
+ Console.WriteLine($" → 8/4 重置日不取新 FR007,沿用循环里 i=0(7/28)取到的旧值 0.0123");
+ Console.WriteLine($" → 这就是根因:算尾规则(calcLast)导致重置日=平仓日时跳过取价");
+ }
+ else
+ {
+ Console.WriteLine($" calcLast=true:8/4 不会被跳过,应能取到新 FR007(0.0213)。");
+ Console.WriteLine($" 若平仓返回的 FloatRate 仍是 0.0123 → 根因在别处(需进一步查 GetFloatRate/循环覆盖)");
+ }
+ }
+
+ ///
+ /// 查这笔交易所有腿 + EOD 快照的 PositionId 映射,搞清哪条腿真正算利息
+ ///
+ [TestMethod]
+ [TestCategory("DbDiagnose")]
+ public void Diagnose_Trade_AllLegs_And_EodMapping()
+ {
+ const string TradeNumber = "GLMS-JIATT-20260805-FICC-01-2180120IB";
+ YLContext db;
+ try { db = DbContextFactory.GetYLDbContext(); }
+ catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
+
+ var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber);
+ if (td == null) { Assert.Inconclusive($"测试库无 {TradeNumber}"); return; }
+
+ Console.WriteLine($"===== 交易 {TradeNumber} (id={td.id}) 全部持仓腿 =====");
+ var allLegs = db.swap_position
+ .Where(p => p.SwapTradeId == td.id && !p.Invalid)
+ .OrderBy(p => p.PosiDirection).ThenBy(p => p.IsInitial).ThenBy(p => p.id)
+ .Select(p => new { p.id, p.PositionId, p.IsInitial, p.InterestMode, p.InterestType, p.PosiDirection, p.InterestPrincipalFix, p.PosiNotionalValue, p.PosiStartDate, p.FloatRateUnderlyingCode })
+ .ToList();
+
+ Console.WriteLine($" {"id",-8}{"PositionId",-12}{"IsInit",-8}{"Mode",-6}{"IntType",-8}{"PosiDir",-8}{"Fix",-16}{"PosiNotional",-16}{"PosiStart",-12}{"FloatCode"}");
+ foreach (var p in allLegs)
+ {
+ Console.WriteLine($" {p.id,-8}{p.PositionId,-12}{p.IsInitial,-8}{p.InterestMode,-6}{p.InterestType,-8}{p.PosiDirection,-8}{p.InterestPrincipalFix,-16}{p.PosiNotionalValue,-16}{p.PosiStartDate:yyyy-MM-dd} {p.FloatRateUnderlyingCode}");
+ }
+ Console.WriteLine();
+
+ // 各腿对应的 EOD 快照数量
+ Console.WriteLine($"===== 各腿 EOD 快照数量(eod_swap_position)=====");
+ var eodCounts = db.eod_swap_position
+ .Where(e => e.SwapTradeId == td.id && !e.Invalid)
+ .GroupBy(e => e.PositionId)
+ .Select(g => new { PositionId = g.Key, Cnt = g.Count(), MinDate = g.Min(x => x.ValueDate), MaxDate = g.Max(x => x.ValueDate) })
+ .ToList();
+ foreach (var c in eodCounts)
+ {
+ Console.WriteLine($" PositionId={c.PositionId} 快照数={c.Cnt} 日期范围={c.MinDate:yyyy-MM-dd}~{c.MaxDate:yyyy-MM-dd}");
+ }
+ Console.WriteLine();
+
+ // 复利腿(Intertype=1)逐日 EOD 明细(所有 PositionId)
+ Console.WriteLine($"===== 复利腿(InterestType=1) EOD 逐日明细(所有 PositionId)=====");
+ var compoundEods = db.eod_swap_position
+ .Where(e => e.SwapTradeId == td.id && !e.Invalid && e.InterestType == 1)
+ .OrderBy(e => e.ValueDate).ThenBy(e => e.PositionId)
+ .Select(e => new { e.ValueDate, e.PositionId, e.InterestProfitSum, e.TdInterestPrincipal, e.FloatRate, e.InterestRateDefault, e.InterestMode })
+ .Take(40)
+ .ToList();
+ Console.WriteLine($" 共 {compoundEods.Count} 条");
+ Console.WriteLine($" {"日期",-12}{"PositionId",-12}{"Mode",-6}{"ProfitSum",-18}{"TdIntPrin",-16}{"FloatRate",-12}{"RateDefault"}");
+ foreach (var e in compoundEods)
+ {
+ Console.WriteLine($" {e.ValueDate:yyyy-MM-dd} {e.PositionId,-12}{e.InterestMode,-6}{e.InterestProfitSum,16:F6} {e.TdInterestPrincipal,14:F4} {e.FloatRate,10:F6} {e.InterestRateDefault}");
+ }
+ }
+ }
+}
diff --git a/UnitTestProject/Modules/SwapModule/ConsumedInterestScenarioTest.cs b/UnitTestProject/Modules/SwapModule/ConsumedInterestScenarioTest.cs
index 2d2b9016..4da4cbb2 100644
--- a/UnitTestProject/Modules/SwapModule/ConsumedInterestScenarioTest.cs
+++ b/UnitTestProject/Modules/SwapModule/ConsumedInterestScenarioTest.cs
@@ -44,18 +44,29 @@ namespace YLErp.Modules.SwapModule
{
private readonly double _floatRate;
private readonly decimal _consumedInterest;
+ private readonly Func _floatRateByDate;
public StubSwapDealService(OptUserInfo optUser, double floatRate, decimal consumedInterest)
: base(optUser)
{
_floatRate = floatRate;
_consumedInterest = consumedInterest;
+ _floatRateByDate = null;
+ }
+
+ /// 按查询日期返回不同浮动利率(用于复现重置日取价 bug)
+ public StubSwapDealService(OptUserInfo optUser, Func floatRateByDate, decimal consumedInterest = 0m)
+ : base(optUser)
+ {
+ _floatRate = 0;
+ _consumedInterest = consumedInterest;
+ _floatRateByDate = floatRateByDate;
}
protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate)
{
- rate = _floatRate;
- return true; // 始终返回固定浮动利率
+ rate = _floatRateByDate != null ? _floatRateByDate(valueDate) : _floatRate;
+ return true;
}
public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate)
@@ -260,5 +271,104 @@ namespace YLErp.Modules.SwapModule
AssertDecimal(baseline - consumed * closePercent, result,
$"partial close should deduct consumed interest by closePercent ({closePercent})");
}
+
+ // ================================================================
+ // 场景7:复现"平仓日=重置日 + calcLast=false → 重置日跳过 FR007 取价"
+ // ================================================================
+
+ ///
+ /// [CI_007] 平仓日恰好是重置日时,calcLast=false 不应导致该重置日的 FR007 取价被跳过
+ /// ----------------------------------------------------------------
+ /// 背景(GLMS-JIATT-20260805 根因):InterestCalcMode='10'(算头不算尾,calcLast=false),
+ /// CalcDailyCompoundInterest 循环里 `if(!calcLast && accrueDate==endDate) continue` 会跳过平仓日当天。
+ /// 若平仓日恰好是重置日(i%period==0),这个跳过会让"重置日取新FR007"的代码块永远不执行,
+ /// 沿用上一个重置周期的旧利率。
+ ///
+ /// 构造:PosiStartDate=4/27, ResetPeriod=3, InterestCalcMode='10'(calcLast=false)
+ /// - FR007 按日期分段:5/3之前返回 rateOld=0.001,5/3及之后返回 rateNew=0.002
+ /// - 对照A:平仓日=5/5(非重置日,9? 不: (5/5-4/27)=8, 8%3=2 非重置) → 不该取新值
+ /// - 对照B:平仓日=5/6(重置日,(5/6-4/27)=9, 9%3=0) → 应取新值 rateNew
+ ///
+ /// 修复前:5/6 重置日被 calcLast 跳过 → 取到旧 rateOld → 与 5/5 相同
+ /// 修复后:5/6 重置日正常取价 → 取到 rateNew → 与 5/5 不同
+ /// ----------------------------------------------------------------
+ ///
+ ///
+ /// [CI_007] 平仓日恰好是重置日时,calcLast=false 不应导致该重置日的 FR007 取价被跳过
+ /// ----------------------------------------------------------------
+ /// 根因(GLMS-JIATT-20260805):InterestCalcMode='10'(calcLast=false),
+ /// CalcDailyCompoundInterest 循环 `if(!calcLast && accrueDate==endDate) continue` 跳过平仓日。
+ /// 若平仓日=重置日,取价代码块被跳过 → flowEvent.FloatRate 停留旧值 → 落库后传染 EOD。
+ ///
+ /// 构造(避开周末,period=7):
+ /// PosiStartDate=4/27(周一), period=7, interest_rule=0, InterestCalcMode='10'
+ /// 重置日:i=0→4/27(周一), i=7→5/4(周一,工作日)
+ /// 平仓日=5/4(=重置日=endDate)
+ /// FR007 分界:rateDate>=5/4 返回 rateNew,否则 rateOld
+ ///
+ /// 修复前:i=7(5/4)被 calcLast 跳过 → FloatRate=rateOld(旧值)
+ /// 修复后:i=7(5/4)正常取价 → FloatRate=rateNew(新值)
+ /// ----------------------------------------------------------------
+ ///
+ [TestMethod]
+ public void CI_007_平仓日等于重置日_calcLast_false_仍应取新FR007()
+ {
+ const double rateOld = 0.001;
+ const double rateNew = 0.002;
+ // 用 6 月日期避开五一/周末:PosiStartDate=6/1(周一), period=7, 平仓日=6/8(周一,重置日)
+ DateTime posiStart = new DateTime(2026, 6, 1);
+ DateTime unwindDate = new DateTime(2026, 6, 8); // (6/8-6/1)=7, 7%7=0 重置日
+ DateTime newRateFrom = new DateTime(2026, 6, 8); // 6/8(查询日,周一工作日)起为新利率
+
+ StubSwapDealService ServiceByDate() => new StubSwapDealService(
+ new OptUserInfo(0, nameof(ConsumedInterestScenarioTest), OptUserFrom.UnitTest),
+ d => d >= newRateFrom ? rateNew : rateOld);
+
+ var td = new trade
+ {
+ id = 1, TradeNumber = "UT-CI007", ClientId = 999998,
+ TradeType = "收益互换", TradeDate = posiStart, StartDate = posiStart,
+ ExerciseDate = ExerciseDate, TradeStatus = "确认成交", ValidState = "Valid",
+ StructureType = "单标的", QuoteCurrency = "CNY", SettlementCurrency = "CNY",
+ trade_extend = new trade_extend
+ {
+ TradeId = 1,
+ ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
+ {
+ AnnualDays = AnnualDays, InterestCalcMode = "10", SettlementRules = 0
+ })
+ }
+ };
+ var position = new swap_position
+ {
+ id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown,
+ InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)InterestModeEnum.标的期初全价,
+ InterestRateDefault = FixedRate, InterestPrincipalFix = Principal,
+ PosiStartDate = posiStart, PosiMatuirityDate = ExerciseDate,
+ IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.复利,
+ IsAnnualized = true, interest_rest_days = 7, interest_rule = 0,
+ FloatRateUnderlyingCode = "FR007",
+ InterestSwapInterval = JsonConvert.SerializeObject(new List
+ {
+ new IntervalModel { Date = ExerciseDate, Rate = FixedRate, Settlement = 0 }
+ })
+ };
+
+ var interests = ServiceByDate().GetInterests(td, td.trade_extend, unwindDate, unwindDate,
+ new List(), new List { position },
+ Principal, Principal, Principal, Principal, 1m,
+ (int)SwapEventTypeEnum.平仓, false, false, Principal, Principal,
+ add: false, settment: false, newCalcLast: false);
+
+ Assert.AreEqual(1, interests.Count);
+ var result = interests[0];
+ Console.WriteLine($"6/8(重置日,周一)平仓:FloatRate={result.FloatRate} Amount={result.InterestAmount:F6}");
+ Console.WriteLine($" 期望 FloatRate={rateNew}(6/8 重置日查询日=6/8工作日,应取新利率)");
+
+ // 核心断言:6/8 是重置日,flowEvent.FloatRate 应反映新利率 rateNew
+ Assert.IsTrue(Math.Abs((result.FloatRate ?? 0) - (decimal)rateNew) < 0.0001m,
+ $"平仓日=重置日时 FloatRate 应={rateNew}(取到新利率)。" +
+ $"实际={result.FloatRate},若={rateOld} 说明 calcLast=false 跳过了重置日取价(GLMS-JIATT-20260805 根因)");
+ }
}
}
diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs
index 53bcaff6..7f631a29 100644
--- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs
+++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs
@@ -1249,28 +1249,31 @@ namespace YLErp.Modules.SwapModule
for (int i = 0; i <= calcDays; i++)
{
var accrueDate = startDate.AddDays(i);
+ // 重置日取价必须在 calcFirst/calcLast 跳过之前完成:calcLast=false(不算尾) 只应跳过计息,
+ // 不应跳过重置日的 FR007 取价。否则平仓日=重置日时会沿用旧周期利率,
+ // 且 flowEvent.FloatRate 落库为旧值,传染后续 EOD(GLMS-JIATT-20260805 根因)。
+ if (accrueDate >= startDate && i % interestPeriod == 0
+ && !string.IsNullOrEmpty(position.FloatRateUnderlyingCode))
+ {
+ var fr007RateDate = QdpCalendarHelper.GetNonHolidayDefore(accrueDate.AddDays(position.interest_rule ?? 0));
+ if (TryGetFloatRate(fr007RateDate, position.FloatRateUnderlyingCode, out double floatRate1))
+ {
+ if (floatRate1 != 0) floatRate = floatRate1;
+ }
+ else
+ {
+ throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fr007RateDate:yyyy年MM月dd日}的价格");
+ }
+ }
if (!calcFirst && accrueDate == startDate) continue; // 首日不算头
- if (!calcLast && accrueDate == endDate) continue; // 到期日不算尾
+ if (!calcLast && accrueDate == endDate) continue; // 到期日不算尾(只跳过计息,取价已在上方完成)
if (accrueDate >= startDate)
{
if (i % interestPeriod == 0)
{
- // 复利时:利息并入本金
+ // 复利时:利息并入本金(FR007 取价已提前到 calcFirst/calcLast 跳过之前完成)
dynomicPrincipal = principal + interest;
tdDynomicPrincipal = principal + interest;
- // 获取新的浮动利率
- if (!string.IsNullOrEmpty(position.FloatRateUnderlyingCode))
- {
- var fr007RateDate = QdpCalendarHelper.GetNonHolidayDefore(accrueDate.AddDays(position.interest_rule ?? 0));
- if (TryGetFloatRate(fr007RateDate, position.FloatRateUnderlyingCode, out double floatRate1))
- {
- if (floatRate1 != 0) floatRate = floatRate1;
- }
- else
- {
- throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fr007RateDate:yyyy年MM月dd日}的价格");
- }
- }
flowEvent.InterestPrincipal = tdDynomicPrincipal;
TdInterestPrincipal = tdDynomicPrincipal;
}
@@ -1292,6 +1295,9 @@ namespace YLErp.Modules.SwapModule
tdinterest += tdinterest1;
}
}
+ // 兜底:若循环因 calcLast 跳过最后一天(重置日=平仓日),flowEvent.FloatRate 不会被循环内赋值,
+ // 用最终 floatRate 兜底,确保落库的 FloatRate 反映最后一个重置日的利率(GLMS-JIATT-20260805)。
+ flowEvent.FloatRate = Convert.ToDecimal(floatRate);
// 复利从头重放得到的是"假设从未结出"的整段总利息,需扣除历史已通过互换结出的利息,
// 否则已结部分会重复计息(类比分红 PosiDividendSum = totalToDate − RealizedDividend)。
// consumedInterest is full-position absolute interest; scale it to this close portion.
diff --git a/项目文档/互换计算口径一致性_残留问题与治理路线.md b/项目文档/互换计算口径一致性_残留问题与治理路线.md
index d09b6e75..2ab87e94 100644
--- a/项目文档/互换计算口径一致性_残留问题与治理路线.md
+++ b/项目文档/互换计算口径一致性_残留问题与治理路线.md
@@ -8,6 +8,25 @@
---
+## ⚠️ 更新状态(2026-08-07)
+
+> 本文档最初分析日期为 2026-08-06。**第七章、第八章 8.1 关于 income 页 `longRatio` 的论断已被代码修复采纳,原文描述已过时**,阅读时请注意:
+>
+> | 文档原文论断 | 当前代码状态 | 修复提交 |
+> |--------------|------------|----------|
+> | income 页漏乘 `longRatio`(多空方向),空头会算出相反符号 | ✅ **已修复**:前端 `incomeSwapTrade.js:200,208`、后端 `FrontendCalcReference.CalcIncome:100,108` 均已补 `longRatio` | `d78d1f48`(2026-08-07) |
+> | income 空头分支无测试覆盖 | ✅ **已补**:`收取空头_价格上涨_应为亏损` + jest 对应用例 | `d78d1f48` |
+> | `FrontendCalcReference.CalcIncome` 注释写"无 longRatio" | ✅ **已更新**为含 longRatio 的公式 | `d78d1f48` |
+>
+> **仍有效的部分**(治理路线主体,代码尚未动):
+> - 第三章 A 节:两页 `MarkClosePnl` 仍**各自内联**(`incomeSwapTrade.js:208` / `unwindSwapTrade.js:306`),未接入共享的 `swapCalc.calcMarkClosePnl`——"单一可信源已有却不采纳"仍在。
+> - 第三章 B 节:硬编码精度魔法数字**全部仍在**(`SwapFlowService:116-118` Round(4)、`SwapDealService:1561` F10、`SwapTradeAutoService:458/460` Round(10))。
+> - 第三章 C 节 / 第四章 / 第五章:swapCalc 未接入生产、精度集中化、分阶段治理路线——**仍是有效的后续路线**。
+>
+> 阅读建议:第三~六章按"仍有效的治理路线"读;第七、八章按"历史论证记录"读(结论已被采纳落地)。
+
+---
+
## 一、这类 BUG 的本质(统一定义)
最新修复的"分红收益误显 -36,160",根因不是某一个 if 写错,而是一种**结构性缺陷**:
@@ -162,25 +181,31 @@
---
-## 六、一句话结论
+## 六、一句话结论 【longRatio 部分已于 d78d1f48 修复】
-> 最新修复根治了"分红预览"这一条链路的分散计算;但**同类结构依然存在**——
-> 前端平仓页/互换页的 `MarkClosePnl` 都用全价,真正的差异是**互换页(income)漏乘 `longRatio`(多空方向)**,
-> 对空头会算出相反符号并直接落库(后端对 income 不重算、原样存前端值);共享的 `swapCalc.calcMarkClosePnl` 两个页面都没用;
-> 后端仍有**费用 4 位 vs 金额 2 位**等硬编码精度错配。
+> 最新修复根治了"分红预览"这一条链路的分散计算;~~互换页(income)漏乘 `longRatio`~~
+> **该问题已于 `d78d1f48` 修复**(income 现已含 longRatio,空头符号与平仓一致)。
+>
+> **当前仍残留的同类结构**:
+> - 前端平仓页/互换页的 `MarkClosePnl` 仍**各自内联**,未接入共享的 `swapCalc.calcMarkClosePnl`(单一可信源已有却不采纳);
+> - 后端仍有**费用 4 位 vs 金额 2 位**等硬编码精度错配(`SwapFlowService` Round(4)、`SwapDealService` F10 等)。
+>
> 治理的关键不是再打补丁,而是**把已建好的 `swapCalc` 单一可信源 + parity 守护真正接入生产**,
> 并按上述 5 个阶段低风险推进。
---
-## 七、如何确认"income 错 / unwind 对"(而非相反)+ 改动安全性
+## 七、如何确认"income 错 / unwind 对"(而非相反)+ 改动安全性 【✅ 已由 d78d1f48 修复,本章留作论证记录】
> 这一章回答一个关键质疑:两页口径不同,凭什么断定是 income 漏了 `longRatio`、而不是 unwind 多算了?
> 以及:给 income 补 `longRatio` 会不会把正确逻辑改坏、或造成"双重翻转"?
-> ⚠️ **状态说明(2026-08-07)**:本章关于"income 漏 longRatio → 错 / unwind 对"的论断,目前是**待业务背书的 Working Hypothesis**,
-> 尚未取得业务/量化签字。且比这更基础的"收益结算的价差基准是否应在结算后滚动"的产品定义仍未确认(见第八章)。
-> **在业务下定论前,本章仅作论证记录,不视为最终定性**——第八章才是如实记录"代码现在实际怎么做"的事实层。
+> ✅ **更新(2026-08-07)**:本章的论断已被团队采纳并落地。`d78d1f48` 按本章论证给 income 补了 `longRatio`
+> (前端 `incomeSwapTrade.js:208`、后端 `FrontendCalcReference.CalcIncome:108`),并补了空头测试。
+> 本章原"待业务背书的 Working Hypothesis"已成为既成事实,保留作论证记录与防回归参考。
+
+> ⚠️ **状态说明(2026-08-07,原文)**:本章关于"income 漏 longRatio → 错 / unwind 对"的论断,原是**待业务背书的 Working Hypothesis**。
+> 后经代码铁证(7.2/7.3)直接采纳修复,见上方更新。
### 7.1 两个方向乘子是**独立轴**(这是避免误判的前提)
@@ -235,26 +260,30 @@
---
-## 八、当前行为实录与待确认项(2026-08-07)
+## 八、当前行为实录与待确认项(2026-08-07)【8.1 longRatio 部分已由 d78d1f48 修复】
> 本章**只记录"代码现在实际怎么做"**(可验证事实),并明确列出"哪些还无法判定谁对"。
> 与第七章(论断层)的区别:第七章给出了"income 错 / unwind 对"的论证,但该论断**尚未取得业务/量化背书**,
> 且涉及"价差基准是否应在收益结算后滚动"这一更基础的产品定义。因此把它们在本章降格为"待确认假设",
> 先如实记录现状,避免过早定性。
> 文档定位:**当前不修改代码、不加测试,仅留痕**。正确性待业务/量化逐项确认后再回填。
+>
+> ✅ **更新(2026-08-07)**:8.1 关于"income 不含 longRatio"的实录**已过时**——`d78d1f48` 已补 longRatio。
+> 8.2(价差基准不滚动)、8.4(待确认问题清单中除 longRatio 外的价差基准/多次结算问题)**仍待业务确认**。
-### 8.1 收益结算(income)页当前行为实录
+### 8.1 收益结算(income)页当前行为实录 【✅ longRatio 部分已修复】
- **界面入口**:交易详情页头部操作区「**收益结算**」按钮(权限 `交易管理_收益互换`),打开 `/swaptrade2/SwapIncome/`
(`SwapIncome.cshtml` + `incomeSwapTrade.js`)。与「**平仓**」按钮(`SwapUnwind.cshtml`)外观相似,
但**没有平仓比例、没有事件日期**——本质是"期间结算、头寸保留",而非关闭头寸。
- **价差损益 `MarkClosePnl` 当前公式(代码事实)**:
- `incomeSwapTrade.js:104` `initPosiGrossPrice = this.floatPosition.PosiGrossPrice`
- - `:199/207` `floatRatio = PayDirection==1 ? 1 : -1`
- - `:207` `MarkClosePnl = positionAmount × (deliveryPrice − initPosiGrossPrice) × floatRatio`
+ - `:199-200` `floatRatio = PayDirection==1 ? 1 : -1`;`longRatio = PositionType==1 ? 1 : -1`
+ - `:208` `MarkClosePnl = positionAmount × (deliveryPrice − initPosiGrossPrice) × floatRatio × longRatio`
(`positionAmount = PositionQty × ContractSize`,`deliveryPrice = getStorageDeliveryPrice()`)
- - 后端同口径 `FrontendCalcReference.CalcIncome:107`:**无 `longRatio`**,`CalcIncome:92` 注释明示"无 longRatio"。
-- **事实结论**:income 页当前**只用 `floatRatio`(收付方向)翻转,未乘 `longRatio`(多空方向)**。
+ - 后端同口径 `FrontendCalcReference.CalcIncome:100,108`:**已含 `longRatio`**(`d78d1f48` 修复)。
+- **事实结论**(已更新):income 页当前**已含 `floatRatio × longRatio`**(`d78d1f48`),与 unwind/后端口径一致。
+ 原文"只用 floatRatio,未乘 longRatio"的描述**已失效**。
### 8.2 价差基准 `swap_position.PosiGrossPrice` 当前行为实录
@@ -273,8 +302,8 @@
| 项 | 已确认事实(代码可验证) | 待确认假设(需业务/量化背书) |
|----|--------------------------|------------------------------|
-| income 是否含 `longRatio` | 当前**不含**(code 实证) | 是否**应该**含(与平仓一致)?——第七章论证"应含",但**未获业务签字** |
-| 空头 income 符号 | 当前公式对空头会得出与平仓/后端**相反**的符号 | 这是否是"错误"?取决于产品对空头收益结算现金流的符号约定 |
+| income 是否含 `longRatio` | ✅ **已含**(`d78d1f48` 修复,`incomeSwapTrade.js:208` / `CalcIncome:108`) | ~~是否应该含~~ —— 已按第七章论证采纳修复,不再待确认 |
+| 空头 income 符号 | ✅ **已修正**(`d78d1f48`,空头涨价=亏损,与平仓一致) | ~~是否是错误~~ —— 已确认是 bug 并修复 |
| 价差基准是否滚动 | 当前**不滚动**(恒为 P0) | 收益结算应是"增量(从上次结算价)"还是"绝对(从 P0)"?——**产品定义未确认** |
| 多次收益结算重复计入 | 当前若对**同一开放持仓做多次收益结算**,每次都按 (当前价−P0) 计,**首段会被重复计入**(数学推导) | 业务实际是否允许/发生过"同一持仓多次收益结算"?——**需生产数据确认** |
| 下游现金流 | `SwapIncome:2007` `AddClientCash(-SwapRealizedPnl)` 用前端值记账(code 实证) | 若公式口径需改,历史已落库金额是否需追溯校正? |
@@ -314,10 +343,11 @@ ORDER BY cnt DESC;
- 若 (b) 返回 0 行 → 当前业务每次结算后即关仓,"基准不滚动"**无害**;
- 若 (b) 有行 → 立即按"结算后把 `swap_position.PosiGrossPrice` 滚动到本次结算价"评估修复。
-### 8.6 多轮回归未暴露的原因(实证,非推断)
+### 8.6 多轮回归未暴露的原因(实证,非推断)【第1点已由 d78d1f48 补测试填补】
1. **覆盖空洞**:`FrontendCalcCharacterizationTest` 的 income 场景 `FC_006~009` **全是 `PositionType=1`(多头)**;
唯一空头场景 `FC_005` 是 unwind。income 的空头分支从未被构造。
+ ✅ **已填补**(`d78d1f48`):新增 `收取空头_价格上涨_应为亏损` 测试(C#)+ jest 对应用例。
2. **校验同源**:`ValidateFrontendPnL`(`SwapDealService.cs:2004`)用 `BuildFrontendValidationDiffs` 以**同一 `CalcIncome`(也无 longRatio)**
重算比对,前端错值 == 后端重算 → diff 恒为 0 → 永不告警("预言机与被测代码共享同一 bug"盲区)。
3. **真实数据隐形**:golden `dividend_trade_1875/1891.json` 中,空头块为 `swap_position` 持仓行(`MarkClosePnl=0`),非结息事件;