From cd615a612a839f3c3db12b9ac1f76e8f533a01ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=94=A6=E9=BA=9F=20=E7=8E=8B?= Date: Thu, 27 Aug 2026 20:41:53 +0800 Subject: [PATCH] =?UTF-8?q?BugFix=20=E9=A2=84=E4=BB=98=E9=87=91=E5=8F=96?= =?UTF-8?q?=E9=94=99=E4=BA=86=E8=B5=84=E4=BA=A7=E7=B1=BB=E5=9E=8B=E8=A7=84?= =?UTF-8?q?=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...arginTemplateV2InstrumentFirstMatchTest.cs | 144 ++++++++++++++++++ .../MarginTemplateV2RateHelper.cs | 46 +++--- 2 files changed, 168 insertions(+), 22 deletions(-) create mode 100644 UnitTestProject/Modules/CalcModules/MarginTemplateV2InstrumentFirstMatchTest.cs diff --git a/UnitTestProject/Modules/CalcModules/MarginTemplateV2InstrumentFirstMatchTest.cs b/UnitTestProject/Modules/CalcModules/MarginTemplateV2InstrumentFirstMatchTest.cs new file mode 100644 index 00000000..6cf60d56 --- /dev/null +++ b/UnitTestProject/Modules/CalcModules/MarginTemplateV2InstrumentFirstMatchTest.cs @@ -0,0 +1,144 @@ +using YLErp.BLL; +using YLErp.DBModels; +using YLErp.Enums; +using YLErp.Modules.MarginModule; + +namespace YLErp.Modules.CalcModules +{ + /// + /// GetRateByTemplate 资产类型先行匹配回归(连 dev 库,2026-08-27 顺序裁定:先品种后期限): + /// 期限档仅利率债允许配置,品种匹配在期限之前——防止非利率债标的被利率债期限档行截胡 + /// (交易2567 实证:信用债标的按 "<5y" 精确匹配到利率债行,信用债行 BondTerm 空永远不参与)。 + /// 标的代码用库里不存在的代码(GetApplicableMarginTerm 无标的兜底返回 <5y),不依赖真实标的行情数据。 + /// 测试数据全部带 "ZZZ-品种先行测试-" 名称前缀,TestInitialize/TestCleanup 双向清理。 + /// + [TestClass] + public class MarginTemplateV2InstrumentFirstMatchTest + { + private const string Marker = "ZZZ-品种先行测试-"; + private DateTime EffectiveDate = new DateTime(2000, 1, 1); + + private YLContext db; + + [TestInitialize] + public void Init() + { + db = new YLContext(); + Cleanup(); + } + + [TestCleanup] + public void CleanupFixture() + { + Cleanup(); + db.Dispose(); + } + + private void Cleanup() + { + var templateIds = db.margin_template_v2.Where(x => x.Name.StartsWith(Marker)).Select(x => x.id).ToList(); + if (templateIds.Count > 0) + { + db.margin_template_detail.RemoveRange(db.margin_template_detail.Where(x => templateIds.Contains(x.MarginTemplateId))); + db.margin_template_v2.RemoveRange(db.margin_template_v2.Where(x => templateIds.Contains(x.id))); + db.SaveChanges(); + } + } + + private margin_template_v2 AddTieredTemplate() + { + var t = new margin_template_v2 + { + Name = Marker + "分档", + IsDefault = false, + IsForClient = false, + IsValid = true, + TradeTypes = "收益互换", + RuleType = (int)MarginRuleTypeEnum.区间追保结构, + UnderlyingSeperateType = (int)UnderlyingSeperateTypeEnum.CustomInstrumentType, + ValueDate = EffectiveDate + }; + db.margin_template_v2.Add(t); + db.SaveChanges(); + return t; + } + + private void AddDetail(int templateId, UnderlyingTypeEnum underlyingType, string bondTermJson, double initRate, double maintainRate) + { + db.margin_template_detail.Add(new margin_template_detail + { + MarginTemplateId = templateId, + ValueDate = EffectiveDate, + UnderlyingType = underlyingType, + SpanConfigJson = bondTermJson, + MarginRatio1 = initRate, + MarginRatio2 = maintainRate + }); + } + + /// + /// 信用债标的不被利率债期限档行截胡:term 恒为 "<5y"(标的不存在兜底), + /// 旧序会精确命中利率债 <5y 行;新序品种先行应命中信用债行(BondTerm 空)。 + /// + [TestMethod] + public void TI_001_信用债标的_命中信用债行_不被利率债期限档截胡() + { + var tpl = AddTieredTemplate(); + AddDetail(tpl.id, UnderlyingTypeEnum.TBonds, "{\"BondTerm\":\"<5y\"}", 0.11, 0.12); + AddDetail(tpl.id, UnderlyingTypeEnum.CreditBonds, null, 0.05, 0.06); + db.SaveChanges(); + + var rate = MarginTemplateV2RateHelper.GetRateByTemplate(tpl, "ZZZ-NOT-EXIST-CD.IB", "CreditBonds", DateTime.Today, db); + Assert.IsNotNull(rate, "品种先行后信用债行(BondTerm 空)应经期限兜底命中"); + Assert.AreEqual(0.05m, rate.InitRate.Value, "应取信用债行的初始预付金率,而非利率债 <5y 行的 0.11"); + Assert.AreEqual(0.06m, rate.MaintainRate.Value, "应取信用债行的维持预付金率,而非利率债 <5y 行的 0.12"); + } + + /// + /// 利率债标的行为不变:品种命中利率债行后,期限精确档 "<5y" 命中对应期限行(压过 5y-10y 行)。 + /// + [TestMethod] + public void TI_002_利率债标的_品种内期限精确档仍生效() + { + var tpl = AddTieredTemplate(); + AddDetail(tpl.id, UnderlyingTypeEnum.TBonds, "{\"BondTerm\":\"<5y\"}", 0.11, 0.12); + AddDetail(tpl.id, UnderlyingTypeEnum.TBonds, "{\"BondTerm\":\"5y-10y\"}", 0.13, 0.14); + db.SaveChanges(); + + var rate = MarginTemplateV2RateHelper.GetRateByTemplate(tpl, "ZZZ-NOT-EXIST-TB.IB", "TBonds", DateTime.Today, db); + Assert.IsNotNull(rate); + Assert.AreEqual(0.11m, rate.InitRate.Value, "期限兜底 <5y 时应精确命中 <5y 档行"); + Assert.AreEqual(0.12m, rate.MaintainRate.Value); + } + + /// + /// 模板未配标的品种时的既有兜底不变:品种行与通配行均无 → 不缩小行集,回落期限匹配(与旧序一致)。 + /// + [TestMethod] + public void TI_003_模板未配品种_回落期限匹配_行为不变() + { + var tpl = AddTieredTemplate(); + AddDetail(tpl.id, UnderlyingTypeEnum.TBonds, "{\"BondTerm\":\"<5y\"}", 0.11, 0.12); + db.SaveChanges(); + + var rate = MarginTemplateV2RateHelper.GetRateByTemplate(tpl, "ZZZ-NOT-EXIST-CF.IB", "CommodityFutures", DateTime.Today, db); + Assert.IsNotNull(rate, "品种落空应回落到期限匹配(旧行为兜底),不应返回 null"); + Assert.AreEqual(0.11m, rate.InitRate.Value); + } + + /// + /// 品种行与期限行均无法匹配时返回 null:非利率债标的不再"借用"利率债期限档行, + /// 由调用方按无预付金要求兜底(引擎不产出 trade_span)。 + /// + [TestMethod] + public void TI_004_品种与期限均无匹配行_返回null() + { + var tpl = AddTieredTemplate(); + AddDetail(tpl.id, UnderlyingTypeEnum.TBonds, "{\"BondTerm\":\"5y-10y\"}", 0.13, 0.14); + db.SaveChanges(); + + var rate = MarginTemplateV2RateHelper.GetRateByTemplate(tpl, "ZZZ-NOT-EXIST-CD.IB", "CreditBonds", DateTime.Today, db); + Assert.IsNull(rate, "信用债标的不应命中利率债 5y-10y 期限行"); + } + } +} diff --git a/YLErpDAL/Modules/MarginModule/MarginTemplateV2RateHelper.cs b/YLErpDAL/Modules/MarginModule/MarginTemplateV2RateHelper.cs index 99e47346..25999088 100644 --- a/YLErpDAL/Modules/MarginModule/MarginTemplateV2RateHelper.cs +++ b/YLErpDAL/Modules/MarginModule/MarginTemplateV2RateHelper.cs @@ -129,19 +129,39 @@ namespace YLErp.Modules.MarginModule var latestValueDate = detailQuery.Max(x => x.ValueDate); var details = detailQuery.Where(x => x.ValueDate == latestValueDate).ToList(); - //4.利率债/分档ETF 期限档匹配:精确档 → "全部"(BondTerm 为空)兜底 + //4.资产类型先行(2026-08-27 顺序裁定:先品种后期限):按资产类型分档的模板先按标的品种缩小行集—— + //品种行 → 通配行(None/All)→ 均无则不缩小(回落到与旧序一致的期限匹配,模板未配该品种的既有兜底不变)。 + //期限档仅利率债允许配置(ConsMarginTerm),品种匹配必须在期限之前:期限精确匹配对任何标的恒有 term + //(GetApplicableMarginTerm 兜底 <5y),非利率债标的会被利率债期限档行截胡、本品种行(BondTerm 空)永远不参与 + //(2026-08-27 交易2567 实证:信用债标的按 "<5y" 命中利率债行多收追保) + var candidates = details; + if (template.UnderlyingSeperateType == (int)UnderlyingSeperateTypeEnum.CustomInstrumentType + && Enum.TryParse(underlyingInstrumentType, out var instrumentFlag)) + { + var byInstrument = details.Where(x => (x.UnderlyingType & instrumentFlag) > 0).ToList(); + if (!byInstrument.Any()) + { + byInstrument = details.Where(x => x.UnderlyingType == UnderlyingTypeEnum.None || x.UnderlyingType == UnderlyingTypeEnum.All).ToList(); + } + if (byInstrument.Any()) + { + candidates = byInstrument; + } + } + + //5.期限档匹配(利率债四档):精确档 → "全部"(BondTerm 为空)兜底 var term = UnderlyingHelper.GetApplicableMarginTerm(underlyingCode, valueDate); - var matched = details.Where(x => x.SpanConfig != null && x.SpanConfig.BondTerm == term).ToList(); + var matched = candidates.Where(x => x.SpanConfig != null && x.SpanConfig.BondTerm == term).ToList(); if (!matched.Any()) { - matched = details.Where(x => x.SpanConfig == null || string.IsNullOrEmpty(x.SpanConfig.BondTerm)).ToList(); + matched = candidates.Where(x => x.SpanConfig == null || string.IsNullOrEmpty(x.SpanConfig.BondTerm)).ToList(); } if (!matched.Any()) { return null; } - //5.ETF 子类行优先(子类区分度高于期限):标的有 EtfSubType(基金类)时优先取 EtfKind=子类 的行—— + //6.ETF 子类行优先(子类区分度高于期限):标的有 EtfSubType(基金类)时优先取 EtfKind=子类 的行—— //期限档匹配未命中子类行时再单独尝试"子类 + BondTerm 空"(子类不分档通配);无子类行维持原 matched(基金通配兜底) var underlyingCategory = GetUnderlyingCategory(underlyingCode, underlyingInstrumentType); if (underlyingCategory != null) @@ -149,24 +169,6 @@ namespace YLErp.Modules.MarginModule matched = PreferCategoryRows(matched, details, underlyingCategory); } - if (template.UnderlyingSeperateType == (int)UnderlyingSeperateTypeEnum.CustomInstrumentType - && Enum.TryParse(underlyingInstrumentType, out var instrumentFlag)) - { - var byInstrument = matched.Where(x => (x.UnderlyingType & instrumentFlag) > 0).ToList(); - if (byInstrument.Any()) - { - matched = byInstrument; - } - else - { - var wildcard = matched.Where(x => x.UnderlyingType == UnderlyingTypeEnum.None || x.UnderlyingType == UnderlyingTypeEnum.All).ToList(); - if (wildcard.Any()) - { - matched = wildcard; - } - } - } - var detail = matched.First(); return new MarginRateResult {