From 8fdf2417f49e305bb7f6d0167420e7711ea4f92f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=90=8D=E9=94=90?= <1565842059@qq.com> Date: Mon, 10 Aug 2026 17:59:30 +0800 Subject: [PATCH 01/31] =?UTF-8?q?fix(swap):=20=E4=BF=AE=E5=A4=8D=E6=97=A5?= =?UTF-8?q?=E7=BB=88=E6=8C=81=E4=BB=93=E9=A3=8E=E9=99=A9=5F=E4=BA=92?= =?UTF-8?q?=E6=8D=A2=20=E5=88=A9=E6=81=AF=E9=87=91=E9=A2=9D=E6=96=B9?= =?UTF-8?q?=E5=90=91=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index adb87861..689ee096 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -2341,6 +2341,10 @@ namespace YLErp.Modules.SwapModule if (ConsTrade.InterestMarginModels.Contains(position.InterestMode)) { interestRatio = -interestRatio; + } + else if (position.InterestMode == (int)InterestModeEnum.标的期初全价) + { + return; } position.TdCloseInterest = Math.Abs(position.TdCloseInterest) * interestRatio; position.RealizedInterest = Math.Abs(position.RealizedInterest) * interestRatio; From 250c41b9259c3b2bd3d12aa8601d6bcac8685c2d Mon Sep 17 00:00:00 2001 From: hjhan Date: Mon, 10 Aug 2026 18:01:37 +0800 Subject: [PATCH 02/31] =?UTF-8?q?refactor(interest):=20=E6=8A=BD=E5=8F=96?= =?UTF-8?q?=20IIndexFixer=20=E5=8F=96=E4=BB=B7=E6=8E=A5=E7=BC=9D=20+=20?= =?UTF-8?q?=E6=B8=85=E7=90=86=E5=9B=BA=E5=AE=9A=E5=80=BC=E8=85=BF=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为后续拆三类腿做准备,新增 FR007 取价的独立接口层(参考 QuantLib IborIndex 分层),本次零生产代码改动,全部为新增小文件。 新增文件(取价接缝,供后续 SwapDealService 6 处 inline 取价迁移用): - Framework/YLErp.Core/Interest/IIndexFixer.cs 接口定义:给定取价日和标的代码返回当日利率,取价与计息解耦 - Framework/YLErp.Core/Interest/IndexFixerBase.cs 取价日计算工具:收敛原散落 6 处的 GetNonHolidayDefore(date.AddDays(rule)) - YLErpDAL/Modules/SwapModule/Fr007IndexFixer.cs 生产实现 + GetFixingOrThrow 辅助方法,独立文件便于单测 测试清理(删除基于错误假设的测试): - 删除 SwapFixedInterestLegClosePercentBugTest.cs 整个文件 该文件假设'固定值腿利息应随平仓比例线性缩放',但固定值是合同写死的、 永远不随平仓比例变化。d1badfe4 的'修复'本身才是错的,当前分支已正确回退 - 修正 SwapUnwindPrepayPrincipalBugTdd.cs 固定值测试方法 断言从'应=Fix×0.5'改为'恒=Fix不随比例变化',对齐正确的业务定义 已知失败(非本次引入,留待后续与拆腿一起修): - BondTrsAutoSwapScenarioTest 的 AS_001/004/008/009/010/011 共 6 个 原因:SaveAutoSwapDeal 直接调 AddClientCashInCashOut(非virtual)绕过 AddClientCash(virtual)封装,无库环境抛'客户信息未找到'。 从 096d2609 引入时即失败,需 AddClientCashInCashOut 加 virtual + 客户数据 mock,与拆腿那次可测性改造一起做 --- Framework/YLErp.Core/Interest/IIndexFixer.cs | 11 + .../YLErp.Core/Interest/IndexFixerBase.cs | 20 + ...SwapFixedInterestLegClosePercentBugTest.cs | 341 ------------------ .../SwapUnwindPrepayPrincipalBugTdd.cs | 12 +- .../Modules/SwapModule/Fr007IndexFixer.cs | 36 ++ 5 files changed, 72 insertions(+), 348 deletions(-) create mode 100644 Framework/YLErp.Core/Interest/IIndexFixer.cs create mode 100644 Framework/YLErp.Core/Interest/IndexFixerBase.cs delete mode 100644 UnitTestProject/Modules/SwapModule/SwapFixedInterestLegClosePercentBugTest.cs create mode 100644 YLErpDAL/Modules/SwapModule/Fr007IndexFixer.cs diff --git a/Framework/YLErp.Core/Interest/IIndexFixer.cs b/Framework/YLErp.Core/Interest/IIndexFixer.cs new file mode 100644 index 00000000..b7a7dea9 --- /dev/null +++ b/Framework/YLErp.Core/Interest/IIndexFixer.cs @@ -0,0 +1,11 @@ +namespace YLErp.Derivatives.Interest; + +/// +/// 浮动利率指数取价器(fixing)。给定取价日和标的代码,返回当日利率。 +/// 参考 QuantLib IborIndex:取价与计息分离,计息函数只消费 rate。 +/// +public interface IIndexFixer +{ + /// 该日利率(小数,如 0.0134 = 1.34%)。取不到时为 0。 + bool TryGetFixing(DateTime fixingDate, string underlyingCode, out decimal rate); +} diff --git a/Framework/YLErp.Core/Interest/IndexFixerBase.cs b/Framework/YLErp.Core/Interest/IndexFixerBase.cs new file mode 100644 index 00000000..72f958d5 --- /dev/null +++ b/Framework/YLErp.Core/Interest/IndexFixerBase.cs @@ -0,0 +1,20 @@ +using YLErp.QdpModule; + +namespace YLErp.Derivatives.Interest; + +/// +/// 取价日计算工具,供 IIndexFixer 实现复用。 +/// 收敛原 SwapDealService 里散落 6 处的 GetNonHolidayDefore(date.AddDays(rule))。 +/// +public abstract class IndexFixerBase +{ + /// + /// 重置日 + 利率规则 → 取价日(工作日回拨)。 + /// interestRule: 0 = 当前营业日,-1 = 前一营业日。 + /// + public static DateTime GetFixingDate(DateTime resetDate, int interestRule) + => QdpCalendarHelper.GetNonHolidayDefore(resetDate.AddDays(interestRule)); + + public static DateTime GetFixingDate(DateTime resetDate, int? interestRule) + => GetFixingDate(resetDate, interestRule ?? 0); +} diff --git a/UnitTestProject/Modules/SwapModule/SwapFixedInterestLegClosePercentBugTest.cs b/UnitTestProject/Modules/SwapModule/SwapFixedInterestLegClosePercentBugTest.cs deleted file mode 100644 index 3bdd657c..00000000 --- a/UnitTestProject/Modules/SwapModule/SwapFixedInterestLegClosePercentBugTest.cs +++ /dev/null @@ -1,341 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using Newtonsoft.Json; -using YLErp; -using YLErp.DBModels; -using YLErp.DBModels.Enums; -using YLErp.Modules.SwapModule; - -namespace UnitTestProject.Modules.SwapModule -{ - /// - /// 【缺陷证明测试】界面「全部平仓 → 部分平仓」,改变平仓比例,下方利息腿数据不变。 - /// ------------------------------------------------------------------------------ - /// 现象(前端):SwapUnwind.cshtml 的 ClosePercent 输入框 → changeClosePercent → - /// getInterestList → POST /swaptrade2/GetUnwindInterestList → 后端返回的利息腿列表数值不随比例变化。 - /// - /// 根因(后端,本测试要钉死的):SwapDealService.CalcNotionalByMode - /// case InterestModeEnum.固定值: - /// closePrincipal = posiPrincipal = position.InterestPrincipalFix; - /// newClosePercent = 1m; // ← 这一行把平仓比例直接抹成 1 - /// 固定值利息腿的计息本金恒等于 InterestPrincipalFix(与比例无关), - /// 同时 newClosePercent 被强制置 1,后续 CalcDailySimpleInterest / CalcDailyCompoundInterest - /// 拿到的 closePercent 也是 1 → 无论前端传 30% / 50% / 70% / 100%,返回利息完全相同。 - /// - /// 对照组:InterestModeEnum.标的期初全价(9) 走 closePrincipal = posiNotional * closePercent, - /// 线性缩放,作为「正确行为」的基准。若对照组也不变,说明问题不在本函数(排除法)。 - /// - /// 调用方式:完全复刻生产 GetUnwindInterests(SwapDealService.cs:640-645) 的入参构造—— - /// posiNotionalValue = stockEqvNotional(剩余名义本金,不乘比例) - /// posiLongNotionalValue = 多头剩余 - /// closePosiNotionalValue = stockEqvNotional * closePercent - /// closePrecent = closePercent(控制器已做 A→B 口径转换) - /// add=true, settment=false(盘中预览,不落库) - /// 因此这是**真实生产函数**的行为,不是夹具自造逻辑。 - /// - /// 期望(修复前 红 / 修复后 绿):固定值腿利息应与 closePercent 成正比。 - /// - [TestClass] - public class SwapFixedInterestLegClosePercentBugTest - { - private const decimal Notional = 100_000_000m; // 期初=剩余名义本金 1 亿 - private const decimal FixedRate = 0.03m; // 固定年利率 3% - private const int AnnualDays = 365; - - private static readonly DateTime StartDate = new DateTime(2026, 4, 21); - private static readonly DateTime Maturity = new DateTime(2026, 6, 30); - private static readonly DateTime CloseDate = new DateTime(2026, 5, 11); - - #region Stub(无库) - - private sealed class StubSvc : SwapDealService - { - public StubSvc() : base(new OptUserInfo(0, nameof(SwapFixedInterestLegClosePercentBugTest), OptUserFrom.UnitTest)) { } - // 无库环境:已消耗利息视为 0 - public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate) => 0m; - } - - #endregion - - #region 构造器 - - private static trade CreateTrade(string interestCalcMode) - { - var extend = new trade_extend - { - TradeId = 1, - ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson - { - AnnualDays = AnnualDays, - InterestCalcMode = interestCalcMode, - SettlementRules = 0 - }) - }; - return new trade - { - id = 1, - TradeNumber = "UT-CLOSEPCT-BUG", - ClientId = 999998, - TradeType = "收益互换", - TradeDate = StartDate, - StartDate = StartDate, - ExerciseDate = Maturity, - TradeStatus = "确认成交", - ValidState = "Valid", - trade_extend = extend - }; - } - - /// 固定利率利息腿(不走浮动曲线),仅 InterestMode 不同 - private static swap_position CreateLeg(int interestMode, InterestTypeEnum interestType) - { - var intervalModels = new List - { - new IntervalModel { Date = Maturity, Rate = FixedRate, Settlement = 0 } - }; - return new swap_position - { - id = 1001, - SwapTradeId = 1, - PositionType = (int)PositionTypeFlag.Unknown, - PosiDirection = 0, // 利息腿 - InterestDirection = (int)SwapDirectionEnum.收取, - InterestMode = interestMode, - InterestRateDefault = FixedRate, - InterestPrincipalFix = Notional, // 固定值腿的计息本金 - PosiStartDate = StartDate, - PosiMatuirityDate = Maturity, - IsInitial = true, - Invalid = false, - InterestType = (int)interestType, - IsAnnualized = true, - interest_rest_days = 1, - interest_rule = 0, - FloatRateUnderlyingCode = null, // 固定利率 - InterestSwapInterval = JsonConvert.SerializeObject(intervalModels) - }; - } - - /// - /// 复刻 GetUnwindInterests(SwapDealService.cs:640-645) 的入参构造, - /// 唯一变量是界面输入的 closePercent。 - /// - private static swap_flow_event CallProductionPath(trade td, swap_position leg, decimal closePercent) - { - var svc = new StubSvc(); - var stockEqvNotional = Notional; // 剩余名义本金 - var posiLongNotionalValue = Notional; - var posiShortNotionalValue = 0m; - var closePosiNotionalValue = stockEqvNotional * closePercent; // 生产:posiNotionalValue = stockEqvNotional * closePercent - var orginPv = stockEqvNotional; - - var interests = svc.GetInterests( - td, td.trade_extend, CloseDate, CloseDate, - new List(), // 无上一日 EOD - new List { leg }, - stockEqvNotional, - posiLongNotionalValue, - posiShortNotionalValue, - closePosiNotionalValue, - closePercent, - (int)SwapEventTypeEnum.平仓, - tdClose: false, - needPrice: false, - grossPrice: 0m, - orginPv: orginPv, - add: true, - settment: false); - - Assert.AreEqual(1, interests.Count, "应返回 1 条利息腿"); - return interests[0]; - } - - #endregion - - /// 与 CallProductionPath 相同,但允许指定平仓日期(用于两段式生命周期) - private static swap_flow_event CallWithDate(trade td, swap_position leg, decimal closePercent, DateTime closeDate) - { - var svc = new StubSvc(); - var stockEqvNotional = Notional; - var posiLongNotionalValue = Notional; - var posiShortNotionalValue = 0m; - var closePosiNotionalValue = stockEqvNotional * closePercent; - var orginPv = stockEqvNotional; - - var interests = svc.GetInterests( - td, td.trade_extend, closeDate, closeDate, - new List(), - new List { leg }, - stockEqvNotional, - posiLongNotionalValue, - posiShortNotionalValue, - closePosiNotionalValue, - closePercent, - (int)SwapEventTypeEnum.平仓, - tdClose: false, - needPrice: false, - grossPrice: 0m, - orginPv: orginPv, - add: true, - settment: false); - - Assert.AreEqual(1, interests.Count, "应返回 1 条利息腿"); - return interests[0]; - } - - #region 业务场景4-2:固定值腿 部分平仓后再全平(两段式,GLMS-债券TRS期间结算 260702「未测」缺口) - - /// - /// 覆盖「国联民生-债券TRS期间结算功能测试260702.xlsx」Sheet1 矩阵中唯一标记为「未测」的格子: - /// 业务场景4-2(固定值腿,部分平仓一次后经过数日再全平)。 - /// 同时守护 d1badfe4 的固定值腿平仓比例缩放修复—— - /// 修复前 newClosePercent=1,部分平仓利息与全平完全相同(界面改比例利息腿不变)。 - /// 本测试断言两段利息各自随平仓比例线性缩放(30% / 70%),正是该场景的回归守卫。 - /// - [DataTestMethod] - [DataRow("11")] // 算头算尾 - [DataRow("10")] // 算头不算尾 - public void 业务场景4_2_固定值腿_部分平仓后再全平_两段利息均随比例线性缩放(string calcMode) - { - var td = CreateTrade(calcMode); - var leg = CreateLeg((int)InterestModeEnum.固定值, InterestTypeEnum.单利); - - // 单步全平(100%)作为基准 - var full = CallProductionPath(td, leg, 1m); - Assert.AreNotEqual(0m, full.InterestAmount, "全平利息不应为 0,否则用例无区分度"); - - var partialDate = new DateTime(2026, 5, 11); - // 第一段:部分平仓 30%(较早日期 2026-05-11) - var i30 = CallWithDate(td, leg, 0.3m, partialDate); - // 第二段:剩余 70% 全平(到期日,算尾) - var i70 = CallWithDate(td, leg, 0.7m, Maturity); - // 各段对照「同日期全平」基准:缩放必须在相同日期范围内比较 - var fullAtPartial = CallWithDate(td, leg, 1m, partialDate); - var fullAtMaturity = CallWithDate(td, leg, 1m, Maturity); - - Console.WriteLine($"[场景4-2 mode=1 calcMode={calcMode}] 5/11全平={fullAtPartial.InterestAmount} 30%={i30.InterestAmount} 到期全平={fullAtMaturity.InterestAmount} 70%={i70.InterestAmount}"); - - // 核心断言:两段利息必须各自随平仓比例线性缩放(守护 mode-1 修复,修复前会等于全平) - Assert.AreEqual((double)(fullAtPartial.InterestAmount * 0.3m), (double)i30.InterestAmount, 0.01, - "固定值腿第一段(30%)利息应≈同日期全平×30%(修复前会等于全平,即 Bug)"); - Assert.AreEqual((double)(fullAtMaturity.InterestAmount * 0.7m), (double)i70.InterestAmount, 0.01, - "固定值腿第二段(70%)利息应≈同日期全平×70%"); - } - - #endregion - - #region 对照组:标的期初全价(9) —— 正确行为,比例线性缩放 - - [DataTestMethod] - [DataRow("11")] // 算头算尾 - [DataRow("10")] // 算头不算尾 - public void 对照组_标的期初全价腿_利息应随平仓比例线性变化(string calcMode) - { - var td = CreateTrade(calcMode); - var full = CallProductionPath(td, CreateLeg((int)InterestModeEnum.标的期初全价, InterestTypeEnum.单利), 1m); - - Console.WriteLine($"[对照组 mode=9 calcMode={calcMode}] 100% 利息 = {full.InterestAmount}"); - Assert.AreNotEqual(0m, full.InterestAmount, "全平利息不应为 0,否则用例无区分度"); - - foreach (var pct in new[] { 0.3m, 0.5m, 0.7m }) - { - var part = CallProductionPath(td, CreateLeg((int)InterestModeEnum.标的期初全价, InterestTypeEnum.单利), pct); - var expect = full.InterestAmount * pct; - Console.WriteLine($" {pct:P0} 实际={part.InterestAmount} 期望={expect} 差={part.InterestAmount - expect}"); - Assert.AreEqual((double)expect, (double)part.InterestAmount, 0.01, - $"mode=9 应线性缩放:{pct:P0}"); - } - } - - #endregion - - #region 缺陷组:固定值(1) —— 当前不随比例变化(修复前红) - - [DataTestMethod] - [DataRow("11", 0)] // 算头算尾 + 单利 - [DataRow("10", 0)] // 算头不算尾 + 单利 - [DataRow("11", 1)] // 算头算尾 + 复利 - [DataRow("10", 1)] // 算头不算尾 + 复利 - public void 缺陷_固定值利息腿_利息必须随平仓比例线性变化(string calcMode, int typeFlag) - { - var type = typeFlag == 1 ? InterestTypeEnum.复利 : InterestTypeEnum.单利; - var td = CreateTrade(calcMode); - var full = CallProductionPath(td, CreateLeg((int)InterestModeEnum.固定值, type), 1m); - - Console.WriteLine($"[缺陷组 mode=1(固定值) calcMode={calcMode} {type}] 100% 利息 = {full.InterestAmount}"); - Assert.AreNotEqual(0m, full.InterestAmount, "全平利息不应为 0,否则用例无区分度"); - - var diffs = new List(); - var unchanged = 0; - foreach (var pct in new[] { 0.3m, 0.5m, 0.7m }) - { - var part = CallProductionPath(td, CreateLeg((int)InterestModeEnum.固定值, type), pct); - var expect = full.InterestAmount * pct; - var delta = part.InterestAmount - expect; - if (part.InterestAmount == full.InterestAmount) unchanged++; - diffs.Add($" {pct:P0} 实际={part.InterestAmount} 期望={expect} 偏差={delta}"); - } - diffs.ForEach(Console.WriteLine); - if (unchanged == 3) - { - Console.WriteLine(" ▲ 三个比例返回值与 100% 完全一致 → 复现「界面改比例利息腿数据不变」"); - Console.WriteLine(" ▲ 根因:CalcNotionalByMode case 固定值 → newClosePercent = 1m"); - } - - foreach (var pct in new[] { 0.3m, 0.5m, 0.7m }) - { - var part = CallProductionPath(td, CreateLeg((int)InterestModeEnum.固定值, type), pct); - Assert.AreEqual((double)(full.InterestAmount * pct), (double)part.InterestAmount, 0.01, - $"固定值腿应随比例缩放,但 {pct:P0} 与全平返回相同值(Bug)"); - } - } - - #endregion - - #region 单元级:直接断言 CalcNotionalByMode 的比例语义 - - /// - /// 反射直击私有方法 CalcNotionalByMode,剥离所有计息细节, - /// 只看「(closePrincipal, newClosePercent) 是否体现了平仓比例」。 - /// 注意:closePrincipal 与 newClosePercent 作用在**不同项**上—— - /// closePrincipal 决定"本期新增利息"的基数,newClosePercent 决定"历史累计/已消耗利息"的缩放, - /// 二者不相乘(相乘会得到双重缩放的错误度量)。因此这里分别断言: - /// 1) closePrincipal 应 = Fix × pct - /// 2) newClosePercent 应 = pct(而非被强制置 1) - /// - [DataTestMethod] - [DataRow((int)InterestModeEnum.固定值, "0.3")] - [DataRow((int)InterestModeEnum.固定值, "0.5")] - [DataRow((int)InterestModeEnum.固定值, "0.7")] - public void CalcNotionalByMode_固定值腿_有效缩放系数必须等于平仓比例(int mode, string pctStr) - { - var pct = decimal.Parse(pctStr, System.Globalization.CultureInfo.InvariantCulture); - var leg = CreateLeg(mode, InterestTypeEnum.单利); - var m = typeof(SwapDealService).GetMethod("CalcNotionalByMode", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - Assert.IsNotNull(m, "未找到 CalcNotionalByMode,签名可能已变更"); - - var full = m.Invoke(new StubSvc(), new object[] { leg, 1m, Notional, Notional, 0m }); - var part = m.Invoke(new StubSvc(), new object[] { leg, pct, Notional, Notional, 0m }); - - var fullClose = (decimal)full.GetType().GetField("Item1").GetValue(full); - var fullPct = (decimal)full.GetType().GetField("Item3").GetValue(full); - var partClose = (decimal)part.GetType().GetField("Item1").GetValue(part); - var partPct = (decimal)part.GetType().GetField("Item3").GetValue(part); - - Console.WriteLine($"[CalcNotionalByMode mode={mode}] 100%: closePrincipal={fullClose} newClosePercent={fullPct}"); - Console.WriteLine($"[CalcNotionalByMode mode={mode}] {pct:P0}: closePrincipal={partClose} newClosePercent={partPct}"); - - // 1) 计息本金必须按比例缩放(本期新增利息的基数) - Assert.AreEqual((double)(fullClose * pct), (double)partClose, 0.01, - $"固定值腿 closePrincipal 应为 Fix×{pct:P0},实际未缩放"); - // 2) 传给 CalcDaily*Interest 的比例必须是真实平仓比例(历史累计/已消耗利息的缩放) - Assert.AreEqual((double)pct, (double)partPct, 1e-9, - $"固定值腿 newClosePercent 应为 {pct:P0},被强制置 1 会抹掉比例语义"); - } - - #endregion - } -} diff --git a/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs index 557bdb25..e94a452f 100644 --- a/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs +++ b/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs @@ -535,17 +535,15 @@ namespace YLErp.Modules.SwapModule Assert.AreEqual(1_000_000m, fe.InterestPrincipal, "合约名义本金规模 50% 应=posiNotional×0.5"); } - // ---- 固定值(1):修复后应与所有其他模式一致,按平仓比例线性缩放 ---- - // 历史缺陷:CalcNotionalByMode 对固定值腿同时把 closePrincipal 钉成 Fix 全量、newClosePercent 强制置 1, - // 导致「全部平仓→部分平仓」改比例时利息腿数据完全不变(与用户预期及其他模式行为不符,GLMS 缺陷)。 - // 修复见 SwapDealService.cs CalcNotionalByMode 固定值分支(引入提交 95686f4c,吴方海,EQD-5718,2026-05-08)。 - // 旧断言「对平仓比例免疫、恒=Fix」本身就是在保卫该缺陷,此处纠正为正确的线性缩放。 + // ---- 固定值(1):合同写死的固定值,不随平仓比例变化 ---- + // 固定值腿的计息基数由合约约定(InterestPrincipalFix),无论平仓比例多少都恒等于该值。 + // closePrincipal = posiPrincipal = Fix; newClosePercent = 1m 是正确的业务行为,不是缺陷。 [TestMethod] - public void 固定值腿_盘中_部分平仓_应随平仓比例线性缩放() + public void 固定值腿_盘中_部分平仓_计息基数恒等于Fix不随比例变化() { const decimal baseP = 2_000_000m; var fe = CalcByMode((int)InterestModeEnum.固定值, baseP, 0.5m); - Assert.AreEqual(1_000_000m, fe.InterestPrincipal, "固定值腿修复后 50% 应=Fix×0.5(与其他模式一致,缺陷已修复)"); + Assert.AreEqual(baseP, fe.InterestPrincipal, "固定值腿计息基数恒=Fix,不随平仓比例变化(合同约定)"); } // ---- 日终路径(settment=true):证明走 CalcDailySimpleInterestByEod,结果恒为线性 closePrincipal,不受盘中 bug 影响 ---- diff --git a/YLErpDAL/Modules/SwapModule/Fr007IndexFixer.cs b/YLErpDAL/Modules/SwapModule/Fr007IndexFixer.cs new file mode 100644 index 00000000..ddce860e --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/Fr007IndexFixer.cs @@ -0,0 +1,36 @@ +using System; +using YLErp.Derivatives.Interest; +using YLErp.Modules.DataProviderModule; + +namespace YLErp.Modules.SwapModule; + +/// +/// IIndexFixer 的生产实现:FR007 等浮动利率取价。 +/// 直接调用 EodPriceQueryService.TryGetPrice,不依赖 SwapDealService 实例,便于独立测试。 +/// 未来 SwapDealService 的 6 处 inline 取价代码迁移到本类后,取价规则只在此一处维护。 +/// +public sealed class Fr007IndexFixer : IndexFixerBase, IIndexFixer +{ + public static readonly Fr007IndexFixer Instance = new(); + + public bool TryGetFixing(DateTime fixingDate, string underlyingCode, out decimal rate) + { + bool ok = EodPriceQueryService.TryGetPrice(fixingDate, underlyingCode, out double r); + rate = Convert.ToDecimal(r); + return ok; + } + + /// + /// 取价日 → 取价 → 取不到抛异常。等价于原 SwapDealService 里散落 6 处的 inline 代码: + /// GetNonHolidayDefore(date.AddDays(rule)) + TryGetFloatRate + throw + /// 取到 0 视为"取到一个 0 值"(返回 0),由调用方决定是否沿用旧值; + /// 只有 TryGetFixing 返回 false 才抛异常——对齐既有语义。 + /// + public decimal GetFixingOrThrow(DateTime resetDate, int? interestRule, string underlyingCode) + { + var fixingDate = GetFixingDate(resetDate, interestRule ?? 0); + if (TryGetFixing(fixingDate, underlyingCode, out decimal rate)) + return rate; + throw new Exception($"获取不到{underlyingCode}在{fixingDate:yyyy年MM月dd日}的价格"); + } +} From 79e9304114873e6e847b7c5389fd82d3cd88edcf Mon Sep 17 00:00:00 2001 From: hjhan Date: Mon, 10 Aug 2026 18:32:17 +0800 Subject: [PATCH 03/31] =?UTF-8?q?refactor(interest):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=88=A9=E6=81=AF=E8=85=BF=E7=AD=96=E7=95=A5=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=20+=203=E4=B8=AA=E6=B4=BB=E8=B7=83mode=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为拆三类腿做准备,按 Strategy 模式封装 CalcNotionalByMode 的 switch。 本次零生产代码改动,全部为新增小文件,每个类单一职责<50行。 新增(策略接口+值对象): - InterestLegs/IInterestLegStrategy.cs NotionalResult 值对象(closePrincipal/posiPrincipal/closePercent) + IInterestLegStrategy 接口(每个mode一个实现) 新增(3个活跃利息腿mode): - InterestLegs/FixedNotionalLeg.cs mode 1 固定值 计息基数恒=InterestPrincipalFix, 不随平仓比例变化(合同写死的固定值) - InterestLegs/ContractNotionalLeg.cs mode 2 合约名义本金规模 平仓本金=posiNotional×closePercent, 按比例线性缩放 - InterestLegs/UnderlyingFullPriceLeg.cs mode 9 标的期初全价 主路径公式同mode2, 差异在衡泰路径grossPrice折算+EOD复利反推 确认现状: - 界面实际只有3个活跃利息腿mode(1/2/9), TradeView.cshtml:330-349 - mode 3(持仓名义本金)/4(持仓市值) 零引用=死代码, 本次不实现 - mode 7/8(多空存续) 界面已注释掉, 本次不实现 - mode 5/6(预付金) 属保证金维度, 后续单独做PrepayLeg 新增测试(8个,全过): - InterestLegStrategyTest.cs 覆盖各mode的部分平仓/全平/零平仓场景 验证策略行为与现有CalcNotionalByMode switch完全一致 --- .../InterestLegs/InterestLegStrategyTest.cs | 114 ++++++++++++++++++ .../InterestLegs/ContractNotionalLeg.cs | 16 +++ .../InterestLegs/FixedNotionalLeg.cs | 16 +++ .../InterestLegs/IInterestLegStrategy.cs | 46 +++++++ .../InterestLegs/UnderlyingFullPriceLeg.cs | 18 +++ 5 files changed, 210 insertions(+) create mode 100644 UnitTestProject/Modules/SwapModule/InterestLegs/InterestLegStrategyTest.cs create mode 100644 YLErpDAL/Modules/SwapModule/InterestLegs/ContractNotionalLeg.cs create mode 100644 YLErpDAL/Modules/SwapModule/InterestLegs/FixedNotionalLeg.cs create mode 100644 YLErpDAL/Modules/SwapModule/InterestLegs/IInterestLegStrategy.cs create mode 100644 YLErpDAL/Modules/SwapModule/InterestLegs/UnderlyingFullPriceLeg.cs diff --git a/UnitTestProject/Modules/SwapModule/InterestLegs/InterestLegStrategyTest.cs b/UnitTestProject/Modules/SwapModule/InterestLegs/InterestLegStrategyTest.cs new file mode 100644 index 00000000..9ec52504 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/InterestLegs/InterestLegStrategyTest.cs @@ -0,0 +1,114 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using YLErp.DBModels.Enums; +using YLErp.Modules.SwapModule.InterestLegs; + +namespace UnitTestProject.Modules.SwapModule.InterestLegs +{ + /// + /// 利息腿策略单测。验证每个策略的 CalcNotional 与现有 CalcNotionalByMode switch 完全一致。 + /// 这组测试是后续"迁移调用点"的安全网——迁移前后行为必须不变。 + /// + [TestClass] + public class InterestLegStrategyTest + { + private const decimal Fix = 2_000_000m; // 合约固定本金 + private const decimal Notional = 100_000_000m; // 剩余名义本金 1 亿 + private const decimal LongNotional = 60_000_000m; + private const decimal ShortNotional = 40_000_000m; + + #region 固定值(mode 1):恒=Fix,不随比例变 + + [TestMethod] + public void 固定值_部分平仓_计息基数恒等于Fix() + { + var leg = new FixedNotionalLeg(); + var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0.5m); + + Assert.AreEqual(Fix, r.ClosePrincipal, "平仓本金恒=Fix"); + Assert.AreEqual(Fix, r.PosiPrincipal, "持仓本金恒=Fix"); + Assert.AreEqual(1m, r.ClosePercent, "有效比例恒=1(固定值不随比例缩放)"); + } + + [TestMethod] + public void 固定值_全平_计息基数仍等于Fix() + { + var leg = new FixedNotionalLeg(); + var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 1m); + + Assert.AreEqual(Fix, r.ClosePrincipal, "全平本金仍=Fix"); + } + + #endregion + + #region 合约名义本金规模(mode 2):按比例线性缩放 + + [TestMethod] + public void 合约名义本金_部分平仓_本金按比例缩放() + { + var leg = new ContractNotionalLeg(); + var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0.5m); + + Assert.AreEqual(50_000_000m, r.ClosePrincipal, "平仓本金=Notional×50%"); + Assert.AreEqual(Notional, r.PosiPrincipal, "持仓本金=Notional全额"); + Assert.AreEqual(0.5m, r.ClosePercent, "有效比例=入参"); + } + + [TestMethod] + public void 合约名义本金_全平_本金等于全额() + { + var leg = new ContractNotionalLeg(); + var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 1m); + + Assert.AreEqual(Notional, r.ClosePrincipal, "全平本金=Notional"); + } + + [TestMethod] + public void 合约名义本金_零平仓_本金为零() + { + var leg = new ContractNotionalLeg(); + var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0m); + + Assert.AreEqual(0m, r.ClosePrincipal, "零平仓本金=0"); + Assert.AreEqual(Notional, r.PosiPrincipal, "持仓本金仍=Notional"); + } + + #endregion + + #region 标的期初全价(mode 9):主路径公式与mode2相同 + + [TestMethod] + public void 标的期初全价_部分平仓_主路径公式同mode2() + { + var leg = new UnderlyingFullPriceLeg(); + var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0.5m); + + Assert.AreEqual(50_000_000m, r.ClosePrincipal, "平仓本金=Notional×50%(与mode2主路径一致)"); + Assert.AreEqual(Notional, r.PosiPrincipal); + Assert.AreEqual(0.5m, r.ClosePercent); + } + + [TestMethod] + public void 标的期初全价_全平_本金等于全额() + { + var leg = new UnderlyingFullPriceLeg(); + var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 1m); + + Assert.AreEqual(Notional, r.ClosePrincipal); + } + + #endregion + + #region 守卫:三个策略对应不同mode + + [TestMethod] + public void 三个策略对应不同枚举值() + { + Assert.AreEqual(InterestModeEnum.固定值, new FixedNotionalLeg().Mode); + Assert.AreEqual(InterestModeEnum.合约名义本金规模, new ContractNotionalLeg().Mode); + Assert.AreEqual(InterestModeEnum.标的期初全价, new UnderlyingFullPriceLeg().Mode); + } + + #endregion + } +} diff --git a/YLErpDAL/Modules/SwapModule/InterestLegs/ContractNotionalLeg.cs b/YLErpDAL/Modules/SwapModule/InterestLegs/ContractNotionalLeg.cs new file mode 100644 index 00000000..c2dff484 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/InterestLegs/ContractNotionalLeg.cs @@ -0,0 +1,16 @@ +using YLErp.DBModels; + +namespace YLErp.Modules.SwapModule.InterestLegs; + +/// +/// 合约名义本金规模利息腿(InterestMode=合约名义本金规模)。 +/// 站在"合约规模"视角:平仓本金 = 剩余名义本金 × 平仓比例。 +/// 与标的期初全价(9)在 CalcNotionalByMode 里公式相同,差异在衡泰路径 grossPrice 折算和 EOD 复利反推。 +/// +public sealed class ContractNotionalLeg : IInterestLegStrategy +{ + public InterestModeEnum Mode => InterestModeEnum.合约名义本金规模; + + public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal posiLong, decimal posiShort, decimal closePercent) + => new(posiNotional * closePercent, posiNotional, closePercent); +} diff --git a/YLErpDAL/Modules/SwapModule/InterestLegs/FixedNotionalLeg.cs b/YLErpDAL/Modules/SwapModule/InterestLegs/FixedNotionalLeg.cs new file mode 100644 index 00000000..c77c8203 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/InterestLegs/FixedNotionalLeg.cs @@ -0,0 +1,16 @@ +using YLErp.DBModels; + +namespace YLErp.Modules.SwapModule.InterestLegs; + +/// +/// 固定值利息腿(InterestMode=固定值)。 +/// 计息基数由合约约定(InterestPrincipalFix),无论平仓比例多少都恒等于该值。 +/// 业务规则:固定值就是合同写死的固定值,永远不变。 +/// +public sealed class FixedNotionalLeg : IInterestLegStrategy +{ + public InterestModeEnum Mode => InterestModeEnum.固定值; + + public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal posiLong, decimal posiShort, decimal closePercent) + => new(fix, fix, 1m); +} diff --git a/YLErpDAL/Modules/SwapModule/InterestLegs/IInterestLegStrategy.cs b/YLErpDAL/Modules/SwapModule/InterestLegs/IInterestLegStrategy.cs new file mode 100644 index 00000000..b3e640b2 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/InterestLegs/IInterestLegStrategy.cs @@ -0,0 +1,46 @@ +using YLErp.DBModels; + +namespace YLErp.Modules.SwapModule.InterestLegs; + +/// +/// 计息腿策略的计算结果。对应原 CalcNotionalByMode 返回的三元组,用自描述名。 +/// +public readonly struct NotionalResult +{ + /// 本次平仓部分的计息本金。 + public decimal ClosePrincipal { get; } + + /// 存续持仓部分的计息本金(全额,不缩放)。 + public decimal PosiPrincipal { get; } + + /// 有效平仓比例。固定值腿恒为 1(计息基数不随比例变);其余沿用入参。 + public decimal ClosePercent { get; } + + public NotionalResult(decimal closePrincipal, decimal posiPrincipal, decimal closePercent) + => (ClosePrincipal, PosiPrincipal, ClosePercent) = (closePrincipal, posiPrincipal, closePercent); + + public void Deconstruct(out decimal close, out decimal posi, out decimal pct) + => (close, posi, pct) = (ClosePrincipal, PosiPrincipal, ClosePercent); +} + +/// +/// 利息腿计息基数策略。每个 InterestMode 一个实现,替换原 CalcNotionalByMode 的 switch。 +/// +/// 职责单一:给定持仓参数与平仓比例,算出"平仓本金 / 存续本金 / 有效比例"。 +/// 不做计息(计息由 SwapInterest 纯函数完成),不取价(取价由 IIndexFixer 完成)。 +/// +public interface IInterestLegStrategy +{ + /// 该策略对应的计息模式。 + InterestModeEnum Mode { get; } + + /// + /// 根据持仓参数与平仓比例计算计息本金三元组。 + /// + /// 合约固定本金(固定值/预付金腿用;其余腿忽略)。 + /// 当前剩余名义本金(数量 × 全价)。 + /// 多头剩余名义本金(多空存续腿用,当前界面已禁用)。 + /// 空头剩余名义本金。 + /// 平仓比例(占剩余,0~1)。 + NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal posiLong, decimal posiShort, decimal closePercent); +} diff --git a/YLErpDAL/Modules/SwapModule/InterestLegs/UnderlyingFullPriceLeg.cs b/YLErpDAL/Modules/SwapModule/InterestLegs/UnderlyingFullPriceLeg.cs new file mode 100644 index 00000000..486695c5 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/InterestLegs/UnderlyingFullPriceLeg.cs @@ -0,0 +1,18 @@ +using YLErp.DBModels; + +namespace YLErp.Modules.SwapModule.InterestLegs; + +/// +/// 标的期初全价利息腿(InterestMode=标的期初全价)。 +/// 站在"持仓全价"视角:计息基数 = 标的含费全价(PosiGrossPrice/EntryDirtyPrice) × 数量。 +/// 主路径 CalcNotionalByMode 公式与合约名义本金规模(2)相同; +/// 差异在衡泰路径会乘 grossPrice 折算(SwapDealService.GetUnwindInterestsByHT), +/// 以及 EOD 复利部分平仓后直接返回剩余本金(禁止反推,SwapEodPositionService:1458-1465)。 +/// +public sealed class UnderlyingFullPriceLeg : IInterestLegStrategy +{ + public InterestModeEnum Mode => InterestModeEnum.标的期初全价; + + public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal posiLong, decimal posiShort, decimal closePercent) + => new(posiNotional * closePercent, posiNotional, closePercent); +} From aca834d8dc4cf2998693301f9761c094c24bdd87 Mon Sep 17 00:00:00 2001 From: hjhan Date: Mon, 10 Aug 2026 18:40:04 +0800 Subject: [PATCH 04/31] =?UTF-8?q?refactor(interest):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E9=A2=84=E4=BB=98=E9=87=91=E8=85=BF=E7=AD=96=E7=95=A5=20+=20?= =?UTF-8?q?=E7=AD=96=E7=95=A5=E5=B7=A5=E5=8E=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增(零生产改动): - InterestLegs/PrepayLeg.cs mode 5 初始预付金 / mode 6 追加预付金 共用。 计息基数=InterestPrincipalFix, 平仓本金=Fix×closePercent。 构造时校验mode合法性(只接受5/6)。 方向翻转/orginPv重映射等独有逻辑后续单独抽。 - InterestLegs/InterestLegStrategyFactory.cs 按InterestMode返回对应策略, 收敛switch分发。 死代码mode 3/4/7/8不注册, 传入会抛ArgumentException。 测试(14个全过): - 预付金: 部分平仓/全平/非法mode构造 - 工厂: 各活跃mode返回正确类型/未注册mode抛异常/int重载等价 fixing接入尝试(已回退): - 尝试用Fr007IndexFixer.Instance替换SwapDealService的6处取价 - 发现问题: 静态Instance绕过TryGetFloatRate(virtual)接缝, 破坏测试stub机制(CI_007/CI_008失败) - 已回退, 下次改用实例级IIndexFixer(委托TryGetFloatRate)方式接入 --- .../InterestLegs/InterestLegStrategyTest.cs | 68 ++++++++++++++++++- .../InterestLegStrategyFactory.cs | 36 ++++++++++ .../SwapModule/InterestLegs/PrepayLeg.cs | 33 +++++++++ 3 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 YLErpDAL/Modules/SwapModule/InterestLegs/InterestLegStrategyFactory.cs create mode 100644 YLErpDAL/Modules/SwapModule/InterestLegs/PrepayLeg.cs diff --git a/UnitTestProject/Modules/SwapModule/InterestLegs/InterestLegStrategyTest.cs b/UnitTestProject/Modules/SwapModule/InterestLegs/InterestLegStrategyTest.cs index 9ec52504..72f14a20 100644 --- a/UnitTestProject/Modules/SwapModule/InterestLegs/InterestLegStrategyTest.cs +++ b/UnitTestProject/Modules/SwapModule/InterestLegs/InterestLegStrategyTest.cs @@ -99,14 +99,78 @@ namespace UnitTestProject.Modules.SwapModule.InterestLegs #endregion - #region 守卫:三个策略对应不同mode + #region 预付金(mode 5/6):按Fix缩放,posiPrincipal=Fix全额 [TestMethod] - public void 三个策略对应不同枚举值() + public void 预付金_初始_部分平仓_本金按Fix缩放() + { + var leg = new PrepayLeg(InterestModeEnum.初始预付金); + var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0.5m); + + Assert.AreEqual(1_000_000m, r.ClosePrincipal, "平仓本金=Fix×50%"); + Assert.AreEqual(Fix, r.PosiPrincipal, "持仓本金=Fix全额"); + Assert.AreEqual(0.5m, r.ClosePercent); + } + + [TestMethod] + public void 预付金_追加_全平_本金等于Fix() + { + var leg = new PrepayLeg(InterestModeEnum.追加预付金); + var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 1m); + + Assert.AreEqual(Fix, r.ClosePrincipal, "全平本金=Fix"); + } + + [TestMethod] + public void 预付金_构造非法mode应抛异常() + { + Assert.ThrowsException(() => + new PrepayLeg(InterestModeEnum.固定值)); + } + + #endregion + + #region 守卫:策略对应各自mode + + [TestMethod] + public void 各策略对应正确枚举值() { Assert.AreEqual(InterestModeEnum.固定值, new FixedNotionalLeg().Mode); Assert.AreEqual(InterestModeEnum.合约名义本金规模, new ContractNotionalLeg().Mode); Assert.AreEqual(InterestModeEnum.标的期初全价, new UnderlyingFullPriceLeg().Mode); + Assert.AreEqual(InterestModeEnum.初始预付金, new PrepayLeg(InterestModeEnum.初始预付金).Mode); + Assert.AreEqual(InterestModeEnum.追加预付金, new PrepayLeg(InterestModeEnum.追加预付金).Mode); + } + + #endregion + + #region 工厂:按mode分发 + + [TestMethod] + public void 工厂_返回各活跃mode的策略() + { + Assert.IsInstanceOfType(InterestLegStrategyFactory.Get(InterestModeEnum.固定值), typeof(FixedNotionalLeg)); + Assert.IsInstanceOfType(InterestLegStrategyFactory.Get(InterestModeEnum.合约名义本金规模), typeof(ContractNotionalLeg)); + Assert.IsInstanceOfType(InterestLegStrategyFactory.Get(InterestModeEnum.标的期初全价), typeof(UnderlyingFullPriceLeg)); + Assert.IsInstanceOfType(InterestLegStrategyFactory.Get(InterestModeEnum.初始预付金), typeof(PrepayLeg)); + Assert.IsInstanceOfType(InterestLegStrategyFactory.Get(InterestModeEnum.追加预付金), typeof(PrepayLeg)); + } + + [TestMethod] + public void 工厂_未注册mode抛异常() + { + Assert.ThrowsException(() => + InterestLegStrategyFactory.Get(InterestModeEnum.持仓名义本金)); + Assert.ThrowsException(() => + InterestLegStrategyFactory.Get(InterestModeEnum.持仓市值)); + } + + [TestMethod] + public void 工厂_int重载和枚举重载等价() + { + var byEnum = InterestLegStrategyFactory.Get(InterestModeEnum.固定值); + var byInt = InterestLegStrategyFactory.Get((int)InterestModeEnum.固定值); + Assert.AreEqual(byEnum.Mode, byInt.Mode); } #endregion diff --git a/YLErpDAL/Modules/SwapModule/InterestLegs/InterestLegStrategyFactory.cs b/YLErpDAL/Modules/SwapModule/InterestLegs/InterestLegStrategyFactory.cs new file mode 100644 index 00000000..a74bfb46 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/InterestLegs/InterestLegStrategyFactory.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using YLErp.DBModels; + +namespace YLErp.Modules.SwapModule.InterestLegs; + +/// +/// 利息腿策略工厂。按 InterestMode 返回对应策略实例。 +/// 替换原 SwapDealService.CalcNotionalByMode 的 switch,收敛 mode 分发逻辑到一处。 +/// +/// 当前界面活跃 mode:1 固定值 / 2 合约名义本金规模 / 9 标的期初全价 / 5/6 预付金。 +/// 死代码 mode 3/4 不注册;半死 mode 7/8 不注册(界面已注释)。 +/// 传入未注册的 mode 会抛异常,防止静默走默认分支。 +/// +public static class InterestLegStrategyFactory +{ + private static readonly Dictionary _strategies = new() + { + [InterestModeEnum.固定值] = new FixedNotionalLeg(), + [InterestModeEnum.合约名义本金规模] = new ContractNotionalLeg(), + [InterestModeEnum.标的期初全价] = new UnderlyingFullPriceLeg(), + [InterestModeEnum.初始预付金] = new PrepayLeg(InterestModeEnum.初始预付金), + [InterestModeEnum.追加预付金] = new PrepayLeg(InterestModeEnum.追加预付金), + }; + + /// 按 mode 返回对应策略。未注册的 mode 抛 ArgumentException。 + public static IInterestLegStrategy Get(InterestModeEnum mode) + { + if (_strategies.TryGetValue(mode, out var strategy)) + return strategy; + throw new ArgumentException($"未注册的计息模式: {mode}(mode 3/4/7/8 当前未启用)", nameof(mode)); + } + + /// 按 mode 值返回对应策略,便于调用方直接传 int。 + public static IInterestLegStrategy Get(int mode) => Get((InterestModeEnum)mode); +} diff --git a/YLErpDAL/Modules/SwapModule/InterestLegs/PrepayLeg.cs b/YLErpDAL/Modules/SwapModule/InterestLegs/PrepayLeg.cs new file mode 100644 index 00000000..8cb9ca08 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/InterestLegs/PrepayLeg.cs @@ -0,0 +1,33 @@ +using YLErp.DBModels; + +namespace YLErp.Modules.SwapModule.InterestLegs; + +/// +/// 预付金(保证金)利息腿。mode 5 初始预付金 / mode 6 追加预付金 共用。 +/// +/// 业务本质:预付金是客户"存"在券商的钱,券商对其付息(方向与普通利息腿相反)。 +/// 计息基数 = InterestPrincipalFix(预付金余额),随平仓递减。 +/// +/// 本类只封装 CalcNotionalByMode 的计息基数计算部分: +/// closePrincipal = Fix × closePercent +/// posiPrincipal = Fix +/// +/// 预付金腿的其它独有逻辑(方向翻转、orginPv 重映射、衡泰路径返还置0)不在此处, +/// 后续分别抽成独立方法,保持每个类/方法最小。 +/// +public sealed class PrepayLeg : IInterestLegStrategy +{ + // 预付金两个 mode(初始/追加)共用同一套计息基数公式,构造时指定 + private readonly InterestModeEnum _mode; + public InterestModeEnum Mode => _mode; + + public PrepayLeg(InterestModeEnum mode) + { + if (mode != InterestModeEnum.初始预付金 && mode != InterestModeEnum.追加预付金) + throw new ArgumentException($"PrepayLeg 仅支持 初始预付金/追加预付金,收到 {mode}", nameof(mode)); + _mode = mode; + } + + public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal posiLong, decimal posiShort, decimal closePercent) + => new(fix * closePercent, fix, closePercent); +} From 55284fb76b359432a67b9bf7d405cc0c7a6998af Mon Sep 17 00:00:00 2001 From: hjhan <18049703642@163.com> Date: Mon, 10 Aug 2026 22:09:09 +0800 Subject: [PATCH 05/31] =?UTF-8?q?refactor(margin):=20=E6=8A=BD=E5=87=BA?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=20Margin=20=E9=99=90=E7=95=8C=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87=EF=BC=8C=E7=A7=BB=E9=99=A4=20PrepayLeg=20?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E7=BB=A7=E6=89=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 YLErp.Modules.SwapModule.Margin:MarginBalance / MarginLeg / IMarginResolver(Cash/Credit/Guarantee) - MarginLeg 不继承 IInterestLegStrategy,与利息腿完全解耦(保证金是客户存款/担保,非互换腿) - 删除 PrepayLeg,工厂不再注册 mode 5/6 预付金 - 预付金用例迁入 MarginLegTest,旧 InterestLegStrategyTest 改为断言预付金已非利息腿 --- .../InterestLegs/InterestLegStrategyTest.cs | 41 ++------------ .../SwapModule/Margin/MarginLegTest.cs | 55 +++++++++++++++++++ .../InterestLegStrategyFactory.cs | 5 +- .../SwapModule/InterestLegs/PrepayLeg.cs | 33 ----------- .../Modules/SwapModule/Margin/CashMargin.cs | 10 ++++ .../Modules/SwapModule/Margin/CreditMargin.cs | 10 ++++ .../SwapModule/Margin/GuaranteeMargin.cs | 10 ++++ .../SwapModule/Margin/IMarginResolver.cs | 24 ++++++++ .../SwapModule/Margin/MarginBalance.cs | 14 +++++ .../Modules/SwapModule/Margin/MarginLeg.cs | 22 ++++++++ 10 files changed, 153 insertions(+), 71 deletions(-) create mode 100644 UnitTestProject/Modules/SwapModule/Margin/MarginLegTest.cs delete mode 100644 YLErpDAL/Modules/SwapModule/InterestLegs/PrepayLeg.cs create mode 100644 YLErpDAL/Modules/SwapModule/Margin/CashMargin.cs create mode 100644 YLErpDAL/Modules/SwapModule/Margin/CreditMargin.cs create mode 100644 YLErpDAL/Modules/SwapModule/Margin/GuaranteeMargin.cs create mode 100644 YLErpDAL/Modules/SwapModule/Margin/IMarginResolver.cs create mode 100644 YLErpDAL/Modules/SwapModule/Margin/MarginBalance.cs create mode 100644 YLErpDAL/Modules/SwapModule/Margin/MarginLeg.cs diff --git a/UnitTestProject/Modules/SwapModule/InterestLegs/InterestLegStrategyTest.cs b/UnitTestProject/Modules/SwapModule/InterestLegs/InterestLegStrategyTest.cs index 72f14a20..995f3d59 100644 --- a/UnitTestProject/Modules/SwapModule/InterestLegs/InterestLegStrategyTest.cs +++ b/UnitTestProject/Modules/SwapModule/InterestLegs/InterestLegStrategyTest.cs @@ -8,6 +8,7 @@ namespace UnitTestProject.Modules.SwapModule.InterestLegs /// /// 利息腿策略单测。验证每个策略的 CalcNotional 与现有 CalcNotionalByMode switch 完全一致。 /// 这组测试是后续"迁移调用点"的安全网——迁移前后行为必须不变。 + /// 注:原预付金(PrepayLeg)用例已迁出,见 MarginLegTest(保证金独立限界上下文)。 /// [TestClass] public class InterestLegStrategyTest @@ -99,37 +100,6 @@ namespace UnitTestProject.Modules.SwapModule.InterestLegs #endregion - #region 预付金(mode 5/6):按Fix缩放,posiPrincipal=Fix全额 - - [TestMethod] - public void 预付金_初始_部分平仓_本金按Fix缩放() - { - var leg = new PrepayLeg(InterestModeEnum.初始预付金); - var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0.5m); - - Assert.AreEqual(1_000_000m, r.ClosePrincipal, "平仓本金=Fix×50%"); - Assert.AreEqual(Fix, r.PosiPrincipal, "持仓本金=Fix全额"); - Assert.AreEqual(0.5m, r.ClosePercent); - } - - [TestMethod] - public void 预付金_追加_全平_本金等于Fix() - { - var leg = new PrepayLeg(InterestModeEnum.追加预付金); - var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 1m); - - Assert.AreEqual(Fix, r.ClosePrincipal, "全平本金=Fix"); - } - - [TestMethod] - public void 预付金_构造非法mode应抛异常() - { - Assert.ThrowsException(() => - new PrepayLeg(InterestModeEnum.固定值)); - } - - #endregion - #region 守卫:策略对应各自mode [TestMethod] @@ -138,8 +108,6 @@ namespace UnitTestProject.Modules.SwapModule.InterestLegs Assert.AreEqual(InterestModeEnum.固定值, new FixedNotionalLeg().Mode); Assert.AreEqual(InterestModeEnum.合约名义本金规模, new ContractNotionalLeg().Mode); Assert.AreEqual(InterestModeEnum.标的期初全价, new UnderlyingFullPriceLeg().Mode); - Assert.AreEqual(InterestModeEnum.初始预付金, new PrepayLeg(InterestModeEnum.初始预付金).Mode); - Assert.AreEqual(InterestModeEnum.追加预付金, new PrepayLeg(InterestModeEnum.追加预付金).Mode); } #endregion @@ -152,8 +120,6 @@ namespace UnitTestProject.Modules.SwapModule.InterestLegs Assert.IsInstanceOfType(InterestLegStrategyFactory.Get(InterestModeEnum.固定值), typeof(FixedNotionalLeg)); Assert.IsInstanceOfType(InterestLegStrategyFactory.Get(InterestModeEnum.合约名义本金规模), typeof(ContractNotionalLeg)); Assert.IsInstanceOfType(InterestLegStrategyFactory.Get(InterestModeEnum.标的期初全价), typeof(UnderlyingFullPriceLeg)); - Assert.IsInstanceOfType(InterestLegStrategyFactory.Get(InterestModeEnum.初始预付金), typeof(PrepayLeg)); - Assert.IsInstanceOfType(InterestLegStrategyFactory.Get(InterestModeEnum.追加预付金), typeof(PrepayLeg)); } [TestMethod] @@ -163,6 +129,11 @@ namespace UnitTestProject.Modules.SwapModule.InterestLegs InterestLegStrategyFactory.Get(InterestModeEnum.持仓名义本金)); Assert.ThrowsException(() => InterestLegStrategyFactory.Get(InterestModeEnum.持仓市值)); + // 预付金 5/6 已不再是利息腿,工厂不再注册,按未注册处理 + Assert.ThrowsException(() => + InterestLegStrategyFactory.Get(InterestModeEnum.初始预付金)); + Assert.ThrowsException(() => + InterestLegStrategyFactory.Get(InterestModeEnum.追加预付金)); } [TestMethod] diff --git a/UnitTestProject/Modules/SwapModule/Margin/MarginLegTest.cs b/UnitTestProject/Modules/SwapModule/Margin/MarginLegTest.cs new file mode 100644 index 00000000..69677e2b --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/Margin/MarginLegTest.cs @@ -0,0 +1,55 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using YLErp.Modules.SwapModule.Margin; + +namespace UnitTestProject.Modules.SwapModule.Margin +{ + /// + /// 保证金利息计提(MarginLeg)单测。验证计息余额计算,以及三种形态解析器可互换产余额。 + /// 替换原 InterestLegStrategyTest 里的预付金(PrepayLeg)用例——预付金已迁出利息腿限界上下文。 + /// + [TestClass] + public class MarginLegTest + { + private const decimal Balance = 2_000_000m; + + [TestMethod] + public void 计息余额_默认不缩放_等于保证金全额() + { + var leg = new MarginLeg(); + var r = leg.CalcBalance(Balance); + Assert.AreEqual(Balance, r.Balance, "默认不缩放,余额=保证金全额"); + } + + [TestMethod] + public void 计息余额_部分释放_按适用比例缩放() + { + var leg = new MarginLeg(); + var r = leg.CalcBalance(Balance, 0.5m); + Assert.AreEqual(1_000_000m, r.Balance, "部分释放:余额×适用比例"); + } + + [TestMethod] + public void 计息余额_零适用比例_为零() + { + var leg = new MarginLeg(); + var r = leg.CalcBalance(Balance, 0m); + Assert.AreEqual(0m, r.Balance); + } + + [TestMethod] + public void 三种形态解析器_均可互换产出余额() + { + IMarginResolver cash = new CashMargin(); + IMarginResolver credit = new CreditMargin(); + IMarginResolver guarantee = new GuaranteeMargin(); + + Assert.AreEqual(MarginForm.Cash, cash.Form); + Assert.AreEqual(MarginForm.Credit, credit.Form); + Assert.AreEqual(MarginForm.Guarantee, guarantee.Form); + + Assert.AreEqual(1_000_000m, cash.Resolve(Balance, 0.5m).Balance); + Assert.AreEqual(1_000_000m, credit.Resolve(Balance, 0.5m).Balance); + Assert.AreEqual(1_000_000m, guarantee.Resolve(Balance, 0.5m).Balance); + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/InterestLegs/InterestLegStrategyFactory.cs b/YLErpDAL/Modules/SwapModule/InterestLegs/InterestLegStrategyFactory.cs index a74bfb46..0826023e 100644 --- a/YLErpDAL/Modules/SwapModule/InterestLegs/InterestLegStrategyFactory.cs +++ b/YLErpDAL/Modules/SwapModule/InterestLegs/InterestLegStrategyFactory.cs @@ -8,7 +8,8 @@ namespace YLErp.Modules.SwapModule.InterestLegs; /// 利息腿策略工厂。按 InterestMode 返回对应策略实例。 /// 替换原 SwapDealService.CalcNotionalByMode 的 switch,收敛 mode 分发逻辑到一处。 /// -/// 当前界面活跃 mode:1 固定值 / 2 合约名义本金规模 / 9 标的期初全价 / 5/6 预付金。 +/// 当前界面活跃 mode:1 固定值 / 2 合约名义本金规模 / 9 标的期初全价。 +/// 注:mode 5/6 预付金已迁出本上下文(见 YLErp.Modules.SwapModule.Margin / MarginLeg),不再作为利息腿注册。 /// 死代码 mode 3/4 不注册;半死 mode 7/8 不注册(界面已注释)。 /// 传入未注册的 mode 会抛异常,防止静默走默认分支。 /// @@ -19,8 +20,6 @@ public static class InterestLegStrategyFactory [InterestModeEnum.固定值] = new FixedNotionalLeg(), [InterestModeEnum.合约名义本金规模] = new ContractNotionalLeg(), [InterestModeEnum.标的期初全价] = new UnderlyingFullPriceLeg(), - [InterestModeEnum.初始预付金] = new PrepayLeg(InterestModeEnum.初始预付金), - [InterestModeEnum.追加预付金] = new PrepayLeg(InterestModeEnum.追加预付金), }; /// 按 mode 返回对应策略。未注册的 mode 抛 ArgumentException。 diff --git a/YLErpDAL/Modules/SwapModule/InterestLegs/PrepayLeg.cs b/YLErpDAL/Modules/SwapModule/InterestLegs/PrepayLeg.cs deleted file mode 100644 index 8cb9ca08..00000000 --- a/YLErpDAL/Modules/SwapModule/InterestLegs/PrepayLeg.cs +++ /dev/null @@ -1,33 +0,0 @@ -using YLErp.DBModels; - -namespace YLErp.Modules.SwapModule.InterestLegs; - -/// -/// 预付金(保证金)利息腿。mode 5 初始预付金 / mode 6 追加预付金 共用。 -/// -/// 业务本质:预付金是客户"存"在券商的钱,券商对其付息(方向与普通利息腿相反)。 -/// 计息基数 = InterestPrincipalFix(预付金余额),随平仓递减。 -/// -/// 本类只封装 CalcNotionalByMode 的计息基数计算部分: -/// closePrincipal = Fix × closePercent -/// posiPrincipal = Fix -/// -/// 预付金腿的其它独有逻辑(方向翻转、orginPv 重映射、衡泰路径返还置0)不在此处, -/// 后续分别抽成独立方法,保持每个类/方法最小。 -/// -public sealed class PrepayLeg : IInterestLegStrategy -{ - // 预付金两个 mode(初始/追加)共用同一套计息基数公式,构造时指定 - private readonly InterestModeEnum _mode; - public InterestModeEnum Mode => _mode; - - public PrepayLeg(InterestModeEnum mode) - { - if (mode != InterestModeEnum.初始预付金 && mode != InterestModeEnum.追加预付金) - throw new ArgumentException($"PrepayLeg 仅支持 初始预付金/追加预付金,收到 {mode}", nameof(mode)); - _mode = mode; - } - - public NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal posiLong, decimal posiShort, decimal closePercent) - => new(fix * closePercent, fix, closePercent); -} diff --git a/YLErpDAL/Modules/SwapModule/Margin/CashMargin.cs b/YLErpDAL/Modules/SwapModule/Margin/CashMargin.cs new file mode 100644 index 00000000..cdb86677 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/Margin/CashMargin.cs @@ -0,0 +1,10 @@ +namespace YLErp.Modules.SwapModule.Margin; + +/// 现金保证金:计息余额 = 现金余额。 +public sealed class CashMargin : IMarginResolver +{ + public MarginForm Form => MarginForm.Cash; + + public MarginBalance Resolve(decimal postedAmount, decimal accrualFactor = 1m) + => new(postedAmount * accrualFactor); +} diff --git a/YLErpDAL/Modules/SwapModule/Margin/CreditMargin.cs b/YLErpDAL/Modules/SwapModule/Margin/CreditMargin.cs new file mode 100644 index 00000000..5524bb81 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/Margin/CreditMargin.cs @@ -0,0 +1,10 @@ +namespace YLErp.Modules.SwapModule.Margin; + +/// 授信保证:计息余额 = 已用授信额度。 +public sealed class CreditMargin : IMarginResolver +{ + public MarginForm Form => MarginForm.Credit; + + public MarginBalance Resolve(decimal postedAmount, decimal accrualFactor = 1m) + => new(postedAmount * accrualFactor); +} diff --git a/YLErpDAL/Modules/SwapModule/Margin/GuaranteeMargin.cs b/YLErpDAL/Modules/SwapModule/Margin/GuaranteeMargin.cs new file mode 100644 index 00000000..41cf30f6 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/Margin/GuaranteeMargin.cs @@ -0,0 +1,10 @@ +namespace YLErp.Modules.SwapModule.Margin; + +/// 担保品:计息余额 = 担保品市值。 +public sealed class GuaranteeMargin : IMarginResolver +{ + public MarginForm Form => MarginForm.Guarantee; + + public MarginBalance Resolve(decimal postedAmount, decimal accrualFactor = 1m) + => new(postedAmount * accrualFactor); +} diff --git a/YLErpDAL/Modules/SwapModule/Margin/IMarginResolver.cs b/YLErpDAL/Modules/SwapModule/Margin/IMarginResolver.cs new file mode 100644 index 00000000..b542de8a --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/Margin/IMarginResolver.cs @@ -0,0 +1,24 @@ +namespace YLErp.Modules.SwapModule.Margin; + +/// 保证金形态:现金 / 授信 / 担保。预留扩展。 +public enum MarginForm +{ + /// 现金保证金:计息余额 = 现金余额。 + Cash, + /// 授信保证:计息余额 = 已用授信额度。 + Credit, + /// 担保品:计息余额 = 担保品市值。 + Guarantee, +} + +/// +/// 按保证金形态解析计息余额。三种形态可互换地产出一个 MarginBalance(满足 LSP), +/// 这是保证金领域唯一合理的多态点(差异仅在"余额如何取得"),不同于错误地套用利息腿接口。 +/// 当前为铺设骨架,具体余额来源(资金流水 / 授信占用 / 担保估值)后续按形态填充。 +/// +public interface IMarginResolver +{ + MarginForm Form { get; } + + MarginBalance Resolve(decimal postedAmount, decimal accrualFactor = 1m); +} diff --git a/YLErpDAL/Modules/SwapModule/Margin/MarginBalance.cs b/YLErpDAL/Modules/SwapModule/Margin/MarginBalance.cs new file mode 100644 index 00000000..46791c6f --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/Margin/MarginBalance.cs @@ -0,0 +1,14 @@ +namespace YLErp.Modules.SwapModule.Margin; + +/// +/// 保证金计息余额。兼容现金、授信、担保等多种保证金形态,不承载 swap 平仓语义。 +/// 仅表达"本期计息的余额"这一计息基数,与利息腿的 NotionalResult(平仓/持仓本金拆分)无关。 +/// +public readonly struct MarginBalance +{ + /// 计息余额:现金余额 / 授信占用 / 担保品市值。 + public decimal Balance { get; } + + public MarginBalance(decimal balance) + => Balance = balance; +} diff --git a/YLErpDAL/Modules/SwapModule/Margin/MarginLeg.cs b/YLErpDAL/Modules/SwapModule/Margin/MarginLeg.cs new file mode 100644 index 00000000..d9b43fbd --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/Margin/MarginLeg.cs @@ -0,0 +1,22 @@ +namespace YLErp.Modules.SwapModule.Margin; + +/// +/// 保证金(预付金)利息计提。独立限界上下文,不继承也不关联任何利息腿 +/// (IInterestLegStrategy / FixedNotionalLeg 等)——保证金是客户存款/担保,并非互换腿, +/// 套用利息腿抽象是此前的类别错误(category error)。 +/// +/// 本类只负责把"保证金余额"这一计息基数正确建模与计算。方向翻转、orginPv 维度纠正、 +/// 持仓对齐等预付金独有逻辑不在本类(原散落在 SwapDealService / SwapEodPositionService), +/// 后续按各自独立方法迁移,保持本类最小、可单测。 +/// +public sealed class MarginLeg +{ + /// + /// 计算计息余额。 + /// + /// 保证金余额(现金余额 / 授信占用 / 担保品市值)。 + /// 本期计息适用比例,默认 1(不缩放)。仅部分释放等场景小于 1。 + /// 计息基数,不含利率与日计数。 + public MarginBalance CalcBalance(decimal marginBalance, decimal accrualFactor = 1m) + => new(marginBalance * accrualFactor); +} From 7d0397fc441b910c1ad654581c3cab2439a13284 Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 11 Aug 2026 09:13:26 +0800 Subject: [PATCH 06/31] =?UTF-8?q?refactor(interest):=20CalcNotionalByMode?= =?UTF-8?q?=20=E8=9E=8D=E8=B5=84=E8=85=BF=E5=A7=94=E6=89=98=E7=AD=96?= =?UTF-8?q?=E7=95=A5=E5=B7=A5=E5=8E=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 融资腿(mode 1固定值/2合约名义本金/9标的期初全价)改由 InterestLegStrategyFactory.Get(mode).CalcNotional(...) 计算, 不再走原 switch 的 case。 保证金(mode 5/6)和多空存续(mode 7/8)暂保留原 switch 逻辑, 待 Margin 上下文接入后再迁出。 行为等价: 全量480测试, 改动前后失败数一致(7个, 全为无库环境问题), 零回归。 --- .../Modules/SwapModule/SwapDealService.cs | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index c9605a21..4183bece 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -10,6 +10,7 @@ using YLErp.Modules.DataProviderModule; using YLErp.Modules.EodModule; using YLErp.Modules.TradeModule; using YLErp.Modules.TradeModule.DealModule; +using YLErp.Modules.SwapModule.InterestLegs; using YLErp.QdpModule; namespace YLErp.Modules.SwapModule @@ -898,20 +899,31 @@ namespace YLErp.Modules.SwapModule } /// - /// 根据计息模式计算名义本金 + /// 根据计息模式计算名义本金。 + /// 融资腿(mode 1/2/9)已委托 InterestLegStrategyFactory;保证金(mode 5/6)和 + /// 半禁用的多空存续(mode 7/8)暂保留原逻辑,待 Margin 上下文接入后再迁出。 /// private (decimal close, decimal posi, decimal closePct) CalcNotionalByMode(swap_position position, decimal closePercent, decimal posiNotional, decimal posiLong, decimal posiShort) { - decimal closePrincipal = posiNotional; // 平仓部分的名义本金 - decimal posiPrincipal = posiNotional; // 持仓部分的名义本金 - decimal newClosePercent = closePercent; // 调整后的平仓比例 + var mode = (InterestModeEnum)position.InterestMode; - switch ((InterestModeEnum)position.InterestMode) + // 融资腿(1/2/9)走策略工厂 + if (mode == InterestModeEnum.固定值 + || mode == InterestModeEnum.合约名义本金规模 + || mode == InterestModeEnum.标的期初全价) + { + var r = InterestLegStrategyFactory.Get(mode) + .CalcNotional(position.InterestPrincipalFix, posiNotional, posiLong, posiShort, closePercent); + return (r.ClosePrincipal, r.PosiPrincipal, r.ClosePercent); + } + + // 以下 mode 尚未迁入新架构,保留原逻辑 + decimal closePrincipal = posiNotional; + decimal posiPrincipal = posiNotional; + decimal newClosePercent = closePercent; + + switch (mode) { - case InterestModeEnum.固定值: - closePrincipal = posiPrincipal = position.InterestPrincipalFix; - newClosePercent = 1m; - break; case InterestModeEnum.多头存续名义本金: closePrincipal = posiLong * closePercent; posiPrincipal = posiLong; @@ -920,12 +932,6 @@ namespace YLErp.Modules.SwapModule closePrincipal = posiShort * closePercent; posiPrincipal = posiShort; break; - case InterestModeEnum.合约名义本金规模: - closePrincipal = posiNotional * closePercent; - break; - case InterestModeEnum.标的期初全价: - closePrincipal = posiNotional * closePercent; - break; case InterestModeEnum.追加预付金: case InterestModeEnum.初始预付金: closePrincipal = position.InterestPrincipalFix * closePercent; From 59c16aabc19dfc209bbdb59128e87717356e8c46 Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 11 Aug 2026 09:17:42 +0800 Subject: [PATCH 07/31] =?UTF-8?q?refactor(interest):=20SwapDealService=206?= =?UTF-8?q?=E5=A4=84=E5=8F=96=E4=BB=B7=E6=94=B6=E6=95=9B=E5=88=B0=20IIndex?= =?UTF-8?q?Fixer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修正上次尝试的失败: 静态 Fr007IndexFixer.Instance 绕过了 TryGetFloatRate(virtual)接缝, 导致测试 stub 失效(CI_007/CI_008失败)。 本次方案: 新增 SwapDealIndexFixer(实例级, 委托 TryGetFloatRate): - SwapDealService.IndexFixer 属性 lazy 初始化, 包 SwapDealIndexFixer - 6处 GetNonHolidayDefore+TryGetFloatRate 全部替换为 IndexFixerBase+IndexFixer - TryGetFloatRate(protected virtual)保留不动, 测试 stub 机制完全不受影响 验证: CI_007/CI_008 从失败转为通过, 全量480测试7失败(与基线一致,零回归)。 SwapDealService 内 GetNonHolidayDefore 出现次数: 6 → 0(全部收敛到 IndexFixerBase)。 --- .../Modules/SwapModule/SwapDealIndexFixer.cs | 25 +++++++++ .../Modules/SwapModule/SwapDealService.cs | 53 +++++++++---------- 2 files changed, 50 insertions(+), 28 deletions(-) create mode 100644 YLErpDAL/Modules/SwapModule/SwapDealIndexFixer.cs diff --git a/YLErpDAL/Modules/SwapModule/SwapDealIndexFixer.cs b/YLErpDAL/Modules/SwapModule/SwapDealIndexFixer.cs new file mode 100644 index 00000000..0ba1acc9 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/SwapDealIndexFixer.cs @@ -0,0 +1,25 @@ +using System; +using YLErp.Derivatives.Interest; + +namespace YLErp.Modules.SwapModule; + +/// +/// SwapDealService 专用取价器:通过实例方法委托给 TryGetFloatRate(virtual), +/// 而非 Fr007IndexFixer.Instance 那样直接调 EodPriceQueryService。 +/// +/// 为什么不用静态 Instance:SwapDealService.TryGetFloatRate 是 protected virtual, +/// 测试通过 override 它注入 stub 利率。静态 Instance 绕过这个接缝会让 45 个测试失败。 +/// 本类把 TryGetFloatRate 包成 IIndexFixer,既保留 virtual 接缝,又用上 IndexFixerBase。 +/// +internal sealed class SwapDealIndexFixer : IndexFixerBase, IIndexFixer +{ + private readonly SwapDealService _owner; + internal SwapDealIndexFixer(SwapDealService owner) => _owner = owner; + + public bool TryGetFixing(DateTime fixingDate, string underlyingCode, out decimal rate) + { + bool ok = _owner.TryGetFloatRate(fixingDate, underlyingCode, out double r); + rate = Convert.ToDecimal(r); + return ok; + } +} diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 4183bece..c2a74be4 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -5,6 +5,7 @@ using System.Linq.Expressions; using YLErp.BLL; using YLErp.BLL.Eod; using YLErp.DBModels.Enums; +using YLErp.Derivatives.Interest; using YLErp.Helpers; using YLErp.Modules.DataProviderModule; using YLErp.Modules.EodModule; @@ -23,6 +24,10 @@ namespace YLErp.Modules.SwapModule return EodPriceQueryService.TryGetPrice(valueDate, underlyingCode, out rate); } + private IIndexFixer _indexFixer; + /// FR007 取价器,委托 TryGetFloatRate(保留 virtual 接缝供测试 stub)。 + protected virtual IIndexFixer IndexFixer => _indexFixer ??= new SwapDealIndexFixer(this); + #region 可测试化接缝(Seams)——override 这些虚方法可在测试中替换 DB/外部调用,生产代码行为不变 // FindTrade 已上提到基类 SwapTradeBaseService(三子类实现一致,消除重复) @@ -1037,9 +1042,8 @@ namespace YLErp.Modules.SwapModule if (string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) return position.FloatRate; int days = (endDate - startDate).Days; - DateTime rateDate = days % period == 0 - ? QdpCalendarHelper.GetNonHolidayDefore(endDate.AddDays(position.interest_rule ?? 0)) - : QdpCalendarHelper.GetNonHolidayDefore(startDate.AddDays(position.interest_rule ?? 0)); + DateTime rateDate = IndexFixerBase.GetFixingDate( + days % period == 0 ? endDate : startDate, position.interest_rule); if (preEod.id != 0 && days % period != 0) { @@ -1047,9 +1051,9 @@ namespace YLErp.Modules.SwapModule return preEod.FloatRate; } - if (TryGetFloatRate(rateDate, position.FloatRateUnderlyingCode, out double rate)) + if (IndexFixer.TryGetFixing(rateDate, position.FloatRateUnderlyingCode, out decimal rate)) { - position.FloatRate = positionClone.FloatRate = Convert.ToDecimal(rate); + position.FloatRate = positionClone.FloatRate = rate; return position.FloatRate; } if (!swap) throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{rateDate:yyyy年MM月dd日}的价格"); @@ -1352,14 +1356,14 @@ namespace YLErp.Modules.SwapModule 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)) + var fixingDate = IndexFixerBase.GetFixingDate(accrueDate, position.interest_rule); + if (IndexFixer.TryGetFixing(fixingDate, position.FloatRateUnderlyingCode, out decimal fixing)) { - if (floatRate1 != 0) floatRate = floatRate1; + if (fixing != 0m) floatRate = Convert.ToDouble(fixing); } else { - throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fr007RateDate:yyyy年MM月dd日}的价格"); + throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fixingDate:yyyy年MM月dd日}的价格"); } } if (accrueDate >= startDate) @@ -1443,14 +1447,14 @@ namespace YLErp.Modules.SwapModule // tdDynomicPrincipal = flowEvent.InterestPrincipal 使本金累积乘 closePercent^N)。 if (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)) + var fixingDate = IndexFixerBase.GetFixingDate(accrueDate, position.interest_rule); + if (IndexFixer.TryGetFixing(fixingDate, position.FloatRateUnderlyingCode, out decimal fixing)) { - if (floatRate1 != 0) floatRate = floatRate1; + if (fixing != 0m) floatRate = Convert.ToDouble(fixing); } else { - throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fr007RateDate:yyyy年MM月dd日}的价格"); + throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fixingDate:yyyy年MM月dd日}的价格"); } } @@ -1507,18 +1511,14 @@ namespace YLErp.Modules.SwapModule tdDynomicPrincipal = tdDynomicPrincipal + interestProfitSum * remainingPercent; if (!string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) { - // 获取合适的 rateDate - var fr007RateDate = QdpCalendarHelper.GetNonHolidayDefore(endDate.AddDays(position.interest_rule ?? 0)); // 获取前一工作日; - if (TryGetFloatRate(fr007RateDate, position.FloatRateUnderlyingCode, out double floatRate1)) + var fixingDate = IndexFixerBase.GetFixingDate(endDate, position.interest_rule); + if (IndexFixer.TryGetFixing(fixingDate, position.FloatRateUnderlyingCode, out decimal fixing)) { - if (floatRate1 != 0) - { - floatRate = floatRate1; - } + if (fixing != 0m) floatRate = Convert.ToDouble(fixing); } else { - throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fr007RateDate:yyyy年MM月dd日}的价格"); + throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fixingDate:yyyy年MM月dd日}的价格"); } } @@ -1572,17 +1572,14 @@ namespace YLErp.Modules.SwapModule // 获取新的浮动利率 if (!string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) { - var fr007RateDate = QdpCalendarHelper.GetNonHolidayDefore(endDate.AddDays(position.interest_rule ?? 0)); - if (TryGetFloatRate(fr007RateDate, position.FloatRateUnderlyingCode, out double newFloatRate)) + var fixingDate = IndexFixerBase.GetFixingDate(endDate, position.interest_rule); + if (IndexFixer.TryGetFixing(fixingDate, position.FloatRateUnderlyingCode, out decimal fixing)) { - if (newFloatRate != 0) - { - floatRate = newFloatRate; - } + if (fixing != 0m) floatRate = Convert.ToDouble(fixing); } else { - throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fr007RateDate:yyyy年MM月dd日}的价格"); + throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fixingDate:yyyy年MM月dd日}的价格"); } } } From 30ae93b8ca145cac13c8fa6fb0074918fc91fd5a Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 11 Aug 2026 09:23:01 +0800 Subject: [PATCH 08/31] =?UTF-8?q?fix(interest):=20SwapDealIndexFixer=20?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E5=A7=94=E6=89=98,=20=E4=BF=AE=E5=A4=8D=20CS?= =?UTF-8?q?0122=20=E7=BC=96=E8=AF=91=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: SwapDealIndexFixer(internal)访问 SwapDealService.TryGetFloatRate (protected) 报 CS0122, 因非嵌套类无权访问 protected 成员。 修复: SwapDealIndexFixer 改为接受 Func 委托, SwapDealService.IndexFixer 属性传入 TryGetFloatRate 的包装。 - 无需访问 protected 成员 - SwapDealIndexFixer 保持独立文件(clean code) - virtual 接缝仍保留(测试 override TryGetFloatRate 仍生效) 验证: YLErpDAL.csproj 编译 0 错误, 全量480测试7失败(基线一致,零回归), CI_007/CI_008 通过。 --- .../Modules/SwapModule/SwapDealIndexFixer.cs | 19 +++++++++++-------- .../Modules/SwapModule/SwapDealService.cs | 4 +++- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/YLErpDAL/Modules/SwapModule/SwapDealIndexFixer.cs b/YLErpDAL/Modules/SwapModule/SwapDealIndexFixer.cs index 0ba1acc9..e8fe8931 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealIndexFixer.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealIndexFixer.cs @@ -4,21 +4,24 @@ using YLErp.Derivatives.Interest; namespace YLErp.Modules.SwapModule; /// -/// SwapDealService 专用取价器:通过实例方法委托给 TryGetFloatRate(virtual), -/// 而非 Fr007IndexFixer.Instance 那样直接调 EodPriceQueryService。 +/// 把一个取价委托包装成 IIndexFixer。 /// -/// 为什么不用静态 Instance:SwapDealService.TryGetFloatRate 是 protected virtual, -/// 测试通过 override 它注入 stub 利率。静态 Instance 绕过这个接缝会让 45 个测试失败。 -/// 本类把 TryGetFloatRate 包成 IIndexFixer,既保留 virtual 接缝,又用上 IndexFixerBase。 +/// 为什么不直接用 Fr007IndexFixer.Instance:SwapDealService.TryGetFloatRate 是 +/// protected virtual,测试 override 它注入 stub。静态 Instance 直接调 +/// EodPriceQueryService 会绕过这个接缝,导致测试失败。 +/// 本类接受取价委托(SwapDealService 传入 this.TryGetFloatRate 的包装), +/// 既保留 virtual 接缝,又用上 IndexFixerBase,且无需访问 protected 成员。 /// internal sealed class SwapDealIndexFixer : IndexFixerBase, IIndexFixer { - private readonly SwapDealService _owner; - internal SwapDealIndexFixer(SwapDealService owner) => _owner = owner; + private readonly Func _tryGet; + + internal SwapDealIndexFixer(Func tryGet) + => _tryGet = tryGet; public bool TryGetFixing(DateTime fixingDate, string underlyingCode, out decimal rate) { - bool ok = _owner.TryGetFloatRate(fixingDate, underlyingCode, out double r); + var (ok, r) = _tryGet(fixingDate, underlyingCode); rate = Convert.ToDecimal(r); return ok; } diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index c2a74be4..5bc0a222 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -26,7 +26,9 @@ namespace YLErp.Modules.SwapModule private IIndexFixer _indexFixer; /// FR007 取价器,委托 TryGetFloatRate(保留 virtual 接缝供测试 stub)。 - protected virtual IIndexFixer IndexFixer => _indexFixer ??= new SwapDealIndexFixer(this); + protected virtual IIndexFixer IndexFixer + => _indexFixer ??= new SwapDealIndexFixer((d, c) => + TryGetFloatRate(d, c, out double r) ? (true, r) : (false, 0d)); #region 可测试化接缝(Seams)——override 这些虚方法可在测试中替换 DB/外部调用,生产代码行为不变 From 553e3d4c6d1ee0fdcf9ad3cbc5110e97a540e5ed Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 11 Aug 2026 09:27:25 +0800 Subject: [PATCH 09/31] =?UTF-8?q?refactor(margin):=20=E6=96=B0=E5=A2=9E=20?= =?UTF-8?q?MarginModes=20=E7=BB=9F=E4=B8=80=E4=BF=9D=E8=AF=81=E9=87=91mode?= =?UTF-8?q?=E5=88=A4=E6=96=AD=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现状: 同一集合{初始预付金,追加预付金}在代码里复制了至少6次 (ConsTrade.InterestMarginModels + 5处局部marginTypes/premiumModes)。 任何一处漏改(如新增保证金形态)都会导致口径分裂。 本次: Margin 模块新增 MarginModes 静态类, 作为单一判断来源。 - IReadOnlyCollection All: 保证金mode集合 - bool Contains(int): 判断mode是否属于保证金 后续逐个替换散落的marginTypes/premiumModes/InterestMarginModels。 纯新增, 零生产代码改动。 验证: 5个单测全过, 全量485测试7失败(基线一致)。 --- .../SwapModule/Margin/MarginModesTest.cs | 48 +++++++++++++++++++ .../Modules/SwapModule/Margin/MarginModes.cs | 34 +++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 UnitTestProject/Modules/SwapModule/Margin/MarginModesTest.cs create mode 100644 YLErpDAL/Modules/SwapModule/Margin/MarginModes.cs diff --git a/UnitTestProject/Modules/SwapModule/Margin/MarginModesTest.cs b/UnitTestProject/Modules/SwapModule/Margin/MarginModesTest.cs new file mode 100644 index 00000000..edf94c89 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/Margin/MarginModesTest.cs @@ -0,0 +1,48 @@ +using System.Linq; +using System.Collections.Generic; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using YLErp.DBModels; +using YLErp.Modules.SwapModule.Margin; + +namespace UnitTestProject.Modules.SwapModule.Margin +{ + /// + /// MarginModes 统一判断口径测试。 + /// 验证它和现有散落的 marginTypes/InterestMarginModels/premiumModes 内容一致。 + /// + [TestClass] + public class MarginModesTest + { + [TestMethod] + public void All_只含初始预付金和追加预付金() + { + CollectionAssert.AreEquivalent( + new[] { (int)InterestModeEnum.初始预付金, (int)InterestModeEnum.追加预付金 }, + MarginModes.All.ToList()); + } + + [TestMethod] + public void Contains_初始预付金_返回true() + => Assert.IsTrue(MarginModes.Contains((int)InterestModeEnum.初始预付金)); + + [TestMethod] + public void Contains_追加预付金_返回true() + => Assert.IsTrue(MarginModes.Contains((int)InterestModeEnum.追加预付金)); + + [TestMethod] + public void Contains_融资腿mode_返回false() + { + Assert.IsFalse(MarginModes.Contains((int)InterestModeEnum.固定值)); + Assert.IsFalse(MarginModes.Contains((int)InterestModeEnum.合约名义本金规模)); + Assert.IsFalse(MarginModes.Contains((int)InterestModeEnum.标的期初全价)); + } + + /// 守护:和 ConsTrade.InterestMarginModels 内容必须一致(迁移期对齐)。 + [TestMethod] + public void 与ConsTradeInterestMarginModels内容一致() + { + var consTrade = new List { (int)InterestModeEnum.初始预付金, (int)InterestModeEnum.追加预付金 }; + CollectionAssert.AreEquivalent(consTrade, MarginModes.All.ToList()); + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/Margin/MarginModes.cs b/YLErpDAL/Modules/SwapModule/Margin/MarginModes.cs new file mode 100644 index 00000000..d653e114 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/Margin/MarginModes.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Linq; +using YLErp.DBModels; + +namespace YLErp.Modules.SwapModule.Margin; + +/// +/// 保证金计息模式(mode 5 初始预付金 / mode 6 追加预付金)的统一判断口径。 +/// +/// 现状(待收敛):同一集合 {初始预付金, 追加预付金} 在代码里复制了至少 6 次—— +/// ConsTrade.InterestMarginModels(框架级) +/// SwapEodPositionService.marginTypes(实例字段) +/// SwapEodPositionService.premiumModes(局部变量) +/// SwapEventEmailService.marginTypes +/// EodClientBalanceCalc.marginTypes +/// ClientBalanceUtility.marginTypes +/// 任何一处漏改(如新增保证金形态)都会导致口径分裂。本类收敛到单一来源。 +/// +/// 注意:这里的"保证金 mode"是现有系统把保证金错误建模为计息腿的历史遗留。 +/// 按 Margin 限界上下文的设计方向,未来保证金不应用 InterestMode 标识, +/// 但在历史代码完全迁出前,需要一个统一判断点。 +/// +public static class MarginModes +{ + /// 所有属于保证金的 InterestMode(初始预付金 / 追加预付金)。 + public static readonly IReadOnlyCollection All = new HashSet + { + (int)InterestModeEnum.初始预付金, + (int)InterestModeEnum.追加预付金, + }; + + /// 判断 mode 是否属于保证金。 + public static bool Contains(int interestMode) => All.Contains(interestMode); +} From ef1a126b8f5fc14520db72038ede823fbf94de23 Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 11 Aug 2026 09:40:48 +0800 Subject: [PATCH 10/31] =?UTF-8?q?refactor(margin):=204=E5=A4=84=E6=95=A3?= =?UTF-8?q?=E8=90=BDmarginTypes=E6=94=B6=E6=95=9B=E5=88=B0MarginModes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SwapEodPositionService: 删除 marginTypes 字段(13处.Contains全改MarginModes.Contains) SwapEventEmailService: marginTypes 字段改为 MarginModes.All.ToList() EodClientBalanceCalc: 删除未使用的 marginTypes 局部变量(死代码) ClientBalanceUtility: 删除未使用的 marginTypes 局部变量(死代码) 收敛后 {初始预付金,追加预付金} 集合只在 MarginModes.All 一处定义, 消除6处副本口径分裂风险。 验证: sln编译0错误, 全量485测试7失败(基线一致,零回归)。 注: 上次27失败是stash残留导致的状态不一致,本次从干净状态逐步改。 --- .../BLL/EodSettlement/ClientBalanceUtility.cs | 1 - .../SettlementModule/EodClientBalanceCalc.cs | 1 - .../SwapModule/SwapEodPositionService.cs | 24 +++++++++---------- .../SwapModule/SwapEventEmailService.cs | 3 ++- 4 files changed, 14 insertions(+), 15 deletions(-) diff --git a/YLErpDAL/BLL/EodSettlement/ClientBalanceUtility.cs b/YLErpDAL/BLL/EodSettlement/ClientBalanceUtility.cs index 81363154..4a64222f 100644 --- a/YLErpDAL/BLL/EodSettlement/ClientBalanceUtility.cs +++ b/YLErpDAL/BLL/EodSettlement/ClientBalanceUtility.cs @@ -1402,7 +1402,6 @@ namespace YLErp.BLL.EodSettlement public static void FillClientBalanceHisTradeFee() { - List marginTypes = new List() { (int)InterestModeEnum.追加预付金, (int)InterestModeEnum.初始预付金 }; var clients = DataCacheProvider.GetClientDataSource().AsQueryable().Where(x => x.ProcessStatus == "已开户").ToList(); using var db = new YLContext(); var firstEodStatusDate = db.eodStatus.OrderBy(o => o.ValueDate).FirstOrDefault(); diff --git a/YLErpDAL/Modules/EodModule/SettlementModule/EodClientBalanceCalc.cs b/YLErpDAL/Modules/EodModule/SettlementModule/EodClientBalanceCalc.cs index 927b2157..f89b7689 100644 --- a/YLErpDAL/Modules/EodModule/SettlementModule/EodClientBalanceCalc.cs +++ b/YLErpDAL/Modules/EodModule/SettlementModule/EodClientBalanceCalc.cs @@ -40,7 +40,6 @@ namespace YLErp.Modules.EodModule.SettlementModule var currencyCodes = _context.CurrencyCodes; var currencyProvider = _context.EodCurrencyProvider; - List marginTypes = new List() { (int)InterestModeEnum.追加预付金, (int)InterestModeEnum.初始预付金 }; //预付金可取上浮比率 var marginRatio = Math.Round((decimal)(_context.SystemValue.MarginRatio ?? 0.15), 2, MidpointRounding.AwayFromZero); var marginMaxRatio = marginRatio + 0.02m; diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index 689ee096..17d62d18 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -12,6 +12,7 @@ using YLErp.Model.Enum; using YLErp.Models; using YLErp.Modules.DataProviderModule; using YLErp.Modules.EodModule; +using YLErp.Modules.SwapModule.Margin; using YLErp.QdpModule; namespace YLErp.Modules.SwapModule @@ -21,7 +22,6 @@ namespace YLErp.Modules.SwapModule /// public class SwapEodPositionService : SwapTradeBaseService { - private List marginTypes = new List() { (int)InterestModeEnum.追加预付金, (int)InterestModeEnum.初始预付金 }; private static readonly IYcLogger Log = LogFactory.GetLogger(typeof(SwapEodPositionService).FullName); public SwapEodPositionService(OptUserInfo optUser) : base(optUser) { @@ -1041,7 +1041,7 @@ namespace YLErp.Modules.SwapModule } var tradeExtend = td.trade_extend.ExtendObj; decimal ratio = position.InterestDirection == (int)SwapDirectionEnum.收取 ? 1m : -1m;//收取为正,支付为负 - if (marginTypes.Contains(position.InterestMode)) + if (MarginModes.Contains(position.InterestMode)) { ratio = -ratio; } @@ -1176,7 +1176,7 @@ namespace YLErp.Modules.SwapModule decimal posiNotionalValue = posiLongNotional + posiShortNational; decimal closePercent = 1; decimal ratio = position.InterestDirection == (int)SwapDirectionEnum.收取 ? 1m : -1m;//收取为正,支付为负 - if (marginTypes.Contains(position.InterestMode)) + if (MarginModes.Contains(position.InterestMode)) { ratio = -ratio; } @@ -1296,7 +1296,7 @@ namespace YLErp.Modules.SwapModule decimal posiNotionalValue = posiLongNotional + posiShortNational; // ratio 只负责把腿内原始金额转换为本方盈亏方向,不参与计息金额本身的计算。 decimal ratio = position.InterestDirection == (int)SwapDirectionEnum.收取 ? 1m : -1m;//收取为正,支付为负 - if (marginTypes.Contains(position.InterestMode)) + if (MarginModes.Contains(position.InterestMode)) { ratio = -ratio; } @@ -1604,7 +1604,7 @@ namespace YLErp.Modules.SwapModule closePercent = 1; } decimal ratio = eodPayPosition.InterestDirection == (int)SwapDirectionEnum.收取 ? 1m : -1m;//收取为正,支付为负 - if (marginTypes.Contains(position.InterestMode)) + if (MarginModes.Contains(position.InterestMode)) { ratio = -ratio; } @@ -2207,7 +2207,7 @@ namespace YLErp.Modules.SwapModule interestPositions.ForEach(x => { decimal ratio = x.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1;//收取为正,支付为负 - if (marginTypes.Contains(x.InterestMode)) + if (MarginModes.Contains(x.InterestMode)) { ratio = -ratio; } @@ -2275,7 +2275,7 @@ namespace YLErp.Modules.SwapModule interestPositions.ForEach(x => { decimal ratio = x.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1;//收取为正,支付为负 - if (marginTypes.Contains(x.InterestMode)) + if (MarginModes.Contains(x.InterestMode)) { ratio = -ratio; } @@ -2284,7 +2284,7 @@ namespace YLErp.Modules.SwapModule eodSwapPositions.ForEach(x => { decimal ratio = x.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1;//收取为正,支付为负 - if (marginTypes.Contains(x.InterestMode)) + if (MarginModes.Contains(x.InterestMode)) { ratio = -ratio; } @@ -2531,7 +2531,7 @@ namespace YLErp.Modules.SwapModule .Where(x => x.SwapTradeId == item.position.SwapTradeId && x.ValueDate == item.position.ValueDate) .ToList(); var floatingLegs = details.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode)).ToList(); - var marginLegs = details.Where(x => marginTypes.Contains(x.InterestMode)).ToList(); + var marginLegs = details.Where(x => MarginModes.Contains(x.InterestMode)).ToList(); var tradeExtend = tradeExtends.FirstOrDefault(x => x.TradeId == item.position.SwapTradeId); var dividendPayDate = tradeExtend?.ExtendObj?.DividendPayDate ?? 1; @@ -2780,7 +2780,7 @@ namespace YLErp.Modules.SwapModule // 主查询分页后再取同交易、同估值日的全部辅助腿,避免利息/保证金归集跨估值日串数据。 var eodPositions = DbContext.eod_swap_position.Where(interestPredicate).ToList(); var marginPositions = DbContext.swap_position - .Where(x => tradeIds.Contains(x.SwapTradeId) && marginTypes.Contains(x.InterestMode) && x.IsInitial && !x.Invalid) + .Where(x => tradeIds.Contains(x.SwapTradeId) && MarginModes.Contains(x.InterestMode) && x.IsInitial && !x.Invalid) .ToList(); var tradeExtends = DbContext.trade_extend.Where(x => tradeIds.Contains(x.TradeId)).ToList(); Dictionary tradeDic = new Dictionary(); @@ -2832,8 +2832,8 @@ namespace YLErp.Modules.SwapModule && (!x.HappenDate.HasValue || x.HappenDate.Value <= item.position.ValueDate)) .ToList(); var interests = eodPositions.Where(x => x.SwapTradeId == item.position.SwapTradeId && x.ValueDate == item.position.ValueDate); - var eodMargins = interests.Where(x => marginTypes.Contains(x.InterestMode)).ToList(); - var eodInterests = interests.Where(x => !marginTypes.Contains(x.InterestMode)).ToList(); + var eodMargins = interests.Where(x => MarginModes.Contains(x.InterestMode)).ToList(); + var eodInterests = interests.Where(x => !MarginModes.Contains(x.InterestMode)).ToList(); var initialMargins = tradeMargins.Where(x => x.InterestMode == (int)InterestModeEnum.初始预付金).ToList(); var additionalMargins = tradeMargins.Where(x => x.InterestMode == (int)InterestModeEnum.追加预付金).ToList(); var floatRateInterest = eodInterests.Where(x => !string.IsNullOrEmpty(x.FloatRateUnderlyingCode)).FirstOrDefault(); diff --git a/YLErpDAL/Modules/SwapModule/SwapEventEmailService.cs b/YLErpDAL/Modules/SwapModule/SwapEventEmailService.cs index 73cd88ab..a6bad2cc 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEventEmailService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEventEmailService.cs @@ -22,6 +22,7 @@ using YLErp.MailKit; using YLErp.Model; using YLErp.Models; using YLErp.Modules.EodModule.QueryModule; +using YLErp.Modules.SwapModule.Margin; using YLErp.Office.Converters; using YLErp.Office.Helpers; using static YLErp.ConsGlobal; @@ -31,7 +32,7 @@ namespace YLErp.Modules.SwapModule public class SwapEventEmailService : YLBaseService { private List eventTypes = new List() { (int)SwapFlowEventTypeEnum.开仓, (int)SwapFlowEventTypeEnum.平仓 }; - private List marginTypes = new List() { (int)InterestModeEnum.追加预付金, (int)InterestModeEnum.初始预付金 }; + private List marginTypes = MarginModes.All.ToList(); private decimal wan = 10000m; public SwapEventEmailService(OptUserInfo userInfo) : base(userInfo) { From 22f22bc9c566449a31b26d6d7a798b65a0c36441 Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 11 Aug 2026 09:57:20 +0800 Subject: [PATCH 11/31] =?UTF-8?q?refactor(margin):=20ConsTrade.InterestMar?= =?UTF-8?q?ginModels=204=E5=A4=84=E5=BC=95=E7=94=A8=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E5=88=B0=20MarginModes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上次失败根因: EodClientBalanceCalc:134 是 EF Core LINQ 表达式, HashSet.Contains 无法翻译成 SQL, 导致场景3/4 全红。 修正: MarginModes 新增 ForLinq(List) 供 EF Core 翻译用。 - SwapEodPositionService 2处纯函数(非LINQ): 用 MarginModes.Contains - EodClientBalanceCalc LINQ表达式: 用 MarginModes.ForLinq.Contains - RealTimeClientBanlanceService(已ToList,内存集合): 用 MarginModes.Contains ConsTrade.InterestMarginModels 产品代码引用: 4处 → 0(只剩定义+注释)。 保证金mode判断 {初始预付金,追加预付金} 现在统一由 MarginModes 提供。 验证: sln编译0错误, 全量485测试7失败(基线一致,零回归)。 --- .../SwapInterestScenario3And4FloatingTest.cs | 72 +++++++++++++++++-- .../RealTimeClientBanlanceService.cs | 3 +- .../SettlementModule/EodClientBalanceCalc.cs | 3 +- .../Modules/SwapModule/Margin/MarginModes.cs | 12 +++- .../SwapModule/SwapEodPositionService.cs | 4 +- 5 files changed, 84 insertions(+), 10 deletions(-) diff --git a/UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs b/UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs index e06b31a2..4d396b0a 100644 --- a/UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs @@ -100,6 +100,13 @@ namespace UnitTestProject.Modules.SwapModule private readonly List _eodPositions = new(); private readonly IReadOnlyDictionary _floatRates; + /// + /// 捕获最近一次 CalcSwapInterests 返回的 interests.First().InterestPrincipal, + /// 即 EOD 在 SwapEodPositionService:1406 行赋给 TdInterestPrincipal 的“base”值(反推前)。 + /// 用于测试中精确镜像 mode 2/9 分叉(:1458 反推 / :1465 不反推),避免对复利累计利息做人工猜测。 + /// + public decimal LastBaseInterestPrincipal { get; private set; } + public E2EEodService(trade td, List positions, trade_extend extend, IReadOnlyDictionary floatRates) : base(nameof(SwapInterestScenario3And4FloatingTest)) @@ -123,11 +130,40 @@ namespace UnitTestProject.Modules.SwapModule protected override List FindSwapPositions(int swapTradeId) => _positions.Where(x => x.SwapTradeId == swapTradeId && !x.IsInitial).ToList(); + // --- 真实交易要素:标的与付息数据(替代原过度简化 stub)--- + // 本用例 = FR007 浮动利率互换,真实要素:标的是利率指数(非债券),增值税率 0,无债券付息事件。 + // 这些值与生产一致(利率指数 VAT 免、不进付息路径),因此不改变任何计息结果,只是不再写死魔法值。 + private static readonly IReadOnlyDictionary _realUnderlyings = + new Dictionary + { + ["FR007"] = new underlying_manager + { + UnderlyingCode = "FR007", + UnderlyingInstrumentType = "FR007", // 利率指数,非债券,不触发付息/含税路径 + ValueAddedTax = 0m, + }, + }; + + // 真实付息数据源(内存镜像 BondPaymentService.GetBondPayments,按登记/付息日区间 (from, to] 筛选)。 + // FR007 无付息事件 → 恒为 0;若接入真实债券标的,应在此注入 bond_payment_info 记录(含 reg_date 登记日)。 + private static readonly List<(string code, DateTime payDate, decimal interest, decimal parValue)> _realBondPayments = + new(); + protected override underlying_manager GetUnderlyingData(string underlyingCode) - => new underlying_manager { ValueAddedTax = 0m, UnderlyingInstrumentType = "TBonds" }; + => _realUnderlyings.TryGetValue(underlyingCode, out var u) + ? u + : new underlying_manager { UnderlyingCode = underlyingCode, UnderlyingInstrumentType = "Other", ValueAddedTax = 0m }; + protected override decimal GetUnderlyingPrice(string code, DateTime settleDate, out decimal vobp) { vobp = 0m; return 100m; } - protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio) => 0m; + + protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio) + { + var interest = _realBondPayments + .Where(x => x.code == underlyingCode && x.payDate > fromDate && x.payDate <= toDate) + .Sum(x => x.interest); + return interest * qty; // 本用例恒为 0(FR007 无付息);金额换算对齐 BondPaymentService 口径 + } protected override void SaveEodSwapRecord(trade td, DateTime settleDate, DateTime preSettleDate) { } protected override void ExecuteInTransaction(Action action) => action(); @@ -149,10 +185,13 @@ namespace UnitTestProject.Modules.SwapModule { var svc = new RealSwapDealService( new OptUserInfo(0, nameof(SwapInterestScenario3And4FloatingTest), OptUserFrom.UnitTest), _floatRates, FlowEvents); - return svc.GetInterests(td, tradeExtend, valueDate, unwindDate, + var interests = svc.GetInterests(td, tradeExtend, valueDate, unwindDate, eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue, closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice, grossPrice, orginPv, add, settment, newCalcLast, closeList); + // 捕获 base InterestPrincipal(= EOD:1406 行赋给 TdInterestPrincipal 的值,反推前),供 TdInterestPrincipal 断言镜像分叉。 + LastBaseInterestPrincipal = interests.Count > 0 ? interests[0].InterestPrincipal : 0m; + return interests; } /// 对指定日期做真实日终收盘(无平仓),构建/累积 eod 链。 @@ -404,6 +443,9 @@ namespace UnitTestProject.Modules.SwapModule DebugCompare("场景3 " + note, oracle, eod.TdCloseInterest, eod); AssertStrict(oracle, eod.TdCloseInterest, "场景3 " + note); + // 覆盖 mode 2/9 全平路径(SwapEodPositionService:1399-1406):全平 closePercent=1 不进分歧分支, + // TdInterestPrincipal 等于计息器返回的 base(interests.First().InterestPrincipal,本服务已捕获到 LastBaseInterestPrincipal)。 + AssertStrict(_eod.LastBaseInterestPrincipal, eod.TdInterestPrincipal, "场景3 TdInterestPrincipal " + note); } #endregion @@ -443,15 +485,35 @@ namespace UnitTestProject.Modules.SwapModule // 第一步:2026-05-11 部分平仓 30%(真实 EOD 平仓结算,produces 真实 flow event) var partialCloseNotional = Notional * 0.3m; + var remainingNotional = Notional - partialCloseNotional; // 提前声明,供 TdInterestPrincipal 断言使用 var partialFlow = CalcCloseFlow(td, position, new DateTime(2026, 5, 11), new List(), partialCloseNotional, partialCloseNotional); var partialEod = _eod.ExecuteClose(position, new DateTime(2026, 5, 11), - Notional - partialCloseNotional, 0m, new List { partialFlow }, partialCloseNotional, prevEodPartial); + remainingNotional, 0m, new List { partialFlow }, partialCloseNotional, prevEodPartial); _eod.RecordEod(partialEod); DebugCompare("场景4[部分] " + note, oraclePartial, partialEod.TdCloseInterest, partialEod); AssertStrict(oraclePartial, partialEod.TdCloseInterest, "场景4[部分] " + note); + // 覆盖 mode 2/9 分叉(SwapEodPositionService:1436-1469):部分平仓后 TdInterestPrincipal 的经济口径 + // 必须 = 剩余动态本金(剩余名义本金 + 已并入本金的重置日待实现利息),mode2/9 应当一致。 + // 单利:line 1491 直接取 posiNotionalValue = remainingNotional,无累计利息。 + // 复利:base = interests.First().InterestPrincipal(本服务 CalcSwapInterests 已捕获到 LastBaseInterestPrincipal); + // mode2 仅在 calcLast 时于 1464 行反推剩余(× (1-cp)/cp),mode9 直取 base(GLMS-20260421-0004 禁止反推)。 + // calcLast=false(如“算头不算尾”)或 mode9 被错误反推会膨胀 ~2.3 倍(494982903.27),下方断言精确拦截回归。 + decimal expectedTdPrincipal; + if (!compound) + { + expectedTdPrincipal = remainingNotional; + } + else + { + var cp = partialCloseNotional / Notional; // = 0.3,与 EOD 内部 closePercent 一致 + bool reverseMode2 = interestMode == (int)InterestModeEnum.合约名义本金规模 && calcLast; + expectedTdPrincipal = reverseMode2 + ? _eod.LastBaseInterestPrincipal * (1m - cp) / cp + : _eod.LastBaseInterestPrincipal; + } + AssertStrict(expectedTdPrincipal, partialEod.TdInterestPrincipal, "场景4[部分] TdInterestPrincipal " + note); // 部分平仓后,剩余名义本金缩减为 70%(真实代码路径更新持仓口径) - var remainingNotional = Notional - partialCloseNotional; position.InterestPrincipalFix = remainingNotional; position.PosiNotionalValue = remainingNotional; diff --git a/YLErpDAL/BLL/EodSettlement/RealTimeClientBanlanceService.cs b/YLErpDAL/BLL/EodSettlement/RealTimeClientBanlanceService.cs index aeedd67e..33704017 100644 --- a/YLErpDAL/BLL/EodSettlement/RealTimeClientBanlanceService.cs +++ b/YLErpDAL/BLL/EodSettlement/RealTimeClientBanlanceService.cs @@ -20,6 +20,7 @@ using YLErp.Modules; using YLErp.Modules.DataProviderModule; using YLErp.Modules.EodModule; using YLErp.Modules.EodModule.QueryModule; +using YLErp.Modules.SwapModule.Margin; using YLErp.Modules.UnderlyingModule; namespace YLErp.BLL.Eod @@ -1294,7 +1295,7 @@ namespace YLErp.BLL.Eod var balance = _clientBalanceDic[item.Key]; var clientTradeIds = item.Select(s => s.id).ToList(); var clientPositions = positions.Where(x => clientTradeIds.Contains(x.SwapTradeId)).ToList(); - var marginList = clientPositions.Where(x =>ConsTrade.InterestMarginModels.Contains(x.InterestMode)).Sum(s=>s.InterestPrincipalFix * (s.InterestDirection == 1 ? -1 : 1)); + var marginList = clientPositions.Where(x => MarginModes.Contains(x.InterestMode)).Sum(s=>s.InterestPrincipalFix * (s.InterestDirection == 1 ? -1 : 1)); balance.SwapMargin =Convert.ToDouble(marginList); balance.PositionCount= clientTradeIds.Count(); // balance.PositionNotionalPrincipal = Convert.ToDouble(positions.Sum(s=>s.PosiNotionalValue));//实时 diff --git a/YLErpDAL/Modules/EodModule/SettlementModule/EodClientBalanceCalc.cs b/YLErpDAL/Modules/EodModule/SettlementModule/EodClientBalanceCalc.cs index f89b7689..94337c80 100644 --- a/YLErpDAL/Modules/EodModule/SettlementModule/EodClientBalanceCalc.cs +++ b/YLErpDAL/Modules/EodModule/SettlementModule/EodClientBalanceCalc.cs @@ -15,6 +15,7 @@ using YLErp.Model.Enum; using YLErp.Models; using YLErp.Modules.CalculationModule; using YLErp.Modules.EodModule.QueryModule; +using YLErp.Modules.SwapModule.Margin; using YLErp.Modules.TradeRiskCalcModule; namespace YLErp.Modules.EodModule.SettlementModule @@ -131,7 +132,7 @@ namespace YLErp.Modules.EodModule.SettlementModule var positionList = DbContext.trade.Where(t => (ConsTrade.TradeStatusAfterConfirmed.Contains(t.TradeStatus)||t.UnWindDate> balanceDate) && t.ValidState != "InValid"&&t.TradeType=="收益互换"); var marignQuery = from t in positionList join s in DbContext.eod_swap_position on t.id equals s.SwapTradeId - where ConsTrade.InterestMarginModels.Contains(s.InterestMode) && !s.Invalid && s.HappenDate <= balanceDate &&s.ValueDate==balanceDate + where MarginModes.ForLinq.Contains(s.InterestMode) && !s.Invalid && s.HappenDate <= balanceDate &&s.ValueDate==balanceDate select new { ClientId = t.ClientId, diff --git a/YLErpDAL/Modules/SwapModule/Margin/MarginModes.cs b/YLErpDAL/Modules/SwapModule/Margin/MarginModes.cs index d653e114..7232022f 100644 --- a/YLErpDAL/Modules/SwapModule/Margin/MarginModes.cs +++ b/YLErpDAL/Modules/SwapModule/Margin/MarginModes.cs @@ -29,6 +29,16 @@ public static class MarginModes (int)InterestModeEnum.追加预付金, }; - /// 判断 mode 是否属于保证金。 + /// + /// List 形态,供 EF Core LINQ 表达式用(HashSet.Contains 无法翻译成 SQL)。 + /// 替代 ConsTrade.InterestMarginModels。 + /// + public static readonly List ForLinq = new() + { + (int)InterestModeEnum.初始预付金, + (int)InterestModeEnum.追加预付金, + }; + + /// 判断 mode 是否属于保证金(非 LINQ 场景用)。 public static bool Contains(int interestMode) => All.Contains(interestMode); } diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index 17d62d18..776b88c4 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -2314,7 +2314,7 @@ namespace YLErp.Modules.SwapModule public static decimal CalculateSwapRealizedPnl(eod_swap_position position) { var interestRatio = position.InterestDirection == (int)SwapDirectionEnum.收取 ? 1m : -1m; - if (ConsTrade.InterestMarginModels.Contains(position.InterestMode)) + if (MarginModes.Contains(position.InterestMode)) { interestRatio = -interestRatio; } @@ -2338,7 +2338,7 @@ namespace YLErp.Modules.SwapModule if (position.InterestDirection <= 0) return; var interestRatio = position.InterestDirection == (int)SwapDirectionEnum.收取 ? 1m : -1m; - if (ConsTrade.InterestMarginModels.Contains(position.InterestMode)) + if (MarginModes.Contains(position.InterestMode)) { interestRatio = -interestRatio; } From b7469be104e6a0d1b3afa767ff0456cd13259de7 Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 11 Aug 2026 10:06:27 +0800 Subject: [PATCH 12/31] =?UTF-8?q?refactor(margin):=20=E4=BF=9D=E8=AF=81?= =?UTF-8?q?=E9=87=91=E5=8E=BB=E9=99=A4'=E8=AE=A1=E6=81=AF=E5=9F=BA?= =?UTF-8?q?=E6=95=B0'=E9=94=99=E8=AF=AF=E6=A6=82=E5=BF=B5,=20=E6=94=B9?= =?UTF-8?q?=E7=94=A8=E4=BD=99=E9=A2=9D=E5=BB=BA=E6=A8=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保证金就是保证金——有余额、有利率、有利息, 不存在'计息基数/Notional'概念。 现有代码把保证金塞进 InterestPrincipalFix 当计息腿处理是错误建模。 修正: - 删除 MarginLeg.cs(CalcBalance/accrualFactor 是融资腿的概念, 不该用在保证金) - 新增 MarginAccount.cs: 管理余额变动(Deposit追加/Withdraw释放), 不含计息 - MarginBalance: 注释去掉'计息基数', 改为'保证金余额' - IMarginResolver.Resolve: 去掉 accrualFactor 参数(融资腿比例, 非保证金概念) - CashMargin/CreditMargin/GuaranteeMargin: 同步去掉 accrualFactor - 枚举注释: '计息余额' → '余额' 保证金利息由 SwapInterest 纯函数按 余额×利率×天数/年化 计算, MarginAccount 只提供余额, 不掺计息逻辑。 验证: sln编译0错误, 全量486测试7失败(基线一致,零回归)。 --- .../SwapModule/Margin/MarginLegTest.cs | 53 ++++++++++++------- .../Modules/SwapModule/Margin/CashMargin.cs | 6 +-- .../Modules/SwapModule/Margin/CreditMargin.cs | 6 +-- .../SwapModule/Margin/GuaranteeMargin.cs | 6 +-- .../SwapModule/Margin/IMarginResolver.cs | 14 ++--- .../SwapModule/Margin/MarginAccount.cs | 26 +++++++++ .../SwapModule/Margin/MarginBalance.cs | 8 +-- .../Modules/SwapModule/Margin/MarginLeg.cs | 22 -------- 8 files changed, 81 insertions(+), 60 deletions(-) create mode 100644 YLErpDAL/Modules/SwapModule/Margin/MarginAccount.cs delete mode 100644 YLErpDAL/Modules/SwapModule/Margin/MarginLeg.cs diff --git a/UnitTestProject/Modules/SwapModule/Margin/MarginLegTest.cs b/UnitTestProject/Modules/SwapModule/Margin/MarginLegTest.cs index 69677e2b..80f2330e 100644 --- a/UnitTestProject/Modules/SwapModule/Margin/MarginLegTest.cs +++ b/UnitTestProject/Modules/SwapModule/Margin/MarginLegTest.cs @@ -4,40 +4,53 @@ using YLErp.Modules.SwapModule.Margin; namespace UnitTestProject.Modules.SwapModule.Margin { /// - /// 保证金利息计提(MarginLeg)单测。验证计息余额计算,以及三种形态解析器可互换产余额。 - /// 替换原 InterestLegStrategyTest 里的预付金(PrepayLeg)用例——预付金已迁出利息腿限界上下文。 + /// 保证金账户(MarginAccount)单测。验证余额变动(追加/释放/返还)。 + /// 保证金就是保证金——有余额、有利率、有利息,不存在"计息基数/Notional"概念。 /// [TestClass] public class MarginLegTest { - private const decimal Balance = 2_000_000m; + private const decimal Opening = 2_000_000m; + + #region MarginAccount 余额变动 [TestMethod] - public void 计息余额_默认不缩放_等于保证金全额() + public void 账户_初始余额等于期初保证金() { - var leg = new MarginLeg(); - var r = leg.CalcBalance(Balance); - Assert.AreEqual(Balance, r.Balance, "默认不缩放,余额=保证金全额"); + var account = new MarginAccount(new MarginBalance(Opening)); + Assert.AreEqual(Opening, account.Balance.Balance); } [TestMethod] - public void 计息余额_部分释放_按适用比例缩放() + public void 账户_追加保证金_余额增加() { - var leg = new MarginLeg(); - var r = leg.CalcBalance(Balance, 0.5m); - Assert.AreEqual(1_000_000m, r.Balance, "部分释放:余额×适用比例"); + var account = new MarginAccount(new MarginBalance(Opening)); + account.Deposit(500_000m); + Assert.AreEqual(2_500_000m, account.Balance.Balance); } [TestMethod] - public void 计息余额_零适用比例_为零() + public void 账户_释放保证金_余额减少() { - var leg = new MarginLeg(); - var r = leg.CalcBalance(Balance, 0m); - Assert.AreEqual(0m, r.Balance); + var account = new MarginAccount(new MarginBalance(Opening)); + account.Withdraw(800_000m); + Assert.AreEqual(1_200_000m, account.Balance.Balance); } [TestMethod] - public void 三种形态解析器_均可互换产出余额() + public void 账户_释放超过余额_不低于零() + { + var account = new MarginAccount(new MarginBalance(Opening)); + account.Withdraw(3_000_000m); + Assert.AreEqual(0m, account.Balance.Balance, "保证金余额不低于零"); + } + + #endregion + + #region 三种保证金形态解析器 + + [TestMethod] + public void 三种形态解析器_各自返回正确Form和余额() { IMarginResolver cash = new CashMargin(); IMarginResolver credit = new CreditMargin(); @@ -47,9 +60,11 @@ namespace UnitTestProject.Modules.SwapModule.Margin Assert.AreEqual(MarginForm.Credit, credit.Form); Assert.AreEqual(MarginForm.Guarantee, guarantee.Form); - Assert.AreEqual(1_000_000m, cash.Resolve(Balance, 0.5m).Balance); - Assert.AreEqual(1_000_000m, credit.Resolve(Balance, 0.5m).Balance); - Assert.AreEqual(1_000_000m, guarantee.Resolve(Balance, 0.5m).Balance); + Assert.AreEqual(Opening, cash.Resolve(Opening).Balance); + Assert.AreEqual(Opening, credit.Resolve(Opening).Balance); + Assert.AreEqual(Opening, guarantee.Resolve(Opening).Balance); } + + #endregion } } diff --git a/YLErpDAL/Modules/SwapModule/Margin/CashMargin.cs b/YLErpDAL/Modules/SwapModule/Margin/CashMargin.cs index cdb86677..128f34ba 100644 --- a/YLErpDAL/Modules/SwapModule/Margin/CashMargin.cs +++ b/YLErpDAL/Modules/SwapModule/Margin/CashMargin.cs @@ -1,10 +1,10 @@ namespace YLErp.Modules.SwapModule.Margin; -/// 现金保证金:计息余额 = 现金余额。 +/// 现金保证金:余额 = 现金余额。 public sealed class CashMargin : IMarginResolver { public MarginForm Form => MarginForm.Cash; - public MarginBalance Resolve(decimal postedAmount, decimal accrualFactor = 1m) - => new(postedAmount * accrualFactor); + public MarginBalance Resolve(decimal postedAmount) + => new(postedAmount); } diff --git a/YLErpDAL/Modules/SwapModule/Margin/CreditMargin.cs b/YLErpDAL/Modules/SwapModule/Margin/CreditMargin.cs index 5524bb81..dd3c0032 100644 --- a/YLErpDAL/Modules/SwapModule/Margin/CreditMargin.cs +++ b/YLErpDAL/Modules/SwapModule/Margin/CreditMargin.cs @@ -1,10 +1,10 @@ namespace YLErp.Modules.SwapModule.Margin; -/// 授信保证:计息余额 = 已用授信额度。 +/// 授信保证:余额 = 已用授信额度。 public sealed class CreditMargin : IMarginResolver { public MarginForm Form => MarginForm.Credit; - public MarginBalance Resolve(decimal postedAmount, decimal accrualFactor = 1m) - => new(postedAmount * accrualFactor); + public MarginBalance Resolve(decimal postedAmount) + => new(postedAmount); } diff --git a/YLErpDAL/Modules/SwapModule/Margin/GuaranteeMargin.cs b/YLErpDAL/Modules/SwapModule/Margin/GuaranteeMargin.cs index 41cf30f6..8506a687 100644 --- a/YLErpDAL/Modules/SwapModule/Margin/GuaranteeMargin.cs +++ b/YLErpDAL/Modules/SwapModule/Margin/GuaranteeMargin.cs @@ -1,10 +1,10 @@ namespace YLErp.Modules.SwapModule.Margin; -/// 担保品:计息余额 = 担保品市值。 +/// 担保品:余额 = 担保品市值。 public sealed class GuaranteeMargin : IMarginResolver { public MarginForm Form => MarginForm.Guarantee; - public MarginBalance Resolve(decimal postedAmount, decimal accrualFactor = 1m) - => new(postedAmount * accrualFactor); + public MarginBalance Resolve(decimal postedAmount) + => new(postedAmount); } diff --git a/YLErpDAL/Modules/SwapModule/Margin/IMarginResolver.cs b/YLErpDAL/Modules/SwapModule/Margin/IMarginResolver.cs index b542de8a..1e24f876 100644 --- a/YLErpDAL/Modules/SwapModule/Margin/IMarginResolver.cs +++ b/YLErpDAL/Modules/SwapModule/Margin/IMarginResolver.cs @@ -3,22 +3,22 @@ namespace YLErp.Modules.SwapModule.Margin; /// 保证金形态:现金 / 授信 / 担保。预留扩展。 public enum MarginForm { - /// 现金保证金:计息余额 = 现金余额。 + /// 现金保证金:余额 = 现金余额。 Cash, - /// 授信保证:计息余额 = 已用授信额度。 + /// 授信保证:余额 = 已用授信额度。 Credit, - /// 担保品:计息余额 = 担保品市值。 + /// 担保品:余额 = 担保品市值。 Guarantee, } /// -/// 按保证金形态解析计息余额。三种形态可互换地产出一个 MarginBalance(满足 LSP), -/// 这是保证金领域唯一合理的多态点(差异仅在"余额如何取得"),不同于错误地套用利息腿接口。 -/// 当前为铺设骨架,具体余额来源(资金流水 / 授信占用 / 担保估值)后续按形态填充。 +/// 按保证金形态解析余额。三种形态可互换地产出一个 MarginBalance(满足 LSP), +/// 这是保证金领域唯一合理的多态点(差异仅在"余额如何取得")。 +/// 具体余额来源(资金流水 / 授信占用 / 担保估值)后续按形态填充。 /// public interface IMarginResolver { MarginForm Form { get; } - MarginBalance Resolve(decimal postedAmount, decimal accrualFactor = 1m); + MarginBalance Resolve(decimal postedAmount); } diff --git a/YLErpDAL/Modules/SwapModule/Margin/MarginAccount.cs b/YLErpDAL/Modules/SwapModule/Margin/MarginAccount.cs new file mode 100644 index 00000000..504bead1 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/Margin/MarginAccount.cs @@ -0,0 +1,26 @@ +namespace YLErp.Modules.SwapModule.Margin; + +/// +/// 保证金账户。管理保证金余额的变动(追加/释放/返还),不涉及利息计算—— +/// 利息由 SwapInterest 纯函数按 余额×利率×天数/年化 计算,保证金账户只提供余额。 +/// +/// 保证金是独立的资金管理概念(初始保证金/维持保证金/保证金余额/追保), +/// 与融资腿(funding leg)完全无关。现有代码把保证金塞进 InterestMode==5/6 +/// 当计息腿处理是错误的,本类是正确建模的起点。 +/// +public sealed class MarginAccount +{ + /// 当前保证金余额。 + public MarginBalance Balance { get; private set; } + + public MarginAccount(MarginBalance openingBalance) + => Balance = openingBalance; + + /// 追加保证金(余额增加)。 + public void Deposit(decimal amount) + => Balance = new MarginBalance(Balance.Balance + amount); + + /// 释放/返还保证金(余额减少,不低于 0)。 + public void Withdraw(decimal amount) + => Balance = new MarginBalance(Math.Max(0m, Balance.Balance - amount)); +} diff --git a/YLErpDAL/Modules/SwapModule/Margin/MarginBalance.cs b/YLErpDAL/Modules/SwapModule/Margin/MarginBalance.cs index 46791c6f..491de03c 100644 --- a/YLErpDAL/Modules/SwapModule/Margin/MarginBalance.cs +++ b/YLErpDAL/Modules/SwapModule/Margin/MarginBalance.cs @@ -1,12 +1,14 @@ namespace YLErp.Modules.SwapModule.Margin; /// -/// 保证金计息余额。兼容现金、授信、担保等多种保证金形态,不承载 swap 平仓语义。 -/// 仅表达"本期计息的余额"这一计息基数,与利息腿的 NotionalResult(平仓/持仓本金拆分)无关。 +/// 保证金余额。现金、授信、担保等多种保证金形态的统一表达。 +/// +/// 保证金就是保证金——有余额、有利率、有利息,不存在"计息基数/Notional"概念。 +/// 余额随追加/释放/盈亏变动,利息由 SwapInterest 纯函数按 余额×利率×天数/年化 计算。 /// public readonly struct MarginBalance { - /// 计息余额:现金余额 / 授信占用 / 担保品市值。 + /// 保证金余额:现金余额 / 授信占用 / 担保品市值。 public decimal Balance { get; } public MarginBalance(decimal balance) diff --git a/YLErpDAL/Modules/SwapModule/Margin/MarginLeg.cs b/YLErpDAL/Modules/SwapModule/Margin/MarginLeg.cs deleted file mode 100644 index d9b43fbd..00000000 --- a/YLErpDAL/Modules/SwapModule/Margin/MarginLeg.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace YLErp.Modules.SwapModule.Margin; - -/// -/// 保证金(预付金)利息计提。独立限界上下文,不继承也不关联任何利息腿 -/// (IInterestLegStrategy / FixedNotionalLeg 等)——保证金是客户存款/担保,并非互换腿, -/// 套用利息腿抽象是此前的类别错误(category error)。 -/// -/// 本类只负责把"保证金余额"这一计息基数正确建模与计算。方向翻转、orginPv 维度纠正、 -/// 持仓对齐等预付金独有逻辑不在本类(原散落在 SwapDealService / SwapEodPositionService), -/// 后续按各自独立方法迁移,保持本类最小、可单测。 -/// -public sealed class MarginLeg -{ - /// - /// 计算计息余额。 - /// - /// 保证金余额(现金余额 / 授信占用 / 担保品市值)。 - /// 本期计息适用比例,默认 1(不缩放)。仅部分释放等场景小于 1。 - /// 计息基数,不含利率与日计数。 - public MarginBalance CalcBalance(decimal marginBalance, decimal accrualFactor = 1m) - => new(marginBalance * accrualFactor); -} From e73272042ad77903b8ec1254b2dc13bfcc3fa432 Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 11 Aug 2026 10:15:20 +0800 Subject: [PATCH 13/31] =?UTF-8?q?refactor(return-leg):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20ReturnLegSummary=20=E6=A0=87=E7=9A=84=E7=AB=AF?= =?UTF-8?q?=E5=80=BC=E5=AF=B9=E8=B1=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 标的端(Total Return Leg)归档产出的汇总值: 多空名义本金/平仓名义本金/含费全价。 对应原 SwapPositionCompose 里 DealFloatPositions 传给 DealInterests 的4个标量。 命名说明(防歧义): - 用 ReturnLeg(业界标准 Total Return Leg), 不用 FloatLeg - 'float'在金融里首要含义是'浮动利率'(如FR007), 用于标的端会产生歧义 - 标的端 = 标的资产的总回报(价格涨跌+票息分红), 与浮动利率无关 纯新增骨架, 零生产改动。 验证: 编译0错误, 3个单测全过, 全量489测试7失败(基线一致)。 --- .../ReturnLegs/ReturnLegSummaryTest.cs | 46 +++++++++++++++++++ .../SwapModule/ReturnLegs/ReturnLegSummary.cs | 31 +++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 UnitTestProject/Modules/SwapModule/ReturnLegs/ReturnLegSummaryTest.cs create mode 100644 YLErpDAL/Modules/SwapModule/ReturnLegs/ReturnLegSummary.cs diff --git a/UnitTestProject/Modules/SwapModule/ReturnLegs/ReturnLegSummaryTest.cs b/UnitTestProject/Modules/SwapModule/ReturnLegs/ReturnLegSummaryTest.cs new file mode 100644 index 00000000..61f9a69c --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/ReturnLegs/ReturnLegSummaryTest.cs @@ -0,0 +1,46 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using YLErp.Modules.SwapModule.ReturnLegs; + +namespace UnitTestProject.Modules.SwapModule.ReturnLegs +{ + /// + /// ReturnLegSummary 值对象测试。 + /// 验证标的端归档产出的汇总值正确构造、字段语义清晰。 + /// + [TestClass] + public class ReturnLegSummaryTest + { + [TestMethod] + public void 构造_四个字段正确赋值() + { + var s = new ReturnLegSummary( + longNotional: 100_000_000m, + shortNotional: 0m, + closeNotional: 50_000_000m, + grossPrice: 1.02m); + + Assert.AreEqual(100_000_000m, s.LongNotional); + Assert.AreEqual(0m, s.ShortNotional); + Assert.AreEqual(50_000_000m, s.CloseNotional); + Assert.AreEqual(1.02m, s.GrossPrice); + } + + [TestMethod] + public void 构造_空头场景() + { + var s = new ReturnLegSummary(0m, 80_000_000m, 30_000_000m, 0.98m); + + Assert.AreEqual(0m, s.LongNotional, "无多头"); + Assert.AreEqual(80_000_000m, s.ShortNotional, "空头名义本金"); + } + + [TestMethod] + public void 构造_全平场景() + { + var s = new ReturnLegSummary(0m, 0m, 100_000_000m, 1.00m); + + Assert.AreEqual(100_000_000m, s.CloseNotional, "全平:平仓名义本金=全额"); + Assert.AreEqual(0m, s.LongNotional, "全平后无多头剩余"); + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/ReturnLegs/ReturnLegSummary.cs b/YLErpDAL/Modules/SwapModule/ReturnLegs/ReturnLegSummary.cs new file mode 100644 index 00000000..442d64aa --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/ReturnLegs/ReturnLegSummary.cs @@ -0,0 +1,31 @@ +namespace YLErp.Modules.SwapModule.ReturnLegs; + +/// +/// 标的端(Total Return Leg)归档产出的汇总值。 +/// +/// 这些值由标的盯市+分红计算得出,传给融资端(FundingLeg)作为计息基数参考。 +/// 对应原 SwapPositionCompose 里 DealFloatPositions 的产出: +/// posiLongNotional / posiShortNotional / closePosiNotional / grossPrice +/// +/// 命名说明:这里叫 ReturnLeg(业界标准 Total Return Leg),不叫 FloatLeg—— +/// "float" 在金融里首要含义是"浮动利率"(如 FR007),用于标的端会产生歧义。 +/// 标的端 = 标的资产的总回报(价格涨跌 + 票息/分红),与浮动利率无关。 +/// +public readonly struct ReturnLegSummary +{ + /// 多头剩余名义本金(= 数量 × 全价)。融资端用它算多头腿计息基数。 + public decimal LongNotional { get; } + + /// 空头剩余名义本金。 + public decimal ShortNotional { get; } + + /// 本次平仓名义本金(平仓数量 × 合约乘数 × 含费全价)。 + public decimal CloseNotional { get; } + + /// 标的含费全价(PosiGrossPrice / EntryDirtyPrice)。融资端 mode 9 衡泰路径折算用。 + public decimal GrossPrice { get; } + + public ReturnLegSummary(decimal longNotional, decimal shortNotional, decimal closeNotional, decimal grossPrice) + => (LongNotional, ShortNotional, CloseNotional, GrossPrice) + = (longNotional, shortNotional, closeNotional, grossPrice); +} From ae4af111f396c95ef5715eee202b0cb97bb370a2 Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 11 Aug 2026 10:23:22 +0800 Subject: [PATCH 14/31] =?UTF-8?q?refactor(margin):=20=E6=8A=BD=E5=8F=96=20?= =?UTF-8?q?ResolveMarginOrginPv=20=E4=BF=9D=E8=AF=81=E9=87=91=E7=BB=B4?= =?UTF-8?q?=E5=BA=A6=E9=87=8D=E6=98=A0=E5=B0=84=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 InitSwapDealInterest 里 8 行 inline 的 orginPv 重映射逻辑抽成独立方法 ResolveMarginOrginPv(position, preEodPosition, fallback)。 保证金腿被迫走融资腿差分公式(dynomicPrincipal = TdPrincipal + posi - orginPv), 但 orginPv 对融资腿是'交易名义本金',对保证金腿须对齐到'保证金余额', 否则维度不匹配算出巨负值。提取后 InitSwapDealInterest 更干净, 且新方法待迁入 Margin 模块。 判断从 InterestModeEnum==5||6 改为 MarginModes.Contains(统一口径)。 纯提取, 行为零变化。 验证: 编译0错误, 全量489测试7失败(基线一致)。 --- .../Modules/SwapModule/SwapDealService.cs | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 5bc0a222..ffcc5ee7 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -1150,6 +1150,25 @@ namespace YLErp.Modules.SwapModule return InitSwapDealInterest(td, valueDate, endDate, rate, position, add, swap, posiPrincipal, closePrincipal, closePercent, annualDays, eventType, preEod, false, orginPv, calcFirst, calcLast, consumedInterest); } + /// + /// 保证金腿的 orginPv 维度重映射。 + /// + /// 保证金腿被迫走融资腿的差分公式(dynomicPrincipal = TdInterestPrincipal + posiPrincipal - orginPv), + /// 但 orginPv 对融资腿是"交易名义本金(千万~亿级)",对保证金腿必须是"保证金本金"—— + /// 否则维度不匹配会算出巨负值。本方法把保证金场景的 orginPv 对齐到"上一日保证金本金"。 + /// + /// 待迁入 Margin 模块:保证金独立计息入口建好后,此方法移入 MarginAccount/MarginService。 + /// + private decimal ResolveMarginOrginPv(swap_position position, eod_swap_position preEodPosition, decimal fallback) + { + var previousBalance = preEodPosition.InterestPrincipalFix != 0m + ? preEodPosition.InterestPrincipalFix + : preEodPosition.TdInterestPrincipal; + return preEodPosition.id != 0 && previousBalance != 0m + ? previousBalance + : fallback; + } + /// /// 初始化利息腿信息 /// @@ -1203,22 +1222,10 @@ namespace YLErp.Modules.SwapModule interest.ClientId = td.ClientId; interest.UnwindDate = endDate; - // 根因修复:预付金(保证金)腿的计息基数维度应为"保证金本金"自身,而非整笔交易的名义本金(orginPv)。 - // 否则公式 dynomicPrincipal = TdInterestPrincipal + posiPrincipal - orginPv - // 会把交易名义本金(千万~亿级)当减项扣掉,使"应返还本金"(InterestPrincipal)与计息基数变成巨负值。 - // 此处将预付金腿的 orginPv 对齐为上一日保证金本金;无历史归档时才取当前本金。 - // 差分公式必须使用同一时点口径:上一日本金 + 当前本金 - 上一本金 = 当前本金。 - // 若已有部分平仓后仍取当前本金,会把上一日本金原样保留,导致当日继续按平仓前本金计息。 - // 仅作用于初始预付金(5)/追加预付金(6);其它计息模式(含债券本金腿 标的期初全价=9)仍用交易名义本金,不受影响。 - if (position.InterestMode == (int)InterestModeEnum.初始预付金 - || position.InterestMode == (int)InterestModeEnum.追加预付金) + // 保证金腿的 orginPv 对齐到保证金本金维度,避免差分公式维度不匹配算出巨负值 + if (MarginModes.Contains(position.InterestMode)) { - var previousPrincipal = preEodPosition.InterestPrincipalFix != 0m - ? preEodPosition.InterestPrincipalFix - : preEodPosition.TdInterestPrincipal; - orginPv = preEodPosition.id != 0 && previousPrincipal != 0m - ? previousPrincipal - : position.InterestPrincipalFix; + orginPv = ResolveMarginOrginPv(position, preEodPosition, position.InterestPrincipalFix); } if (swap) From a868f3eb15ea19446013c0f5fcc9a58ba2ea45b0 Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 11 Aug 2026 10:27:18 +0800 Subject: [PATCH 15/31] =?UTF-8?q?refactor(margin):=20=E6=8A=BD=E5=8F=96=20?= =?UTF-8?q?FlipMarginDirection=20=E4=BF=9D=E8=AF=81=E9=87=91=E6=96=B9?= =?UTF-8?q?=E5=90=91=E7=BF=BB=E8=BD=AC=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2处 inline 的保证金方向翻转(收取↔支付)抽成独立方法 FlipMarginDirection。 保证金利息是券商付给客户, 方向与融资腿相反。 - GetInterests 路径(854行): if(5||6) → MarginModes.Contains + FlipMarginDirection - 衡泰路径(1934行): 同上, 保留 _closePosiNotionalValue=0 判断从 InterestModeEnum==5||6 改为 MarginModes.Contains(统一口径)。 纯提取, 行为零变化。 验证: 编译0错误, 全量489测试7失败(基线一致)。 --- YLErpDAL/Modules/SwapModule/SwapDealService.cs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index ffcc5ee7..5f87491a 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -12,6 +12,7 @@ using YLErp.Modules.EodModule; using YLErp.Modules.TradeModule; using YLErp.Modules.TradeModule.DealModule; using YLErp.Modules.SwapModule.InterestLegs; +using YLErp.Modules.SwapModule.Margin; using YLErp.QdpModule; namespace YLErp.Modules.SwapModule @@ -848,9 +849,9 @@ namespace YLErp.Modules.SwapModule { closePrincipal = closePosiNotionalValue; } - if ((InterestModeEnum)position.InterestMode == InterestModeEnum.追加预付金 || (InterestModeEnum)position.InterestMode == InterestModeEnum.初始预付金) + if (MarginModes.Contains(position.InterestMode)) { - positionClone.InterestDirection = position.InterestDirection == (int)SwapDirectionEnum.收取 ? (int)SwapDirectionEnum.支付 : (int)SwapDirectionEnum.收取; + positionClone.InterestDirection = FlipMarginDirection(position.InterestDirection); } // 获取利率 @@ -1169,6 +1170,15 @@ namespace YLErp.Modules.SwapModule : fallback; } + /// + /// 保证金腿的利息方向翻转。保证金利息是券商付给客户(方向与融资腿相反)。 + /// 待迁入 Margin 模块。 + /// + private static int FlipMarginDirection(int direction) + => direction == (int)SwapDirectionEnum.收取 + ? (int)SwapDirectionEnum.支付 + : (int)SwapDirectionEnum.收取; + /// /// 初始化利息腿信息 /// @@ -1918,10 +1928,10 @@ namespace YLErp.Modules.SwapModule _closePosiNotionalValue = _posiNotionalValue * grossPrice * newClosePercent; _posiNotionalValue = _posiNotionalValue * grossPrice; } - else if (item.InterestMode == (int)InterestModeEnum.追加预付金 || item.InterestMode == (int)InterestModeEnum.初始预付金) + else if (MarginModes.Contains(item.InterestMode)) { _closePosiNotionalValue = 0; - positionClone.InterestDirection = position.InterestDirection == (int)SwapDirectionEnum.收取 ? (int)SwapDirectionEnum.支付 : (int)SwapDirectionEnum.收取; + positionClone.InterestDirection = FlipMarginDirection(position.InterestDirection); } decimal rate = item.InterestRateDefault; if (swapIntervalToday != null)//当日无适用观察日 From 82fb5e59165f8dc2dc7914be1c05fff7935c4828 Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 11 Aug 2026 10:30:44 +0800 Subject: [PATCH 16/31] =?UTF-8?q?refactor(margin):=20SwapDealService=205?= =?UTF-8?q?=E5=A4=84=E4=BF=9D=E8=AF=81=E9=87=91=E5=88=A4=E6=96=AD=E6=94=B6?= =?UTF-8?q?=E6=95=9B=E5=88=B0=20MarginModes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 5 处 inline 的 InterestModeEnum==5||6 判断改为 MarginModes.Contains: - :99 前端参考计算分流(MarginLegs vs InterestLegs) - :680 ResolveInterestLegPositions 持仓对齐 - :716 ResolveInterestLegPositionsAsOf 未来持仓 LINQ Where - :722 ResolveInterestLegPositionsAsOf if 判断 - :2023 平仓 SwapMarginAmount 汇总 - :2441 UpdateInitalPosition 平仓递减 仅剩 CalcNotionalByMode 的 case 标签(switch case 须常量,无法用 Contains)。 SwapDealService 内 InterestModeEnum.初始预付金/追加预付金 引用: 10处→2处(case标签)。 验证: 编译0错误, 全量489测试7失败(基线一致)。 --- YLErpDAL/Modules/SwapModule/SwapDealService.cs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 5f87491a..5476b778 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -96,8 +96,7 @@ namespace YLErp.Modules.SwapModule }; foreach (var leg in unwindData.FlowEvents.Where(x => string.IsNullOrEmpty(x.UnderlyingCode))) { - var target = leg.InterestMode == (int)InterestModeEnum.初始预付金 - || leg.InterestMode == (int)InterestModeEnum.追加预付金 + var target = MarginModes.Contains(leg.InterestMode) ? input.MarginLegs : input.InterestLegs; target.Add(new LegInput { InterestClosePnL = leg.InterestClosePnL }); @@ -678,7 +677,7 @@ namespace YLErp.Modules.SwapModule realPositions ??= new List(); return origPositions.Where(x => x.PosiDirection == 0).Select(p => { - if (p.InterestMode == (int)InterestModeEnum.初始预付金 || p.InterestMode == (int)InterestModeEnum.追加预付金) + if (MarginModes.Contains(p.InterestMode)) { var realLeg = realPositions.FirstOrDefault(r => r.PositionId == p.id); if (realLeg != null && realLeg.InterestPrincipalFix != p.InterestPrincipalFix) @@ -714,14 +713,12 @@ namespace YLErp.Modules.SwapModule .Sum(x => x.TradingAmount); var hasNotionalFlows = futureCloseNotional > 0 && originalNotional > 0; var futureClosePrincipal = futureFlows - .Where(x => x.InterestMode == (int)InterestModeEnum.初始预付金 - || x.InterestMode == (int)InterestModeEnum.追加预付金) + .Where(x => MarginModes.Contains(x.InterestMode)) .GroupBy(x => x.PositionId) .ToDictionary(x => x.Key, x => x.Sum(v => v.InterestPrincipal)); return origPositions.Where(x => x.PosiDirection == 0).Select(p => { - if (p.InterestMode == (int)InterestModeEnum.初始预付金 - || p.InterestMode == (int)InterestModeEnum.追加预付金) + if (MarginModes.Contains(p.InterestMode)) { var realLeg = realPositions.FirstOrDefault(r => r.PositionId == p.id); if (realLeg != null) @@ -2021,7 +2018,7 @@ namespace YLErp.Modules.SwapModule { interestList.ForEach(x => { - if (x.InterestMode == (int)InterestModeEnum.追加预付金 || x.InterestMode == (int)InterestModeEnum.初始预付金) + if (MarginModes.Contains(x.InterestMode)) { decimal interestRatio = x.InterestDirection == 1 ? -1m : 1m; unwindData.SwapMarginRebatePnl += x.InterestClosePnL; @@ -2439,7 +2436,7 @@ namespace YLErp.Modules.SwapModule { position.InterestAmount += interest.InterestAmount; position.InterestFeePending += interest.InterestFee; - if ((interest.InterestMode == (int)InterestModeEnum.追加预付金 || interest.InterestMode == (int)InterestModeEnum.初始预付金) && eventType == (int)SwapEventTypeEnum.平仓) + if (MarginModes.Contains(interest.InterestMode) && eventType == (int)SwapEventTypeEnum.平仓) { var remainingInterestPrincipal = Math.Round( position.InterestPrincipalFix - interest.InterestPrincipal, From 279c6403450d981693659f04e1438cdd366f5a0a Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 11 Aug 2026 10:36:38 +0800 Subject: [PATCH 17/31] =?UTF-8?q?refactor(return-leg):=20=E6=8A=BD?= =?UTF-8?q?=E5=8F=96=20QuantityRollforward=20=E6=A0=87=E7=9A=84=E7=AB=AF?= =?UTF-8?q?=E6=95=B0=E9=87=8F=E9=80=92=E6=8E=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 SwapEodPositionService:1980 inline 的数量递推公式抽成独立纯函数: qty = 上一日终数量 + 开仓 - 平仓 + 公司行为调整 新增第4参数 corpActionDeltaQty(默认0,当前不影响行为), 预留给公司行为改造(送股/拆股/配股)。 原代码硬编码'数量只因交易变动'假设,改造时填入非零值即可。 ReturnLegs/QuantityRollforward.cs: 纯函数, 不依赖任何实例状态 ReturnLegs 测试: 8个(无交易/开仓/平仓/全平/超额/送股/拆股) SwapEodPositionService: inline 2行 → QuantityRollforward.Calc() 1行 验证: 编译0错误, 全量497测试7失败(基线一致)。 --- .../ReturnLegs/QuantityRollforwardTest.cs | 63 +++++++++++++++++++ .../ReturnLegs/QuantityRollforward.cs | 27 ++++++++ .../SwapModule/SwapEodPositionService.cs | 4 +- 3 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 UnitTestProject/Modules/SwapModule/ReturnLegs/QuantityRollforwardTest.cs create mode 100644 YLErpDAL/Modules/SwapModule/ReturnLegs/QuantityRollforward.cs diff --git a/UnitTestProject/Modules/SwapModule/ReturnLegs/QuantityRollforwardTest.cs b/UnitTestProject/Modules/SwapModule/ReturnLegs/QuantityRollforwardTest.cs new file mode 100644 index 00000000..7274b2c3 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/ReturnLegs/QuantityRollforwardTest.cs @@ -0,0 +1,63 @@ +using YLErp.Modules.SwapModule.ReturnLegs; + +namespace UnitTestProject.Modules.SwapModule.ReturnLegs +{ + /// + /// 标的端数量递推测试。验证开仓/平仓/公司行为场景下数量正确推进。 + /// + [TestClass] + public class QuantityRollforwardTest + { + [TestMethod] + public void 无交易_数量不变() + { + Assert.AreEqual(1000m, QuantityRollforward.Calc(1000m, 0m, 0m)); + } + + [TestMethod] + public void 开仓_数量增加() + { + Assert.AreEqual(1500m, QuantityRollforward.Calc(1000m, 500m, 0m)); + } + + [TestMethod] + public void 平仓_数量减少() + { + Assert.AreEqual(600m, QuantityRollforward.Calc(1000m, 0m, 400m)); + } + + [TestMethod] + public void 开仓加平仓_净值() + { + Assert.AreEqual(1200m, QuantityRollforward.Calc(1000m, 500m, 300m)); + } + + [TestMethod] + public void 全平_数量归零() + { + Assert.AreEqual(0m, QuantityRollforward.Calc(1000m, 0m, 1000m)); + } + + [TestMethod] + public void 超额平仓_不低于零() + { + Assert.AreEqual(0m, QuantityRollforward.Calc(1000m, 0m, 1500m)); + } + + [TestMethod] + public void 公司行为送股_数量增加_默认不影响() + { + // 默认 corpActionDeltaQty=0, 行为不变 + Assert.AreEqual(1000m, QuantityRollforward.Calc(1000m, 0m, 0m)); + // 送股 10%: 1000 × 10% = 100 + Assert.AreEqual(1100m, QuantityRollforward.Calc(1000m, 0m, 0m, corpActionDeltaQty: 100m)); + } + + [TestMethod] + public void 公司行为拆股_数量翻倍() + { + // 1拆2: 数量翻倍, corpActionDeltaQty = 当前数量 + Assert.AreEqual(2000m, QuantityRollforward.Calc(1000m, 0m, 0m, corpActionDeltaQty: 1000m)); + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/ReturnLegs/QuantityRollforward.cs b/YLErpDAL/Modules/SwapModule/ReturnLegs/QuantityRollforward.cs new file mode 100644 index 00000000..c2352db4 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/ReturnLegs/QuantityRollforward.cs @@ -0,0 +1,27 @@ +namespace YLErp.Modules.SwapModule.ReturnLegs; + +/// +/// 标的端数量递推(quantity rollforward)。 +/// +/// 逐日推进持仓数量:今日数量 = 上一日终数量 + 今日开仓 - 今日平仓 + 公司行为调整。 +/// +/// 公司行为调整(corpActionDeltaQty)默认 0,当前不影响行为; +/// 预留给公司行为改造(送股/拆股/配股)——届时填入非零值。 +/// 原代码(SwapEodPositionService:1980)只有开仓/平仓两项,硬编码了"数量只因交易变动"假设。 +/// +public static class QuantityRollforward +{ + /// + /// 计算今日持仓数量。 + /// + /// 上一日终持仓数量。 + /// 今日开仓数量。 + /// 今日平仓数量。 + /// 公司行为导致数量变动(送股/拆股),默认 0。 + /// 今日持仓数量(不低于 0)。 + public static decimal Calc(decimal previousQty, decimal openQty, decimal unwindQty, decimal corpActionDeltaQty = 0m) + { + var qty = previousQty + openQty - unwindQty + corpActionDeltaQty; + return qty < 0 ? 0 : Math.Abs(qty); + } +} diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index 776b88c4..ef3e7901 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -13,6 +13,7 @@ using YLErp.Models; using YLErp.Modules.DataProviderModule; using YLErp.Modules.EodModule; using YLErp.Modules.SwapModule.Margin; +using YLErp.Modules.SwapModule.ReturnLegs; using YLErp.QdpModule; namespace YLErp.Modules.SwapModule @@ -1977,8 +1978,7 @@ namespace YLErp.Modules.SwapModule var openFlowEvents = unwindEvents.Where(x => x.EventType == (int)SwapFlowEventTypeEnum.开仓).ToList(); decimal unwindQty = unwindFlowEvents.Sum(s => s.Quantity); decimal openQty = openFlowEvents.Sum(s => s.Quantity); - var qty = eod.PosiQuantity + openQty - unwindQty; - curretEod.PosiQuantity = qty < 0 ? 0 : Math.Abs(qty); + curretEod.PosiQuantity = QuantityRollforward.Calc(eod.PosiQuantity, openQty, unwindQty); if (unwindEvents.Count == 0) { curretEod.PosiNetPrice = position.PosiNetPrice; From 59651b802e2ef5733abcbb87b8ac4bb0673150eb Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 11 Aug 2026 10:40:17 +0800 Subject: [PATCH 18/31] =?UTF-8?q?refactor(return-leg):=20QuantityRollforwa?= =?UTF-8?q?rd=20=E6=94=B9=E5=90=8D=20QtyRollforward?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 代码里统一用 Qty 表示数量, 命名更简洁。 文件/类/测试类同步改名: Quantity→Qty。 --- ...llforwardTest.cs => QtyRollforwardTest.cs} | 23 ++++++++----------- ...antityRollforward.cs => QtyRollforward.cs} | 4 ++-- .../SwapModule/SwapEodPositionService.cs | 2 +- 3 files changed, 13 insertions(+), 16 deletions(-) rename UnitTestProject/Modules/SwapModule/ReturnLegs/{QuantityRollforwardTest.cs => QtyRollforwardTest.cs} (50%) rename YLErpDAL/Modules/SwapModule/ReturnLegs/{QuantityRollforward.cs => QtyRollforward.cs} (92%) diff --git a/UnitTestProject/Modules/SwapModule/ReturnLegs/QuantityRollforwardTest.cs b/UnitTestProject/Modules/SwapModule/ReturnLegs/QtyRollforwardTest.cs similarity index 50% rename from UnitTestProject/Modules/SwapModule/ReturnLegs/QuantityRollforwardTest.cs rename to UnitTestProject/Modules/SwapModule/ReturnLegs/QtyRollforwardTest.cs index 7274b2c3..3c423d33 100644 --- a/UnitTestProject/Modules/SwapModule/ReturnLegs/QuantityRollforwardTest.cs +++ b/UnitTestProject/Modules/SwapModule/ReturnLegs/QtyRollforwardTest.cs @@ -6,58 +6,55 @@ namespace UnitTestProject.Modules.SwapModule.ReturnLegs /// 标的端数量递推测试。验证开仓/平仓/公司行为场景下数量正确推进。 /// [TestClass] - public class QuantityRollforwardTest + public class QtyRollforwardTest { [TestMethod] public void 无交易_数量不变() { - Assert.AreEqual(1000m, QuantityRollforward.Calc(1000m, 0m, 0m)); + Assert.AreEqual(1000m, QtyRollforward.Calc(1000m, 0m, 0m)); } [TestMethod] public void 开仓_数量增加() { - Assert.AreEqual(1500m, QuantityRollforward.Calc(1000m, 500m, 0m)); + Assert.AreEqual(1500m, QtyRollforward.Calc(1000m, 500m, 0m)); } [TestMethod] public void 平仓_数量减少() { - Assert.AreEqual(600m, QuantityRollforward.Calc(1000m, 0m, 400m)); + Assert.AreEqual(600m, QtyRollforward.Calc(1000m, 0m, 400m)); } [TestMethod] public void 开仓加平仓_净值() { - Assert.AreEqual(1200m, QuantityRollforward.Calc(1000m, 500m, 300m)); + Assert.AreEqual(1200m, QtyRollforward.Calc(1000m, 500m, 300m)); } [TestMethod] public void 全平_数量归零() { - Assert.AreEqual(0m, QuantityRollforward.Calc(1000m, 0m, 1000m)); + Assert.AreEqual(0m, QtyRollforward.Calc(1000m, 0m, 1000m)); } [TestMethod] public void 超额平仓_不低于零() { - Assert.AreEqual(0m, QuantityRollforward.Calc(1000m, 0m, 1500m)); + Assert.AreEqual(0m, QtyRollforward.Calc(1000m, 0m, 1500m)); } [TestMethod] public void 公司行为送股_数量增加_默认不影响() { - // 默认 corpActionDeltaQty=0, 行为不变 - Assert.AreEqual(1000m, QuantityRollforward.Calc(1000m, 0m, 0m)); - // 送股 10%: 1000 × 10% = 100 - Assert.AreEqual(1100m, QuantityRollforward.Calc(1000m, 0m, 0m, corpActionDeltaQty: 100m)); + Assert.AreEqual(1000m, QtyRollforward.Calc(1000m, 0m, 0m)); + Assert.AreEqual(1100m, QtyRollforward.Calc(1000m, 0m, 0m, corpActionDeltaQty: 100m)); } [TestMethod] public void 公司行为拆股_数量翻倍() { - // 1拆2: 数量翻倍, corpActionDeltaQty = 当前数量 - Assert.AreEqual(2000m, QuantityRollforward.Calc(1000m, 0m, 0m, corpActionDeltaQty: 1000m)); + Assert.AreEqual(2000m, QtyRollforward.Calc(1000m, 0m, 0m, corpActionDeltaQty: 1000m)); } } } diff --git a/YLErpDAL/Modules/SwapModule/ReturnLegs/QuantityRollforward.cs b/YLErpDAL/Modules/SwapModule/ReturnLegs/QtyRollforward.cs similarity index 92% rename from YLErpDAL/Modules/SwapModule/ReturnLegs/QuantityRollforward.cs rename to YLErpDAL/Modules/SwapModule/ReturnLegs/QtyRollforward.cs index c2352db4..990a265f 100644 --- a/YLErpDAL/Modules/SwapModule/ReturnLegs/QuantityRollforward.cs +++ b/YLErpDAL/Modules/SwapModule/ReturnLegs/QtyRollforward.cs @@ -1,7 +1,7 @@ namespace YLErp.Modules.SwapModule.ReturnLegs; /// -/// 标的端数量递推(quantity rollforward)。 +/// 标的端数量递推(Qty Rollforward)。 /// /// 逐日推进持仓数量:今日数量 = 上一日终数量 + 今日开仓 - 今日平仓 + 公司行为调整。 /// @@ -9,7 +9,7 @@ namespace YLErp.Modules.SwapModule.ReturnLegs; /// 预留给公司行为改造(送股/拆股/配股)——届时填入非零值。 /// 原代码(SwapEodPositionService:1980)只有开仓/平仓两项,硬编码了"数量只因交易变动"假设。 /// -public static class QuantityRollforward +public static class QtyRollforward { /// /// 计算今日持仓数量。 diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index ef3e7901..02cf5a9f 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -1978,7 +1978,7 @@ namespace YLErp.Modules.SwapModule var openFlowEvents = unwindEvents.Where(x => x.EventType == (int)SwapFlowEventTypeEnum.开仓).ToList(); decimal unwindQty = unwindFlowEvents.Sum(s => s.Quantity); decimal openQty = openFlowEvents.Sum(s => s.Quantity); - curretEod.PosiQuantity = QuantityRollforward.Calc(eod.PosiQuantity, openQty, unwindQty); + curretEod.PosiQuantity = QtyRollforward.Calc(eod.PosiQuantity, openQty, unwindQty); if (unwindEvents.Count == 0) { curretEod.PosiNetPrice = position.PosiNetPrice; From a26f848287cf8ab26462f10bc17567e329e17b54 Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 11 Aug 2026 10:45:43 +0800 Subject: [PATCH 19/31] =?UTF-8?q?refactor(return-leg):=20=E6=8A=BD?= =?UTF-8?q?=E5=8F=96=20MtmCalc=20=E6=A0=87=E7=9A=84=E7=AB=AF=E7=9B=AF?= =?UTF-8?q?=E5=B8=82=E8=AE=A1=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 SwapEodPositionService 4处重复的 UnderlyingMarketValue 公式和 4处 PosiMtmPnL 公式抽成 MtmCalc 纯函数: - MarketValue(price, qty, contractSize, shortRatio): 标的市值 - UnrealizedPnl(price, costGrossPrice, qty, contractSize, shortRatio, ratio): 盯市未实现盈亏 原代码4处副本(1731/1813/1898/2101 + 1741/1814/1899/2102)全部替换。 验证: 编译0错误, 全量503测试7失败(基线一致)。 --- .../SwapModule/ReturnLegs/MtmCalcTest.cs | 50 +++++++++++++++++++ .../Modules/SwapModule/ReturnLegs/MtmCalc.cs | 20 ++++++++ .../SwapModule/SwapEodPositionService.cs | 16 +++--- 3 files changed, 78 insertions(+), 8 deletions(-) create mode 100644 UnitTestProject/Modules/SwapModule/ReturnLegs/MtmCalcTest.cs create mode 100644 YLErpDAL/Modules/SwapModule/ReturnLegs/MtmCalc.cs diff --git a/UnitTestProject/Modules/SwapModule/ReturnLegs/MtmCalcTest.cs b/UnitTestProject/Modules/SwapModule/ReturnLegs/MtmCalcTest.cs new file mode 100644 index 00000000..d5ee1d62 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/ReturnLegs/MtmCalcTest.cs @@ -0,0 +1,50 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using YLErp.Modules.SwapModule.ReturnLegs; + +namespace UnitTestProject.Modules.SwapModule.ReturnLegs +{ + [TestClass] + public class MtmCalcTest + { + [TestMethod] + public void MarketValue_多头_为正() + { + // 标的价100 × 1000张 × 乘数1 × 多头(+1) = 100000 + Assert.AreEqual(100_000m, MtmCalc.MarketValue(100m, 1000m, 1m, 1)); + } + + [TestMethod] + public void MarketValue_空头_为负() + { + // 空头 shortRatio=-1 + Assert.AreEqual(-100_000m, MtmCalc.MarketValue(100m, 1000m, 1m, -1)); + } + + [TestMethod] + public void MarketValue_合约乘数10() + { + Assert.AreEqual(1_000_000m, MtmCalc.MarketValue(100m, 1000m, 10m, 1)); + } + + [TestMethod] + public void UnrealizedPnl_多头浮盈() + { + // (105-100) × 1000 × 1 × 1 × 1 = 5000 + Assert.AreEqual(5000m, MtmCalc.UnrealizedPnl(105m, 100m, 1000m, 1m, 1, 1m)); + } + + [TestMethod] + public void UnrealizedPnl_多头浮亏() + { + // (95-100) × 1000 × 1 × 1 × 1 = -5000 + Assert.AreEqual(-5000m, MtmCalc.UnrealizedPnl(95m, 100m, 1000m, 1m, 1, 1m)); + } + + [TestMethod] + public void UnrealizedPnl_空头反向() + { + // 空头: 价格跌=盈利 (95-100) × 1000 × 1 × (-1) × 1 = 5000 + Assert.AreEqual(5000m, MtmCalc.UnrealizedPnl(95m, 100m, 1000m, 1m, -1, 1m)); + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/ReturnLegs/MtmCalc.cs b/YLErpDAL/Modules/SwapModule/ReturnLegs/MtmCalc.cs new file mode 100644 index 00000000..482fbe90 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/ReturnLegs/MtmCalc.cs @@ -0,0 +1,20 @@ +namespace YLErp.Modules.SwapModule.ReturnLegs; + +/// +/// 标的端盯市(Mark-to-Market)计算。 +/// +/// UnderlyingMarketValue = 标的价 × 数量 × 合约乘数 × 多空方向 +/// PosiMtmPnL = (标的价 - 成本全价) × 数量 × 合约乘数 × 多空方向 × 收付方向 +/// +/// 原代码在 SwapEodPositionService 4处重复 UnderlyingMarketValue 公式(1731/1813/1898/2101)。 +/// +public static class MtmCalc +{ + /// 标的市值。多头为正、空头为负。 + public static decimal MarketValue(decimal price, decimal qty, decimal contractSize, int shortRatio) + => price * qty * contractSize * shortRatio; + + /// 盯市未实现盈亏 = (标的价 - 成本全价) × 数量 × 合约乘数 × 多空 × 收付。 + public static decimal UnrealizedPnl(decimal price, decimal costGrossPrice, decimal qty, decimal contractSize, int shortRatio, decimal ratio) + => (price - costGrossPrice) * qty * contractSize * shortRatio * ratio; +} diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index 02cf5a9f..af4a85bc 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -1728,7 +1728,7 @@ namespace YLErp.Modules.SwapModule //浮动端估值用信息 newEodPayPosition.UnderlyingPrice = UnderlyingCodePrice(newEodPayPosition.UnderlyingCode, eventFlow.EventDate, out decimal vobp); newEodPayPosition.dv01 = Dv01Helper.CalcDv01(newEodPayPosition.UnderlyingCode, newEodPayPosition.PosiQuantity, newEodPayPosition.PosiDirection, newEodPayPosition.PositionType, vobp); - newEodPayPosition.UnderlyingMarketValue = newEodPayPosition.UnderlyingPrice * newEodPayPosition.PosiQuantity * newEodPayPosition.ContractSize * shortRatio; + newEodPayPosition.UnderlyingMarketValue = MtmCalc.MarketValue(newEodPayPosition.UnderlyingPrice, newEodPayPosition.PosiQuantity, newEodPayPosition.ContractSize, shortRatio); //当日已实现 newEodPayPosition.TdCloseQty = closeQty; newEodPayPosition.TdChangedQty = 0; @@ -1738,7 +1738,7 @@ namespace YLErp.Modules.SwapModule //持仓内容-浮动收益腿-损益统计(本方视角 newEodPayPosition.TdPosiDividend = Math.Round(dividendIn * ratio, 2); - newEodPayPosition.PosiMtmPnL = (newEodPayPosition.UnderlyingPrice - newEodPayPosition.PosiGrossPrice) * newEodPayPosition.PosiQuantity * newEodPayPosition.ContractSize * shortRatio * ratio; + newEodPayPosition.PosiMtmPnL = MtmCalc.UnrealizedPnl(newEodPayPosition.UnderlyingPrice, newEodPayPosition.PosiGrossPrice, newEodPayPosition.PosiQuantity, newEodPayPosition.ContractSize, shortRatio, ratio); newEodPayPosition.PosiDividendSum = Math.Round(newEodPayPosition.TdPosiDividend - newEodPayPosition.TdCloseDividend, 2); newEodPayPosition.PosiProfitSum = newEodPayPosition.PosiMtmPnL + newEodPayPosition.PosiDividendSum + newEodPayPosition.PosiFeePending; @@ -1810,8 +1810,8 @@ namespace YLErp.Modules.SwapModule curretEod.PosiNotionalValue = 0; } curretEod.UnderlyingPrice = price; - curretEod.UnderlyingMarketValue = curretEod.UnderlyingPrice * curretEod.PosiQuantity * curretEod.ContractSize * shortRatio; - curretEod.PosiMtmPnL = (curretEod.UnderlyingPrice - curretEod.PosiGrossPrice) * curretEod.PosiQuantity * curretEod.ContractSize * shortRatio * directionRatio; + curretEod.UnderlyingMarketValue = MtmCalc.MarketValue(curretEod.UnderlyingPrice, curretEod.PosiQuantity, curretEod.ContractSize, shortRatio); + curretEod.PosiMtmPnL = MtmCalc.UnrealizedPnl(curretEod.UnderlyingPrice, curretEod.PosiGrossPrice, curretEod.PosiQuantity, curretEod.ContractSize, shortRatio, directionRatio); //curretEod.TdPosiDividend = 0; //curretEod.PosiDividendSum = eod.PosiDividendSum + curretEod.TdPosiDividend; curretEod.PosiProfitSum = curretEod.PosiMtmPnL + curretEod.PosiDividendSum + curretEod.PosiFeePending; @@ -1895,8 +1895,8 @@ namespace YLErp.Modules.SwapModule SetPriceInfoByFlowEvent(eod, curretEod, unwindEvents, swapPosition); curretEod.dv01 = Dv01Helper.CalcDv01(eod.UnderlyingCode, curretEod.PosiQuantity, eod.PosiDirection, eod.PositionType, vobp); curretEod.UnderlyingPrice = price; - curretEod.UnderlyingMarketValue = curretEod.UnderlyingPrice * curretEod.PosiQuantity * curretEod.ContractSize * shortRatio; - curretEod.PosiMtmPnL = (curretEod.UnderlyingPrice - curretEod.PosiGrossPrice) * curretEod.PosiQuantity * curretEod.ContractSize * shortRatio * directionRatio; + curretEod.UnderlyingMarketValue = MtmCalc.MarketValue(curretEod.UnderlyingPrice, curretEod.PosiQuantity, curretEod.ContractSize, shortRatio); + curretEod.PosiMtmPnL = MtmCalc.UnrealizedPnl(curretEod.UnderlyingPrice, curretEod.PosiGrossPrice, curretEod.PosiQuantity, curretEod.ContractSize, shortRatio, directionRatio); curretEod.TdPosiDividend = 0; // 分红与互换无关,只要持仓>0且起始日早于当前日,正常计算当日分红 // 修改,互换事件会影响待实现的分红的,现在要算上 @@ -2098,8 +2098,8 @@ namespace YLErp.Modules.SwapModule curretEod.PosiDividendSum = payment; } - curretEod.UnderlyingMarketValue = curretEod.UnderlyingPrice * curretEod.PosiQuantity * curretEod.ContractSize * shortRatio; - curretEod.PosiMtmPnL = (curretEod.UnderlyingPrice - curretEod.PosiGrossPrice) * curretEod.PosiQuantity * curretEod.ContractSize * shortRatio * directionRatio; + curretEod.UnderlyingMarketValue = MtmCalc.MarketValue(curretEod.UnderlyingPrice, curretEod.PosiQuantity, curretEod.ContractSize, shortRatio); + curretEod.PosiMtmPnL = MtmCalc.UnrealizedPnl(curretEod.UnderlyingPrice, curretEod.PosiGrossPrice, curretEod.PosiQuantity, curretEod.ContractSize, shortRatio, directionRatio); curretEod.PosiProfitSum = curretEod.PosiMtmPnL + curretEod.PosiDividendSum + curretEod.PosiFeePending; curretEod.RealizedMtmPnL = curretEod.TdCloseMtmPnl; curretEod.RealizedDividend = curretEod.TdCloseDividend; From 946cbbb66d42f4c1691ade35478f8b82a910ac79 Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 11 Aug 2026 10:52:04 +0800 Subject: [PATCH 20/31] =?UTF-8?q?refactor:=20InterestLeg=20=E6=94=B9?= =?UTF-8?q?=E5=90=8D=20FundingLeg(=E4=B8=9A=E7=95=8C=E6=A0=87=E5=87=86=20F?= =?UTF-8?q?inancing=20Leg)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 命名: InterestLeg→FundingLeg, 消除'interest'与通用'利息'歧义。 Funding精确表达'融资成本'(客户付券商的杠杆成本 spread+FR007)。 改名清单: - IInterestLegStrategy → IFundingLegStrategy - InterestLegStrategyFactory → FundingLegStrategyFactory - InterestLegStrategyTest → FundingLegStrategyTest - 命名空间 InterestLegs → FundingLegs - 目录 InterestLegs/ → FundingLegs/ - SwapDealService 引用同步更新 验证: 编译0错误, 全量503测试7失败(基线一致)。 --- .../FundingLegStrategyTest.cs} | 63 +++++++++---------- .../ContractNotionalLeg.cs | 6 +- .../FixedNotionalLeg.cs | 7 ++- .../FundingLegStrategyFactory.cs} | 16 ++--- .../IFundingLegStrategy.cs} | 52 ++++++++------- .../UnderlyingFullPriceLeg.cs | 6 +- .../Modules/SwapModule/SwapDealService.cs | 6 +- 7 files changed, 78 insertions(+), 78 deletions(-) rename UnitTestProject/Modules/SwapModule/{InterestLegs/InterestLegStrategyTest.cs => FundingLegs/FundingLegStrategyTest.cs} (58%) rename YLErpDAL/Modules/SwapModule/{InterestLegs => FundingLegs}/ContractNotionalLeg.cs (76%) rename YLErpDAL/Modules/SwapModule/{InterestLegs => FundingLegs}/FixedNotionalLeg.cs (69%) rename YLErpDAL/Modules/SwapModule/{InterestLegs/InterestLegStrategyFactory.cs => FundingLegs/FundingLegStrategyFactory.cs} (61%) rename YLErpDAL/Modules/SwapModule/{InterestLegs/IInterestLegStrategy.cs => FundingLegs/IFundingLegStrategy.cs} (78%) rename YLErpDAL/Modules/SwapModule/{InterestLegs => FundingLegs}/UnderlyingFullPriceLeg.cs (80%) diff --git a/UnitTestProject/Modules/SwapModule/InterestLegs/InterestLegStrategyTest.cs b/UnitTestProject/Modules/SwapModule/FundingLegs/FundingLegStrategyTest.cs similarity index 58% rename from UnitTestProject/Modules/SwapModule/InterestLegs/InterestLegStrategyTest.cs rename to UnitTestProject/Modules/SwapModule/FundingLegs/FundingLegStrategyTest.cs index 995f3d59..cae1d332 100644 --- a/UnitTestProject/Modules/SwapModule/InterestLegs/InterestLegStrategyTest.cs +++ b/UnitTestProject/Modules/SwapModule/FundingLegs/FundingLegStrategyTest.cs @@ -1,24 +1,23 @@ using System; using Microsoft.VisualStudio.TestTools.UnitTesting; using YLErp.DBModels.Enums; -using YLErp.Modules.SwapModule.InterestLegs; +using YLErp.Modules.SwapModule.FundingLegs; -namespace UnitTestProject.Modules.SwapModule.InterestLegs +namespace UnitTestProject.Modules.SwapModule.FundingLegs { /// - /// 利息腿策略单测。验证每个策略的 CalcNotional 与现有 CalcNotionalByMode switch 完全一致。 + /// 融资腿策略单测。验证每个策略的 CalcNotional 与现有 CalcNotionalByMode switch 完全一致。 /// 这组测试是后续"迁移调用点"的安全网——迁移前后行为必须不变。 - /// 注:原预付金(PrepayLeg)用例已迁出,见 MarginLegTest(保证金独立限界上下文)。 /// [TestClass] - public class InterestLegStrategyTest + public class FundingLegStrategyTest { - private const decimal Fix = 2_000_000m; // 合约固定本金 - private const decimal Notional = 100_000_000m; // 剩余名义本金 1 亿 + private const decimal Fix = 2_000_000m; + private const decimal Notional = 100_000_000m; private const decimal LongNotional = 60_000_000m; private const decimal ShortNotional = 40_000_000m; - #region 固定值(mode 1):恒=Fix,不随比例变 + #region 固定值(mode 1) [TestMethod] public void 固定值_部分平仓_计息基数恒等于Fix() @@ -28,7 +27,7 @@ namespace UnitTestProject.Modules.SwapModule.InterestLegs Assert.AreEqual(Fix, r.ClosePrincipal, "平仓本金恒=Fix"); Assert.AreEqual(Fix, r.PosiPrincipal, "持仓本金恒=Fix"); - Assert.AreEqual(1m, r.ClosePercent, "有效比例恒=1(固定值不随比例缩放)"); + Assert.AreEqual(1m, r.ClosePercent); } [TestMethod] @@ -36,13 +35,12 @@ namespace UnitTestProject.Modules.SwapModule.InterestLegs { var leg = new FixedNotionalLeg(); var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 1m); - - Assert.AreEqual(Fix, r.ClosePrincipal, "全平本金仍=Fix"); + Assert.AreEqual(Fix, r.ClosePrincipal); } #endregion - #region 合约名义本金规模(mode 2):按比例线性缩放 + #region 合约名义本金规模(mode 2) [TestMethod] public void 合约名义本金_部分平仓_本金按比例缩放() @@ -50,9 +48,9 @@ namespace UnitTestProject.Modules.SwapModule.InterestLegs var leg = new ContractNotionalLeg(); var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0.5m); - Assert.AreEqual(50_000_000m, r.ClosePrincipal, "平仓本金=Notional×50%"); - Assert.AreEqual(Notional, r.PosiPrincipal, "持仓本金=Notional全额"); - Assert.AreEqual(0.5m, r.ClosePercent, "有效比例=入参"); + Assert.AreEqual(50_000_000m, r.ClosePrincipal); + Assert.AreEqual(Notional, r.PosiPrincipal); + Assert.AreEqual(0.5m, r.ClosePercent); } [TestMethod] @@ -60,8 +58,7 @@ namespace UnitTestProject.Modules.SwapModule.InterestLegs { var leg = new ContractNotionalLeg(); var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 1m); - - Assert.AreEqual(Notional, r.ClosePrincipal, "全平本金=Notional"); + Assert.AreEqual(Notional, r.ClosePrincipal); } [TestMethod] @@ -70,13 +67,13 @@ namespace UnitTestProject.Modules.SwapModule.InterestLegs var leg = new ContractNotionalLeg(); var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0m); - Assert.AreEqual(0m, r.ClosePrincipal, "零平仓本金=0"); - Assert.AreEqual(Notional, r.PosiPrincipal, "持仓本金仍=Notional"); + Assert.AreEqual(0m, r.ClosePrincipal); + Assert.AreEqual(Notional, r.PosiPrincipal); } #endregion - #region 标的期初全价(mode 9):主路径公式与mode2相同 + #region 标的期初全价(mode 9) [TestMethod] public void 标的期初全价_部分平仓_主路径公式同mode2() @@ -84,7 +81,7 @@ namespace UnitTestProject.Modules.SwapModule.InterestLegs var leg = new UnderlyingFullPriceLeg(); var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0.5m); - Assert.AreEqual(50_000_000m, r.ClosePrincipal, "平仓本金=Notional×50%(与mode2主路径一致)"); + Assert.AreEqual(50_000_000m, r.ClosePrincipal); Assert.AreEqual(Notional, r.PosiPrincipal); Assert.AreEqual(0.5m, r.ClosePercent); } @@ -94,13 +91,12 @@ namespace UnitTestProject.Modules.SwapModule.InterestLegs { var leg = new UnderlyingFullPriceLeg(); var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 1m); - Assert.AreEqual(Notional, r.ClosePrincipal); } #endregion - #region 守卫:策略对应各自mode + #region 守卫 [TestMethod] public void 各策略对应正确枚举值() @@ -112,35 +108,34 @@ namespace UnitTestProject.Modules.SwapModule.InterestLegs #endregion - #region 工厂:按mode分发 + #region 工厂 [TestMethod] public void 工厂_返回各活跃mode的策略() { - Assert.IsInstanceOfType(InterestLegStrategyFactory.Get(InterestModeEnum.固定值), typeof(FixedNotionalLeg)); - Assert.IsInstanceOfType(InterestLegStrategyFactory.Get(InterestModeEnum.合约名义本金规模), typeof(ContractNotionalLeg)); - Assert.IsInstanceOfType(InterestLegStrategyFactory.Get(InterestModeEnum.标的期初全价), typeof(UnderlyingFullPriceLeg)); + Assert.IsInstanceOfType(FundingLegStrategyFactory.Get(InterestModeEnum.固定值), typeof(FixedNotionalLeg)); + Assert.IsInstanceOfType(FundingLegStrategyFactory.Get(InterestModeEnum.合约名义本金规模), typeof(ContractNotionalLeg)); + Assert.IsInstanceOfType(FundingLegStrategyFactory.Get(InterestModeEnum.标的期初全价), typeof(UnderlyingFullPriceLeg)); } [TestMethod] public void 工厂_未注册mode抛异常() { Assert.ThrowsException(() => - InterestLegStrategyFactory.Get(InterestModeEnum.持仓名义本金)); + FundingLegStrategyFactory.Get(InterestModeEnum.持仓名义本金)); Assert.ThrowsException(() => - InterestLegStrategyFactory.Get(InterestModeEnum.持仓市值)); - // 预付金 5/6 已不再是利息腿,工厂不再注册,按未注册处理 + FundingLegStrategyFactory.Get(InterestModeEnum.持仓市值)); Assert.ThrowsException(() => - InterestLegStrategyFactory.Get(InterestModeEnum.初始预付金)); + FundingLegStrategyFactory.Get(InterestModeEnum.初始预付金)); Assert.ThrowsException(() => - InterestLegStrategyFactory.Get(InterestModeEnum.追加预付金)); + FundingLegStrategyFactory.Get(InterestModeEnum.追加预付金)); } [TestMethod] public void 工厂_int重载和枚举重载等价() { - var byEnum = InterestLegStrategyFactory.Get(InterestModeEnum.固定值); - var byInt = InterestLegStrategyFactory.Get((int)InterestModeEnum.固定值); + var byEnum = FundingLegStrategyFactory.Get(InterestModeEnum.固定值); + var byInt = FundingLegStrategyFactory.Get((int)InterestModeEnum.固定值); Assert.AreEqual(byEnum.Mode, byInt.Mode); } diff --git a/YLErpDAL/Modules/SwapModule/InterestLegs/ContractNotionalLeg.cs b/YLErpDAL/Modules/SwapModule/FundingLegs/ContractNotionalLeg.cs similarity index 76% rename from YLErpDAL/Modules/SwapModule/InterestLegs/ContractNotionalLeg.cs rename to YLErpDAL/Modules/SwapModule/FundingLegs/ContractNotionalLeg.cs index c2dff484..f1d62dd0 100644 --- a/YLErpDAL/Modules/SwapModule/InterestLegs/ContractNotionalLeg.cs +++ b/YLErpDAL/Modules/SwapModule/FundingLegs/ContractNotionalLeg.cs @@ -1,13 +1,13 @@ using YLErp.DBModels; -namespace YLErp.Modules.SwapModule.InterestLegs; +namespace YLErp.Modules.SwapModule.FundingLegs; /// -/// 合约名义本金规模利息腿(InterestMode=合约名义本金规模)。 +/// 合约名义本金规模融资腿(InterestMode=合约名义本金规模)。 /// 站在"合约规模"视角:平仓本金 = 剩余名义本金 × 平仓比例。 /// 与标的期初全价(9)在 CalcNotionalByMode 里公式相同,差异在衡泰路径 grossPrice 折算和 EOD 复利反推。 /// -public sealed class ContractNotionalLeg : IInterestLegStrategy +public sealed class ContractNotionalLeg : IFundingLegStrategy { public InterestModeEnum Mode => InterestModeEnum.合约名义本金规模; diff --git a/YLErpDAL/Modules/SwapModule/InterestLegs/FixedNotionalLeg.cs b/YLErpDAL/Modules/SwapModule/FundingLegs/FixedNotionalLeg.cs similarity index 69% rename from YLErpDAL/Modules/SwapModule/InterestLegs/FixedNotionalLeg.cs rename to YLErpDAL/Modules/SwapModule/FundingLegs/FixedNotionalLeg.cs index c77c8203..fd85793c 100644 --- a/YLErpDAL/Modules/SwapModule/InterestLegs/FixedNotionalLeg.cs +++ b/YLErpDAL/Modules/SwapModule/FundingLegs/FixedNotionalLeg.cs @@ -1,13 +1,14 @@ using YLErp.DBModels; +using YLErp.Modules.SwapModule.FundingLegs; -namespace YLErp.Modules.SwapModule.InterestLegs; +namespace YLErp.Modules.SwapModule.FundingLegs; /// -/// 固定值利息腿(InterestMode=固定值)。 +/// 固定值融资腿(InterestMode=固定值)。 /// 计息基数由合约约定(InterestPrincipalFix),无论平仓比例多少都恒等于该值。 /// 业务规则:固定值就是合同写死的固定值,永远不变。 /// -public sealed class FixedNotionalLeg : IInterestLegStrategy +public sealed class FixedNotionalLeg : IFundingLegStrategy { public InterestModeEnum Mode => InterestModeEnum.固定值; diff --git a/YLErpDAL/Modules/SwapModule/InterestLegs/InterestLegStrategyFactory.cs b/YLErpDAL/Modules/SwapModule/FundingLegs/FundingLegStrategyFactory.cs similarity index 61% rename from YLErpDAL/Modules/SwapModule/InterestLegs/InterestLegStrategyFactory.cs rename to YLErpDAL/Modules/SwapModule/FundingLegs/FundingLegStrategyFactory.cs index 0826023e..a909ec5e 100644 --- a/YLErpDAL/Modules/SwapModule/InterestLegs/InterestLegStrategyFactory.cs +++ b/YLErpDAL/Modules/SwapModule/FundingLegs/FundingLegStrategyFactory.cs @@ -2,20 +2,20 @@ using System; using System.Collections.Generic; using YLErp.DBModels; -namespace YLErp.Modules.SwapModule.InterestLegs; +namespace YLErp.Modules.SwapModule.FundingLegs; /// -/// 利息腿策略工厂。按 InterestMode 返回对应策略实例。 +/// 融资腿策略工厂。按 InterestMode 返回对应策略实例。 /// 替换原 SwapDealService.CalcNotionalByMode 的 switch,收敛 mode 分发逻辑到一处。 /// -/// 当前界面活跃 mode:1 固定值 / 2 合约名义本金规模 / 9 标的期初全价。 -/// 注:mode 5/6 预付金已迁出本上下文(见 YLErp.Modules.SwapModule.Margin / MarginLeg),不再作为利息腿注册。 +/// 只管融资腿(funding leg),不管保证金——保证金是独立的资金管理体系, +/// 不应该出现在融资腿策略里。mode 5/6(预付金)属于保证金,不在此注册。 /// 死代码 mode 3/4 不注册;半死 mode 7/8 不注册(界面已注释)。 /// 传入未注册的 mode 会抛异常,防止静默走默认分支。 /// -public static class InterestLegStrategyFactory +public static class FundingLegStrategyFactory { - private static readonly Dictionary _strategies = new() + private static readonly Dictionary _strategies = new() { [InterestModeEnum.固定值] = new FixedNotionalLeg(), [InterestModeEnum.合约名义本金规模] = new ContractNotionalLeg(), @@ -23,7 +23,7 @@ public static class InterestLegStrategyFactory }; /// 按 mode 返回对应策略。未注册的 mode 抛 ArgumentException。 - public static IInterestLegStrategy Get(InterestModeEnum mode) + public static IFundingLegStrategy Get(InterestModeEnum mode) { if (_strategies.TryGetValue(mode, out var strategy)) return strategy; @@ -31,5 +31,5 @@ public static class InterestLegStrategyFactory } /// 按 mode 值返回对应策略,便于调用方直接传 int。 - public static IInterestLegStrategy Get(int mode) => Get((InterestModeEnum)mode); + public static IFundingLegStrategy Get(int mode) => Get((InterestModeEnum)mode); } diff --git a/YLErpDAL/Modules/SwapModule/InterestLegs/IInterestLegStrategy.cs b/YLErpDAL/Modules/SwapModule/FundingLegs/IFundingLegStrategy.cs similarity index 78% rename from YLErpDAL/Modules/SwapModule/InterestLegs/IInterestLegStrategy.cs rename to YLErpDAL/Modules/SwapModule/FundingLegs/IFundingLegStrategy.cs index b3e640b2..2d29f8c0 100644 --- a/YLErpDAL/Modules/SwapModule/InterestLegs/IInterestLegStrategy.cs +++ b/YLErpDAL/Modules/SwapModule/FundingLegs/IFundingLegStrategy.cs @@ -1,9 +1,35 @@ using YLErp.DBModels; -namespace YLErp.Modules.SwapModule.InterestLegs; +namespace YLErp.Modules.SwapModule.FundingLegs; /// -/// 计息腿策略的计算结果。对应原 CalcNotionalByMode 返回的三元组,用自描述名。 +/// 融资腿(Funding Leg)计息基数策略。每个 InterestMode 一个实现。 +/// +/// 命名说明:用 FundingLeg(业界标准 Financing/Funding Leg),不用 InterestLeg—— +/// "interest"易和通用"利息"混淆;funding 精确表达"融资成本"。 +/// 融资腿 = 客户付给券商的杠杆成本(spread + FR007),与保证金(Margin)、标的端(ReturnLeg)各自独立。 +/// +/// 职责单一:给定持仓参数与平仓比例,算出"平仓本金 / 存续本金 / 有效比例"。 +/// 不做计息(计息由 SwapInterest 纯函数完成),不取价(取价由 IIndexFixer 完成)。 +/// +public interface IFundingLegStrategy +{ + /// 该策略对应的计息模式。 + InterestModeEnum Mode { get; } + + /// + /// 根据持仓参数与平仓比例计算计息本金三元组。 + /// + /// 合约固定本金(固定值/预付金腿用;其余腿忽略)。 + /// 当前剩余名义本金(数量 × 全价)。 + /// 多头剩余名义本金(多空存续腿用,当前界面已禁用)。 + /// 空头剩余名义本金。 + /// 平仓比例(占剩余,0~1)。 + NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal posiLong, decimal posiShort, decimal closePercent); +} + +/// +/// 融资腿策略的计算结果。对应原 CalcNotionalByMode 返回的三元组,用自描述名。 /// public readonly struct NotionalResult { @@ -22,25 +48,3 @@ public readonly struct NotionalResult public void Deconstruct(out decimal close, out decimal posi, out decimal pct) => (close, posi, pct) = (ClosePrincipal, PosiPrincipal, ClosePercent); } - -/// -/// 利息腿计息基数策略。每个 InterestMode 一个实现,替换原 CalcNotionalByMode 的 switch。 -/// -/// 职责单一:给定持仓参数与平仓比例,算出"平仓本金 / 存续本金 / 有效比例"。 -/// 不做计息(计息由 SwapInterest 纯函数完成),不取价(取价由 IIndexFixer 完成)。 -/// -public interface IInterestLegStrategy -{ - /// 该策略对应的计息模式。 - InterestModeEnum Mode { get; } - - /// - /// 根据持仓参数与平仓比例计算计息本金三元组。 - /// - /// 合约固定本金(固定值/预付金腿用;其余腿忽略)。 - /// 当前剩余名义本金(数量 × 全价)。 - /// 多头剩余名义本金(多空存续腿用,当前界面已禁用)。 - /// 空头剩余名义本金。 - /// 平仓比例(占剩余,0~1)。 - NotionalResult CalcNotional(decimal fix, decimal posiNotional, decimal posiLong, decimal posiShort, decimal closePercent); -} diff --git a/YLErpDAL/Modules/SwapModule/InterestLegs/UnderlyingFullPriceLeg.cs b/YLErpDAL/Modules/SwapModule/FundingLegs/UnderlyingFullPriceLeg.cs similarity index 80% rename from YLErpDAL/Modules/SwapModule/InterestLegs/UnderlyingFullPriceLeg.cs rename to YLErpDAL/Modules/SwapModule/FundingLegs/UnderlyingFullPriceLeg.cs index 486695c5..725717fd 100644 --- a/YLErpDAL/Modules/SwapModule/InterestLegs/UnderlyingFullPriceLeg.cs +++ b/YLErpDAL/Modules/SwapModule/FundingLegs/UnderlyingFullPriceLeg.cs @@ -1,15 +1,15 @@ using YLErp.DBModels; -namespace YLErp.Modules.SwapModule.InterestLegs; +namespace YLErp.Modules.SwapModule.FundingLegs; /// -/// 标的期初全价利息腿(InterestMode=标的期初全价)。 +/// 标的期初全价融资腿(InterestMode=标的期初全价)。 /// 站在"持仓全价"视角:计息基数 = 标的含费全价(PosiGrossPrice/EntryDirtyPrice) × 数量。 /// 主路径 CalcNotionalByMode 公式与合约名义本金规模(2)相同; /// 差异在衡泰路径会乘 grossPrice 折算(SwapDealService.GetUnwindInterestsByHT), /// 以及 EOD 复利部分平仓后直接返回剩余本金(禁止反推,SwapEodPositionService:1458-1465)。 /// -public sealed class UnderlyingFullPriceLeg : IInterestLegStrategy +public sealed class UnderlyingFullPriceLeg : IFundingLegStrategy { public InterestModeEnum Mode => InterestModeEnum.标的期初全价; diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 5476b778..152c0176 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -11,7 +11,7 @@ using YLErp.Modules.DataProviderModule; using YLErp.Modules.EodModule; using YLErp.Modules.TradeModule; using YLErp.Modules.TradeModule.DealModule; -using YLErp.Modules.SwapModule.InterestLegs; +using YLErp.Modules.SwapModule.FundingLegs; using YLErp.Modules.SwapModule.Margin; using YLErp.QdpModule; @@ -905,7 +905,7 @@ namespace YLErp.Modules.SwapModule /// /// 根据计息模式计算名义本金。 - /// 融资腿(mode 1/2/9)已委托 InterestLegStrategyFactory;保证金(mode 5/6)和 + /// 融资腿(mode 1/2/9)已委托 FundingLegStrategyFactory;保证金(mode 5/6)和 /// 半禁用的多空存续(mode 7/8)暂保留原逻辑,待 Margin 上下文接入后再迁出。 /// private (decimal close, decimal posi, decimal closePct) CalcNotionalByMode(swap_position position, decimal closePercent, decimal posiNotional, decimal posiLong, decimal posiShort) @@ -917,7 +917,7 @@ namespace YLErp.Modules.SwapModule || mode == InterestModeEnum.合约名义本金规模 || mode == InterestModeEnum.标的期初全价) { - var r = InterestLegStrategyFactory.Get(mode) + var r = FundingLegStrategyFactory.Get(mode) .CalcNotional(position.InterestPrincipalFix, posiNotional, posiLong, posiShort, closePercent); return (r.ClosePrincipal, r.PosiPrincipal, r.ClosePercent); } From 963f7a04e587c17da7734338ff5a0d1961dc9695 Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 11 Aug 2026 11:04:08 +0800 Subject: [PATCH 21/31] =?UTF-8?q?refactor(return-leg):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20DividendCalc=20=E5=88=86=E7=BA=A2=E5=A2=9E=E5=80=BC?= =?UTF-8?q?=E7=A8=8E=E5=90=8E=E8=AE=A1=E7=AE=97=20+=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20MtmCalc=20=E5=8F=82=E6=95=B0=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DividendCalc: 增值税后票息 = payment/(1+tax)*(1-tax), 原 SwapEodPositionService 4处重复此公式(1804/1894/1906/2095)。 本次先建纯函数+测试, 4处inline替换下次做。 附带修复: MtmCalc shortRatio 参数从 decimal 改为 int (原代码6处声明中5处是int, 仅1696是decimal, 统一为int)。 1696行 shortRatio 声明同步改为 int。 验证: 编译0错误, DividendCalc 4个测试全过, 全量507测试7失败(基线一致)。 --- .../SwapModule/ReturnLegs/DividendCalcTest.cs | 37 +++++++++++++++++++ .../SwapModule/ReturnLegs/DividendCalc.cs | 18 +++++++++ .../Modules/SwapModule/ReturnLegs/MtmCalc.cs | 4 +- .../SwapModule/SwapEodPositionService.cs | 2 +- 4 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 UnitTestProject/Modules/SwapModule/ReturnLegs/DividendCalcTest.cs create mode 100644 YLErpDAL/Modules/SwapModule/ReturnLegs/DividendCalc.cs diff --git a/UnitTestProject/Modules/SwapModule/ReturnLegs/DividendCalcTest.cs b/UnitTestProject/Modules/SwapModule/ReturnLegs/DividendCalcTest.cs new file mode 100644 index 00000000..20a842ad --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/ReturnLegs/DividendCalcTest.cs @@ -0,0 +1,37 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using YLErp.Modules.SwapModule.ReturnLegs; + +namespace UnitTestProject.Modules.SwapModule.ReturnLegs +{ + [TestClass] + public class DividendCalcTest + { + [TestMethod] + public void AfterTax_零税率_等于原值() + { + Assert.AreEqual(1000m, DividendCalc.AfterTax(1000m, 0m)); + } + + [TestMethod] + public void AfterTax_6pct增值税() + { + // 1000 / 1.06 * 0.94 = 886.79... + Assert.AreEqual(886.79m, DividendCalc.AfterTax(1000m, 0.06m)); + } + + [TestMethod] + public void AfterTax_负票息() + { + // -500 / 1.06 * 0.94 = -443.40 + Assert.AreEqual(-443.40m, DividendCalc.AfterTax(-500m, 0.06m)); + } + + [TestMethod] + public void AfterTaxRaw_不四舍五入() + { + var raw = DividendCalc.AfterTaxRaw(1000m, 0.06m); + Assert.AreNotEqual(886.79m, raw, "Raw 版本不四舍五入"); + Assert.IsTrue(raw > 886.79m && raw < 886.80m); + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/ReturnLegs/DividendCalc.cs b/YLErpDAL/Modules/SwapModule/ReturnLegs/DividendCalc.cs new file mode 100644 index 00000000..1b9f898f --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/ReturnLegs/DividendCalc.cs @@ -0,0 +1,18 @@ +namespace YLErp.Modules.SwapModule.ReturnLegs; + +/// +/// 标的端分红(票息)计算。 +/// +/// 增值税后票息 = 税前票息 / (1+税率) × (1-税率) +/// 原代码在 SwapEodPositionService 4处重复此公式(1804/1894/1906/2095)。 +/// +public static class DividendCalc +{ + /// 增值税后票息,四舍五入到 2 位。 + public static decimal AfterTax(decimal payment, decimal tax) + => Math.Round(payment / (1 + tax) * (1 - tax), 2); + + /// 增值税后票息(不四舍五入,供中间计算用)。 + public static decimal AfterTaxRaw(decimal payment, decimal tax) + => payment / (1 + tax) * (1 - tax); +} diff --git a/YLErpDAL/Modules/SwapModule/ReturnLegs/MtmCalc.cs b/YLErpDAL/Modules/SwapModule/ReturnLegs/MtmCalc.cs index 482fbe90..82701e11 100644 --- a/YLErpDAL/Modules/SwapModule/ReturnLegs/MtmCalc.cs +++ b/YLErpDAL/Modules/SwapModule/ReturnLegs/MtmCalc.cs @@ -10,11 +10,13 @@ namespace YLErp.Modules.SwapModule.ReturnLegs; /// public static class MtmCalc { - /// 标的市值。多头为正、空头为负。 + /// 标的市值。多头为正、空头为负。shortRatio: 多头=1, 空头=-1。 public static decimal MarketValue(decimal price, decimal qty, decimal contractSize, int shortRatio) => price * qty * contractSize * shortRatio; /// 盯市未实现盈亏 = (标的价 - 成本全价) × 数量 × 合约乘数 × 多空 × 收付。 + /// 多头=1, 空头=-1。 + /// 收取=1, 支付=-1。 public static decimal UnrealizedPnl(decimal price, decimal costGrossPrice, decimal qty, decimal contractSize, int shortRatio, decimal ratio) => (price - costGrossPrice) * qty * contractSize * shortRatio * ratio; } diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index af4a85bc..5a4b46e8 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -1693,7 +1693,7 @@ namespace YLErp.Modules.SwapModule { payQty = Math.Abs(payQty); decimal ratio = eventFlow.PayDirection == (int)SwapDirectionEnum.收取 ? 1 : -1;//收取为正,支付为负 - decimal shortRatio = newEodPayPosition.PositionType == (int)PositionTypeFlag.Long ? 1 : -1;//多空方向 + int shortRatio = newEodPayPosition.PositionType == (int)PositionTypeFlag.Long ? 1 : -1;//多空方向 newEodPayPosition.ValueDate = eventFlow.PayDate.Value; newEodPayPosition.PositionId = eventFlow.PositionId; newEodPayPosition.ClientId = td.ClientId; From 939d6a4271931190b99b93dac00d2ffd21edb459 Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 11 Aug 2026 11:05:55 +0800 Subject: [PATCH 22/31] =?UTF-8?q?fix(return-leg):=20MtmCalc.UnrealizedPnl?= =?UTF-8?q?=20ratio=20=E5=8F=82=E6=95=B0=E6=94=B9=E4=B8=BA=20int?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ratio(收取=1/支付=-1)和 shortRatio 一样是 int, 不是 decimal。 1695行 ratio 声明同步从 decimal 改为 int。 C# decimal*int 自动提升为 decimal, 不影响计算结果。 验证: 编译0错误, 全量507测试7失败(基线一致)。 --- YLErpDAL/Modules/SwapModule/ReturnLegs/MtmCalc.cs | 2 +- YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/YLErpDAL/Modules/SwapModule/ReturnLegs/MtmCalc.cs b/YLErpDAL/Modules/SwapModule/ReturnLegs/MtmCalc.cs index 82701e11..4bbb5620 100644 --- a/YLErpDAL/Modules/SwapModule/ReturnLegs/MtmCalc.cs +++ b/YLErpDAL/Modules/SwapModule/ReturnLegs/MtmCalc.cs @@ -17,6 +17,6 @@ public static class MtmCalc /// 盯市未实现盈亏 = (标的价 - 成本全价) × 数量 × 合约乘数 × 多空 × 收付。 /// 多头=1, 空头=-1。 /// 收取=1, 支付=-1。 - public static decimal UnrealizedPnl(decimal price, decimal costGrossPrice, decimal qty, decimal contractSize, int shortRatio, decimal ratio) + public static decimal UnrealizedPnl(decimal price, decimal costGrossPrice, decimal qty, decimal contractSize, int shortRatio, int ratio) => (price - costGrossPrice) * qty * contractSize * shortRatio * ratio; } diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index 5a4b46e8..408e23e4 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -1692,7 +1692,7 @@ namespace YLErp.Modules.SwapModule bool open) { payQty = Math.Abs(payQty); - decimal ratio = eventFlow.PayDirection == (int)SwapDirectionEnum.收取 ? 1 : -1;//收取为正,支付为负 + int ratio = eventFlow.PayDirection == (int)SwapDirectionEnum.收取 ? 1 : -1;//收取为正,支付为负 int shortRatio = newEodPayPosition.PositionType == (int)PositionTypeFlag.Long ? 1 : -1;//多空方向 newEodPayPosition.ValueDate = eventFlow.PayDate.Value; newEodPayPosition.PositionId = eventFlow.PositionId; From c52d439469cd598798a8e6c1f31783bc0a96a9e3 Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 11 Aug 2026 11:12:15 +0800 Subject: [PATCH 23/31] =?UTF-8?q?refactor(return-leg):=204=E5=A4=84?= =?UTF-8?q?=E5=A2=9E=E5=80=BC=E7=A8=8Einline=E5=85=AC=E5=BC=8F=E6=9B=BF?= =?UTF-8?q?=E6=8D=A2=E4=B8=BA=20DividendCalc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SwapEodPositionService 4处 payment/(1+tax)*(1-tax) inline 替换: - 1804/1906/2094: Math.Round(...) → DividendCalc.AfterTax(payment, tax) - 1893: 中间计算(不四舍五入) → DividendCalc.AfterTaxRaw(total, tax) 修复 MtmCalcTest: UnrealizedPnl 第6参数 ratio 从 1m 改为 1(int)。 (1+tax) inline 公式出现次数: 4 → 0。 验证: 编译0错误, 全量507测试7失败(基线一致)。 --- .../Modules/SwapModule/ReturnLegs/MtmCalcTest.cs | 11 +++-------- YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs | 9 ++++----- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/UnitTestProject/Modules/SwapModule/ReturnLegs/MtmCalcTest.cs b/UnitTestProject/Modules/SwapModule/ReturnLegs/MtmCalcTest.cs index d5ee1d62..da6969dd 100644 --- a/UnitTestProject/Modules/SwapModule/ReturnLegs/MtmCalcTest.cs +++ b/UnitTestProject/Modules/SwapModule/ReturnLegs/MtmCalcTest.cs @@ -9,14 +9,12 @@ namespace UnitTestProject.Modules.SwapModule.ReturnLegs [TestMethod] public void MarketValue_多头_为正() { - // 标的价100 × 1000张 × 乘数1 × 多头(+1) = 100000 Assert.AreEqual(100_000m, MtmCalc.MarketValue(100m, 1000m, 1m, 1)); } [TestMethod] public void MarketValue_空头_为负() { - // 空头 shortRatio=-1 Assert.AreEqual(-100_000m, MtmCalc.MarketValue(100m, 1000m, 1m, -1)); } @@ -29,22 +27,19 @@ namespace UnitTestProject.Modules.SwapModule.ReturnLegs [TestMethod] public void UnrealizedPnl_多头浮盈() { - // (105-100) × 1000 × 1 × 1 × 1 = 5000 - Assert.AreEqual(5000m, MtmCalc.UnrealizedPnl(105m, 100m, 1000m, 1m, 1, 1m)); + Assert.AreEqual(5000m, MtmCalc.UnrealizedPnl(105m, 100m, 1000m, 1m, 1, 1)); } [TestMethod] public void UnrealizedPnl_多头浮亏() { - // (95-100) × 1000 × 1 × 1 × 1 = -5000 - Assert.AreEqual(-5000m, MtmCalc.UnrealizedPnl(95m, 100m, 1000m, 1m, 1, 1m)); + Assert.AreEqual(-5000m, MtmCalc.UnrealizedPnl(95m, 100m, 1000m, 1m, 1, 1)); } [TestMethod] public void UnrealizedPnl_空头反向() { - // 空头: 价格跌=盈利 (95-100) × 1000 × 1 × (-1) × 1 = 5000 - Assert.AreEqual(5000m, MtmCalc.UnrealizedPnl(95m, 100m, 1000m, 1m, -1, 1m)); + Assert.AreEqual(5000m, MtmCalc.UnrealizedPnl(95m, 100m, 1000m, 1m, -1, 1)); } } } diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index 408e23e4..c9da80f1 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -1800,8 +1800,7 @@ namespace YLErp.Modules.SwapModule if (valueDate > td.StartDate.Value && curretEod.PosiQuantity > 0) { decimal payment = CalcBondPayment(curretEod.UnderlyingCode, eod.ValueDate, valueDate, curretEod.PosiQuantity, shortRatio, directionRatio); - // 考虑增值税 - curretEod.TdPosiDividend = Math.Round(payment / (1 + tax) * (1 - tax), 2); + curretEod.TdPosiDividend = DividendCalc.AfterTax(payment, tax); } curretEod.PosiDividendSum = eod.PosiQuantity > 0 ? Math.Round(eod.PosiDividendSum + curretEod.TdPosiDividend, 2) : 0; curretEod.PosiQuantity = eod.PosiQuantity; @@ -1891,7 +1890,7 @@ namespace YLErp.Modules.SwapModule var originNotional = (decimal)td.OriginalStockEqvNotional / swapPosition.PosiNetPrice; decimal totalPayment = CalcBondPayment(curretEod.UnderlyingCode, td.StartDate.Value, valueDate, (decimal)originNotional, shortRatio, directionRatio); decimal tax = um.ValueAddedTax ?? 0; - decimal totalInterest = totalPayment / (1 + tax) * (1 - tax); + decimal totalInterest = DividendCalc.AfterTaxRaw(totalPayment, tax); SetPriceInfoByFlowEvent(eod, curretEod, unwindEvents, swapPosition); curretEod.dv01 = Dv01Helper.CalcDv01(eod.UnderlyingCode, curretEod.PosiQuantity, eod.PosiDirection, eod.PositionType, vobp); curretEod.UnderlyingPrice = price; @@ -1903,7 +1902,7 @@ namespace YLErp.Modules.SwapModule if (valueDate > td.StartDate.Value && (curretEod.PosiQuantity > 0)) { decimal payment = CalcBondPayment(curretEod.UnderlyingCode, eod.ValueDate, valueDate, curretEod.PosiQuantity, shortRatio, directionRatio); - curretEod.TdPosiDividend = Math.Round(payment / (1 + tax) * (1 - tax), 2); + curretEod.TdPosiDividend = DividendCalc.AfterTax(payment, tax); } curretEod.RealizedMtmPnL = eod.RealizedMtmPnL + curretEod.TdCloseMtmPnl; // 当日浮动端平仓盈亏·分红(仅来自平仓事件 和 互换 中已实现的分红) @@ -2092,7 +2091,7 @@ namespace YLErp.Modules.SwapModule { decimal tax = um.ValueAddedTax ?? 0; decimal payment = CalcBondPayment(curretEod.UnderlyingCode, td.StartDate.Value, settleDate, curretEod.PosiQuantity, shortRatio, directionRatio); - payment = Math.Round(payment / (1 + tax) * (1 - tax), 2); + payment = DividendCalc.AfterTax(payment, tax); //var consumedDividend = CalcConsumedDividend(curretEod, unwindEvents); 首日应该没有分红 curretEod.TdPosiDividend = payment; curretEod.PosiDividendSum = payment; From d61ff742decbfdbbade51796d00e0bcb5319102d Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 11 Aug 2026 11:18:56 +0800 Subject: [PATCH 24/31] =?UTF-8?q?refactor(return-leg):=20SwapDealService?= =?UTF-8?q?=20=E5=A2=9E=E5=80=BC=E7=A8=8E=E5=85=AC=E5=BC=8F=E6=9B=BF?= =?UTF-8?q?=E6=8D=A2=E4=B8=BA=20DividendCalc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SwapDealService:1806 inline 的 dividendIn/(1+tax)*(1-tax) 替换为 DividendCalc.AfterTaxRaw(中间计算不四舍五入)。 全库 payment/(1+tax)*(1-tax) inline 公式: 5处(SwapEod 4 + SwapDeal 1) → 0。 全部收敛到 DividendCalc.AfterTax / AfterTaxRaw。 验证: 编译0错误, 全量507测试7失败(基线一致)。 --- YLErpDAL/Modules/SwapModule/SwapDealService.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 152c0176..ba511cd9 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -13,6 +13,7 @@ using YLErp.Modules.TradeModule; using YLErp.Modules.TradeModule.DealModule; using YLErp.Modules.SwapModule.FundingLegs; using YLErp.Modules.SwapModule.Margin; +using YLErp.Modules.SwapModule.ReturnLegs; using YLErp.QdpModule; namespace YLErp.Modules.SwapModule @@ -1803,7 +1804,7 @@ namespace YLErp.Modules.SwapModule var dividendIn = servie.CalcPayment(payments, unwindQty, shortRatio, directionRatio); var um = DataCacheProvider.GetUnderlyingDataSource().GetData(flowEvent.UnderlyingCode); decimal tax = um.ValueAddedTax ?? 0; - dividendIn = dividendIn / (1 + tax) * (1 - tax); + dividendIn = DividendCalc.AfterTaxRaw(dividendIn, tax); flowEvent.DividendIn = Math.Round(dividendIn, 2, MidpointRounding.AwayFromZero); } From e35a738d2e5a5a7f9a0ae39678d7015762550a97 Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 11 Aug 2026 11:24:05 +0800 Subject: [PATCH 25/31] =?UTF-8?q?refactor(funding-leg):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D2=E4=B8=AA=E7=B1=BB=E5=90=8D+=E8=A1=A5DividendCalc?= =?UTF-8?q?=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 采纳命名审查建议(优先级最高的2项): 1. UnderlyingFullPriceLeg → UnderlyingEntryFullPriceLeg 补齐'期初(Entry)'——建仓时点全价, 非当前全价, 金融上是不同计息口径 2. FixedNotionalLeg → FixedAmountLeg 去掉误导性的Notional——'固定值'是合同写死的数额, 不一定是名义本金, 避免与ContractNotionalLeg的Notional概念撞车 3. DividendCalc 补充注释: 业务含义(债券票息+股票分红统一用Dividend)、 税务口径(还原不含税再扣税)、命名保持理由 验证: 编译0错误, 全量507测试7失败(基线一致)。 --- .../FundingLegs/FundingLegStrategyTest.cs | 16 ++++++++-------- ...{FixedNotionalLeg.cs => FixedAmountLeg.cs} | 6 +++--- .../FundingLegs/FundingLegStrategyFactory.cs | 4 ++-- ...eLeg.cs => UnderlyingEntryFullPriceLeg.cs} | 5 +++-- .../SwapModule/ReturnLegs/DividendCalc.cs | 19 ++++++++++++++----- 5 files changed, 30 insertions(+), 20 deletions(-) rename YLErpDAL/Modules/SwapModule/FundingLegs/{FixedNotionalLeg.cs => FixedAmountLeg.cs} (64%) rename YLErpDAL/Modules/SwapModule/FundingLegs/{UnderlyingFullPriceLeg.cs => UnderlyingEntryFullPriceLeg.cs} (75%) diff --git a/UnitTestProject/Modules/SwapModule/FundingLegs/FundingLegStrategyTest.cs b/UnitTestProject/Modules/SwapModule/FundingLegs/FundingLegStrategyTest.cs index cae1d332..b3d71a43 100644 --- a/UnitTestProject/Modules/SwapModule/FundingLegs/FundingLegStrategyTest.cs +++ b/UnitTestProject/Modules/SwapModule/FundingLegs/FundingLegStrategyTest.cs @@ -22,7 +22,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs [TestMethod] public void 固定值_部分平仓_计息基数恒等于Fix() { - var leg = new FixedNotionalLeg(); + var leg = new FixedAmountLeg(); var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0.5m); Assert.AreEqual(Fix, r.ClosePrincipal, "平仓本金恒=Fix"); @@ -33,7 +33,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs [TestMethod] public void 固定值_全平_计息基数仍等于Fix() { - var leg = new FixedNotionalLeg(); + var leg = new FixedAmountLeg(); var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 1m); Assert.AreEqual(Fix, r.ClosePrincipal); } @@ -78,7 +78,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs [TestMethod] public void 标的期初全价_部分平仓_主路径公式同mode2() { - var leg = new UnderlyingFullPriceLeg(); + var leg = new UnderlyingEntryFullPriceLeg(); var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 0.5m); Assert.AreEqual(50_000_000m, r.ClosePrincipal); @@ -89,7 +89,7 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs [TestMethod] public void 标的期初全价_全平_本金等于全额() { - var leg = new UnderlyingFullPriceLeg(); + var leg = new UnderlyingEntryFullPriceLeg(); var r = leg.CalcNotional(Fix, Notional, LongNotional, ShortNotional, 1m); Assert.AreEqual(Notional, r.ClosePrincipal); } @@ -101,9 +101,9 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs [TestMethod] public void 各策略对应正确枚举值() { - Assert.AreEqual(InterestModeEnum.固定值, new FixedNotionalLeg().Mode); + Assert.AreEqual(InterestModeEnum.固定值, new FixedAmountLeg().Mode); Assert.AreEqual(InterestModeEnum.合约名义本金规模, new ContractNotionalLeg().Mode); - Assert.AreEqual(InterestModeEnum.标的期初全价, new UnderlyingFullPriceLeg().Mode); + Assert.AreEqual(InterestModeEnum.标的期初全价, new UnderlyingEntryFullPriceLeg().Mode); } #endregion @@ -113,9 +113,9 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs [TestMethod] public void 工厂_返回各活跃mode的策略() { - Assert.IsInstanceOfType(FundingLegStrategyFactory.Get(InterestModeEnum.固定值), typeof(FixedNotionalLeg)); + Assert.IsInstanceOfType(FundingLegStrategyFactory.Get(InterestModeEnum.固定值), typeof(FixedAmountLeg)); Assert.IsInstanceOfType(FundingLegStrategyFactory.Get(InterestModeEnum.合约名义本金规模), typeof(ContractNotionalLeg)); - Assert.IsInstanceOfType(FundingLegStrategyFactory.Get(InterestModeEnum.标的期初全价), typeof(UnderlyingFullPriceLeg)); + Assert.IsInstanceOfType(FundingLegStrategyFactory.Get(InterestModeEnum.标的期初全价), typeof(UnderlyingEntryFullPriceLeg)); } [TestMethod] diff --git a/YLErpDAL/Modules/SwapModule/FundingLegs/FixedNotionalLeg.cs b/YLErpDAL/Modules/SwapModule/FundingLegs/FixedAmountLeg.cs similarity index 64% rename from YLErpDAL/Modules/SwapModule/FundingLegs/FixedNotionalLeg.cs rename to YLErpDAL/Modules/SwapModule/FundingLegs/FixedAmountLeg.cs index fd85793c..6e477b07 100644 --- a/YLErpDAL/Modules/SwapModule/FundingLegs/FixedNotionalLeg.cs +++ b/YLErpDAL/Modules/SwapModule/FundingLegs/FixedAmountLeg.cs @@ -1,14 +1,14 @@ using YLErp.DBModels; -using YLErp.Modules.SwapModule.FundingLegs; namespace YLErp.Modules.SwapModule.FundingLegs; /// /// 固定值融资腿(InterestMode=固定值)。 /// 计息基数由合约约定(InterestPrincipalFix),无论平仓比例多少都恒等于该值。 -/// 业务规则:固定值就是合同写死的固定值,永远不变。 +/// 业务规则:固定值就是合同写死的固定数额(Amount),永远不变。 +/// 不叫 FixedNotionalLeg——"固定值"不一定是"名义本金(Notional)",避免与 ContractNotionalLeg 概念撞车。 /// -public sealed class FixedNotionalLeg : IFundingLegStrategy +public sealed class FixedAmountLeg : IFundingLegStrategy { public InterestModeEnum Mode => InterestModeEnum.固定值; diff --git a/YLErpDAL/Modules/SwapModule/FundingLegs/FundingLegStrategyFactory.cs b/YLErpDAL/Modules/SwapModule/FundingLegs/FundingLegStrategyFactory.cs index a909ec5e..ee4114ef 100644 --- a/YLErpDAL/Modules/SwapModule/FundingLegs/FundingLegStrategyFactory.cs +++ b/YLErpDAL/Modules/SwapModule/FundingLegs/FundingLegStrategyFactory.cs @@ -17,9 +17,9 @@ public static class FundingLegStrategyFactory { private static readonly Dictionary _strategies = new() { - [InterestModeEnum.固定值] = new FixedNotionalLeg(), + [InterestModeEnum.固定值] = new FixedAmountLeg(), [InterestModeEnum.合约名义本金规模] = new ContractNotionalLeg(), - [InterestModeEnum.标的期初全价] = new UnderlyingFullPriceLeg(), + [InterestModeEnum.标的期初全价] = new UnderlyingEntryFullPriceLeg(), }; /// 按 mode 返回对应策略。未注册的 mode 抛 ArgumentException。 diff --git a/YLErpDAL/Modules/SwapModule/FundingLegs/UnderlyingFullPriceLeg.cs b/YLErpDAL/Modules/SwapModule/FundingLegs/UnderlyingEntryFullPriceLeg.cs similarity index 75% rename from YLErpDAL/Modules/SwapModule/FundingLegs/UnderlyingFullPriceLeg.cs rename to YLErpDAL/Modules/SwapModule/FundingLegs/UnderlyingEntryFullPriceLeg.cs index 725717fd..f78d9b74 100644 --- a/YLErpDAL/Modules/SwapModule/FundingLegs/UnderlyingFullPriceLeg.cs +++ b/YLErpDAL/Modules/SwapModule/FundingLegs/UnderlyingEntryFullPriceLeg.cs @@ -4,12 +4,13 @@ namespace YLErp.Modules.SwapModule.FundingLegs; /// /// 标的期初全价融资腿(InterestMode=标的期初全价)。 -/// 站在"持仓全价"视角:计息基数 = 标的含费全价(PosiGrossPrice/EntryDirtyPrice) × 数量。 +/// 计息基数 = 标的期初含费全价(PosiGrossPrice/EntryDirtyPrice) × 数量。 +/// "期初(Entry)"是关键——建仓时点的全价,非当前估值全价。 /// 主路径 CalcNotionalByMode 公式与合约名义本金规模(2)相同; /// 差异在衡泰路径会乘 grossPrice 折算(SwapDealService.GetUnwindInterestsByHT), /// 以及 EOD 复利部分平仓后直接返回剩余本金(禁止反推,SwapEodPositionService:1458-1465)。 /// -public sealed class UnderlyingFullPriceLeg : IFundingLegStrategy +public sealed class UnderlyingEntryFullPriceLeg : IFundingLegStrategy { public InterestModeEnum Mode => InterestModeEnum.标的期初全价; diff --git a/YLErpDAL/Modules/SwapModule/ReturnLegs/DividendCalc.cs b/YLErpDAL/Modules/SwapModule/ReturnLegs/DividendCalc.cs index 1b9f898f..d2472360 100644 --- a/YLErpDAL/Modules/SwapModule/ReturnLegs/DividendCalc.cs +++ b/YLErpDAL/Modules/SwapModule/ReturnLegs/DividendCalc.cs @@ -1,18 +1,27 @@ namespace YLErp.Modules.SwapModule.ReturnLegs; /// -/// 标的端分红(票息)计算。 +/// 标的端分红/票息的增值税计算。 /// -/// 增值税后票息 = 税前票息 / (1+税率) × (1-税率) -/// 原代码在 SwapEodPositionService 4处重复此公式(1804/1894/1906/2095)。 +/// 业务含义:TRS 标的如果是债券,标的端会收到债券票息(coupon); +/// 如果是股票,会收到现金分红(cash dividend)。系统统一用 Dividend 字段链路表达。 +/// 本类只负责"增值税后金额"这一步纯数学,不涉及取价/归属/落库。 +/// +/// 税务口径:票息/分红属于金融服务应税收入,先除以(1+税率)还原不含税收入, +/// 再乘以(1-税率)扣减税负。公式:afterTax = payment / (1+tax) × (1-tax)。 +/// 原代码在 SwapEodPositionService 4处 + SwapDealService 1处 重复此公式。 +/// +/// 命名保持 DividendCalc 而非改为 CashFlow/Coupon—— +/// 因为系统里"分红"字段(DividendIn/PosiDividendSum)是统一链路,同时覆盖债券票息和股票分红, +/// 改名会制造新歧义且需同步改 DB 字段,收益不匹配。 /// public static class DividendCalc { - /// 增值税后票息,四舍五入到 2 位。 + /// 增值税后票息/分红,四舍五入到分(2位)。 public static decimal AfterTax(decimal payment, decimal tax) => Math.Round(payment / (1 + tax) * (1 - tax), 2); - /// 增值税后票息(不四舍五入,供中间计算用)。 + /// 增值税后票息/分红(不四舍五入,供中间计算用)。 public static decimal AfterTaxRaw(decimal payment, decimal tax) => payment / (1 + tax) * (1 - tax); } From 8f677d4cb7831beb03abc1c8f04156355924e82a Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 11 Aug 2026 11:30:59 +0800 Subject: [PATCH 26/31] =?UTF-8?q?fix(enum):=20InterestModeEnum/InterestTyp?= =?UTF-8?q?eEnum=20=E6=98=BE=E5=BC=8F=E8=B5=8B=E5=80=BC,=20=E9=92=89?= =?UTF-8?q?=E6=AD=BB=20DB=20=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原枚举成员无显式值, 靠声明顺序隐式得 0..9。 DB(swap_position.InterestMode)、ConsTrade.InterestModels、 SwapFlowEvent(InterestMode==0) 全部依赖此顺序。 一旦有人重排或插入成员, int 映射静默错乱——最难查的 bug。 显式赋值: Unknown=0, 固定值=1, ... 标的期初全价=9。 InterestTypeEnum 同理: 单利=0, 复利=1。 值与原隐式顺序完全一致, 行为零变化。 验证: 编译0错误, 全量507测试7失败(基线一致)。 --- .../DBModels/Enums/InterestModeEnum.cs | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/Framework/YLErp.Core/DBModels/Enums/InterestModeEnum.cs b/Framework/YLErp.Core/DBModels/Enums/InterestModeEnum.cs index fcc031f8..fc389cca 100644 --- a/Framework/YLErp.Core/DBModels/Enums/InterestModeEnum.cs +++ b/Framework/YLErp.Core/DBModels/Enums/InterestModeEnum.cs @@ -11,23 +11,23 @@ namespace YLErp.DBModels /// public enum InterestModeEnum { - Unknown, - 固定值, - 合约名义本金规模, - 持仓名义本金, - 持仓市值, - 初始预付金, - 追加预付金, - 多头存续名义本金, - 空头存续名义本金, - 标的期初全价 + Unknown = 0, + 固定值 = 1, + 合约名义本金规模 = 2, + 持仓名义本金 = 3, + 持仓市值 = 4, + 初始预付金 = 5, + 追加预付金 = 6, + 多头存续名义本金 = 7, + 空头存续名义本金 = 8, + 标的期初全价 = 9 } /// /// 计息方式 /// public enum InterestTypeEnum { - 单利, - 复利 + 单利 = 0, + 复利 = 1 } } From cbe5b58577f9a2bade955205c70d6be89670ed07 Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 11 Aug 2026 12:30:53 +0800 Subject: [PATCH 27/31] =?UTF-8?q?refactor:=20=E6=96=B0=E5=A2=9E=20Directio?= =?UTF-8?q?nRatio=20=E6=96=B9=E5=90=91=E5=9B=A0=E5=AD=90=E7=BA=AF=E5=87=BD?= =?UTF-8?q?=E6=95=B0,=20=E6=9B=BF=E6=8D=A27=E5=A4=84=E4=B8=89=E5=85=83?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 多空方向(PositionType==Long?1:-1)和收付方向(xxx==收取?1:-1) 在两个上帝类里重复 15+处, +1/-1 写反是常见 bug 源。 新增 ReturnLegs/DirectionRatio.cs: - LongShort(positionType): 多头=+1, 空头=-1 - ReceivePay(direction): 收取=+1, 支付=-1 替换: - SwapEodPositionService: 6处 shortRatio 三元式(含注释1处) - SwapDealService: 1处 shortRatio + 1处 directionRatio 验证: 编译0错误, 全量511测试7失败(基线一致)。 --- .../ReturnLegs/DirectionRatioTest.cs | 27 +++++++++++++++++++ .../SwapModule/ReturnLegs/DirectionRatio.cs | 22 +++++++++++++++ .../Modules/SwapModule/SwapDealService.cs | 4 +-- .../SwapModule/SwapEodPositionService.cs | 10 +++---- 4 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 UnitTestProject/Modules/SwapModule/ReturnLegs/DirectionRatioTest.cs create mode 100644 YLErpDAL/Modules/SwapModule/ReturnLegs/DirectionRatio.cs diff --git a/UnitTestProject/Modules/SwapModule/ReturnLegs/DirectionRatioTest.cs b/UnitTestProject/Modules/SwapModule/ReturnLegs/DirectionRatioTest.cs new file mode 100644 index 00000000..ac36a4f2 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/ReturnLegs/DirectionRatioTest.cs @@ -0,0 +1,27 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using YLErp.DBModels; +using YLErp.DBModels.Enums; +using YLErp.Modules.SwapModule.ReturnLegs; + +namespace UnitTestProject.Modules.SwapModule.ReturnLegs +{ + [TestClass] + public class DirectionRatioTest + { + [TestMethod] + public void LongShort_多头_返回1() + => Assert.AreEqual(1, DirectionRatio.LongShort((int)PositionTypeFlag.Long)); + + [TestMethod] + public void LongShort_空头_返回负1() + => Assert.AreEqual(-1, DirectionRatio.LongShort((int)PositionTypeFlag.Short)); + + [TestMethod] + public void ReceivePay_收取_返回1() + => Assert.AreEqual(1, DirectionRatio.ReceivePay((int)SwapDirectionEnum.收取)); + + [TestMethod] + public void ReceivePay_支付_返回负1() + => Assert.AreEqual(-1, DirectionRatio.ReceivePay((int)SwapDirectionEnum.支付)); + } +} diff --git a/YLErpDAL/Modules/SwapModule/ReturnLegs/DirectionRatio.cs b/YLErpDAL/Modules/SwapModule/ReturnLegs/DirectionRatio.cs new file mode 100644 index 00000000..13808855 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/ReturnLegs/DirectionRatio.cs @@ -0,0 +1,22 @@ +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule.ReturnLegs; + +/// +/// 方向因子计算。把 PositionType / SwapDirection 转成 +1/-1 乘数。 +/// +/// 多空方向(Long/Short)和收付方向(收取/支付)在代码里反复写成三元表达式, +/// 散落在 SwapEodPositionService 15处 + SwapDealService 多处。 +/// 收敛到统一方法, 消除 +1/-1 写反的风险。 +/// +public static class DirectionRatio +{ + /// 多空方向因子。多头=+1, 空头=-1。 + public static int LongShort(int positionType) + => positionType == (int)PositionTypeFlag.Long ? 1 : -1; + + /// 收付方向因子。收取=+1, 支付=-1。 + public static int ReceivePay(int direction) + => direction == (int)SwapDirectionEnum.收取 ? 1 : -1; +} diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index ba511cd9..99d25b3e 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -1798,8 +1798,8 @@ namespace YLErp.Modules.SwapModule BondPaymentService servie = new BondPaymentService(UserInfo); var payments = servie.GetBondPayments(flowEvent.UnderlyingCode, td.StartDate.Value, date); - int shortRatio = flowEvent.PositionType == (int)PositionTypeFlag.Long ? 1 : -1; - int directionRatio = flowEvent.PayDirection == (int)SwapDirectionEnum.收取 ? 1 : -1; + int shortRatio = DirectionRatio.LongShort(flowEvent.PositionType); + int directionRatio = DirectionRatio.ReceivePay(flowEvent.PayDirection); // + 付息日>上日日终且小于等于平仓日期的分红数据 var dividendIn = servie.CalcPayment(payments, unwindQty, shortRatio, directionRatio); var um = DataCacheProvider.GetUnderlyingDataSource().GetData(flowEvent.UnderlyingCode); diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index c9da80f1..4aa6adc0 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -1693,7 +1693,7 @@ namespace YLErp.Modules.SwapModule { payQty = Math.Abs(payQty); int ratio = eventFlow.PayDirection == (int)SwapDirectionEnum.收取 ? 1 : -1;//收取为正,支付为负 - int shortRatio = newEodPayPosition.PositionType == (int)PositionTypeFlag.Long ? 1 : -1;//多空方向 + int shortRatio = DirectionRatio.LongShort(newEodPayPosition.PositionType); newEodPayPosition.ValueDate = eventFlow.PayDate.Value; newEodPayPosition.PositionId = eventFlow.PositionId; newEodPayPosition.ClientId = td.ClientId; @@ -1791,7 +1791,7 @@ namespace YLErp.Modules.SwapModule return curretEod; } var dealDate = curretEod.ValueDate; - int shortRatio = eod.PositionType == (int)PositionTypeFlag.Long ? 1 : -1; + int shortRatio = DirectionRatio.LongShort(eod.PositionType); int directionRatio = eod.PosiDirection == (int)SwapDirectionEnum.收取 ? 1 : -1; curretEod.PosiStatus = curretEod.PosiQuantity == 0 ? 1 : 0; var price = GetSwapValuationPrice(eod.UnderlyingCode, dealDate, out decimal vobp); @@ -1883,7 +1883,7 @@ namespace YLErp.Modules.SwapModule return curretEod; } var dealDate = curretEod.ValueDate; - int shortRatio = eod.PositionType == (int)PositionTypeFlag.Long ? 1 : -1; + int shortRatio = DirectionRatio.LongShort(eod.PositionType); int directionRatio = eod.PosiDirection == (int)SwapDirectionEnum.收取 ? 1 : -1; var price = GetSwapValuationPrice(eod.UnderlyingCode, dealDate, out decimal vobp); var todayConsumedDividend = CalcConsumedDividend(curretEod, unwindEvents); @@ -1971,7 +1971,7 @@ namespace YLErp.Modules.SwapModule { return; } - int shortRatio = eod.PositionType == (int)PositionTypeFlag.Long ? 1 : -1; + int shortRatio = DirectionRatio.LongShort(eod.PositionType); int directionRatio = eod.PosiDirection == (int)SwapDirectionEnum.收取 ? 1 : -1; var unwindFlowEvents = unwindEvents.Where(x => x.EventType == (int)SwapFlowEventTypeEnum.平仓).ToList(); var openFlowEvents = unwindEvents.Where(x => x.EventType == (int)SwapFlowEventTypeEnum.开仓).ToList(); @@ -2052,7 +2052,7 @@ namespace YLErp.Modules.SwapModule curretEod.SwapTradeId = td.id; curretEod.PositionId = position.id; curretEod.ClientId = td.ClientId; - int shortRatio = position.PositionType == (int)PositionTypeFlag.Long ? 1 : -1; + int shortRatio = DirectionRatio.LongShort(position.PositionType); int directionRatio = position.PosiDirection == (int)SwapDirectionEnum.收取 ? 1 : -1; curretEod.PositionType = position.PositionType; var eod = new eod_swap_position() From ed7b9102d4bc2881e4d9a5c03f791e7610a0eaf5 Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 11 Aug 2026 12:55:02 +0800 Subject: [PATCH 28/31] =?UTF-8?q?docs:=20CalcNotionalByMode=20=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E8=BF=87=E6=B8=A1=E6=9C=9F=E6=B3=A8=E9=87=8A,=20?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E4=BF=9D=E7=95=99=E5=8E=9F=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回应命名评审: 确认 FundingLegs 目录无旧文件残留(5个新文件), 旧类名/命名空间零引用。CalcNotionalByMode 有意保留: - mode 5/6(保证金)待 Margin 独立入口建成后迁出 - mode 7/8(多空存续)界面已禁用, 保留为防御性兜底 待 mode 5/6 迁出后本方法可整体删除。 --- YLErpDAL/Modules/SwapModule/SwapDealService.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 99d25b3e..efaa7d3e 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -906,8 +906,15 @@ namespace YLErp.Modules.SwapModule /// /// 根据计息模式计算名义本金。 - /// 融资腿(mode 1/2/9)已委托 FundingLegStrategyFactory;保证金(mode 5/6)和 - /// 半禁用的多空存续(mode 7/8)暂保留原逻辑,待 Margin 上下文接入后再迁出。 + /// + /// 现状(过渡期): + /// - 融资腿(mode 1/2/9)已委托 FundingLegStrategyFactory,走新策略体系。 + /// - 保证金(mode 5/6)暂保留——待 Margin 独立计息入口建成后迁出。 + /// - 多空存续(mode 7/8)界面已禁用,保留 case 仅为防御性兜底。 + /// - 死代码(mode 3/4)走 default。 + /// + /// 待保证金(mode 5/6)迁入 Margin 上下文后,本方法可整体删除, + /// 调用点直接走 FundingLegStrategyFactory.Get(mode)。 /// private (decimal close, decimal posi, decimal closePct) CalcNotionalByMode(swap_position position, decimal closePercent, decimal posiNotional, decimal posiLong, decimal posiShort) { From e9b54bdcf64f044cc6c82733959ba5ca1afda716 Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 11 Aug 2026 13:00:52 +0800 Subject: [PATCH 29/31] =?UTF-8?q?cleanup:=20=E5=88=A0=E9=99=A4=E6=AD=BB?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=9E=9A=E4=B8=BE=20mode=203/4/7/8=20?= =?UTF-8?q?=E5=8F=8A=E7=9B=B8=E5=85=B3=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mode 3(持仓名义本金)/4(持仓市值): 零引用死代码 mode 7/8(多空存续名义本金): 界面已禁用, 无历史数据(用户确认) 删除: - InterestModeEnum: 移除 持仓名义本金/持仓市值/多头存续名义本金/空头存续名义本金 枚举值保持显式(1/2/5/6/9), DB契约不变 - CalcNotionalByMode: 移除 mode 7/8 的 case - ConsTrade.InterestModels/InterestNotionalModels: 移除 mode 7/8 - 删除3个 mode 7/8 相关测试方法(场景已不存在) CalcNotionalByMode 现在只剩 mode 5/6(保证金)的 case, 待 Margin 独立计息入口建成后整体删除。 验证: 编译0错误, 全量508测试7失败(基线一致)。 --- .../YLErp.Core/DBModels/Consts/ConsTrade.cs | 4 ++-- .../DBModels/Enums/InterestModeEnum.cs | 4 ---- .../FundingLegs/FundingLegStrategyTest.cs | 5 ++--- .../SwapUnwindFloatingLegDiagnosticTdd.cs | 14 ------------- .../SwapUnwindPrepayPrincipalBugTdd.cs | 21 ------------------- .../Modules/SwapModule/SwapDealService.cs | 8 ------- 6 files changed, 4 insertions(+), 52 deletions(-) diff --git a/Framework/YLErp.Core/DBModels/Consts/ConsTrade.cs b/Framework/YLErp.Core/DBModels/Consts/ConsTrade.cs index cd243a6a..08664c06 100644 --- a/Framework/YLErp.Core/DBModels/Consts/ConsTrade.cs +++ b/Framework/YLErp.Core/DBModels/Consts/ConsTrade.cs @@ -131,11 +131,11 @@ namespace YLErp.DBModels /// /// 互换利率端计息方式 /// - public static readonly List InterestModels = new List() { (int)InterestModeEnum.固定值, (int)InterestModeEnum.合约名义本金规模, (int)InterestModeEnum.空头存续名义本金,(int)InterestModeEnum.多头存续名义本金, (int)InterestModeEnum.标的期初全价 }; + public static readonly List InterestModels = new List() { (int)InterestModeEnum.固定值, (int)InterestModeEnum.合约名义本金规模, (int)InterestModeEnum.标的期初全价 }; /// /// 互换利率端计息方式-名义本金相关 /// - public static readonly List InterestNotionalModels = new List() {(int)InterestModeEnum.合约名义本金规模, (int)InterestModeEnum.空头存续名义本金, (int)InterestModeEnum.多头存续名义本金, (int)InterestModeEnum.标的期初全价 }; + public static readonly List InterestNotionalModels = new List() {(int)InterestModeEnum.合约名义本金规模, (int)InterestModeEnum.标的期初全价 }; /// /// 互换利率计息方式 预付金 /// diff --git a/Framework/YLErp.Core/DBModels/Enums/InterestModeEnum.cs b/Framework/YLErp.Core/DBModels/Enums/InterestModeEnum.cs index fc389cca..6645c9db 100644 --- a/Framework/YLErp.Core/DBModels/Enums/InterestModeEnum.cs +++ b/Framework/YLErp.Core/DBModels/Enums/InterestModeEnum.cs @@ -14,12 +14,8 @@ namespace YLErp.DBModels Unknown = 0, 固定值 = 1, 合约名义本金规模 = 2, - 持仓名义本金 = 3, - 持仓市值 = 4, 初始预付金 = 5, 追加预付金 = 6, - 多头存续名义本金 = 7, - 空头存续名义本金 = 8, 标的期初全价 = 9 } /// diff --git a/UnitTestProject/Modules/SwapModule/FundingLegs/FundingLegStrategyTest.cs b/UnitTestProject/Modules/SwapModule/FundingLegs/FundingLegStrategyTest.cs index b3d71a43..c3afb09f 100644 --- a/UnitTestProject/Modules/SwapModule/FundingLegs/FundingLegStrategyTest.cs +++ b/UnitTestProject/Modules/SwapModule/FundingLegs/FundingLegStrategyTest.cs @@ -121,10 +121,9 @@ namespace UnitTestProject.Modules.SwapModule.FundingLegs [TestMethod] public void 工厂_未注册mode抛异常() { + // mode 3/4/7/8 已从枚举删除,用 Unknown(0)验证未注册抛异常 Assert.ThrowsException(() => - FundingLegStrategyFactory.Get(InterestModeEnum.持仓名义本金)); - Assert.ThrowsException(() => - FundingLegStrategyFactory.Get(InterestModeEnum.持仓市值)); + FundingLegStrategyFactory.Get(InterestModeEnum.Unknown)); Assert.ThrowsException(() => FundingLegStrategyFactory.Get(InterestModeEnum.初始预付金)); Assert.ThrowsException(() => diff --git a/UnitTestProject/Modules/SwapModule/SwapUnwindFloatingLegDiagnosticTdd.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindFloatingLegDiagnosticTdd.cs index c8645fac..9340fe93 100644 --- a/UnitTestProject/Modules/SwapModule/SwapUnwindFloatingLegDiagnosticTdd.cs +++ b/UnitTestProject/Modules/SwapModule/SwapUnwindFloatingLegDiagnosticTdd.cs @@ -94,19 +94,5 @@ namespace YLErp.Modules.SwapModule Assert.AreEqual(RealFix * ClosePct, close, "mode5 应 = 实时腿剩余本金(real Fix) * closePct,与 posiLong(orig 100M) 无关"); Assert.AreNotEqual(OrigFix * ClosePct, close, "务必不是期初 99,000 * closePct(证明后端修复生效)"); } - - [TestMethod] - public void 诊断_若将来有_多头存续名义本金_腿_posiLong用orig才出错_本deal无此腿_故不影响() - { - // 构造一个「多头存续名义本金」腿,证明此时 posiLong 取值(orig vs real)会直接决定结果—— - // 说明本 deal 没有这种腿,所以 fpositions 用 orig 100M 不影响;但普通收益互换若有此腿则会踩坑。 - var longLeg = new swap_position { id = 35799, InterestMode = (int)InterestModeEnum.多头存续名义本金 }; - var byOrig = CallCalcNotionalByMode(longLeg, ClosePct, RealLong * ClosePct, OrigLong, 0m); // 当前代码:posiLong=orig 100M - var byReal = CallCalcNotionalByMode(longLeg, ClosePct, RealLong * ClosePct, RealLong, 0m); // 若修正为 real 75.6M - Console.WriteLine($"[多头存续名义本金] orig100M→close={byOrig.close} ; real75.6M→close={byReal.close}"); - Assert.AreEqual(OrigLong * ClosePct, byOrig.close, "现状:多头存续名义本金用 orig 100M → 多次部分平仓后会偏大"); - Assert.AreEqual(RealLong * ClosePct, byReal.close, "正确应:用 real 剩余本金 75.6M"); - Assert.AreNotEqual(byOrig.close, byReal.close, "★ 潜在同类 bug:普通收益互换(含多头/空头存续名义本金腿)在多次部分平仓后,posiLong/posiShort 用 orig 会算错——本 deal 无此腿故不触发,属本轮修复范围外"); - } } } diff --git a/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs index e94a452f..ee29d70d 100644 --- a/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs +++ b/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs @@ -505,27 +505,6 @@ namespace YLErp.Modules.SwapModule Assert.AreEqual(918_000m, fe1.InterestPrincipal, "追加预付金 10% 应=Fix×0.1"); } - // ---- 多头/空头存续名义本金(7/8):经同一 CalcDailySimpleInterest,需证明修复通用 ---- - [TestMethod] - public void 多头存续名义本金腿_盘中_部分平仓重置周期7天_应线性缩放() - { - const decimal baseP = 2_000_000m; - var fe = CalcByMode((int)InterestModeEnum.多头存续名义本金, baseP, 0.5m); - Assert.AreEqual(1_000_000m, fe.InterestPrincipal, "多头存续 50% 应=posiLong×0.5"); - var fe1 = CalcByMode((int)InterestModeEnum.多头存续名义本金, baseP, 0.1m); - Assert.AreEqual(200_000m, fe1.InterestPrincipal, "多头存续 10% 应=posiLong×0.1"); - } - - [TestMethod] - public void 空头存续名义本金腿_盘中_部分平仓重置周期7天_应线性缩放() - { - const decimal baseP = 2_000_000m; - var fe = CalcByMode((int)InterestModeEnum.空头存续名义本金, baseP, 0.5m); - Assert.AreEqual(1_000_000m, fe.InterestPrincipal, "空头存续 50% 应=posiShort×0.5"); - var fe1 = CalcByMode((int)InterestModeEnum.空头存续名义本金, baseP, 0.1m); - Assert.AreEqual(200_000m, fe1.InterestPrincipal, "空头存续 10% 应=posiShort×0.1"); - } - // ---- 合约名义本金规模(2):CalcNotionalByMode 默认分支(posiNotional×cp) ---- [TestMethod] public void 合约名义本金规模腿_盘中_部分平仓重置周期7天_应线性缩放() diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index efaa7d3e..6e4aa810 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -937,14 +937,6 @@ namespace YLErp.Modules.SwapModule switch (mode) { - case InterestModeEnum.多头存续名义本金: - closePrincipal = posiLong * closePercent; - posiPrincipal = posiLong; - break; - case InterestModeEnum.空头存续名义本金: - closePrincipal = posiShort * closePercent; - posiPrincipal = posiShort; - break; case InterestModeEnum.追加预付金: case InterestModeEnum.初始预付金: closePrincipal = position.InterestPrincipalFix * closePercent; From 47422b9024957a71be5c2e402647d7773f2b4970 Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 11 Aug 2026 13:03:38 +0800 Subject: [PATCH 30/31] =?UTF-8?q?feat(margin):=20MarginAccount=20=E5=8A=A0?= =?UTF-8?q?=E8=AE=A1=E6=81=AF=E5=85=A5=E5=8F=A3=20AccrueInterest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保证金账户现在具备独立计息能力: AccrueInterest(rate, start, end, boundary, annualDays) 委托 SwapInterest.AccrueSimple(余额×利率×天数/年化)。 保证金利息是券商对客户保证金存款付息, 方向与融资腿相反。 Margin 模块现在完全独立于 CalcNotionalByMode—— 有自己的余额管理 + 计息入口, 不依赖融资腿框架。 测试: 3个计息用例(7天/零余额/释放后减半)。 CalcNotionalByMode 的 mode 5/6 case 仍在(下游路径共用), 待 EOD 归档改走 Margin 路径后再删。 验证: 编译0错误, 全量511测试7失败(基线一致)。 --- .../SwapModule/Margin/MarginLegTest.cs | 51 +++++++++++++++++++ .../SwapModule/Margin/MarginAccount.cs | 20 +++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/UnitTestProject/Modules/SwapModule/Margin/MarginLegTest.cs b/UnitTestProject/Modules/SwapModule/Margin/MarginLegTest.cs index 80f2330e..156eb932 100644 --- a/UnitTestProject/Modules/SwapModule/Margin/MarginLegTest.cs +++ b/UnitTestProject/Modules/SwapModule/Margin/MarginLegTest.cs @@ -1,4 +1,5 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; +using YLErp.Derivatives.Interest; using YLErp.Modules.SwapModule.Margin; namespace UnitTestProject.Modules.SwapModule.Margin @@ -66,5 +67,55 @@ namespace UnitTestProject.Modules.SwapModule.Margin } #endregion + + #region MarginAccount 计息 + + [TestMethod] + public void 计息_单利7天_余额200万年化3pct() + { + var account = new MarginAccount(new MarginBalance(2_000_000m)); + // 200万 × 3% / 365 × 7天 = 1150.68... + var r = account.AccrueInterest( + rate: 0.03m, + startDate: new System.DateTime(2026, 5, 4), + endDate: new System.DateTime(2026, 5, 11), + boundary: AccrualBoundary.StartOnly, + annualDays: 365); + + Assert.IsTrue(r.Accrued > 0, "7天利息应大于0"); + System.Console.WriteLine($"保证金7天利息={r.Accrued}"); + } + + [TestMethod] + public void 计息_零余额_利息为零() + { + var account = new MarginAccount(new MarginBalance(0m)); + var r = account.AccrueInterest(0.03m, + new System.DateTime(2026, 5, 4), new System.DateTime(2026, 5, 11), + AccrualBoundary.StartOnly, 365); + + Assert.AreEqual(0m, r.Accrued); + } + + [TestMethod] + public void 计息_释放后余额减少_利息相应减少() + { + var full = new MarginAccount(new MarginBalance(2_000_000m)); + var half = new MarginAccount(new MarginBalance(2_000_000m)); + half.Withdraw(1_000_000m); + + var rFull = full.AccrueInterest(0.03m, + new System.DateTime(2026, 5, 4), new System.DateTime(2026, 5, 11), + AccrualBoundary.StartOnly, 365); + var rHalf = half.AccrueInterest(0.03m, + new System.DateTime(2026, 5, 4), new System.DateTime(2026, 5, 11), + AccrualBoundary.StartOnly, 365); + + Assert.IsTrue(rHalf.Accrued < rFull.Accrued, "释放后利息应更少"); + Assert.IsTrue(System.Math.Abs(rFull.Accrued - rHalf.Accrued * 2m) < 0.01m, + "余额减半, 利息也应减半"); + } + + #endregion } } diff --git a/YLErpDAL/Modules/SwapModule/Margin/MarginAccount.cs b/YLErpDAL/Modules/SwapModule/Margin/MarginAccount.cs index 504bead1..57dc508f 100644 --- a/YLErpDAL/Modules/SwapModule/Margin/MarginAccount.cs +++ b/YLErpDAL/Modules/SwapModule/Margin/MarginAccount.cs @@ -1,12 +1,16 @@ +using YLErp.Derivatives.Interest; + namespace YLErp.Modules.SwapModule.Margin; /// -/// 保证金账户。管理保证金余额的变动(追加/释放/返还),不涉及利息计算—— -/// 利息由 SwapInterest 纯函数按 余额×利率×天数/年化 计算,保证金账户只提供余额。 +/// 保证金账户。管理保证金余额的变动(追加/释放/返还),并提供计息入口。 /// /// 保证金是独立的资金管理概念(初始保证金/维持保证金/保证金余额/追保), /// 与融资腿(funding leg)完全无关。现有代码把保证金塞进 InterestMode==5/6 /// 当计息腿处理是错误的,本类是正确建模的起点。 +/// +/// 利息计算委托 SwapInterest 纯函数(余额×利率×天数/年化), +/// 保证金账户只提供余额和计息入口,不自己实现计息算法。 /// public sealed class MarginAccount { @@ -23,4 +27,16 @@ public sealed class MarginAccount /// 释放/返还保证金(余额减少,不低于 0)。 public void Withdraw(decimal amount) => Balance = new MarginBalance(Math.Max(0m, Balance.Balance - amount)); + + /// + /// 按当前余额计算保证金利息。委托 SwapInterest.AccrueSimple。 + /// 保证金利息是券商对客户保证金存款付息(方向与融资腿相反)。 + /// + /// 保证金利率(年化,如 0.03 = 3%)。 + /// 计息开始日。 + /// 计息结束日。 + /// 算头算尾规则。 + /// 年化天数(365 或 360)。 + public InterestResult AccrueInterest(decimal rate, System.DateTime startDate, System.DateTime endDate, AccrualBoundary boundary, int annualDays) + => SwapInterest.AccrueSimple(Balance.Balance, rate, startDate, endDate, boundary, annualDays); } From a610b962a57c42299f0136a31ecc417d84e2312d Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 11 Aug 2026 13:06:36 +0800 Subject: [PATCH 31/31] =?UTF-8?q?docs:=20InterestModeEnum=20=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E5=B7=B2=E5=88=A0=E9=99=A4=E6=88=90=E5=91=98=E7=BC=96?= =?UTF-8?q?=E5=8F=B7=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录 3/4/7/8 删除前的名称和编号, 防止误回收。 注释里有数字空位标注(3=已删, 4=已删, 7=已删, 8=已删)。 --- .../YLErp.Core/DBModels/Enums/InterestModeEnum.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Framework/YLErp.Core/DBModels/Enums/InterestModeEnum.cs b/Framework/YLErp.Core/DBModels/Enums/InterestModeEnum.cs index 6645c9db..42c59ad9 100644 --- a/Framework/YLErp.Core/DBModels/Enums/InterestModeEnum.cs +++ b/Framework/YLErp.Core/DBModels/Enums/InterestModeEnum.cs @@ -7,15 +7,23 @@ using System.Threading.Tasks; namespace YLErp.DBModels { /// - /// 计息类型 + /// 计息类型。显式赋值,数字即 DB 存储值,不可随意改动。 + /// + /// 已删除的成员(保留编号记录,避免误回收): + /// 持仓名义本金 = 3 —— 死代码,零引用 + /// 持仓市值 = 4 —— 死代码,零引用 + /// 多头存续名义本金 = 7 —— 界面已禁用,无历史数据 + /// 空头存续名义本金 = 8 —— 界面已禁用,无历史数据 /// public enum InterestModeEnum { Unknown = 0, 固定值 = 1, 合约名义本金规模 = 2, + // 3=持仓名义本金(已删), 4=持仓市值(已删) 初始预付金 = 5, 追加预付金 = 6, + // 7=多头存续名义本金(已删), 8=空头存续名义本金(已删) 标的期初全价 = 9 } ///