From 07764e7e3f32a3db82f55cbb77a8258dae9fc2fa Mon Sep 17 00:00:00 2001 From: hjhan Date: Sun, 16 Aug 2026 12:19:28 +0800 Subject: [PATCH] =?UTF-8?q?test(swap):=20=E5=A5=91=E7=BA=A6=E5=8F=82?= =?UTF-8?q?=E8=80=83=E5=AE=9E=E7=8E=B0=E8=90=BD=E5=9C=B0,=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E4=B8=BB=E5=8A=9B=E6=97=8F=E7=9B=98=E4=B8=AD=E9=87=8D?= =?UTF-8?q?=E6=94=BE=E9=A6=96=E6=AC=A1=E5=AF=B9=E9=BD=90=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E4=B9=A6=E5=85=AC=E5=BC=8F=20oracle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ContractReferenceCalc 独立实现 §8a 确认书公式(∏/重置期切分/末段收口/ 利率确定日=重置日上一营业日),禁止引用生产计息类防同源;4 例手算锚点 自验证(真实规模 5000万/2.05%/90天=75999.04 与玩具锚点)。 引擎对照首批 3 例全绿:mode9/mode2 × 复利 × "10" × T+0 部分平仓30%, 盘中重放与契约 oracle 容差 0.01 元内逐分吻合——盘中口径=确认书公式 自此有机器强制,后续补格直接复用 oracle 供给(TEST-MATRIX §7.5 已登记)。 Co-Authored-By: Claude Opus 4.7 --- .../Accrual/ContractReferenceCalc.cs | 72 +++++++ .../Accrual/ContractReferenceOracleTest.cs | 196 ++++++++++++++++++ YLErpDAL/Modules/SwapModule/TEST-MATRIX.md | 6 +- 3 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 UnitTestProject/Modules/SwapModule/Accrual/ContractReferenceCalc.cs create mode 100644 UnitTestProject/Modules/SwapModule/Accrual/ContractReferenceOracleTest.cs diff --git a/UnitTestProject/Modules/SwapModule/Accrual/ContractReferenceCalc.cs b/UnitTestProject/Modules/SwapModule/Accrual/ContractReferenceCalc.cs new file mode 100644 index 00000000..abf233dd --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/Accrual/ContractReferenceCalc.cs @@ -0,0 +1,72 @@ +namespace UnitTestProject.Modules.SwapModule.Accrual +{ + /// + /// 契约参考实现(确认书公式,TEST-MATRIX §8a)——全矩阵统一 oracle 供给。 + /// + /// 【独立性约束·勿破坏】本类只实现确认书公式原文,禁止引用任何生产计息引擎类 + /// (YLErp.Modules.SwapModule.Accrual.* / SwapDealService),否则 oracle 与被测对象同源, + /// 失去"独立参考"资格(oracle 分级第一级,见 TEST-MATRIX §7.4)。 + /// + /// 确认书公式(国联民生收益互换确认书-现券/ETF 四份一致): + /// 参考利率(绝对) = ∏[i=1..k] ( 1 + (FR007i + 利差) × di / 365 ) − 1 + /// 结息额(平仓部分) = 实际平掉额 × 参考利率(绝对) + /// - k = 计息期包含的重置期个数;完整重置期 di = 重置频率(生产 7 天),末段不足按实际日历日 + /// - 重置期自计息期首日按重置频率依次推算;首个重置期始于计息期首日;末段收口到计息期最后一日 + /// - 利率确定日 = 每个重置期首日(重置日)的上一个营业日,取该日 FR007 + /// - 计息期 = 自起始日(含)至到期日(不含)——即算头不算尾 "10"(生产主力条款) + /// - 计息基准 A/365 + /// + /// 营业日准则:本参考实现按周末近似(周六/周日非营业日);法定节假日历由调用方通过 + /// 取价委托自行吸收(如按确定日提供同一利率)。测试与生产参数对齐(§8):重置 7 天 / 365。 + /// + public static class ContractReferenceCalc + { + /// + /// 参考利率(绝对) = ∏(1 + (FR007i+利差)×di/annualDays) − 1。 + /// + /// 计息期首日(含) + /// 计息期末日("10"不含/"11"含,由 calcLast 决定) + /// 重置频率天数(生产 7) + /// 利差(InterestRateDefault,如 +0.25% = 0.0025) + /// 取价委托:入参=利率确定日(重置日上一营业日),返回该日 FR007 + /// 算头(生产 "10"/"11" 为 true) + /// 算尾(生产 "10" 为 false) + /// 计息基准(生产 365) + public static decimal ReferenceRateAbsolute( + DateTime startDate, DateTime endDate, + int resetDays, decimal spread, + Func fixing, + bool calcFirst = true, bool calcLast = false, + int annualDays = 365) + { + var totalDays = (endDate - startDate).Days + (calcFirst ? 0 : -1) + (calcLast ? 1 : 0); + if (totalDays <= 0) return 0m; + + decimal factor = 1m; + var resetDate = startDate; // 首个重置期始于计息期首日 + var remaining = totalDays; + while (remaining > 0) + { + var di = Math.Min(resetDays, remaining); // 完整期 di=resetDays,末段按实际日历日 + var fixingDate = PreviousBusinessDay(resetDate); + var allIn = fixing(fixingDate) + spread; + factor *= 1m + allIn * di / annualDays; + remaining -= di; + resetDate = resetDate.AddDays(di); + } + return factor - 1m; + } + + /// 结息额(平仓部分)= 实际平掉额 × 参考利率(绝对)。 + public static decimal ClosedInterest(decimal closedNotional, decimal referenceRate) + => closedNotional * referenceRate; + + /// 利率确定日 = 重置日的上一营业日(周末近似)。 + public static DateTime PreviousBusinessDay(DateTime date) + { + do { date = date.AddDays(-1); } + while (date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday); + return date; + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/Accrual/ContractReferenceOracleTest.cs b/UnitTestProject/Modules/SwapModule/Accrual/ContractReferenceOracleTest.cs new file mode 100644 index 00000000..7a3ce5ed --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/Accrual/ContractReferenceOracleTest.cs @@ -0,0 +1,196 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; +using YLErp; +using YLErp.DBModels.Enums; +using YLErp.Modules.SwapModule; + +namespace UnitTestProject.Modules.SwapModule.Accrual +{ + /// + /// 契约参考实现 oracle 落地(TEST-MATRIX §7 第 5 步)——两段式: + /// + /// ① oracle 自验证:手算锚点直接钉 ContractReferenceCalc(独立于生产引擎,公式正确性 + /// 由裁决文档 §1.1/§1.2 已核过的手算值保证——真实规模 5000 万/2.05%/90 天 与玩具 4 天)。 + /// ② 引擎对照:主力族(mode9 标的期初全价 / mode2 合约名义本金规模 × FR007 × 复利 × "10") + /// 盘中 T+0 部分平仓 30%,GetInterests 重放结果 必须 == 契约 oracle(容差 0.01 元,§7.4)。 + /// 这是本矩阵第一个"契约公式独立参考实现"级 oracle 的引擎对照用例(此前仅有 Excel 手算/工单值)。 + /// + /// 引擎对照用恒定 FR007 利率表——刻意免疫"利率确定日=重置日上一营业日 vs 当日"的取价日 + /// 约定差异(任何确定日取到的都是同一利率),单独验证 ∏ 公式/重置期切分/算头不算尾/末段收口; + /// 取价日维度(E 维,66a97e03)由变利率用例在 oracle 侧钉住(§①第 4 例),引擎侧后续补。 + /// + /// 坐标登记:mode9/mode2 × 复利 × "10" × T+0 × 部分平仓30% × B=跨12个完整重置期+末段 × E=恒定利率。 + /// + [TestClass] + public class ContractReferenceOracleTest + { + // ── 生产参数(TEST-MATRIX §8:7 天重置 / A365 / 真实点差 +0.25% / 千万级名义)── + private const decimal Spread = 0.0025m; // 点差 +0.25%(确认书真实点差) + private const decimal Fr007 = 0.018m; // FR007 示意水平 1.8% → all-in 2.05% + private const int ResetDays = 7; + private const int AnnualDaysConst = 365; + private const decimal Notional = 50_000_000m; // 名义 5000 万 + private const decimal ClosedNotional = 15_000_000m; // 平掉 30% = 1500 万 + private const decimal ClosePercent = 0.3m; + + private static readonly DateTime StartDate = new(2026, 4, 27); // 周一,起息日 + private static readonly DateTime Unwind90 = new(2026, 7, 26); // 90 天 = 12×7 + 6 末段 + private static readonly DateTime Unwind89 = new(2026, 7, 25); // 89 天 = 12×7 + 5 末段 + private static readonly DateTime ExerciseDate = new(2027, 4, 27); + + #region ① oracle 自验证(手算锚点) + + [TestMethod] + public void 契约公式_恒定利率_90天12整期加6天末段_等于手算() + { + var rate = ContractReferenceCalc.ReferenceRateAbsolute( + StartDate, Unwind90, ResetDays, Spread, _ => Fr007, + calcFirst: true, calcLast: false, annualDays: AnnualDaysConst); + // 手算:(1+0.0205×7/365)^12 × (1+0.0205×6/365) − 1(python 高精度复核) + Assert.AreEqual(0.0050666026m, rate, 0.0000000009m, "90 天参考利率(绝对)必须等于 ∏ 公式手算值"); + + var interest = ContractReferenceCalc.ClosedInterest(ClosedNotional, rate); + Assert.AreEqual(75999.04m, interest, 0.01m, "平掉 1500 万 × 参考利率 = 裁决文档 §1.1 应结值"); + } + + [TestMethod] + public void 契约公式_恒定利率_89天末段5天_等于手算() + { + var rate = ContractReferenceCalc.ReferenceRateAbsolute( + StartDate, Unwind89, ResetDays, Spread, _ => Fr007, + calcFirst: true, calcLast: false, annualDays: AnnualDaysConst); + Assert.AreEqual(0.0050101727m, rate, 0.0000000009m, "89 天参考利率(绝对)手算值"); + + var interest = ContractReferenceCalc.ClosedInterest(ClosedNotional, rate); + Assert.AreEqual(75152.59m, interest, 0.01m); + } + + [TestMethod] + public void 契约公式_玩具参数_算头算尾4天_等于裁决文档手算锚点() + { + // 裁决文档 §1.2:300×[(1+0.011×3/365)×(1+0.011×1/365)−1] = 0.0361652(重置 3 天,利差 1%,FR 0.1%) + var rate = ContractReferenceCalc.ReferenceRateAbsolute( + new DateTime(2026, 4, 27), new DateTime(2026, 4, 30), resetDays: 3, + spread: 0.01m, fixing: _ => 0.001m, + calcFirst: true, calcLast: true, annualDays: 365); + var interest = ContractReferenceCalc.ClosedInterest(300m, rate); + Assert.AreEqual(0.0361652m, interest, 0.000001m); + } + + [TestMethod] + public void 契约公式_分段变利率_利率确定日为重置日上一营业日() + { + // 计息期 [5/4(一), 5/15(五)) "10" → 11 天 = 7 + 4 末段;重置日 5/4、5/11(均为周一) + // 契约:利率确定日 = 重置日上一营业日 → 5/1(五)、5/8(五) + Assert.AreEqual(new DateTime(2026, 5, 1), ContractReferenceCalc.PreviousBusinessDay(new DateTime(2026, 5, 4)), "5/4(一)的上一营业日是 5/1(五)"); + Assert.AreEqual(new DateTime(2026, 5, 8), ContractReferenceCalc.PreviousBusinessDay(new DateTime(2026, 5, 11)), "5/11(一)的上一营业日是 5/8(五)"); + + var fixings = new Dictionary + { + [new DateTime(2026, 5, 1)] = 0.02m, // 第一段 FR007 2.0% → all-in 2.25% + [new DateTime(2026, 5, 8)] = 0.03m, // 第二段 FR007 3.0% → all-in 3.25% + }; + var rate = ContractReferenceCalc.ReferenceRateAbsolute( + new DateTime(2026, 5, 4), new DateTime(2026, 5, 15), ResetDays, Spread, + d => fixings[d], calcFirst: true, calcLast: false, annualDays: AnnualDaysConst); + // 手算:(1+0.0225×7/365)×(1+0.0325×4/365)−1 = 0.0007878249 + Assert.AreEqual(0.0007878249m, rate, 0.0000000009m, + "分段变利率下每段必须用各自确定日的 FR007(E 维:取价日=重置日上一营业日)"); + } + + #endregion + + #region ② 引擎对照(恒定 FR007,免疫取价日约定) + + private sealed class StubSwapDealService : SwapDealService + { + public StubSwapDealService() : base( + new OptUserInfo(0, nameof(ContractReferenceOracleTest), OptUserFrom.UnitTest)) { } + + protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate) + { + if (!string.Equals(underlyingCode, "FR007", StringComparison.OrdinalIgnoreCase)) { rate = 0; return false; } + rate = (double)Fr007; + return true; + } + + /// fresh 重放无历史已结利息,覆写掉 DB 查询(本场景语义即 0)。 + public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate) => 0m; + } + + private static trade CreateTrade() + { + var extend = new trade_extend + { + TradeId = 1, + ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { + AnnualDays = AnnualDaysConst, + InterestCalcMode = "10", // 算头不算尾(生产主力条款) + SettlementRules = 0 + }) + }; + return new trade + { + id = 1, TradeNumber = "UT-CONTRACT-REF-ORACLE", ClientId = 999998, + TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate, + ExerciseDate = ExerciseDate, TradeStatus = "确认成交", ValidState = "Valid", + trade_extend = extend + }; + } + + private static swap_position CreatePosition(InterestModeEnum mode) => + new() + { + id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown, + InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)mode, + InterestRateDefault = Spread, InterestPrincipalFix = Notional, + PosiStartDate = StartDate, PosiMatuirityDate = ExerciseDate, + IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.复利, + IsAnnualized = true, interest_rest_days = ResetDays, interest_rule = 0, + FloatRateUnderlyingCode = "FR007", + InterestSwapInterval = JsonConvert.SerializeObject( + new List { new() { Date = ExerciseDate, Rate = Spread, Settlement = 0 } }) + }; + + /// 引擎盘中重放(T+0 fresh 持仓,T0 形状)vs 契约 oracle,容差 0.01 元。 + private static void AssertEngineMatchesOracle( + InterestModeEnum mode, DateTime unwindDate, decimal posi, decimal closePosi, + decimal expectedOracleInterest) + { + var td = CreateTrade(); + var position = CreatePosition(mode); + var interests = new StubSwapDealService().GetInterests( + td, td.trade_extend, unwindDate, unwindDate, + new List(), new List { position }, + posi, closePosi, ClosePercent, + (int)SwapEventTypeEnum.平仓, + tdClose: false, orginPv: posi, add: false, settment: false, newCalcLast: false, closeList: null); + + Assert.AreEqual(1, interests.Count); + Assert.IsTrue(Math.Abs(interests[0].InterestAmount - expectedOracleInterest) <= 0.01m, + $"mode={mode} 引擎重放 {interests[0].InterestAmount} vs 契约 oracle {expectedOracleInterest}," + + $"diff={interests[0].InterestAmount - expectedOracleInterest}——引擎偏离确认书公式(TEST-MATRIX §8a)"); + } + + private static decimal OracleInterest(DateTime unwindDate) => + ContractReferenceCalc.ClosedInterest(ClosedNotional, + ContractReferenceCalc.ReferenceRateAbsolute( + StartDate, unwindDate, ResetDays, Spread, _ => Fr007, + calcFirst: true, calcLast: false, annualDays: AnnualDaysConst)); + + [TestMethod] + public void 引擎_mode9_复利FR007_10_部分平仓30_90天_等于契约oracle() + => AssertEngineMatchesOracle(InterestModeEnum.标的期初全价, Unwind90, Notional, Notional, OracleInterest(Unwind90)); + + [TestMethod] + public void 引擎_mode9_复利FR007_10_部分平仓30_89天_等于契约oracle() + => AssertEngineMatchesOracle(InterestModeEnum.标的期初全价, Unwind89, Notional, Notional, OracleInterest(Unwind89)); + + [TestMethod] + public void 引擎_mode2_复利FR007_10_部分平仓30_显式平掉额_等于契约oracle() + => AssertEngineMatchesOracle(InterestModeEnum.合约名义本金规模, Unwind90, Notional, ClosedNotional, OracleInterest(Unwind90)); + + #endregion + } +} diff --git a/YLErpDAL/Modules/SwapModule/TEST-MATRIX.md b/YLErpDAL/Modules/SwapModule/TEST-MATRIX.md index 7f3e2341..f4836a18 100644 --- a/YLErpDAL/Modules/SwapModule/TEST-MATRIX.md +++ b/YLErpDAL/Modules/SwapModule/TEST-MATRIX.md @@ -71,6 +71,7 @@ InterestMode = 9 标的期初全价 × FR007 浮动(±点差) × InterestType = | ConsumedInterestScenarioTest | 已结利息差分族 | 工单期望值 | | SwapUnwindSameDayDoublePartialTest | C=同日两次 | **字符化(非独立 oracle)** | | GetInterestsEntrySemanticsTest | F=双入口一致性 | **字符化** | +| **ContractReferenceOracleTest(Accrual/,7)** | mode9/mode2 × 复利 × "10" × T+0 × 部分30% × B=跨12整期+末段(90/89天) × E=恒定利率(取价日免疫) | **契约公式参考实现(§7.4 第一级)**——引擎盘中重放已逐分对齐 oracle | | GetInterestsUnitTest_T0/T1(89) | mode 1 固定值 T+0/T+1 族(非本族) | 单点断言 | | GLMS20260805FR007UnderlyingIdDiagnoseTest | B=重置日=平仓日 × E | 诊断+断言 | @@ -92,7 +93,10 @@ InterestMode = 9 标的期初全价 × FR007 浮动(±点差) × InterestType = 2. 空洞 1/2 优先:按 §2-G 四投影补 EOD平仓后收盘 × 部分 用例,oracle 用确认书公式 Excel 模板。 3. 空洞 6 补独立 oracle(确认书公式),替换字符化地位(保留字符化作回归钉)。 4. 每格期望值来源分级(已升级,见 §8a):**契约公式独立参考实现** > 生产已对账数字 > 业务签认 Excel > 新旧影子对比;**禁止当前代码输出充当 oracle**。 -5. 契约参考实现(§8a 公式)先行落地,作为全矩阵统一 oracle 供给;确认书生成器与引擎参数同源(`swap_position`),可加一致性断言:每笔交易的确认书参数 == 引擎计息参数。 +5. 契约参考实现(§8a 公式)**已落地**(`UnitTestProject/Modules/SwapModule/Accrual/ContractReferenceCalc.cs`, + 独立于生产引擎,禁止引用计息类防同源),引擎对照首批 3 例全绿(mode9/mode2 × "10" × 部分30%, + `ContractReferenceOracleTest`)。后续补格直接复用:期望值 = `ClosedInterest(平掉额, ReferenceRateAbsolute(...))`。 + 待办:变利率引擎侧对照(取价日 E 维)、确认书生成器参数同源断言(`swap_position`)。 ## 8. 生产参数(已确认,2026-08)