diff --git a/Framework/YLErp.Core/DBModels/Consts/ConsMarginTerm.cs b/Framework/YLErp.Core/DBModels/Consts/ConsMarginTerm.cs index 301df0a0..74779a4c 100644 --- a/Framework/YLErp.Core/DBModels/Consts/ConsMarginTerm.cs +++ b/Framework/YLErp.Core/DBModels/Consts/ConsMarginTerm.cs @@ -10,13 +10,33 @@ namespace YLErp.DBModels /// /// 允许配置期限档(SpanConfig.BondTerm)的标的资产类型标志位。 /// 区间追保结构"按资产类型分类"时,仅这些类型允许设置非空期限档; - /// 本期仅含 利率债(TBonds=1<<4=16),后续按资产分类扩展(转债ETF/科创债ETF 等)时在此补充。 + /// 本期仅含 利率债(TBonds=1<<4=16);ETF 子类分档能力见 TermTierEnabledEtfKinds。 /// public static readonly UnderlyingTypeEnum[] TermTierEnabledUnderlyingTypes = { UnderlyingTypeEnum.TBonds }; + /// + /// 允许配置期限档的 ETF 子类白名单(来自数据字典"ETF 子类")。 + /// 选择这些子类的 ETF 区块按固定4档展开(同利率债);其余子类/不区分的 ETF 区块单套参数、不分档。 + /// 取值与 underlying_manager.EtfSubType 字典项一致,由标的维护页维护。 + /// 注意:分档 ETF 子类行的 UnderlyingType 仍为 基金(32768),不走 TermTierEnabledUnderlyingTypes 位掩码(§1.2 决策:不再加枚举位)。 + /// + public static readonly List TermTierEnabledEtfKinds = new List + { + "可转债 ETF", + "科创债 ETF" + }; + + /// + /// 判断 EtfKind 是否允许分档(在 TermTierEnabledEtfKinds 白名单内)。 + /// + public static bool IsTermTierEnabledEtfKind(string etfKind) + { + return !string.IsNullOrEmpty(etfKind) && TermTierEnabledEtfKinds.Contains(etfKind); + } + /// /// 5年以下(同时也是兜底默认期限) /// diff --git a/UnitTestProject/Modules/CalcModules/MarginTemplateV2EtfKindMatchTest.cs b/UnitTestProject/Modules/CalcModules/MarginTemplateV2EtfKindMatchTest.cs new file mode 100644 index 00000000..dc2d4592 --- /dev/null +++ b/UnitTestProject/Modules/CalcModules/MarginTemplateV2EtfKindMatchTest.cs @@ -0,0 +1,116 @@ +using YLErp.DBModels; +using YLErp.Enums; +using YLErp.Modules.MarginModule; + +namespace YLErp.Modules.CalcModules +{ + /// + /// ETF 子类分档取数(MarginTemplateV2RateHelper.PreferCategoryRows 纯函数): + /// 标的有 EtfSubType(基金类,分类判定钩子实装=读 underlying_manager.EtfSubType)时, + /// 期限档匹配结果内优先取 EtfKind=子类 的行;未命中时回落"子类+BondTerm 空"行; + /// 均无则维持原期限档匹配结果(基金通配行兜底)。 + /// + [TestClass] + public class MarginTemplateV2EtfKindMatchTest + { + private static margin_template_detail Row(int id, UnderlyingTypeEnum ut, string etfKind, string bondTerm) + => new() + { + id = id, + UnderlyingType = ut, + SpanConfigJson = null, + SpanConfig = new SpanConfig { EtfKind = etfKind, BondTerm = bondTerm } + }; + + private const string CB = "可转债 ETF"; + private const string STAR = "科创债 ETF"; + + [TestMethod] + public void EK_001_期限档命中子类行_优先取子类() + { + //期限档匹配结果内既有 子类行又有 通配行:取子类行 + var termMatched = new List + { + Row(1, UnderlyingTypeEnum.Fund, CB, "<5y"), //子类+精确档 + Row(2, UnderlyingTypeEnum.Fund, null, "<5y"), //通配+精确档 + }; + var all = new List(termMatched); + var matched = MarginTemplateV2RateHelper.PreferCategoryRows(termMatched, all, CB); + Assert.AreEqual(1, matched.Count); + Assert.AreEqual(1, matched[0].id); + } + + [TestMethod] + public void EK_002_期限档未命中子类行_回落子类空档行() + { + //期限档匹配(>30y)结果内无子类行 → 取"子类+BondTerm 空"的未分档行 + var termMatched = new List + { + Row(2, UnderlyingTypeEnum.Fund, null, null), //通配(BondTerm 空兜底命中) + }; + var all = new List + { + Row(1, UnderlyingTypeEnum.Fund, CB, "<5y"), + Row(3, UnderlyingTypeEnum.Fund, CB, null), //子类未分档行(兜底候选) + Row(2, UnderlyingTypeEnum.Fund, null, null), + }; + var matched = MarginTemplateV2RateHelper.PreferCategoryRows(termMatched, all, CB); + Assert.AreEqual(1, matched.Count); + Assert.AreEqual(3, matched[0].id); + } + + [TestMethod] + public void EK_003_无子类行_维持原期限档匹配结果() + { + //模板无该子类任何行:维持原结果(通配兜底) + var termMatched = new List + { + Row(2, UnderlyingTypeEnum.Fund, null, null), + }; + var all = new List(termMatched) + { + Row(4, UnderlyingTypeEnum.Fund, STAR, "5y-10y"), //别的子类不相关 + }; + var matched = MarginTemplateV2RateHelper.PreferCategoryRows(termMatched, all, CB); + Assert.AreEqual(1, matched.Count); + Assert.AreEqual(2, matched[0].id); + } + + [TestMethod] + public void EK_004_多子类并存_各自命中不串() + { + //转债ETF 与 科创债ETF 各自成组:转债标的只命中转债行 + var termMatched = new List + { + Row(1, UnderlyingTypeEnum.Fund, CB, "<5y"), + Row(4, UnderlyingTypeEnum.Fund, STAR, "<5y"), + Row(2, UnderlyingTypeEnum.Fund, null, "<5y"), + }; + var all = new List(termMatched); + var cbMatched = MarginTemplateV2RateHelper.PreferCategoryRows(termMatched, all, CB); + Assert.AreEqual(1, cbMatched.Count); + Assert.AreEqual(1, cbMatched[0].id); + var starMatched = MarginTemplateV2RateHelper.PreferCategoryRows(termMatched, all, STAR); + Assert.AreEqual(1, starMatched.Count); + Assert.AreEqual(4, starMatched[0].id); + } + + [TestMethod] + public void EK_005_子类行但期限档不带BondTerm_不视为未分档兜底() + { + //子类+分档行(EtfKind=CB + BondTerm 非空)不能当"子类+空档"兜底候选 + var termMatched = new List + { + Row(2, UnderlyingTypeEnum.Fund, null, null), + }; + var all = new List + { + Row(1, UnderlyingTypeEnum.Fund, CB, "<5y"), //分档行不是兜底候选 + Row(2, UnderlyingTypeEnum.Fund, null, null), + }; + var matched = MarginTemplateV2RateHelper.PreferCategoryRows(termMatched, all, CB); + Assert.AreEqual(1, matched.Count); + Assert.AreEqual(2, matched[0].id); + } + } +} diff --git a/YLErpDAL/Modules/MarginModule/MarginTemplateV2RateHelper.cs b/YLErpDAL/Modules/MarginModule/MarginTemplateV2RateHelper.cs index 9a7b854f..f29641a3 100644 --- a/YLErpDAL/Modules/MarginModule/MarginTemplateV2RateHelper.cs +++ b/YLErpDAL/Modules/MarginModule/MarginTemplateV2RateHelper.cs @@ -108,7 +108,7 @@ namespace YLErp.Modules.MarginModule var latestValueDate = detailQuery.Max(x => x.ValueDate); var details = detailQuery.Where(x => x.ValueDate == latestValueDate).ToList(); - //4.利率债期限档匹配:精确档 → "全部"(BondTerm 为空)兜底 + //4.利率债/分档ETF 期限档匹配:精确档 → "全部"(BondTerm 为空)兜底 var term = UnderlyingHelper.GetApplicableMarginTerm(underlyingCode, valueDate); var matched = details.Where(x => x.SpanConfig != null && x.SpanConfig.BondTerm == term).ToList(); if (!matched.Any()) @@ -120,13 +120,12 @@ namespace YLErp.Modules.MarginModule return null; } - //5.标的资产类型匹配(仅当模板选了"按资产类型分类"):精确标志位 → 通配行(None/All)兜底 - //标的细分分类判定钩子:本期默认返回 null → 走下方原有标志位匹配/通配行兜底,行为与现状一致; - //后续需求(转债ETF/科创债ETF/中债指数等细分)实现判定规则后,此处按 (BondTerm, EtfKind) 元组匹配明细行。 + //5.ETF 子类行优先(子类区分度高于期限):标的有 EtfSubType(基金类)时优先取 EtfKind=子类 的行—— + //期限档匹配未命中子类行时再单独尝试"子类 + BondTerm 空"(子类不分档通配);无子类行维持原 matched(基金通配兜底) var underlyingCategory = GetUnderlyingCategory(underlyingCode, underlyingInstrumentType); if (underlyingCategory != null) { - //TODO: 后续需求实现:按 (SpanConfig.BondTerm, SpanConfig.EtfKind) 元组匹配明细行,替代/补充下方标志位匹配 + matched = PreferCategoryRows(matched, details, underlyingCategory); } if (template.UnderlyingSeperateType == (int)UnderlyingSeperateTypeEnum.CustomInstrumentType @@ -157,6 +156,26 @@ namespace YLErp.Modules.MarginModule }; } + /// + /// ETF 子类行优先匹配(纯函数,单测覆盖): + /// termMatched 为期限档匹配结果(含精确档/BondTerm 空兜底),allDetails 为该模板当日全部明细行。 + /// 优先取 termMatched 中 EtfKind=category 的行;没有则取"allDetails 中 EtfKind=category 且 BondTerm 空"的子类不分档行; + /// 都没有时原样返回 termMatched(基金通配行兜底)。 + /// + public static List PreferCategoryRows(List termMatched, + List allDetails, string category) + { + var byCategory = termMatched.Where(x => x.SpanConfig != null && x.SpanConfig.EtfKind == category).ToList(); + if (byCategory.Any()) + { + return byCategory; + } + var categoryWildcard = allDetails + .Where(x => x.SpanConfig != null && x.SpanConfig.EtfKind == category && string.IsNullOrEmpty(x.SpanConfig.BondTerm)) + .ToList(); + return categoryWildcard.Count > 0 ? categoryWildcard : termMatched; + } + /// /// R1 模板三层级找到即停解析(public 供集成测试与后续消费方复用): /// 1.交易绑定 trade_margin_template(自定义/录入时显式选择,ValueDate 最新 ≤ 业务日)——找到即停, @@ -348,12 +367,19 @@ namespace YLErp.Modules.MarginModule } /// - /// 标的细分分类判定钩子(转债ETF/科创债ETF/中债指数等),public 供单元测试回归默认行为。 - /// 本期默认返回 null → 走通配行兜底,行为与现状一致;后续需求按业务给的判定规则(代码段/标的维护字段)实现。 + /// 标的细分分类判定钩子(ETF 子类,2026-08-24 实装):基金类标的返回标的维护页维护的 + /// underlying_manager.EtfSubType(取值来自数据字典"ETF 子类":国债 ETF/政金债 ETF/…/科创债 ETF/可转债 ETF, + /// 基金类必填);非基金或未维护返回 null → 走标志位匹配/通配行兜底(债券/股票等行为与现状一致)。 + /// 后续其他细分(中债指数 IndexKind 等)在本方法内扩展。 /// public static string GetUnderlyingCategory(string underlyingCode, string underlyingInstrumentType) { - return null; + var underlying = DataCacheProvider.GetUnderlyingDataSource().GetData(underlyingCode); + if (underlying == null || underlying.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund) + { + return null; + } + return string.IsNullOrWhiteSpace(underlying.EtfSubType) ? null : underlying.EtfSubType.Trim(); } /// diff --git a/YLErpWeb/Controllers/margin_template_v2Controller.cs b/YLErpWeb/Controllers/margin_template_v2Controller.cs index 80fcb5d0..591b8f45 100644 --- a/YLErpWeb/Controllers/margin_template_v2Controller.cs +++ b/YLErpWeb/Controllers/margin_template_v2Controller.cs @@ -299,7 +299,8 @@ namespace YLErp.Web.Controllers } } - //区间追保结构 + 按资产类型分类时,期限档仅允许"允许分期限档的资产类型"(本期仅利率债)配置,防止信用债等非分档类型误配期限档; + //区间追保结构 + 按资产类型分类时,期限档仅允许"允许分期限档的资产类型"(利率债)或"允许分档的 ETF 子类" + //(可转债 ETF/科创债 ETF,行 UnderlyingType=基金 + EtfKind=子类,不加枚举位)配置,防止非分档类型误配期限档; //该校验按行生效,与明细行数无关(单行明细同样拦截); //按严格掩码判定:行标的类型位必须全部落在可分档类型内(混合标志位如 利率债|信用债 配期限档同样拦截,与取数侧整行期限档过滤语义一致); //UnderlyingType 为空的通配行允许配期限档(取数侧期限档过滤在前、通配匹配在后,语义自洽) @@ -307,13 +308,34 @@ namespace YLErp.Web.Controllers && marginTemplate.UnderlyingSeperateType == (int)UnderlyingSeperateTypeEnum.CustomInstrumentType) { var enabledMask = ConsMarginTerm.TermTierEnabledUnderlyingTypes.Aggregate(UnderlyingTypeEnum.None, (a, t) => a | t); + var tierEnabledEtfKinds = ConsMarginTerm.TermTierEnabledEtfKinds; foreach (var detail in marginTemplate.Details) { + var etfKind = detail.SpanConfig?.EtfKind; + //ETF 子类行:仅允许纯基金行(配置页子类选择器也只在纯基金区块出现) + if (!string.IsNullOrEmpty(etfKind) && detail.UnderlyingType != UnderlyingTypeEnum.Fund) + { + throw new Exception("配置了 ETF 子类(" + etfKind + ")的参数组资产类型必须为 基金及基金专户"); + } + var isTierEnabledEtfKind = tierEnabledEtfKinds.Contains(etfKind ?? ""); if (!string.IsNullOrEmpty(detail.SpanConfig?.BondTerm) - && (detail.UnderlyingType & ~enabledMask) != UnderlyingTypeEnum.None) + && (detail.UnderlyingType & ~enabledMask) != UnderlyingTypeEnum.None + && !isTierEnabledEtfKind) { throw new Exception("标的类型不允许配置期限档:" + UnderlyingTypeUtil.GetDesc(detail.UnderlyingType)); } + //分档 ETF 子类必须配期限档(区块固定4档),其余子类行禁配期限档 + if (!string.IsNullOrEmpty(etfKind)) + { + if (isTierEnabledEtfKind && string.IsNullOrEmpty(detail.SpanConfig?.BondTerm)) + { + throw new Exception("ETF 子类 " + etfKind + " 为分期限档类型,参数行必须配置期限档"); + } + if (!isTierEnabledEtfKind && !string.IsNullOrEmpty(detail.SpanConfig?.BondTerm)) + { + throw new Exception("ETF 子类 " + etfKind + " 不分期限档,参数行不能配置期限档"); + } + } } } diff --git a/YLErpWeb/Views/margin_template_v2/margin_template_v2ClientEdit.cshtml b/YLErpWeb/Views/margin_template_v2/margin_template_v2ClientEdit.cshtml index 815a2769..860ce4b0 100644 --- a/YLErpWeb/Views/margin_template_v2/margin_template_v2ClientEdit.cshtml +++ b/YLErpWeb/Views/margin_template_v2/margin_template_v2ClientEdit.cshtml @@ -10,7 +10,8 @@ isAdd = Model == null || Model.id == 0, marginTemplate = Model, detail = new margin_template_detail() { ValueDate = new DateTime(2020, 1, 1) }, - isGFSM = PS.Config.Company == CompanyEnum.广发商贸 + isGFSM = PS.Config.Company == CompanyEnum.广发商贸, + etfSubtypeItems = YLErp.BLL.DictionaryBLL.GetDictionaryItems("ETF 子类").Select(x => x.Name).ToList() }; } @@ -210,11 +211,14 @@ @*规则15区间追保录入(客户原型全量手工录入,资产类型区块式):纯利率债区块固定展开4个期限档、每档一套参数;其他资产类型区块一套参数、无期限档;x/y 字段保留兼容存量数据*@

- 参数组{{blk.no}}:资产类型:{{underlyingTypeNames(blk.ut)}}(按期限分档:≤5y、(5y-10y]、(10y-30y]、>30y,每个档位独立设置) + 参数组{{blk.no}}:资产类型:{{underlyingTypeNames(blk.ut)}} + @*ETF 子类(仅纯基金区块展示):选 可转债ETF/科创债ETF 分期限档(展开4档),其他子类/不区分不分档*@ +  ETF 子类: + (按期限分档:≤5y、(5y-10y]、(10y-30y]、>30y,每个档位独立设置)

存量 x/y 配置保留但不再展示,请按新区间结构录入。

-

期限档位:{{sec.termLabel}}

+

期限档位:{{sec.termLabel}}