diff --git a/Framework/YLErp.Core/DBModels/eod_bond_lending_rate.cs b/Framework/YLErp.Core/DBModels/eod_bond_lending_rate.cs new file mode 100644 index 00000000..e1b9c54e --- /dev/null +++ b/Framework/YLErp.Core/DBModels/eod_bond_lending_rate.cs @@ -0,0 +1,66 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace YLErp.DBModels +{ + [Table("eod_bond_lending_rate")] + public class eod_bond_lending_rate + { + [Key] + public long id { get; set; } + + [DisplayName("业务唯一代码")] + public string RecordCode { get; set; } + + [DisplayName("业务日期")] + public DateTime ValueDate { get; set; } + + [DisplayName("标的债券代码")] + public string UnderlyingSecurityId { get; set; } + + [DisplayName("标的债券名称")] + public string UnderlyingSymbol { get; set; } + + [DisplayName("前收盘费率(%)")] + public decimal? PreCloseRate { get; set; } + + [DisplayName("前加权平均费率(%)")] + public decimal? PreWeightedAvgRate { get; set; } + + [DisplayName("开盘费率(%)")] + public decimal? OpenRate { get; set; } + + [DisplayName("最新费率(%)")] + public decimal? LatestRate { get; set; } + + [DisplayName("最高费率(%)")] + public decimal? HighRate { get; set; } + + [DisplayName("最低费率(%)")] + public decimal? LowRate { get; set; } + + [DisplayName("收盘费率(%)")] + public decimal? CloseRate { get; set; } + + [DisplayName("加权平均费率(%)")] + public decimal? WeightedAvgRate { get; set; } + + [DisplayName("成交量(元)")] + public decimal? TurnoverAmount { get; set; } + + [DisplayName("操作人ID")] + public int OptId { get; set; } + + [DisplayName("操作人名称")] + public string OptName { get; set; } + + [DisplayName("操作时间")] + public DateTime OptDate { get; set; } + + [DisplayName("数据来源")] + public string DataSource { get; set; } + + [DisplayName("源行情时间")] + public string SourceTime { get; set; } + } +} diff --git a/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看多】-【债券ETF】-清洁版.docx b/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看多】-【债券ETF】-清洁版.docx index 030e6c1c..8fde2716 100644 Binary files a/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看多】-【债券ETF】-清洁版.docx and b/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看多】-【债券ETF】-清洁版.docx differ diff --git a/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看多】-【现券】-清洁版.docx b/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看多】-【现券】-清洁版.docx index f1f7c61e..5e7127c1 100644 Binary files a/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看多】-【现券】-清洁版.docx and b/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看多】-【现券】-清洁版.docx differ diff --git a/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看空】-【债券ETF】-清洁版.docx b/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看空】-【债券ETF】-清洁版.docx index c6da85b2..61b412b9 100644 Binary files a/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看空】-【债券ETF】-清洁版.docx and b/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看空】-【债券ETF】-清洁版.docx differ diff --git a/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看空】-【现券】-清洁版.docx b/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看空】-【现券】-清洁版.docx index 19243728..b81fc9e7 100644 Binary files a/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看空】-【现券】-清洁版.docx and b/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看空】-【现券】-清洁版.docx differ diff --git a/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs b/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs index d7d32888..e626cb4b 100644 --- a/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs +++ b/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs @@ -8,6 +8,7 @@ using YLErp.Modules.MarginModule; using YLErp.Modules.UnderlyingModule; using YLErp.Plugins.TradeDocGenerator; using YLErp.Plugins.TradeDocGenerator.Abstracts; +using YieldChain.Helpers; namespace YLErp.Plugins.GuoLian.DocumentGenerator { @@ -245,11 +246,26 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator } dic["主协议编号"] = mainProtocolCode ?? ""; - // 主协议类型:仅 NAFMII 使用中国银行间市场协议,其余类型按 SAC 展示。 + // 主协议类型取客户开户维护的下拉选择(client_meta.MainProtocolType):1=NAFMII,其余按 SAC。 var mainProtocolType = Context.GetClientMeta(client.id, "MainProtocolType")?.MetaValue; var isNafmii = mainProtocolType == "1"; - dic["IsSac"] = !isNafmii; - dic["IsNafmii"] = isNafmii; + if (string.IsNullOrWhiteSpace(mainProtocolType)) + { + LogFactory.GetLogger("确认书生成").Info($"客户 {client.Number} {client.Name} 未维护主协议类型,确认书协议段按 SAC 勾选展示"); + } + + // 协议段勾选框对应模板占位符,按客户签署的主协议类型动态勾选,替代模板中硬编码的 Wingdings 2 复选框 + static string Check(bool on) => on ? "☑" : "□"; + dic["主协议SAC勾选"] = Check(!isNafmii); + dic["主协议NAFMII勾选"] = Check(isNafmii); + dic["补充协议SAC勾选"] = Check(!isNafmii); + dic["补充协议NAFMII勾选"] = Check(isNafmii); + // 定义文件签署维度暂无客户协议数据,统一展示空框,待客户资料补齐后接入 + dic["协会证券业勾选"] = "□"; + dic["协会交易商勾选"] = "□"; + dic["定义文件商品勾选"] = "□"; + dic["定义文件利率勾选"] = "□"; + dic["定义文件债券勾选"] = "□"; // 补充协议编号:优先取 client 表字段,为空时从 client_meta 表兜底 var supProtocolCode = client.SupProtocolCode; diff --git a/UnitTestProject/Modules/EodModule/TrsContractKafkaPushServiceTest.cs b/UnitTestProject/Modules/EodModule/TrsContractKafkaPushServiceTest.cs index 88ffb133..5c31e45a 100644 --- a/UnitTestProject/Modules/EodModule/TrsContractKafkaPushServiceTest.cs +++ b/UnitTestProject/Modules/EodModule/TrsContractKafkaPushServiceTest.cs @@ -142,6 +142,88 @@ namespace YLErp.Modules.EodModuleTests Assert.AreEqual(0m, item.InitMarginLoss); } + [TestMethod] + public void BuildContract_历史类别为空_仍推送全部TRS合约() + { + var valueDate = new DateTime(2026, 2, 10); + var eodSwap = new eod_swap { ValueDate = valueDate, SwapTradeId = 1645, SwapTradeNo = "ZSZQ-IS-202602090001" }; + var positions = new List + { + new() { SwapTradeId = 1645, PositionId = 33306, UnderlyingCode = "220208.IB", PositionType = 1 }, + new() { SwapTradeId = 1645, PositionId = 33307, InterestMode = (int)InterestModeEnum.标的期初全价, InterestRateDefault = 0.001m, InterestDirection = 1 } + }; + var swapPositions = new Dictionary + { + [33306] = new() { id = 33306, category_tag = null }, + [33307] = new() { id = 33307, category_tag = null } + }; + + var item = TrsContractKafkaPushService.BuildContract( + eodSwap, + new Dictionary { [1645] = new() { id = 1645, UnderlyingCode = "220208.IB" } }, + positions, + swapPositions); + + Assert.AreEqual(0m, item.FixedRate); + Assert.AreEqual(1, item.InterestDirection); + Assert.AreEqual(1, item.FloatingDirection); + } + + [TestMethod] + public void BuildContract_增强收益腿不参与固定利率取值() + { + var eodSwap = new eod_swap { ValueDate = new DateTime(2026, 8, 24), SwapTradeId = 7 }; + var positions = new List + { + new() { SwapTradeId = 7, PositionId = 101, UnderlyingCode = "600000.SH", PositionType = 2 }, + new() { SwapTradeId = 7, PositionId = 102, InterestMode = (int)InterestModeEnum.固定值, InterestRateDefault = 0.0123m, InterestDirection = 1 }, + new() { SwapTradeId = 7, PositionId = 103, InterestMode = (int)InterestModeEnum.固定值, InterestRateDefault = 0.0999m, InterestDirection = 2 } + }; + var swapPositions = new Dictionary + { + [101] = new() { id = 101, category_tag = null }, + [102] = new() { id = 102, category_tag = "互换利率" }, + [103] = new() { id = 103, category_tag = "增强收益" } + }; + + var item = TrsContractKafkaPushService.BuildContract( + eodSwap, + new Dictionary { [7] = new() { id = 7, UnderlyingCode = "600000.SH" } }, + positions, + swapPositions); + + Assert.AreEqual(0.0123m, item.FixedRate); + Assert.AreEqual(1, item.InterestDirection); + Assert.AreEqual(2, item.FloatingDirection); + } + + [TestMethod] + public void BuildContract_多条互换利率腿_取第一条() + { + var eodSwap = new eod_swap { ValueDate = new DateTime(2026, 8, 24), SwapTradeId = 7 }; + var positions = new List + { + new() { SwapTradeId = 7, PositionId = 101, UnderlyingCode = "600000.SH", PositionType = 1 }, + new() { SwapTradeId = 7, PositionId = 102, InterestMode = (int)InterestModeEnum.固定值, InterestRateDefault = 0.0123m, InterestDirection = 1 }, + new() { SwapTradeId = 7, PositionId = 103, InterestMode = (int)InterestModeEnum.固定值, InterestRateDefault = 0.0456m, InterestDirection = 2 } + }; + var swapPositions = new Dictionary + { + [101] = new() { id = 101, category_tag = null }, + [102] = new() { id = 102, category_tag = "互换利率" }, + [103] = new() { id = 103, category_tag = "互换利率" } + }; + + var item = TrsContractKafkaPushService.BuildContract( + eodSwap, + new Dictionary { [7] = new() { id = 7, UnderlyingCode = "600000.SH" } }, + positions, + swapPositions); + + Assert.AreEqual(0.0123m, item.FixedRate); + Assert.AreEqual(1, item.InterestDirection); + } + private static TestableTrsContractKafkaPushService CreateService(RecordingKafkaProducer producer, DateTime valueDate) { return new TestableTrsContractKafkaPushService( diff --git a/UnitTestProject/Modules/SwapModule/SwapAdditionalMarginCalcTest.cs b/UnitTestProject/Modules/SwapModule/SwapAdditionalMarginCalcTest.cs index f1d27fa9..7069ed32 100644 --- a/UnitTestProject/Modules/SwapModule/SwapAdditionalMarginCalcTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapAdditionalMarginCalcTest.cs @@ -94,6 +94,23 @@ namespace YLErp.Modules.SwapModule Assert.AreEqual(20, day3, 1e-6); } + /// + /// 授信垫付的初始预付金计入已缴(2026-08-27 修正,交易2538实证): + /// 初始预付金走授信腿不产生应付预付金流水(现金净收额=0),已缴初始=授信初始占用净额—— + /// 目标追加 = 维持 −(现金净收额+初始授信占用净额),否则每个结算日按维持全额重复开追加。 + /// + [TestMethod] + public void AM_009_授信初始预付金计入已缴_不重复追加() + { + //交易2538 首日实际数据:维持2,808,000;初始预付金2,000,000全走授信(现金净收额0) + var payableNetCash = 0d; + var initCreditNet = 2_000_000d; + var target = SwapAdditionalMarginCalc.CalcTarget(2_808_000, payableNetCash + initCreditNet); + Assert.AreEqual(808_000, target, 1e-6); + + //对照:修正前只扣现金净收额 → 目标2,808,000 全额追加(多收2,000,000 授信占用,即本BUG) + } + /// /// 追保回落(维持下降到应付之下)不返还:目标为 0 → 已补足保持,新增 0; /// 超付部分由可用资金公式的负缺口(Σ维持−累计)体现,不产生返还记录。 diff --git a/UnitTestProject/Modules/SwapModule/SwapSpanMarginCalcTest.cs b/UnitTestProject/Modules/SwapModule/SwapSpanMarginCalcTest.cs index 9407a1c6..1c092896 100644 --- a/UnitTestProject/Modules/SwapModule/SwapSpanMarginCalcTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapSpanMarginCalcTest.cs @@ -131,6 +131,55 @@ namespace YLErp.Modules.SwapModule Assert.AreSame(tiers[3], SwapSpanMarginCalc.MatchTier(tiers, false, 2.00)); } + /// + /// 层间空隙不落最深层(2026-08-27 修正,交易2538实证): + /// 真实模板空头档位 (−∞,0.99]∪(1.00,1.03],(0.99,1.00] 为价格在期初附近小幅波动的"未触发追保"空隙—— + /// 此前兜底一律按最深层计,ratio≈0.99999 被错误收取最深档(0.04×基数)。 + /// 修正后:空隙返回 null(追加保证金按0);仅穿出最深层边界(ratio>最深上界)才按最深层计。 + /// + [TestMethod] + public void SS_008_空头层间空隙_不落最深层_追加为零() + { + //模板"保证金测试01"实际配置形态:第1层 (−∞,0.99],第2~4层 (1.00,1.03] + var tiers = new List + { + Tier(null, 0.99, 0.01), + Tier(1.00, 1.01, 0.02), + Tier(1.01, 1.02, 0.03), + Tier(1.02, 1.03, 0.04), + }; + //交易2538 08-24:净价100 → ratio = 100/100.0011 ≈ 0.99999 落 (0.99,1.00] 空隙 → 不追保 + Assert.IsNull(SwapSpanMarginCalc.MatchTier(tiers, false, 0.99999)); + Assert.IsNull(SwapSpanMarginCalc.MatchTier(tiers, false, 0.995)); + //空隙边界归第1层 + Assert.AreSame(tiers[0], SwapSpanMarginCalc.MatchTier(tiers, false, 0.99)); + //空隙上方正常落档 + Assert.AreSame(tiers[1], SwapSpanMarginCalc.MatchTier(tiers, false, 1.005)); + Assert.AreSame(tiers[2], SwapSpanMarginCalc.MatchTier(tiers, false, 1.015)); + Assert.AreSame(tiers[3], SwapSpanMarginCalc.MatchTier(tiers, false, 1.025)); + //穿出最深层上界 → 按最深层计(原有语义不变) + Assert.AreSame(tiers[3], SwapSpanMarginCalc.MatchTier(tiers, false, 1.0301)); + } + + /// + /// 多头层间空隙同理:空隙返回 null(追加0),仅跌破最深层下界才按最深层计。 + /// + [TestMethod] + public void SS_009_多头层间空隙_不落最深层() + { + var tiers = new List + { + Tier(0.98, null, 0.01), + Tier(0.96, 0.97, 0.03), //留出 (0.97,0.98) 空隙 + Tier(0.95, 0.96, 0.04), + }; + Assert.IsNull(SwapSpanMarginCalc.MatchTier(tiers, true, 0.975)); + Assert.AreSame(tiers[1], SwapSpanMarginCalc.MatchTier(tiers, true, 0.9699)); + Assert.AreSame(tiers[2], SwapSpanMarginCalc.MatchTier(tiers, true, 0.955)); + //穿出最深层下界 → 按最深层计(原有语义不变) + Assert.AreSame(tiers[2], SwapSpanMarginCalc.MatchTier(tiers, true, 0.94)); + } + [TestMethod] public void SS_008_无可用层_返回null() { diff --git a/UnitTestProject/PluginTests/TestGuoLianConfirmTemplate.cs b/UnitTestProject/PluginTests/TestGuoLianConfirmTemplate.cs new file mode 100644 index 00000000..16135144 --- /dev/null +++ b/UnitTestProject/PluginTests/TestGuoLianConfirmTemplate.cs @@ -0,0 +1,89 @@ +using System.IO.Compression; +using System.Text; +using Newtonsoft.Json.Linq; +using YLErp.Office.Converters; + +namespace YLErp.PluginTests +{ + /// + /// 国联确认书模板协议段勾选框占位符化后的渲染验证: + /// 客户开户维护的主协议类型(client_meta.MainProtocolType,1=NAFMII,其余 SAC) + /// 决定协议段 ☑/□ 勾选位置,模板不再依赖 Wingdings 2 硬编码复选框。 + /// + [TestClass] + public class TestGuoLianConfirmTemplate + { + private static string TemplatePath => + Path.Combine(AppContext.BaseDirectory, "..\\..\\..\\..\\Plugins\\YLErp.Plugins.GuoLian\\App_Docs\\contract_template", + "国联民生-收益互换交易确认书-境内模板-【客户看多】-【现券】-清洁版.docx"); + + private static string OutputDir => Path.Combine(AppContext.BaseDirectory, "TestOutputs"); + + private static JsonVarDic BuildVarDic(bool isNafmii) + { + var varDic = new JsonVarDic(); + varDic.SetVar("合同编号", "GL-20260827-001"); + varDic.SetVar("客户名称", "测试客户"); + varDic.SetVar("主协议编号", "GL-ZU-2026-001"); + varDic.SetVar("补充协议编号", "GL-BC-2026-001"); + + static string Check(bool on) => on ? "☑" : "□"; + varDic.SetVar("主协议SAC勾选", Check(!isNafmii)); + varDic.SetVar("主协议NAFMII勾选", Check(isNafmii)); + varDic.SetVar("补充协议SAC勾选", Check(!isNafmii)); + varDic.SetVar("补充协议NAFMII勾选", Check(isNafmii)); + varDic.SetVar("协会证券业勾选", "□"); + varDic.SetVar("协会交易商勾选", "□"); + varDic.SetVar("定义文件商品勾选", "□"); + varDic.SetVar("定义文件利率勾选", "□"); + varDic.SetVar("定义文件债券勾选", "□"); + // 利率类型勾选框(同模板内另一处 ☑/□ 占位符),一并驱动以便预览完整 + varDic.SetVar("IsFixed", isNafmii ? "□" : "☑"); + varDic.SetVar("IsFloat", isNafmii ? "☑" : "□"); + return varDic; + } + + private static string RenderAndReadBody(bool isNafmii, string fileName) + { + Directory.CreateDirectory(OutputDir); + var targetPath = Path.Combine(OutputDir, fileName); + OfficeFileConverter.ConvertByUsingDocTemplate(TemplatePath, targetPath, BuildVarDic(isNafmii)); + + using var zip = ZipFile.OpenRead(targetPath); + var entry = zip.GetEntry("word/document.xml"); + using var stream = entry.Open(); + using var reader = new StreamReader(stream, Encoding.UTF8); + var xml = reader.ReadToEnd(); + + // 勾选框与协议名称分属不同 run,断言前拼接纯文本 + var plainText = new StringBuilder(); + foreach (System.Text.RegularExpressions.Match m in System.Text.RegularExpressions.Regex.Matches(xml, @"]*>(.*?)")) + { + plainText.Append(m.Groups[1].Value); + } + return plainText.ToString(); + } + + [TestMethod("测试-国联确认书-SAC客户协议段勾选")] + public void TestSacAgreementChecked() + { + var body = RenderAndReadBody(isNafmii: false, "guolian_confirm_sac.docx"); + + Assert.IsTrue(body.Contains("☑《中国证券期货市场衍生品交易主协议》/"), "SAC 客户主协议应勾选 SAC"); + Assert.IsTrue(body.Contains("□《中国银行间市场金融衍生产品交易主协议(2009年版)》"), "SAC 客户主协议不应勾选 NAFMII"); + Assert.IsTrue(body.Contains("☑《中国证券期货市场衍生品交易主协议》补充协议"), "SAC 客户补充协议应勾选 SAC"); + Assert.IsFalse(body.Contains("{{主协议SAC勾选}}"), "协议段占位符应全部被替换"); + } + + [TestMethod("测试-国联确认书-NAFMII客户协议段勾选")] + public void TestNafmiiAgreementChecked() + { + var body = RenderAndReadBody(isNafmii: true, "guolian_confirm_nafmii.docx"); + + Assert.IsTrue(body.Contains("□《中国证券期货市场衍生品交易主协议》/"), "NAFMII 客户主协议不应勾选 SAC"); + Assert.IsTrue(body.Contains("☑《中国银行间市场金融衍生产品交易主协议(2009年版)》"), "NAFMII 客户主协议应勾选 NAFMII"); + Assert.IsTrue(body.Contains("☑《中国银行间市场金融衍生产品交易主协议(2009年版)补充协议》"), "NAFMII 客户补充协议应勾选 NAFMII"); + Assert.IsFalse(body.Contains("{{主协议NAFMII勾选}}"), "协议段占位符应全部被替换"); + } + } +} diff --git a/YLErpDAL/BLL/EodSettlement/ClientBalanceUtility.cs b/YLErpDAL/BLL/EodSettlement/ClientBalanceUtility.cs index 52ffb2ca..a4cc847b 100644 --- a/YLErpDAL/BLL/EodSettlement/ClientBalanceUtility.cs +++ b/YLErpDAL/BLL/EodSettlement/ClientBalanceUtility.cs @@ -1627,8 +1627,10 @@ namespace YLErp.BLL.EodSettlement VmFundSum = roundFunc(item.VmFundSum), OtherFund = roundFunc(item.OtherFund), AmountFund = roundFunc(item.AmountFund), - MySideMargin = roundFunc(item.MySideMargin), - MaintenanceMargin = roundFunc(item.MaintenanceMargin), + //初始/维持保证金金额与每日估值报告同口径(SettlementReportService 账户状况块): + //初始=应付预付金流水净额(SwapInitMargin),维持=−MySideMargin(client_span 反号聚合) + MySideMargin = roundFunc(item.SwapInitMargin), + MaintenanceMargin = roundFunc(-item.MySideMargin), SwapMarketAmount = roundFunc(item.SwapMarketAmount), SwapMarketAmountPercent = item.SwapMarketAmountPercent, AvailableAmount = roundFunc(item.AvailableAmount), diff --git a/YLErpDAL/DataBase/YLContext.cs b/YLErpDAL/DataBase/YLContext.cs index 1c828a21..064a77ef 100644 --- a/YLErpDAL/DataBase/YLContext.cs +++ b/YLErpDAL/DataBase/YLContext.cs @@ -255,6 +255,7 @@ namespace YLErp.BLL public DbSet eod_trade_risk_hedgevol_s { get; set; } public DbSet eod_trade_risk_openvol_s { get; set; } public DbSet eod_currency_rate { get; set; } + public DbSet eod_bond_lending_rate { get; set; } public DbSet Stock_BlackWhite { get; set; } public DbSet SalesCommissionInfo { get; set; } public DbSet SalesCommissionDetail { get; set; } diff --git a/YLErpDAL/Modules/DataProviderModule/EodPriceQueryService.cs b/YLErpDAL/Modules/DataProviderModule/EodPriceQueryService.cs index 23e1dd26..b6db9bed 100644 --- a/YLErpDAL/Modules/DataProviderModule/EodPriceQueryService.cs +++ b/YLErpDAL/Modules/DataProviderModule/EodPriceQueryService.cs @@ -280,6 +280,36 @@ namespace YLErp.Modules.DataProviderModule return bondPrice; } /// + /// 获取债券借贷费率最新行情 + /// + /// + /// + /// + public static eod_bond_lending_rate GetBondLendingRate(DateTime valueDate, string underlyingSecurityId) + { + if (string.IsNullOrWhiteSpace(underlyingSecurityId)) + { + return null; + } + + using var db = DbContextFactory.GetYLDbContext(); + return db.eod_bond_lending_rate + .Where(x => x.UnderlyingSecurityId == underlyingSecurityId && x.ValueDate <= valueDate.Date) + .OrderByDescending(x => x.ValueDate) + .FirstOrDefault(); + } + /// + /// 尝试获取债券借贷费率最新行情 + /// + /// + /// + /// + /// + public static bool TryGetBondLendingRate(DateTime valueDate, string underlyingSecurityId, out eod_bond_lending_rate bondLendingRate) + { + return (bondLendingRate = GetBondLendingRate(valueDate, underlyingSecurityId)) != null; + } + /// /// 获取标的收盘价格 /// /// 标的代码 diff --git a/YLErpDAL/Modules/EodModule/SettlementModule/EodCheckMonitoredTrade.cs b/YLErpDAL/Modules/EodModule/SettlementModule/EodCheckMonitoredTrade.cs index 1e3a79a6..cf64c1f7 100644 --- a/YLErpDAL/Modules/EodModule/SettlementModule/EodCheckMonitoredTrade.cs +++ b/YLErpDAL/Modules/EodModule/SettlementModule/EodCheckMonitoredTrade.cs @@ -1,4 +1,4 @@ -using System.Linq.Expressions; +using System.Linq.Expressions; using YLErp.Helpers; using YLErp.Model; using YLErp.Modules.DataProviderModule; @@ -170,7 +170,7 @@ namespace YLErp.Modules.EodModule.SettlementModule var yesterday = settleDate.AddDays(i - 1); var today = settleDate.AddDays(i); new SwapEodPositionService(OptUser).SwapPositionCompose(today, yesterday, clientIds); - // CalculateMargin(today, yesterday, clientIds); + CalculateMargin(today, yesterday, clientIds); new SwapEodPositionService(OptUser).SwapEodCompose(today, yesterday, clientIds); } diff --git a/YLErpDAL/Modules/EodModule/TrsContractKafkaPushService.cs b/YLErpDAL/Modules/EodModule/TrsContractKafkaPushService.cs index 0efc85e1..60f59edf 100644 --- a/YLErpDAL/Modules/EodModule/TrsContractKafkaPushService.cs +++ b/YLErpDAL/Modules/EodModule/TrsContractKafkaPushService.cs @@ -140,7 +140,7 @@ namespace YLErp.Modules.EodModule .ToList(); var positionIds = eodPositions.Select(x => x.PositionId).Distinct().ToList(); var swapPositions = _dbContext.swap_position - .Where(x => positionIds.Contains(x.id) && !x.Invalid && x.category_tag == InterestCategory) + .Where(x => positionIds.Contains(x.id) && !x.Invalid) .AsNoTracking() .ToDictionary(x => x.id); @@ -167,18 +167,27 @@ namespace YLErp.Modules.EodModule } var positions = eodPositions.Where(x => x.SwapTradeId == eodSwap.SwapTradeId).ToList(); - var floating = positions.Where(x => !string.IsNullOrWhiteSpace(x.UnderlyingCode) && swapPositions.ContainsKey(x.PositionId)).ToList(); - var interest = positions.Where(x => string.IsNullOrWhiteSpace(x.UnderlyingCode) + var floating = positions.Where(x => !string.IsNullOrWhiteSpace(x.UnderlyingCode)).ToList(); + var interestCandidates = positions.Where(x => string.IsNullOrWhiteSpace(x.UnderlyingCode) && ConsTrade.InterestModels.Contains(x.InterestMode) && swapPositions.TryGetValue(x.PositionId, out var swapPosition) - && swapPosition.category_tag == InterestCategory).ToList(); + && (swapPosition.category_tag == InterestCategory || string.IsNullOrWhiteSpace(swapPosition.category_tag))) + .ToList(); - if (floating.Count != 1 || interest.Count != 1) + // 互换利率腿优先;同类别多腿按当前查询顺序取第一条。历史类别为空时保留利息方向, + // 但 fixedRate 按约定置 0,避免把未标注类别的历史值当作已确认利率。 + var interest = interestCandidates.FirstOrDefault(x => + swapPositions.TryGetValue(x.PositionId, out var swapPosition) + && swapPosition.category_tag == InterestCategory); + var isUncategorizedInterest = interest == null && interestCandidates.Count > 0; + interest ??= interestCandidates.FirstOrDefault(); + + if (floating.Count != 1 || interest == null) { - throw new InvalidOperationException($"TRS legs invalid, swapTradeId:{eodSwap.SwapTradeId}, floating:{floating.Count}, interest:{interest.Count}"); + throw new InvalidOperationException($"TRS legs invalid, swapTradeId:{eodSwap.SwapTradeId}, floating:{floating.Count}, interest:{(interest == null ? 0 : 1)}"); } - var interestLeg = interest[0]; + var interestLeg = interest; var floatingLeg = floating[0]; return new TrsContractSnapshotItem { @@ -193,7 +202,7 @@ namespace YLErp.Modules.EodModule Dv01 = eodSwap.dv01 ?? 0, StartDate = trade.StartDate?.ToString(DateFormat), MaturityDate = trade.ExerciseDate?.ToString(DateFormat), - FixedRate = interestLeg.InterestRateDefault, + FixedRate = isUncategorizedInterest ? 0 : interestLeg.InterestRateDefault, InterestDirection = interestLeg.InterestDirection, FloatingDirection = floatingLeg.PositionType, InitMarginGain = eodSwap.InitMarginGain, diff --git a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs index c8b74717..c5a5fc68 100644 --- a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs +++ b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs @@ -26,6 +26,13 @@ namespace YLErp.Modules.RiskEngine /// public string TriggerPoint { get; set; } + /// + /// 当前平仓请求提交的平仓日期 + /// + public DateTime? UnwindDate { get; set; } + + public DateTime? PayDate { get; set; } + /// /// 数据库上下文,供规则公式直接查询数据库 /// @@ -396,6 +403,8 @@ namespace YLErp.Modules.RiskEngine { TradeId = ctx.TradeId, TriggerPoint = ctx.TriggerPoint, + UnwindDate = ctx.UnwindDate, + PayDate = ctx.PayDate, DbContext = ctx.DbContext }; return runner(globals).GetAwaiter().GetResult(); diff --git a/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs b/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs index 5109aee0..8ea8d558 100644 --- a/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs +++ b/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs @@ -14,11 +14,33 @@ namespace YLErp.Modules.RiskEngine /// public static class RiskCalendarHelper { + private const string CalendarDateFormat = "yyyy,MM,dd"; + + /// + /// 日历数据异常直接影响平仓日期校验和上一交易日取数,单独使用固定 logger 名称便于线上按模块检索。 + /// + private static readonly IYcLogger Logger = LogFactory.GetLogger("RiskCalendarHelper"); + + /// + /// calendar.HolidayJson 历史主格式是 yyyy,MM,dd;这里额外兼容常见日期格式,避免历史数据被静默当作交易日。 + /// 解析后统一转成 DateTime.Date,后续交易日判断不再依赖原始字符串格式。 + /// + private static readonly string[] SupportedCalendarDateFormats = + { + CalendarDateFormat, + "yyyy/MM/dd", + "yyyy-MM-dd", + "yyyyMMdd", + "yyyy-MM-ddTHH:mm:ss", + "yyyy-MM-ddTHH:mm:ssK", + "yyyy-MM-ddTHH:mm:ss.fffK" + }; + /// /// 按当前风控执行使用的数据库上下文保存日历缓存,同一次风控检查内复用,数据库上下文释放后不阻止缓存被回收。 /// - private static readonly ConditionalWeakTable>> HolidayCaches = - new ConditionalWeakTable>>(); + private static readonly ConditionalWeakTable>> HolidayCaches = + new ConditionalWeakTable>>(); /// /// 获取指定日期的上一银行间交易日。 @@ -33,6 +55,24 @@ namespace YLErp.Modules.RiskEngine return GetPreviousTradingDay(dbContext, date, "IB", "银行间"); } + /// + /// 判断指定日期是否为银行间交易日。 + /// + /// 当前风控执行使用的数据库上下文。 + /// 待判断的平仓日期。 + /// 日期不在银行间非交易日集合中时返回 true。 + /// 数据库上下文为空。 + /// 缺少银行间日历或日历内容异常。 + public static bool IsInterbankTradingDay(YLContext dbContext, DateTime date) + { + if (dbContext == null) + throw new ArgumentNullException(nameof(dbContext)); + + var holidayCache = HolidayCaches.GetOrCreateValue(dbContext); + var holidays = GetHolidays(dbContext, date.Year, "IB", "银行间", holidayCache); + return !holidays.Contains(date.Date); + } + public static DateTime GetPreviousExchangeTradingDay(YLContext dbContext, DateTime date) { return GetPreviousTradingDay(dbContext, date, "CHN", "交易所"); @@ -51,10 +91,9 @@ namespace YLErp.Modules.RiskEngine for (var i = 0; i < 370; i++) { var holidays = GetHolidays(dbContext, currentDate.Year, country, calendarName, holidayCache); - var currentDateText = currentDate.ToString("yyyy,MM,dd", CultureInfo.InvariantCulture); // calendar.HolidayJson 存的是非交易日;不在非交易日集合内,即认为是对应市场的交易日。 - if (!holidays.Contains(currentDateText)) + if (!holidays.Contains(currentDate)) return currentDate; currentDate = currentDate.AddDays(-1); @@ -64,30 +103,51 @@ namespace YLErp.Modules.RiskEngine } /// - /// 获取指定年份的银行间非交易日集合。 + /// 获取指定年份的非交易日集合。 /// /// 当前风控执行使用的数据库上下文。 /// 日历年份。 /// 单次风控检查内按市场和年份共享的非交易日缓存。 - /// 格式为 yyyy,MM,dd 的非交易日集合。 - private static HashSet GetHolidays(YLContext dbContext, int year, string country, string calendarName, Dictionary> holidayCache) + /// 按 Date 归一化后的非交易日集合。 + private static HashSet GetHolidays(YLContext dbContext, int year, string country, string calendarName, Dictionary> holidayCache) { - // 缓存需要同时区分市场和年份,避免银行间与交易所同一年日历相互串用。 - var cacheKey = $"{country.ToUpperInvariant()}:{year}"; + var normalizedCountry = NormalizeCountry(country); + var cacheKey = $"{normalizedCountry}:{year}"; if (holidayCache.TryGetValue(cacheKey, out var holidays)) return holidays; - // 同一年可能存在多种市场日历,按规则对应的市场代码读取非交易日。 - var calendar = dbContext.calendar - .Where(c => c.Year == year && (c.ValidState == null || c.ValidState != ConsGlobal.InValid)) - .ToList() - .FirstOrDefault(c => string.Equals(c.Country, country, StringComparison.OrdinalIgnoreCase)); + // 先按年份和有效状态缩小范围,再在内存里做 Trim + ToUpperInvariant 匹配,兼容历史 Country 存在大小写或前后空格的情况。 + // 这里没有在数据库查询里直接 Trim,是为了避免不同 EF/数据库提供方对字符串函数翻译不一致。 + var validCalendars = dbContext.calendar + .Where(c => c.Year == year + && (c.ValidState == null || c.ValidState != ConsGlobal.InValid) + && c.Country != null) + .ToList(); + var matchedCalendars = validCalendars.Where(c => NormalizeCountry(c.Country) == normalizedCountry).ToList(); + var calendar = matchedCalendars.FirstOrDefault(); if (calendar == null) - throw new Exception($"未找到{year}年{calendarName}日历"); + { + var availableCountries = string.Join(",", validCalendars.Select(c => c.Country?.Trim()).Where(c => !string.IsNullOrWhiteSpace(c)).Distinct()); + Logger.Error($"[风控日历] 未找到目标市场日历 - Year:{year}, CalendarName:{calendarName}, Country:{normalizedCountry}, AvailableCountries:{availableCountries}"); + throw new Exception($"未找到{year}年{calendarName}日历,Country={normalizedCountry},当前可用日历:{availableCountries}"); + } + if (matchedCalendars.Count > 1) + { + // 多条匹配不改变原有“取第一条”的行为,只记录数据质量问题,避免线上突然因历史重复配置中断风控。 + Logger.Info($"[风控日历] 同一年存在多个匹配市场日历,使用第一条 - Year:{year}, CalendarName:{calendarName}, Country:{normalizedCountry}, MatchedIds:{string.Join(",", matchedCalendars.Select(c => c.id))}"); + } + if (!string.Equals(calendar.Country, normalizedCountry, StringComparison.Ordinal)) + { + // Country 能通过归一化匹配说明历史数据存在大小写或空格差异,只在日历首次加载时记录一次,便于后续清洗数据。 + Logger.Info($"[风控日历] 日历Country已归一化匹配 - Year:{year}, CalendarName:{calendarName}, RawCountry:{calendar.Country}, NormalizedCountry:{normalizedCountry}, CalendarId:{calendar.id}"); + } if (string.IsNullOrWhiteSpace(calendar.HolidayJson)) + { + Logger.Error($"[风控日历] HolidayJson为空 - Year:{year}, CalendarName:{calendarName}, Country:{normalizedCountry}, CalendarId:{calendar.id}"); throw new Exception($"{year}年{calendarName}日历HolidayJson为空"); + } List holidayList; try @@ -96,12 +156,70 @@ namespace YLErp.Modules.RiskEngine } catch (Exception ex) { + Logger.Error($"[风控日历] HolidayJson解析失败 - Year:{year}, CalendarName:{calendarName}, Country:{normalizedCountry}, CalendarId:{calendar.id}, Error:{ex.Message}"); throw new Exception($"{year}年{calendarName}日历HolidayJson解析失败", ex); } - holidays = new HashSet(holidayList ?? new List()); + // 日历解析和日志统计只在缓存未命中时执行;同一次风控检查内重复判断交易日不会重复解析 HolidayJson。 + holidays = NormalizeHolidays(holidayList, year, calendarName, normalizedCountry, calendar.id); holidayCache[cacheKey] = holidays; return holidays; } + + private static string NormalizeCountry(string country) + { + return (country ?? string.Empty).Trim().ToUpperInvariant(); + } + + private static HashSet NormalizeHolidays(IEnumerable holidayList, int year, string calendarName, string normalizedCountry, int calendarId) + { + var holidays = new HashSet(); + var formatCounts = new Dictionary(); + var rawCount = 0; + var blankCount = 0; + var duplicateCount = 0; + foreach (var holidayText in holidayList ?? Enumerable.Empty()) + { + rawCount++; + if (string.IsNullOrWhiteSpace(holidayText)) + { + blankCount++; + continue; + } + + // 每条原始日期只在缓存加载阶段解析一次;正常数据只累计格式分布,避免大量节假日逐条写日志。 + var holidayDate = ParseHolidayDate(holidayText, year, calendarName, normalizedCountry, calendarId, out var matchedFormat); + if (!holidays.Add(holidayDate)) + { + duplicateCount++; + } + formatCounts[matchedFormat] = formatCounts.TryGetValue(matchedFormat, out var count) ? count + 1 : 1; + } + Logger.Info($"[风控日历] HolidayJson日期归一化完成 - Year:{year}, CalendarName:{calendarName}, Country:{normalizedCountry}, CalendarId:{calendarId}, RawCount:{rawCount}, HolidayCount:{holidays.Count}, BlankCount:{blankCount}, DuplicateCount:{duplicateCount}, Formats:{string.Join(",", formatCounts.Select(item => item.Key + ":" + item.Value))}"); + return holidays; + } + + private static DateTime ParseHolidayDate(string holidayText, int year, string calendarName, string normalizedCountry, int calendarId, out string matchedFormat) + { + var normalizedHolidayText = holidayText.Trim(); + foreach (var format in SupportedCalendarDateFormats) + { + if (DateTime.TryParseExact(normalizedHolidayText, format, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var holidayDate)) + { + matchedFormat = format; + return holidayDate.Date; + } + } + + // 支持带时区的历史数据;最终取日期部分用于非交易日集合匹配,避免字符串格式差异导致静默漏判。 + if (DateTimeOffset.TryParse(normalizedHolidayText, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var holidayDateOffset)) + { + matchedFormat = "DateTimeOffset.TryParse"; + return holidayDateOffset.Date; + } + + Logger.Error($"[风控日历] HolidayJson日期格式无法识别 - Year:{year}, CalendarName:{calendarName}, Country:{normalizedCountry}, CalendarId:{calendarId}, RawValue:{holidayText}"); + throw new Exception($"{year}年{calendarName}日历HolidayJson存在无法识别的日期格式:{holidayText}"); + } } } diff --git a/YLErpDAL/Modules/RiskEngine/RiskContext.cs b/YLErpDAL/Modules/RiskEngine/RiskContext.cs index 9686f980..7a86b0a1 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskContext.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskContext.cs @@ -17,6 +17,13 @@ namespace YLErp.Modules.RiskEngine /// public string TriggerPoint { get; set; } + /// + /// 当前平仓请求提交的平仓日期,供平仓阶段规则直接使用。 + /// + public DateTime? UnwindDate { get; set; } + + public DateTime? PayDate { get; set; } + /// /// 数据库上下文,供规则公式直接查询数据库 /// diff --git a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs index 6cd51d3e..295a5f18 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs @@ -202,8 +202,7 @@ namespace YLErp.Modules.RiskEngine "BOOK_CONFIRM", "CLOSE_REVIEW", "UPLOAD_CONFIRMATION", - "EVENT_TRIGGER", - "FUND_PAYMENT" + "EVENT_TRIGGER" }; @@ -792,11 +791,10 @@ namespace YLErp.Modules.RiskEngine private static readonly Dictionary TriggerPointCnMap = new Dictionary { - ["BOOK_CONFIRM"] = "交易录入确认", - ["CLOSE_REVIEW"] = "平仓审核", + ["BOOK_CONFIRM"] = "簿记交易确认", + ["CLOSE_REVIEW"] = "平仓审核提交", ["UPLOAD_CONFIRMATION"] = "上传确认书", - ["EVENT_TRIGGER"] = "事件触发", - ["FUND_PAYMENT"] = "资金支付" + ["EVENT_TRIGGER"] = "事件发生时" }; /// diff --git a/YLErpDAL/Modules/RiskEngine/RiskTrialService.cs b/YLErpDAL/Modules/RiskEngine/RiskTrialService.cs new file mode 100644 index 00000000..bfd85d60 --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/RiskTrialService.cs @@ -0,0 +1,303 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Qdp.Foundation.Utilities; +using YLErp.BLL; +using YLErp.DBModels; +using YLErp.Enums; + +namespace YLErp.Modules.RiskEngine +{ + /// + /// 新风控通用试算服务,负责按业务触发时点执行规则、保存试算结果并校验二次确认。 + /// + public class RiskTrialService : YLBaseService + { + private readonly IYcLogger _logger = LogFactory.GetLogger("RiskTrialService"); + + /// + /// 使用当前业务服务的用户和数据库上下文创建新风控试算服务。 + /// + /// 当前业务服务。 + public RiskTrialService(YLBaseService baseService) : base(baseService) + { + } + + /// + /// 执行一次独立于老风控的新风控试算并保存试算记录。 + /// quotaTrial 在现有交易确认和平仓链路中按“每次试算快照/历史流水”使用,不作为审批状态表;二次确认会重新试算并生成新的快照。 + /// + /// 包含交易、触发时点及业务日期的风控上下文。 + /// 试算来源,由具体业务场景约定。 + /// 二次确认信息;首次试算传空。 + /// 包含阻断、审批、提示及本次试算记录ID的统一结果。 + public RiskTrialResult CheckRisk(RiskContext context, int trialSource, RiskTrialConfirmation confirmation = null) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + if (context.TradeId <= 0) + { + throw new ArgumentException("交易ID必须大于0", nameof(context)); + } + if (string.IsNullOrWhiteSpace(context.TriggerPoint)) + { + throw new ArgumentException("风控触发时点不能为空", nameof(context)); + } + + var ignoreRiskRuleIds = new HashSet(); + var confirmationExpired = false; + if (confirmation != null) + { + confirmationExpired = !TryLoadIgnoreRiskRuleIds(context, trialSource, confirmation, ignoreRiskRuleIds); + } + + var riskResult = new RiskEngineService(this).EvaluateRisk(context, context.TriggerPoint); + // 与交易确认流程保持一致:二次确认放行范围包含“需审批”和“提示”,避免已确认过的提示规则重复弹出。 + var currentApprovalRules = riskResult.TriggeredRules + .Where(rule => rule.ControlStrategy == RiskControlStrategy.Approval) + .ToList(); + var currentTipRules = riskResult.TriggeredRules + .Where(rule => rule.ControlStrategy == RiskControlStrategy.ShowTip) + .ToList(); + // 二次提交时,若首次试算记录有效,则剔除已确认过的审批和提示规则;记录过期或上下文不匹配时重新展示全部当前命中的审批和提示规则。 + var pendingApprovalRules = confirmationExpired + ? currentApprovalRules + : currentApprovalRules.Where(rule => !ignoreRiskRuleIds.Contains(rule.RuleId)).ToList(); + var pendingTipRules = confirmationExpired + ? currentTipRules + : currentTipRules.Where(rule => !ignoreRiskRuleIds.Contains(rule.RuleId)).ToList(); + var trial = CreateQuotaTrial(context, trialSource, riskResult, pendingApprovalRules, pendingTipRules); + // 与交易确认 RunQuotaTrial/RunNewRiskTrial 保持一致:每次风控执行保存一条 quotaTrial 快照,用于详情展示、历史追溯和二次确认时效校验,不回写上一条记录为已确认。 + new Modules.RiskModule.QuotaMonitorService(this).SaveQuotaTrial(trial); + + var result = new RiskTrialResult + { + Blocked = riskResult.Blocked, + NeedApproval = pendingApprovalRules.Any(), + ShowTip = pendingTipRules.Any(), + ConfirmationExpired = confirmationExpired, + TrialDataId = trial.id, + Message = trial.RiskWarningDetails, + ApprovalRuleIds = pendingApprovalRules.Concat(pendingTipRules) + .Select(rule => rule.RuleId) + .Where(ruleId => !string.IsNullOrWhiteSpace(ruleId)) + .Distinct() + .ToList() + }; + result.Passed = !result.Blocked && !result.NeedApproval; + + _logger.Info($"[新风控试算] TradeId: {context.TradeId}, TriggerPoint: {context.TriggerPoint}, TrialSource: {trialSource}, TrialDataId: {result.TrialDataId}, Passed: {result.Passed}, Blocked: {result.Blocked}, NeedApproval: {result.NeedApproval}, ShowTip: {result.ShowTip}, ConfirmationExpired: {result.ConfirmationExpired}"); + return result; + } + + /// + /// 校验二次确认记录,并恢复首次试算中已确认的审批和提示规则ID。 + /// + private bool TryLoadIgnoreRiskRuleIds(RiskContext context, int trialSource, RiskTrialConfirmation confirmation, ISet ignoreRiskRuleIds) + { + if (confirmation.TrialDataId <= 0 || confirmation.ExpireSeconds <= 0) + { + return false; + } + + var trial = DbContext.quotaTrial.FirstOrDefault(item => item.id == confirmation.TrialDataId); + if (trial == null + || trial.TradeId != context.TradeId + || trial.TrialSource != trialSource + || !trial.OptDate.HasValue + || !HasMatchingRequestContext(trial.RiskWarningDetails, context)) + { + return false; + } + if (DateTime.Now - trial.OptDate.Value > TimeSpan.FromSeconds(confirmation.ExpireSeconds)) + { + return false; + } + + foreach (var ruleId in GetIgnoreRiskRuleIds(confirmation, trial.RiskWarningDetails)) + { + ignoreRiskRuleIds.Add(ruleId); + } + return true; + } + + /// + /// 校验二次提交与首次试算的触发点和平仓日期一致,避免复用其他业务参数生成的审批记录。 + /// + private static bool HasMatchingRequestContext(string details, RiskContext context) + { + var expectedContext = BuildRequestContext(context); + return (details ?? string.Empty) + .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .Any(line => string.Equals(line, expectedContext, StringComparison.Ordinal)); + } + + private static IEnumerable GetIgnoreRiskRuleIds(RiskTrialConfirmation confirmation, string details) + { + var ignoreRiskRuleIds = confirmation.IgnoreRiskRuleIds ?? new List(); + return ignoreRiskRuleIds.Any() + ? ignoreRiskRuleIds.Where(ruleId => !string.IsNullOrWhiteSpace(ruleId)).Select(ruleId => ruleId.Trim()).Distinct() + : ParseIgnoreRiskRuleIds(details); + } + + /// + /// 从系统生成的审批和提示规则明细中恢复规则ID,兼容未回传忽略规则ID的旧入口。 + /// + private static IEnumerable ParseIgnoreRiskRuleIds(string details) + { + const string ruleIdPrefix = "规则ID:"; + var inConfirmableSection = false; + foreach (var line in (details ?? string.Empty).Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) + { + if (line.StartsWith("[风控引擎] 规则触发:", StringComparison.Ordinal)) + { + inConfirmableSection = line.StartsWith("[风控引擎] 规则触发:需审批", StringComparison.Ordinal) + || line.StartsWith("[风控引擎] 规则触发:提示", StringComparison.Ordinal); + continue; + } + if (!inConfirmableSection) + { + continue; + } + + var ruleIdStart = line.IndexOf(ruleIdPrefix, StringComparison.Ordinal); + if (ruleIdStart < 0) + { + continue; + } + ruleIdStart += ruleIdPrefix.Length; + var ruleIdEnd = line.IndexOf(';', ruleIdStart); + var ruleId = (ruleIdEnd < 0 ? line.Substring(ruleIdStart) : line.Substring(ruleIdStart, ruleIdEnd - ruleIdStart)).Trim(); + if (!string.IsNullOrWhiteSpace(ruleId)) + { + yield return ruleId; + } + } + } + + /// + /// 将新风控引擎结果转换为现有试算记录,供详情页面和二次确认复用。 + /// + private QuotaTrial CreateQuotaTrial(RiskContext context, int trialSource, RiskResult riskResult, IReadOnlyCollection pendingApprovalRules, IReadOnlyCollection pendingTipRules) + { + // 业务入口已通过Find加载交易,优先复用当前上下文跟踪的实体,避免重复查询数据库。 + var trade = DbContext.trade.Local.FirstOrDefault(item => item.id == context.TradeId) + ?? DbContext.trade.Find(context.TradeId); + var trial = new QuotaTrial + { + TradeId = context.TradeId, + TradeNumber = trade?.TradeNumber ?? string.Empty, + ClientName = trade?.ClientName ?? string.Empty, + TrialSource = trialSource, + TrialStatus = riskResult.Blocked + ? QuotaTrialStatusEnum.Error + : pendingApprovalRules.Any() ? QuotaTrialStatusEnum.RiskWarning : QuotaTrialStatusEnum.Success, + NewRiskBlocked = riskResult.Blocked, + NewRiskNeedApproval = pendingApprovalRules.Any(), + RiskWarningDetails = BuildRiskDetails(context, riskResult, pendingApprovalRules, pendingTipRules) + }; + trial.ApprovalRuleIds = pendingApprovalRules.Concat(pendingTipRules) + .Select(rule => rule.RuleId) + .Where(ruleId => !string.IsNullOrWhiteSpace(ruleId)) + .Distinct() + .ToList(); + return trial; + } + + /// + /// 按控制策略生成与现有试算详情页面兼容的规则明细。 + /// + private static string BuildRiskDetails(RiskContext context, RiskResult riskResult, IReadOnlyCollection pendingApprovalRules, IReadOnlyCollection pendingTipRules) + { + var lines = new List + { + BuildRequestContext(context) + }; + AppendRuleDetails(lines, "禁止", riskResult.TriggeredRules.Where(rule => rule.ControlStrategy == RiskControlStrategy.Block)); + AppendRuleDetails(lines, "需审批", pendingApprovalRules); + AppendRuleDetails(lines, "提示", pendingTipRules); + return string.Join("\n", lines); + } + + /// + /// 生成首次试算的业务请求标识,供二次确认严格校验业务参数。 + /// + private static string BuildRequestContext(RiskContext context) + { + return $"[风控引擎] 请求上下文:触发点:{GetTriggerPointDisplayName(context.TriggerPoint)};平仓日期:{context.UnwindDate?.Date:yyyy-MM-dd};支付日期:{context.PayDate?.Date:yyyy-MM-dd}"; + } + + private static string GetTriggerPointDisplayName(string triggerPoint) + { + return triggerPoint switch + { + "BOOK_CONFIRM" => "簿记交易确认", + "CLOSE_REVIEW" => "平仓审核提交", + "UPLOAD_CONFIRMATION" => "上传确认书", + "EVENT_TRIGGER" => "事件发生时", + _ => triggerPoint ?? string.Empty + }; + } + + /// + /// 追加指定控制策略的规则明细。 + /// + private static void AppendRuleDetails(ICollection lines, string strategyName, IEnumerable rules) + { + var ruleList = rules.ToList(); + if (!ruleList.Any()) + { + return; + } + + lines.Add($"[风控引擎] 规则触发:{strategyName}"); + foreach (var rule in ruleList) + { + var detail = $"应用ID:{rule.ApplicationId?.ToString() ?? "-"};规则ID:{rule.RuleId};规则名称:{rule.RuleName};规则说明:{rule.RuleText}"; + if (!string.IsNullOrWhiteSpace(rule.Message)) + { + detail += $";信息:{rule.Message}"; + } + lines.Add(detail); + } + } + } + + /// + /// 新风控二次确认参数。 + /// + public class RiskTrialConfirmation + { + /// + /// 首次风控试算记录ID。 + /// + public int TrialDataId { get; set; } + + /// + /// 首次试算允许确认放行的有效时长,单位为秒。 + /// + public int ExpireSeconds { get; set; } + + /// + /// 首次试算返回并经前端回传的需忽略规则ID。 + /// + public List IgnoreRiskRuleIds { get; set; } = new List(); + } + + /// + /// 新风控通用试算结果。 + /// + public class RiskTrialResult + { + public bool Passed { get; set; } + public bool Blocked { get; set; } + public bool NeedApproval { get; set; } + public bool ShowTip { get; set; } + public bool ConfirmationExpired { get; set; } + public int TrialDataId { get; set; } + public string Message { get; set; } + public List ApprovalRuleIds { get; set; } = new List(); + } +} diff --git a/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs b/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs index 73c8cb7d..1a46ee33 100644 --- a/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs +++ b/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs @@ -519,8 +519,10 @@ namespace YLErp.Modules.RiskEngine /// private static string BuildBooleanMessage(glms_risk_variable variable, ValueExecuteResult value, bool expected) { + if (!string.IsNullOrWhiteSpace(value.DetailMessage)) + return value.DetailMessage.Trim(); + var parts = new List(); - AddDetail(parts, value.DetailMessage); parts.Add($"{variable.VariableName}为{FormatDisplayValue(value.Value)}"); parts.Add($"期望为{FormatDisplayValue(expected)}"); return string.Join(",", parts); diff --git a/YLErpDAL/Modules/RiskEngine/测试用例.md b/YLErpDAL/Modules/RiskEngine/测试用例.md index 2eb3b5fe..1d543383 100644 --- a/YLErpDAL/Modules/RiskEngine/测试用例.md +++ b/YLErpDAL/Modules/RiskEngine/测试用例.md @@ -2180,17 +2180,16 @@ WHERE t.id = @TradeId; ## 19. 规则 8 查询结果排查 SQL -规则 8:支付日为银行间交易日(本地)。用于核对交易平仓窗口的支付日期是否为银行间交易日;若支付日期未落在 IB 日历的非交易日列表中,则命中。 +规则 8:支付日为银行间交易日(本地)。用于核对互换交易-交易查看-交易平仓窗口中本次提交的支付日期是否为银行间交易日;若支付日期未落在 IB 日历的非交易日列表中,则命中。 取数流程: ```text -1. 根据 TradeId 查 swap_flow_event。 -2. 限定 SwapTradeId = TradeId、EventType = 2、DataState <> 0,取平仓事件。 -3. 取 swap_flow_event.PayDate,作为交易平仓窗口的支付日期。 -4. 根据 PayDate.Value.Year 查询 calendar 表中 Country = 'IB' 的银行间日历。 -5. calendar.HolidayJson 存储该年非交易日,格式为 yyyy,MM,dd。 -6. 若 HolidayJson 不包含支付日期对应的 yyyy,MM,dd,则说明支付日期是银行间交易日,命中规则。 +1. 用户从互换交易-交易查看进入交易平仓窗口。 +2. 取交易平仓窗口本次提交的支付日期,即 UnwindData.PayDate。 +3. 根据 PayDate.Value.Year 查询 calendar 表中 Country = 'IB' 的银行间日历。 +4. calendar.HolidayJson 存储该年非交易日,格式为 yyyy,MM,dd。 +5. 若 HolidayJson 不包含支付日期对应的 yyyy,MM,dd,则说明支付日期是银行间交易日,命中规则。 ``` 规则公式: @@ -2202,10 +2201,9 @@ WHERE t.id = @TradeId; 规则字段口径: ```text -支付日期:swap_flow_event.PayDate,对应交易平仓窗口的支付日期 -平仓事件:swap_flow_event.EventType = 2 +支付日期:UnwindData.PayDate,对应互换交易-交易查看-交易平仓窗口的支付日期 银行间日历:calendar.Country = 'IB' -日历年份:calendar.Year = swap_flow_event.PayDate.Year +日历年份:calendar.Year = UnwindData.PayDate.Year 非交易日:calendar.HolidayJson ``` @@ -2216,6 +2214,30 @@ WHERE t.id = @TradeId; ``` +变量配置:规则 8 需要同时准备日期变量和布尔判断变量,规则前端配置时使用“平仓支付日期为银行间交易日 isTrue”;如果需要与平仓日期组合比较,可使用“平仓支付日期”日期变量。 + +变量 1:平仓支付日期,Category 为 BookingElement,DataType 为 Date,ValueDomain 为 yyyy-MM-dd。该变量必须使用本次平仓请求中的 PayDate,不能查询 swap_flow_event 历史平仓事件,否则首次提交和修改支付日期后的试算会取不到当前页面值。 + +```csharp +if (!PayDate.HasValue) + throw new Exception("平仓支付日期为空"); + +return new RiskVariableValueDetail( + PayDate.Value.Date, + $"交易ID {TradeId},本次平仓支付日期为{PayDate.Value:yyyy-MM-dd}"); +``` + +变量 2:平仓支付日期为银行间交易日,Category 为 BooleanCheck,DataType 为 Boolean,ValueDomain 为 true/false。 + +```csharp +if (!PayDate.HasValue) + throw new Exception("平仓支付日期为空"); + +return new RiskVariableValueDetail( + RiskCalendarHelper.IsInterbankTradingDay(DbContext, PayDate.Value), + $"交易ID {TradeId},本次平仓支付日期为{PayDate.Value:yyyy-MM-dd}"); +``` + 注释规则定义: ```csharp @@ -2223,8 +2245,8 @@ WHERE t.id = @TradeId; //{ // Id = 1000008, // RuleName = "支付日为银行间交易日(本地)", -// RuleText = "取值字段:通过 DbContext.swap_flow_event 按 SwapTradeId=TradeId、EventType=2、DataState<>0 取平仓事件的 PayDate,PayDate 对应交易平仓窗口的支付日期;通过 DbContext.calendar 按 Country='IB' 且 Year=PayDate.Year 取银行间日历,HolidayJson 存储该年非交易日。计算逻辑:若支付日期对应的 yyyy,MM,dd 不存在于 IB 日历 HolidayJson 中,则说明支付日期是银行间交易日,触发审批。", -// RuleExpr = "DbContext.swap_flow_event.Any(e => e.SwapTradeId == TradeId && e.EventType == 2 && e.DataState != 0 && e.PayDate.HasValue) && !DbContext.calendar.First(c => c.Country == \"IB\" && c.Year == DbContext.swap_flow_event.Where(e => e.SwapTradeId == TradeId && e.EventType == 2 && e.DataState != 0 && e.PayDate.HasValue).OrderByDescending(e => e.id).First().PayDate.Value.Year && c.ValidState != \"InValid\").HolidayJson.Contains(DbContext.swap_flow_event.Where(e => e.SwapTradeId == TradeId && e.EventType == 2 && e.DataState != 0 && e.PayDate.HasValue).OrderByDescending(e => e.id).First().PayDate.Value.ToString(\"yyyy,MM,dd\"))", +// RuleText = "取值字段:取互换交易-交易查看-交易平仓窗口本次提交的 UnwindData.PayDate,PayDate 对应页面支付日期;通过 DbContext.calendar 按 Country='IB' 且 Year=PayDate.Year 取银行间日历,HolidayJson 存储该年非交易日。计算逻辑:若支付日期对应的 yyyy,MM,dd 不存在于 IB 日历 HolidayJson 中,则说明支付日期是银行间交易日,触发审批。", +// RuleExpr = "PayDate.HasValue && RiskCalendarHelper.IsInterbankTradingDay(DbContext, PayDate.Value)", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -2236,6 +2258,8 @@ WHERE t.id = @TradeId; //}); ``` +变量值核对 SQL:用于核对已落库平仓事件的支付日期是否为银行间交易日;首次提交前的页面值应以请求中的 UnwindData.PayDate 为准,不能只依赖该 SQL。 + ```sql SET @TradeId = 3001699; @@ -2274,33 +2298,79 @@ WHERE t.id = @TradeId; ## 20. 规则 9 查询结果排查 SQL -规则 9:到期日为银行间交易日(本地)。用于核对互换交易界面的到期日是否为银行间交易日;若到期日未落在 IB 日历的非交易日列表中,则命中。 +规则 9:到期日为银行间交易日(本地)。用于核对新增互换交易页面本次提交的到期日期是否为银行间交易日;若到期日期未落在 IB 日历的非交易日列表中,则命中。到期日期指 swapTradeEdit.js 中绑定的 trade.ExerciseDate。 取数流程: ```text -1. 根据 TradeId 查当前 trade。 -2. 取 trade.ExerciseDate,作为互换交易界面的到期日。 -3. 根据 ExerciseDate.Value.Year 查询 calendar 表中 Country = 'IB' 的银行间日历。 -4. calendar.HolidayJson 存储该年非交易日,格式为 yyyy,MM,dd。 -5. 若 HolidayJson 不包含到期日对应的 yyyy,MM,dd,则说明到期日是银行间交易日,命中规则。 +1. 前端互换交易编辑页 swapTradeEdit.js 使用 trade.ExerciseDate 作为到期日期。 +2. 新增互换交易提交后,后端交易模型承接为 trade.ExerciseDate。 +3. 确认成交触发风控时,初始 swap_position 已由 trade.ExerciseDate 生成,读取 swap_position.PosiMatuirityDate。 +4. 限定 SwapTradeId = TradeId、IsInitial = 1、Invalid = 0、PosiMatuirityDate 不为空,按 id 取第一条初始持仓。 +5. 根据 PosiMatuirityDate.Value.Year 查询 calendar 表中 Country = 'IB' 的银行间日历。 +6. calendar.HolidayJson 存储该年非交易日,格式为 yyyy,MM,dd。 +7. 若 HolidayJson 不包含到期日期对应的 yyyy,MM,dd,则说明到期日期是银行间交易日,命中规则。 ``` 规则公式: ```text -到期日 ∉ IB 日历非交易日 +到期日期 ∉ IB 日历非交易日 ``` 规则字段口径: ```text -到期日:trade.ExerciseDate,对应互换交易界面的到期日 +到期日期:swapTradeEdit.js 的 trade.ExerciseDate,对应新增互换交易页面的“到期日期” +确认成交风控取值:swap_position.PosiMatuirityDate,由 Trade.ExerciseDate 生成 +初始持仓:swap_position.SwapTradeId = TradeId、IsInitial = 1、Invalid = 0 银行间日历:calendar.Country = 'IB' -日历年份:calendar.Year = trade.ExerciseDate.Year +日历年份:calendar.Year = swap_position.PosiMatuirityDate.Year 非交易日:calendar.HolidayJson ``` +变量配置:规则 9 需要同时准备日期变量和布尔判断变量,规则前端配置时使用“互换到期日期为银行间交易日 isTrue”;如果需要与成交日、起始日组合比较,可使用“互换到期日期”日期变量。 + +变量 1:互换到期日期,Category 为 BookingElement,DataType 为 Date,ValueDomain 为 yyyy-MM-dd。确认成交触发点使用 swap_position.PosiMatuirityDate,需限定初始有效持仓,避免后续互换/平仓产生的非初始持仓干扰。 + +```csharp +var maturityDate = DbContext.swap_position + .Where(p => p.SwapTradeId == TradeId + && p.IsInitial + && !p.Invalid + && p.PosiMatuirityDate.HasValue) + .OrderBy(p => p.id) + .Select(p => p.PosiMatuirityDate) + .FirstOrDefault(); + +if (!maturityDate.HasValue) + throw new Exception("互换到期日期为空"); + +return new RiskVariableValueDetail( + maturityDate.Value.Date, + $"交易ID {TradeId},本次互换到期日期为{maturityDate.Value:yyyy-MM-dd}"); +``` + +变量 2:互换到期日期为银行间交易日,Category 为 BooleanCheck,DataType 为 Boolean,ValueDomain 为 true/false。 + +```csharp +var maturityDate = DbContext.swap_position + .Where(p => p.SwapTradeId == TradeId + && p.IsInitial + && !p.Invalid + && p.PosiMatuirityDate.HasValue) + .OrderBy(p => p.id) + .Select(p => p.PosiMatuirityDate) + .FirstOrDefault(); + +if (!maturityDate.HasValue) + throw new Exception("互换到期日期为空"); + +return new RiskVariableValueDetail( + RiskCalendarHelper.IsInterbankTradingDay(DbContext, maturityDate.Value), + $"交易ID {TradeId},本次互换到期日期为{maturityDate.Value:yyyy-MM-dd}"); +``` + 注释规则定义: ```csharp @@ -2308,8 +2378,8 @@ WHERE t.id = @TradeId; //{ // Id = 1000009, // RuleName = "到期日为银行间交易日(本地)", -// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 ExerciseDate,ExerciseDate 对应互换交易界面的到期日;通过 DbContext.calendar 按 Country='IB' 且 Year=ExerciseDate.Year 取银行间日历,HolidayJson 存储该年非交易日。计算逻辑:若到期日对应的 yyyy,MM,dd 不存在于 IB 日历 HolidayJson 中,则说明到期日是银行间交易日,触发审批。", -// RuleExpr = "DbContext.trade.First(t => t.id == TradeId).ExerciseDate.HasValue && !DbContext.calendar.First(c => c.Country == \"IB\" && c.Year == DbContext.trade.First(t => t.id == TradeId).ExerciseDate.Value.Year && c.ValidState != \"InValid\").HolidayJson.Contains(DbContext.trade.First(t => t.id == TradeId).ExerciseDate.Value.ToString(\"yyyy,MM,dd\"))", +// RuleText = "取值字段:确认成交触发点下初始 swap_position 已由 trade.ExerciseDate 生成,通过 DbContext.swap_position 按 SwapTradeId=TradeId、IsInitial=true、Invalid=false 取 PosiMatuirityDate,PosiMatuirityDate 对应 swapTradeEdit.js 页面上的到期日期;通过 DbContext.calendar 按 Country='IB' 且 Year=PosiMatuirityDate.Year 取银行间日历,HolidayJson 存储该年非交易日。计算逻辑:若到期日期对应的 yyyy,MM,dd 不存在于 IB 日历 HolidayJson 中,则说明到期日期是银行间交易日,触发审批。", +// RuleExpr = "DbContext.swap_position.Any(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiMatuirityDate.HasValue) && RiskCalendarHelper.IsInterbankTradingDay(DbContext, DbContext.swap_position.Where(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiMatuirityDate.HasValue).OrderBy(p => p.id).First().PosiMatuirityDate.Value)", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -2327,19 +2397,33 @@ SET @TradeId = 3001699; SELECT t.id AS TradeId, t.TradeType, - t.ExerciseDate AS MaturityDate, + t.ExerciseDate AS TradeExerciseDate, + sp.id AS SwapPositionId, + sp.IsInitial, + sp.Invalid, + sp.PosiMatuirityDate AS MaturityDate, c.Country AS CalendarCountry, c.Year AS CalendarYear, - DATE_FORMAT(t.ExerciseDate, '%Y,%m,%d') AS MaturityDateText, + DATE_FORMAT(sp.PosiMatuirityDate, '%Y,%m,%d') AS MaturityDateText, CASE - WHEN t.ExerciseDate IS NOT NULL - AND c.HolidayJson NOT LIKE CONCAT('%', DATE_FORMAT(t.ExerciseDate, '%Y,%m,%d'), '%') THEN 1 + WHEN sp.PosiMatuirityDate IS NOT NULL + AND c.HolidayJson NOT LIKE CONCAT('%', DATE_FORMAT(sp.PosiMatuirityDate, '%Y,%m,%d'), '%') THEN 1 ELSE 0 END AS IsMaturityDateIBTradingDay FROM trade t +LEFT JOIN ( + SELECT * + FROM swap_position + WHERE SwapTradeId = @TradeId + AND IsInitial = 1 + AND Invalid = 0 + AND PosiMatuirityDate IS NOT NULL + ORDER BY id + LIMIT 1 +) sp ON sp.SwapTradeId = t.id LEFT JOIN calendar c ON c.Country = 'IB' - AND c.Year = YEAR(t.ExerciseDate) + AND c.Year = YEAR(sp.PosiMatuirityDate) AND c.ValidState <> 'InValid' WHERE t.id = @TradeId; ``` @@ -2348,17 +2432,16 @@ WHERE t.id = @TradeId; ## 21. 规则 10 查询结果排查 SQL -规则 10:平仓日为银行间交易日(本地)。用于核对交易平仓窗口的平仓日期是否为银行间交易日;若平仓日期未落在 IB 日历的非交易日列表中,则命中。 +规则 10:平仓日为银行间交易日(本地)。用于核对互换交易-交易查看-交易平仓窗口中本次提交的平仓日期是否为银行间交易日;若平仓日期未落在 IB 日历的非交易日列表中,则命中。 取数流程: ```text -1. 根据 TradeId 查 swap_flow_event。 -2. 限定 SwapTradeId = TradeId、EventType = 2、DataState <> 0,取平仓事件。 -3. 取 swap_flow_event.UnwindDate,作为交易平仓窗口的平仓日期。 -4. 根据 UnwindDate.Value.Year 查询 calendar 表中 Country = 'IB' 的银行间日历。 -5. calendar.HolidayJson 存储该年非交易日,格式为 yyyy,MM,dd。 -6. 若 HolidayJson 不包含平仓日期对应的 yyyy,MM,dd,则说明平仓日期是银行间交易日,命中规则。 +1. 用户从互换交易-交易查看进入交易平仓窗口。 +2. 取交易平仓窗口本次提交的平仓日期,即 UnwindData.UnwindDate。 +3. 根据 UnwindDate.Value.Year 查询 calendar 表中 Country = 'IB' 的银行间日历。 +4. calendar.HolidayJson 存储该年非交易日,格式为 yyyy,MM,dd。 +5. 若 HolidayJson 不包含平仓日期对应的 yyyy,MM,dd,则说明平仓日期是银行间交易日,命中规则。 ``` 规则公式: @@ -2370,10 +2453,9 @@ WHERE t.id = @TradeId; 规则字段口径: ```text -平仓日期:swap_flow_event.UnwindDate,对应交易平仓窗口的平仓日期 -平仓事件:swap_flow_event.EventType = 2 +平仓日期:UnwindData.UnwindDate,对应互换交易-交易查看-交易平仓窗口的平仓日期 银行间日历:calendar.Country = 'IB' -日历年份:calendar.Year = swap_flow_event.UnwindDate.Year +日历年份:calendar.Year = UnwindData.UnwindDate.Year 非交易日:calendar.HolidayJson ``` @@ -2384,6 +2466,30 @@ WHERE t.id = @TradeId; ``` +变量配置:规则 10 需要同时准备日期变量和布尔判断变量,规则前端配置时使用“平仓日期为银行间交易日 isTrue”;如果需要与支付日期组合比较,可使用“平仓日期”日期变量。 + +变量 1:平仓日期,Category 为 BookingElement,DataType 为 Date,ValueDomain 为 yyyy-MM-dd。该变量必须使用本次平仓请求中的 UnwindDate,不能查询 swap_flow_event 历史平仓事件,否则首次提交和修改平仓日期后的试算会取不到当前页面值。 + +```csharp +if (!UnwindDate.HasValue) + throw new Exception("平仓日期为空"); + +return new RiskVariableValueDetail( + UnwindDate.Value.Date, + $"交易ID {TradeId},本次平仓日期为{UnwindDate.Value:yyyy-MM-dd}"); +``` + +变量 2:平仓日期为银行间交易日,Category 为 BooleanCheck,DataType 为 Boolean,ValueDomain 为 true/false。 + +```csharp +if (!UnwindDate.HasValue) + throw new Exception("平仓日期为空"); + +return new RiskVariableValueDetail( + RiskCalendarHelper.IsInterbankTradingDay(DbContext, UnwindDate.Value), + $"交易ID {TradeId},本次平仓日期为{UnwindDate.Value:yyyy-MM-dd}"); +``` + 注释规则定义: ```csharp @@ -2391,8 +2497,8 @@ WHERE t.id = @TradeId; //{ // Id = 1000010, // RuleName = "平仓日为银行间交易日(本地)", -// RuleText = "取值字段:通过 DbContext.swap_flow_event 按 SwapTradeId=TradeId、EventType=2、DataState<>0 取平仓事件的 UnwindDate,UnwindDate 对应交易平仓窗口的平仓日期;通过 DbContext.calendar 按 Country='IB' 且 Year=UnwindDate.Year 取银行间日历,HolidayJson 存储该年非交易日。计算逻辑:若平仓日期对应的 yyyy,MM,dd 不存在于 IB 日历 HolidayJson 中,则说明平仓日期是银行间交易日,触发审批。", -// RuleExpr = "DbContext.swap_flow_event.Any(e => e.SwapTradeId == TradeId && e.EventType == 2 && e.DataState != 0 && e.UnwindDate.HasValue) && !DbContext.calendar.First(c => c.Country == \"IB\" && c.Year == DbContext.swap_flow_event.Where(e => e.SwapTradeId == TradeId && e.EventType == 2 && e.DataState != 0 && e.UnwindDate.HasValue).OrderByDescending(e => e.id).First().UnwindDate.Value.Year && c.ValidState != \"InValid\").HolidayJson.Contains(DbContext.swap_flow_event.Where(e => e.SwapTradeId == TradeId && e.EventType == 2 && e.DataState != 0 && e.UnwindDate.HasValue).OrderByDescending(e => e.id).First().UnwindDate.Value.ToString(\"yyyy,MM,dd\"))", +// RuleText = "取值字段:取互换交易-交易查看-交易平仓窗口本次提交的 UnwindData.UnwindDate,UnwindDate 对应页面平仓日期;通过 DbContext.calendar 按 Country='IB' 且 Year=UnwindDate.Year 取银行间日历,HolidayJson 存储该年非交易日。计算逻辑:若平仓日期对应的 yyyy,MM,dd 不存在于 IB 日历 HolidayJson 中,则说明平仓日期是银行间交易日,触发审批。", +// RuleExpr = "UnwindDate.HasValue && RiskCalendarHelper.IsInterbankTradingDay(DbContext, UnwindDate.Value)", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -2404,6 +2510,8 @@ WHERE t.id = @TradeId; //}); ``` +变量值核对 SQL:用于核对已落库平仓事件的平仓日期是否为银行间交易日;首次提交前的页面值应以请求中的 UnwindData.UnwindDate 为准,不能只依赖该 SQL。 + ```sql SET @TradeId = 3001699; @@ -3461,22 +3569,27 @@ ORDER BY ## 27. 规则 16 查询结果排查 SQL -规则 16:多头支付固定端利率偏离(本地)。用于核对利息端收入固定利息方向的点差百分比绝对值是否大于阈值。 +规则 16:多头支付固定端利率偏离(本地)。用于核对多头浮动端对应利息端点差百分比绝对值是否大于阈值,利息端不按 InterestDirection 区分收取或支付。 取数流程: ```text 1. 根据 TradeId 查 swap_position。 -2. 限定 InterestDirection=1,取固定利息端收取方向记录。 -3. 从该利息端记录取 InterestRateDefault。 -4. InterestRateDefault 只代表利率文本框中 + 号后的点差,不包含 FR007 基准利率,库内为小数原值,界面按百分比显示。 -5. 计算 ABS(InterestRateDefault * 100),大于 5 时命中。 +2. 先限定 IsInitial=1、Invalid=0、PosiDirection>0、PositionType=1,取多头浮动端记录。 +3. 若不存在多头浮动端记录,说明当前 trade 不适用规则16,变量返回 0,不触发风控。 +4. 再取同一 TradeId、同为初始有效、PosiDirection=0、PositionType=0、UnderlyingCode 为空、InterestMode=9 的利息端记录,不按 InterestDirection 区分收取或支付。 +5. 该识别口径来自当前建仓保存逻辑:浮动端记录会赋值 PosiDirection、PositionType、UnderlyingCode;普通利息端记录单独创建,InterestMode 设置为 9(标的期初全价),只赋值 InterestDirection、InterestRateDefault、InterestMode、FloatRateUnderlyingCode 等利息字段,未赋值浮动端字段时落库为默认的 0、0、空;保证金/预付金记录用 InterestMode=5 或 6,不纳入规则16利息端。 +6. 从该利息端记录取 InterestRateDefault 和 FloatRateUnderlyingCode。 +7. InterestRateDefault 只代表利率文本框中 + 号后的点差,不包含 FR007 基准利率,库内为小数原值,界面按百分比显示。 +8. 基准利率保护:FloatRateUnderlyingCode 必须为 FR007;为空或不是 FR007 时,说明规则16的 FR007 抵消前提不成立,抛异常触发风控报警。 +9. 所有普通利息端都要检查;若存在基准利率非法记录,异常信息同时列出基准利率非法记录,以及已满足 FR007 的记录偏离明细,避免只看到第一类问题。 +10. 若基准利率全部为 FR007,则逐条计算 ABS(InterestRateDefault * 100),取最大值与规则阈值比较;同时返回逐条明细,由结构化执行器按当前规则阈值拆分展示超限记录和未超限记录。 ``` 规则公式: ```text -ABS(利息端.InterestRateDefault * 100) > 5 +存在多头浮动端时,逐条检查普通利息端;若 FloatRateUnderlyingCode 为空或不是 FR007,则抛异常触发风控报警,异常信息同时列出基准利率非法记录和已满足 FR007 的记录偏离明细;若基准利率全部为 FR007,则取 ABS(InterestRateDefault * 100) 的最大值与阈值比较,并由结构化执行器按规则阈值拆分展示超限记录和未超限记录;不存在多头浮动端时返回 0,不触发 ``` 注释规则定义: @@ -3486,8 +3599,8 @@ ABS(利息端.InterestRateDefault * 100) > 5 //{ // Id = 1000016, // RuleName = "多头支付固定端利率偏离(本地)", -// RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取利息端收入固定利息方向记录的 InterestRateDefault。InterestRateDefault 只代表利率文本框中 + 号后的点差,不包含 FR007 基准利率。计算逻辑:按 ABS(InterestRateDefault) 计算点差绝对值,绝对值小于 5% 时触发审批。", -// RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.InterestDirection == 1).InterestRateDefault)*100m > 5m", +// RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 先取 IsInitial=true、Invalid=false、PosiDirection>0、PositionType=1 的多头浮动端记录;若不存在多头浮动端记录,说明当前 trade 不适用规则16,变量返回 0,不触发风控;若存在多头浮动端记录,再取同一 TradeId、同为初始有效、PosiDirection=0、PositionType=0、UnderlyingCode 为空、InterestMode=9 的普通利息端记录,不按 InterestDirection 区分收取或支付,排除 InterestMode=5/6 的保证金/预付金记录。InterestRateDefault 只代表利率文本框中 + 号后的点差,不包含 FR007 基准利率。基准利率保护:FloatRateUnderlyingCode 必须为 FR007;为空或不是 FR007 时,说明规则16的 FR007 抵消前提不成立,抛异常触发风控报警,异常信息同时列出基准利率非法记录和已满足 FR007 的记录偏离明细。计算逻辑:基准利率全部校验通过后,按 ABS(InterestRateDefault * 100) 计算点差百分比绝对值,取最大值与阈值比较;变量明细逐条返回偏离值,结构化执行器按规则阈值拆分展示超限记录和未超限记录。", +// RuleExpr = "DbContext.swap_position.Any(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection > 0 && p.PositionType == 1) && !DbContext.swap_position.Where(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 0 && p.PositionType == 0 && string.IsNullOrEmpty(p.UnderlyingCode) && p.InterestMode == 9).Any(p => string.IsNullOrEmpty(p.FloatRateUnderlyingCode) || p.FloatRateUnderlyingCode != \"FR007\") && DbContext.swap_position.Where(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 0 && p.PositionType == 0 && string.IsNullOrEmpty(p.UnderlyingCode) && p.InterestMode == 9).Max(p => Math.Abs(p.InterestRateDefault * 100m)) > 5m", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -3501,113 +3614,265 @@ ABS(利息端.InterestRateDefault * 100) > 5 变量形式: -变量名:固定端利率偏离绝对值 +变量名:多头支付固定端利率偏离值 DataType:Numeric -规则配置:固定端利率偏离绝对值 > 阈值(示例 5) +规则配置:多头支付固定端利率偏离值 > 阈值(示例 5) 变量取值表达式: ```csharp +var longFloatItems = DbContext.swap_position + .Where(p => p.SwapTradeId == TradeId + && p.IsInitial + && !p.Invalid + && p.PosiDirection > 0 + && p.PositionType == 1) + .Select(p => new { p.id }) + .ToList(); + +if (!longFloatItems.Any()) + return new RiskVariableValueDetail( + 0m, + $"交易ID {TradeId} 不存在多头浮动端记录,规则16不适用,不触发风控"); + var interestItems = DbContext.swap_position .Where(p => p.SwapTradeId == TradeId - && p.InterestDirection == 1) + && p.IsInitial + && !p.Invalid + && p.PosiDirection == 0 + && p.PositionType == 0 + && string.IsNullOrEmpty(p.UnderlyingCode) + && p.InterestMode == 9) .Select(p => new { p.id, + p.InterestMode, + p.InterestDirection, + p.FloatRateUnderlyingCode, p.InterestRateDefault, InterestRateDeviation = Math.Abs(p.InterestRateDefault * 100m) }) .ToList(); if (!interestItems.Any()) - throw new Exception("利息端收取方向记录不存在"); + throw new Exception("同次录入的利息端记录不存在"); -var maxDeviationItem = interestItems +var invalidFloatRateItems = interestItems + .Where(p => string.IsNullOrEmpty(p.FloatRateUnderlyingCode) || p.FloatRateUnderlyingCode != "FR007") + .ToList(); + +if (invalidFloatRateItems.Any()) +{ + var invalidFloatRateMessage = "基准利率不合法记录:" + string.Join(";", invalidFloatRateItems + .OrderBy(p => p.id) + .Select(p => $"记录ID为{p.id},基准利率为{(string.IsNullOrEmpty(p.FloatRateUnderlyingCode) ? "空" : p.FloatRateUnderlyingCode)}")); + + var validDeviationItems = interestItems + .Where(p => p.FloatRateUnderlyingCode == "FR007") + .OrderByDescending(p => p.InterestRateDeviation) + .ThenBy(p => p.id) + .ToList(); + + var validDeviationMessage = validDeviationItems.Any() + ? ";FR007记录偏离明细:" + string.Join(";", validDeviationItems.Select(p => $"记录ID为{p.id},点差为{p.InterestRateDefault * 100m:0.#########}%,偏离绝对值为{p.InterestRateDeviation:0.#########}%,收支方向为{(p.InterestDirection == 1 ? "收取" : p.InterestDirection == 2 ? "支付" : "未知:" + p.InterestDirection)}")) + : string.Empty; + + throw new Exception($"基准利率保护触发:规则16要求基准利率必须为FR007。{invalidFloatRateMessage}{validDeviationMessage}"); +} + +var orderedInterestItems = interestItems .OrderByDescending(p => p.InterestRateDeviation) .ThenBy(p => p.id) - .First(); + .ToList(); + +var maxDeviationItem = orderedInterestItems.First(); + +var interestDetails = orderedInterestItems + .Select(p => new RiskVariableValueDetailItem( + p.InterestRateDeviation, + $"记录ID为{p.id},基准利率为{(string.IsNullOrEmpty(p.FloatRateUnderlyingCode) ? "空" : p.FloatRateUnderlyingCode)},点差为{p.InterestRateDefault * 100m:0.#########}%,偏离绝对值为{p.InterestRateDeviation:0.#########}%,收支方向为{(p.InterestDirection == 1 ? "收取" : p.InterestDirection == 2 ? "支付" : "未知:" + p.InterestDirection)}")) + .ToList(); return new RiskVariableValueDetail( maxDeviationItem.InterestRateDeviation, - $"交易ID {TradeId},利息端记录ID为{maxDeviationItem.id},点差为{maxDeviationItem.InterestRateDefault * 100m:0.#########}%,最大偏离绝对值为{maxDeviationItem.InterestRateDeviation:0.#########}%"); + $"交易ID {TradeId},多头浮动端记录ID为{string.Join("、", longFloatItems.Select(p => p.id))},普通利息端共{orderedInterestItems.Count}条,最大偏离绝对值为{maxDeviationItem.InterestRateDeviation:0.#########}%", + interestDetails); ``` -汇总 SQL:与变量一样取全部符合条件记录中的最大偏离值;命中说明只展示产生最大偏离值的记录。 +汇总 SQL:先确认存在多头浮动端,再按同次录入普通利息端记录取最大偏离值用于规则比较;若存在基准利率为空或不是 FR007 的记录则实际执行时会抛异常触发风控报警,SQL 中单独列出此类记录数供排查。 ```sql SET @TradeId = 3001699; SET @Threshold = 5; SELECT - MAX(ABS(sp.InterestRateDefault * 100)) AS MaxInterestRateDeviation, CASE - WHEN MAX(ABS(sp.InterestRateDefault * 100)) > @Threshold THEN 1 + WHEN COUNT(DISTINCT fp.id) = 0 THEN NULL + ELSE MAX(ABS(ip.InterestRateDefault * 100)) + END AS MaxInterestRateDeviation, + CASE + WHEN COUNT(DISTINCT fp.id) = 0 THEN 0 + WHEN MAX(ABS(ip.InterestRateDefault * 100)) > @Threshold THEN 1 + ELSE 0 + END AS IsGreaterThanThreshold, + SUM(CASE WHEN ip.FloatRateUnderlyingCode IS NULL OR ip.FloatRateUnderlyingCode = '' OR ip.FloatRateUnderlyingCode <> 'FR007' THEN 1 ELSE 0 END) AS InvalidFloatRateCodeCount +FROM swap_position fp +LEFT JOIN swap_position ip + ON ip.SwapTradeId = fp.SwapTradeId + AND ip.IsInitial = fp.IsInitial + AND ip.Invalid = fp.Invalid + AND ip.PosiDirection = 0 + AND ip.PositionType = 0 + AND (ip.UnderlyingCode IS NULL OR ip.UnderlyingCode = '') + AND ip.InterestMode = 9 +WHERE fp.SwapTradeId = @TradeId + AND fp.IsInitial = 1 + AND fp.Invalid = 0 + AND fp.PosiDirection > 0 + AND fp.PositionType = 1; +``` + +明细 SQL:用于排查多头浮动端及其同次录入普通利息端记录;按偏离绝对值倒序展示所有纳入变量计算的普通利息端记录,基准利率为空或不是 FR007 的记录会标记 IsProtectionTrigger=1,实际执行时抛异常触发风控报警。 + +```sql +SET @TradeId = 3001699; +SET @Threshold = 5; + +SELECT + fp.id AS FloatPositionId, + ip.id AS InterestPositionId, + fp.SwapTradeId, + fp.IsInitial, + fp.Invalid, + fp.PosiDirection, + fp.PositionType, + ip.InterestMode, + ip.InterestDirection, + ip.FloatRateUnderlyingCode, + CASE + WHEN ip.FloatRateUnderlyingCode IS NULL OR ip.FloatRateUnderlyingCode = '' OR ip.FloatRateUnderlyingCode <> 'FR007' THEN 1 + ELSE 0 + END AS IsProtectionTrigger, + ip.InterestRateDefault, + ip.InterestRateDefault * 100 AS InterestRateDefault_100, + ABS(ip.InterestRateDefault * 100) AS DiffAbs, + CASE + WHEN ABS(ip.InterestRateDefault * 100) > @Threshold THEN 1 ELSE 0 END AS IsGreaterThanThreshold -FROM swap_position sp -WHERE sp.SwapTradeId = @TradeId - AND sp.InterestDirection = 1; +FROM swap_position fp +LEFT JOIN swap_position ip + ON ip.SwapTradeId = fp.SwapTradeId + AND ip.IsInitial = fp.IsInitial + AND ip.Invalid = fp.Invalid + AND ip.PosiDirection = 0 + AND ip.PositionType = 0 + AND (ip.UnderlyingCode IS NULL OR ip.UnderlyingCode = '') + AND ip.InterestMode = 9 +WHERE fp.SwapTradeId = @TradeId + AND fp.IsInitial = 1 + AND fp.Invalid = 0 + AND fp.PosiDirection > 0 + AND fp.PositionType = 1 +ORDER BY DiffAbs DESC, ip.id ASC; ``` -明细 SQL:用于排查全部利息端记录;变量命中说明只展示产生最大偏离值的记录。 +异常排查 SQL:用于排查变量执行失败“同次录入的利息端记录不存在”。重点看是否存在多头浮动端,以及是否存在同一 TradeId 下初始有效、PosiDirection=0、PositionType=0、UnderlyingCode 为空、InterestMode=9 的普通利息端记录;InterestMode=5/6 为保证金/预付金,不应计入规则16利息端。 ```sql SET @TradeId = 3001699; -SET @Threshold = 5; SELECT - sp.id AS SwapPositionId, + '当前交易初始有效持仓概览' AS CheckItem, + sp.id, sp.SwapTradeId, + sp.IsInitial, + sp.Invalid, + sp.PosiDirection, + sp.PositionType, + CASE sp.PositionType + WHEN 1 THEN '多头' + WHEN 2 THEN '空头' + ELSE CONCAT('未知:', sp.PositionType) + END AS PositionTypeName, + sp.InterestMode, + CASE sp.InterestMode + WHEN 5 THEN '初始预付金' + WHEN 6 THEN '追加预付金' + WHEN 9 THEN '标的期初全价' + ELSE CONCAT('未知:', sp.InterestMode) + END AS InterestModeName, sp.InterestDirection, + CASE sp.InterestDirection + WHEN 1 THEN '收取' + WHEN 2 THEN '支付' + ELSE CONCAT('未知:', sp.InterestDirection) + END AS InterestDirectionName, + sp.UnderlyingCode, + sp.FloatRateUnderlyingCode, sp.InterestRateDefault, sp.InterestRateDefault * 100 AS InterestRateDefault_100, - ABS(sp.InterestRateDefault * 100) AS DiffAbs, - CASE - WHEN ABS(sp.InterestRateDefault * 100) > @Threshold THEN 1 - ELSE 0 - END AS IsGreaterThanThreshold + ABS(sp.InterestRateDefault * 100) AS DiffAbs FROM swap_position sp WHERE sp.SwapTradeId = @TradeId - AND sp.InterestDirection = 1 -ORDER BY DiffAbs DESC, sp.id ASC; + AND sp.IsInitial = 1 + AND sp.Invalid = 0 +ORDER BY + sp.PosiDirection DESC, + sp.PositionType, + sp.InterestDirection, + sp.id; + +SELECT + SUM(CASE WHEN sp.PosiDirection > 0 AND sp.PositionType = 1 THEN 1 ELSE 0 END) AS LongFloatCount, + SUM(CASE WHEN sp.PosiDirection = 0 AND sp.PositionType = 0 AND (sp.UnderlyingCode IS NULL OR sp.UnderlyingCode = '') AND sp.InterestMode = 9 THEN 1 ELSE 0 END) AS InterestLegCount, + SUM(CASE WHEN sp.PosiDirection = 0 AND sp.PositionType = 0 AND (sp.UnderlyingCode IS NULL OR sp.UnderlyingCode = '') AND sp.InterestMode IN (5, 6) THEN 1 ELSE 0 END) AS MarginLegCount, + SUM(CASE WHEN sp.PosiDirection = 0 AND sp.PositionType = 0 AND (sp.UnderlyingCode IS NULL OR sp.UnderlyingCode = '') AND sp.InterestMode = 9 AND sp.InterestDirection = 1 THEN 1 ELSE 0 END) AS InterestLegDirection1Count, + SUM(CASE WHEN sp.PosiDirection = 0 AND sp.PositionType = 0 AND (sp.UnderlyingCode IS NULL OR sp.UnderlyingCode = '') AND sp.InterestMode = 9 AND sp.InterestDirection = 2 THEN 1 ELSE 0 END) AS InterestLegDirection2Count, + SUM(CASE WHEN sp.PosiDirection = 0 AND sp.PositionType = 0 AND (sp.UnderlyingCode IS NULL OR sp.UnderlyingCode = '') AND sp.InterestMode = 9 AND (sp.FloatRateUnderlyingCode IS NULL OR sp.FloatRateUnderlyingCode = '' OR sp.FloatRateUnderlyingCode <> 'FR007') THEN 1 ELSE 0 END) AS InvalidFloatRateCodeCount, + CASE + WHEN SUM(CASE WHEN sp.PosiDirection > 0 AND sp.PositionType = 1 THEN 1 ELSE 0 END) = 0 + THEN '不存在多头浮动端,规则16不适用,应返回0不触发' + WHEN SUM(CASE WHEN sp.PosiDirection = 0 AND sp.PositionType = 0 AND (sp.UnderlyingCode IS NULL OR sp.UnderlyingCode = '') AND sp.InterestMode = 9 THEN 1 ELSE 0 END) = 0 + THEN '存在多头浮动端,但不存在对应普通利息端记录;需检查同次录入利息端是否满足 PosiDirection=0、PositionType=0、UnderlyingCode 为空、InterestMode=9,不能把 InterestMode=5/6 的保证金/预付金当作利息端' + WHEN SUM(CASE WHEN sp.PosiDirection = 0 AND sp.PositionType = 0 AND (sp.UnderlyingCode IS NULL OR sp.UnderlyingCode = '') AND sp.InterestMode = 9 AND (sp.FloatRateUnderlyingCode IS NULL OR sp.FloatRateUnderlyingCode = '' OR sp.FloatRateUnderlyingCode <> 'FR007') THEN 1 ELSE 0 END) > 0 + THEN '存在普通利息端基准利率为空或不是FR007,规则16应触发基准利率保护异常,不应继续计算点差偏离' + ELSE '存在多头浮动端且存在对应普通利息端记录,基准利率均为FR007,可以计算规则16偏离值;规则16不按 InterestDirection 区分收取或支付,并排除保证金/预付金记录' + END AS DiagnosticResult +FROM swap_position sp +WHERE sp.SwapTradeId = @TradeId + AND sp.IsInitial = 1 + AND sp.Invalid = 0; ``` --- -## 28. 规则 18 查询结果排查 SQL +## 28. 规则 17 查询结果排查 SQL -规则 18:账户授权收支方向不匹配(本地)。用于核对账户授权的收支方向与交易浮动端多空方向是否匹配;不符合允许场景时命中。 - -校验内容: - -```text -不符合以下场景则不通过: -1. 利息端 = 收取,且浮动端 = 空头 -2. 利息端 = 支付,且浮动端 = 多头 -``` +规则 17:空头利息端利率与借贷加权费率偏离(本地)。用于核对空头浮动端同次录入的普通利息端利率与标的借贷加权费率的偏离绝对值是否大于阈值,利息端不按 InterestDirection 区分收取或支付。 取数流程: ```text 1. 根据 TradeId 查 swap_position。 -2. 限定 IsInitial=1、Invalid=0、PosiDirection>0,取浮动端初始持仓记录。 -3. 取 swap_position.InterestDirection 作为利息端收支方向,1=收取,2=支付。 -4. 取 swap_position.PositionType 作为浮动端多空方向,1=多头,2=空头。 -5. 若不是“利息端收取且浮动端空头”,也不是“利息端支付且浮动端多头”,则命中。 +2. 先限定 IsInitial=1、Invalid=0、PosiDirection>0、PositionType=2,取空头浮动端记录,得到标的 UnderlyingCode。 +3. 若不存在空头浮动端记录,说明当前 trade 不适用规则17,变量返回 0,不触发风控。 +4. 空头浮动端标的必须为“数字.IB”或“数字.BC”格式;合法时取点号前数字作为借贷费率查询标的,其他格式抛异常触发风控报警。 +5. 再取同一 TradeId、同为初始有效、PosiDirection=0、PositionType=0、UnderlyingCode 为空、InterestMode=9 的普通利息端记录,不按 InterestDirection 区分收取或支付,排除 InterestMode=5/6 的保证金/预付金记录。 +6. 从普通利息端记录取 InterestRateDefault 和 FloatRateUnderlyingCode。 +7. InterestRateDefault 只代表利率文本框中 + 号后的点差,库内为小数原值,界面按百分比显示。 +8. 基准利率保护:FloatRateUnderlyingCode 必须为 FR007;为空或不是 FR007 时抛异常触发风控报警。 +9. 所有空头浮动端和普通利息端都要检查;若存在空头浮动端标的非法或基准利率不是 FR007 的记录,异常信息同时列出非法记录,以及合法记录组合后的偏离明细,避免只看到第一类问题。 +10. 从 eod_commodity_future_price 取该基准利率代码在 TradeDate 之前最近一条的 ReferencePrice(小数口径,如 0.025 表示 2.5%)。 +11. 从 eod_bond_lending_rate 取空头浮动端标的点号前数字在 TradeDate 之前最近一条的 WeightedAvgRate(百分比值,如 2.5 表示 2.5%)。 +12. 利息端利率(%) = (基准利率 + 点差) * 100,借贷加权费率(%) = WeightedAvgRate。 +13. 若空头浮动端标的和基准利率均合法,则对所有空头浮动端和普通利息端组合逐条计算 ABS(利息端利率(%) - 借贷加权费率(%)),取最大值与规则阈值比较;同时返回逐条明细,由结构化执行器按当前规则阈值拆分展示超限记录和未超限记录。 ``` 规则公式: ```text -NOT ((InterestDirection = 1 AND PositionType = 2) OR (InterestDirection = 2 AND PositionType = 1)) -``` - -规则字段口径: - -```text -利息端收支方向:swap_position.InterestDirection,1=收取,2=支付 -浮动端多空方向:swap_position.PositionType,1=多头,2=空头 -浮动端记录:swap_position.IsInitial=1、Invalid=0、PosiDirection>0 +存在空头浮动端时,逐条检查空头浮动端和普通利息端;若空头浮动端标的不是“数字.IB”或“数字.BC”格式,或 FloatRateUnderlyingCode 为空/不是 FR007,则抛异常触发风控报警,异常信息同时列出非法记录和合法记录组合后的偏离明细;若空头浮动端标的和基准利率均合法,则用空头浮动端标的点号前数字查借贷加权费率,并取所有空头浮动端和普通利息端组合 ABS((基准利率 + InterestRateDefault) * 100 - 借贷加权费率) 的最大值与阈值比较;变量明细逐条返回偏离值,由结构化执行器按规则阈值拆分展示超限记录和未超限记录;不存在空头浮动端时返回 0,不触发 ``` 注释规则定义: @@ -3615,10 +3880,10 @@ NOT ((InterestDirection = 1 AND PositionType = 2) OR (InterestDirection = 2 AND ```csharp //rules.Add(new RiskRule //{ -// Id = 1000018, -// RuleName = "账户授权收支方向不匹配(本地)", -// RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、PosiDirection>0 的浮动端初始持仓记录;InterestDirection 表示利息端收支方向,1=收取、2=支付;PositionType 表示浮动端多空方向,1=多头、2=空头。计算逻辑:仅允许利息端=收取且浮动端=空头,或利息端=支付且浮动端=多头;其他组合触发审批。", -// RuleExpr = "!(((DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection > 0).InterestDirection == 1) && (DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection > 0).PositionType == 2)) || ((DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection > 0).InterestDirection == 2) && (DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection > 0).PositionType == 1)))", +// Id = 1000017, +// RuleName = "空头利息端利率与借贷加权费率偏离(本地)", +// RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 先取 IsInitial=true、Invalid=false、PosiDirection>0、PositionType=2 的空头浮动端记录;若不存在空头浮动端记录,说明当前 trade 不适用规则17,变量返回 0,不触发风控;若存在空头浮动端记录,再取同一 TradeId、同为初始有效、PosiDirection=0、PositionType=0、UnderlyingCode 为空、InterestMode=9 的普通利息端记录,不按 InterestDirection 区分收取或支付,排除 InterestMode=5/6 的保证金/预付金记录。利息端利率 = 基准利率 + 点差,基准利率取 eod_commodity_future_price 中 FloatRateUnderlyingCode 在 TradeDate 前最近一条的 ReferencePrice(小数口径);FloatRateUnderlyingCode 必须为 FR007,为空或不是 FR007 时抛异常触发风控报警,异常信息同时列出基准利率非法记录和基准利率为 FR007 的合法记录与空头浮动端组合后的偏离明细。点差为 InterestRateDefault(小数原值)。空头浮动端 UnderlyingCode 必须为“数字.IB”或“数字.BC”格式;合法时取点号前数字作为 eod_bond_lending_rate.UnderlyingSecurityId 查询 TradeDate 前最近一条 WeightedAvgRate(百分比值),其他格式抛异常触发风控报警。计算逻辑:对所有合法空头浮动端和普通利息端组合逐条按 ABS((基准利率 + 点差) * 100 - 借贷加权费率) 计算偏离绝对值,取最大值与阈值比较;变量明细逐条返回偏离值,结构化执行器按规则阈值拆分展示超限记录和未超限记录。", +// RuleExpr = "", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -3630,35 +3895,522 @@ NOT ((InterestDirection = 1 AND PositionType = 2) OR (InterestDirection = 2 AND //}); ``` +变量形式: + +变量名:利息端利率与借贷加权费率偏离度 +DataType:Numeric +规则配置:利息端利率与借贷加权费率偏离度 > 阈值(示例 5) + +变量取值表达式: + +```csharp +var shortFloatItems = DbContext.swap_position + .Where(p => p.SwapTradeId == TradeId + && p.IsInitial + && !p.Invalid + && p.PosiDirection > 0 + && p.PositionType == 2) + .Select(p => new { p.id, p.UnderlyingCode }) + .ToList(); + +if (!shortFloatItems.Any()) + return new RiskVariableValueDetail( + 0m, + $"交易ID {TradeId} 不存在空头浮动端记录,规则17不适用,不触发风控"); + +var invalidShortFloatItems = shortFloatItems + .Where(p => string.IsNullOrEmpty(p.UnderlyingCode) + || !(p.UnderlyingCode.EndsWith(".IB") || p.UnderlyingCode.EndsWith(".BC")) + || p.UnderlyingCode.LastIndexOf(".") <= 0 + || !p.UnderlyingCode.Substring(0, p.UnderlyingCode.LastIndexOf(".")).All(c => char.IsDigit(c))) + .ToList(); + +var validShortFloatItems = shortFloatItems + .Where(p => !string.IsNullOrEmpty(p.UnderlyingCode) + && (p.UnderlyingCode.EndsWith(".IB") || p.UnderlyingCode.EndsWith(".BC")) + && p.UnderlyingCode.LastIndexOf(".") > 0 + && p.UnderlyingCode.Substring(0, p.UnderlyingCode.LastIndexOf(".")).All(c => char.IsDigit(c))) + .Select(p => new + { + p.id, + p.UnderlyingCode, + LendingRateUnderlyingCode = p.UnderlyingCode.Substring(0, p.UnderlyingCode.LastIndexOf(".")) + }) + .ToList(); + +var interestItems = DbContext.swap_position + .Where(p => p.SwapTradeId == TradeId + && p.IsInitial + && !p.Invalid + && p.PosiDirection == 0 + && p.PositionType == 0 + && string.IsNullOrEmpty(p.UnderlyingCode) + && p.InterestMode == 9) + .Select(p => new { p.id, p.InterestMode, p.InterestDirection, p.InterestRateDefault, p.FloatRateUnderlyingCode }) + .ToList(); + +if (!interestItems.Any()) + throw new Exception("同次录入的普通利息端记录不存在"); + +var invalidFloatRateItems = interestItems + .Where(p => string.IsNullOrEmpty(p.FloatRateUnderlyingCode) || p.FloatRateUnderlyingCode != "FR007") + .ToList(); + +var validInterestItems = interestItems + .Where(p => p.FloatRateUnderlyingCode == "FR007") + .ToList(); + +if ((invalidShortFloatItems.Any() && !validShortFloatItems.Any()) || (invalidFloatRateItems.Any() && !validInterestItems.Any())) +{ + var invalidShortFloatMessage = invalidShortFloatItems.Any() + ? "空头浮动端标的不合法记录:" + string.Join(";", invalidShortFloatItems + .OrderBy(p => p.id) + .Select(p => $"记录ID为{p.id},标的为{(string.IsNullOrEmpty(p.UnderlyingCode) ? "空" : p.UnderlyingCode)},仅允许数字.IB或数字.BC格式")) + : string.Empty; + + var invalidFloatRateMessage = invalidFloatRateItems.Any() + ? (string.IsNullOrEmpty(invalidShortFloatMessage) ? string.Empty : ";") + "基准利率非法记录:" + string.Join(";", invalidFloatRateItems + .OrderBy(p => p.id) + .Select(p => $"记录ID为{p.id},基准利率为{(string.IsNullOrEmpty(p.FloatRateUnderlyingCode) ? "空" : p.FloatRateUnderlyingCode)},点差为{p.InterestRateDefault * 100m:0.#########}%,收支方向为{(p.InterestDirection == 1 ? "收取" : p.InterestDirection == 2 ? "支付" : "未知:" + p.InterestDirection)}")) + : string.Empty; + + throw new Exception($"规则17保护触发:空头浮动端标的仅允许数字.IB或数字.BC格式,普通利息端基准利率必须为FR007。{invalidShortFloatMessage}{invalidFloatRateMessage}"); +} + +var calculationInterestItems = invalidFloatRateItems.Any() ? validInterestItems : interestItems; + +var tradeDate = DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date; + +var orderedDeviationItems = validShortFloatItems + .OrderBy(p => p.id) + .SelectMany(shortFloatItem => + { + var lendingRate = DbContext.eod_bond_lending_rate + .Where(x => x.UnderlyingSecurityId == shortFloatItem.LendingRateUnderlyingCode && x.ValueDate <= tradeDate) + .OrderByDescending(x => x.ValueDate) + .FirstOrDefault(); + + if (lendingRate == null || !lendingRate.WeightedAvgRate.HasValue) + throw new Exception($"标的 {shortFloatItem.UnderlyingCode} 对应借贷费率查询标的 {shortFloatItem.LendingRateUnderlyingCode} 在交易日 {tradeDate:yyyy-MM-dd} 前未找到借贷加权费率"); + + var lendingRatePercent = lendingRate.WeightedAvgRate.Value; + + return calculationInterestItems + .OrderBy(p => p.id) + .Select(interestItem => + { + var floatRateCode = interestItem.FloatRateUnderlyingCode; + + var floatRatePrice = DbContext.eod_commodity_future_price + .Where(e => e.UnderlyingCode == floatRateCode && e.ValueDate <= tradeDate) + .OrderByDescending(e => e.ValueDate) + .FirstOrDefault(); + + var floatRate = floatRatePrice != null && floatRatePrice.ReferencePrice.HasValue ? Convert.ToDecimal(floatRatePrice.ReferencePrice.Value) : 0m; + var interestRatePercent = (floatRate + interestItem.InterestRateDefault) * 100m; + var deviation = Math.Abs(interestRatePercent - lendingRatePercent); + + return new + { + FloatPositionId = shortFloatItem.id, + shortFloatItem.UnderlyingCode, + shortFloatItem.LendingRateUnderlyingCode, + InterestPositionId = interestItem.id, + interestItem.InterestMode, + interestItem.InterestDirection, + interestItem.InterestRateDefault, + FloatRateCode = floatRateCode, + FloatRate = floatRate, + InterestRatePercent = interestRatePercent, + LendingRatePercent = lendingRatePercent, + Deviation = deviation + }; + }); + }) + .OrderByDescending(p => p.Deviation) + .ThenBy(p => p.FloatPositionId) + .ThenBy(p => p.InterestPositionId) + .ToList(); + +if (invalidShortFloatItems.Any() || invalidFloatRateItems.Any()) +{ + var invalidShortFloatMessage = invalidShortFloatItems.Any() + ? "空头浮动端标的不合法记录:" + string.Join(";", invalidShortFloatItems + .OrderBy(p => p.id) + .Select(p => $"记录ID为{p.id},标的为{(string.IsNullOrEmpty(p.UnderlyingCode) ? "空" : p.UnderlyingCode)},仅允许数字.IB或数字.BC格式")) + : string.Empty; + + var invalidFloatRateMessage = invalidFloatRateItems.Any() + ? (string.IsNullOrEmpty(invalidShortFloatMessage) ? string.Empty : ";") + "基准利率非法记录:" + string.Join(";", invalidFloatRateItems + .OrderBy(p => p.id) + .Select(p => $"记录ID为{p.id},基准利率为{(string.IsNullOrEmpty(p.FloatRateUnderlyingCode) ? "空" : p.FloatRateUnderlyingCode)},点差为{p.InterestRateDefault * 100m:0.#########}%,收支方向为{(p.InterestDirection == 1 ? "收取" : p.InterestDirection == 2 ? "支付" : "未知:" + p.InterestDirection)}")) + : string.Empty; + + var validDeviationMessage = orderedDeviationItems.Any() + ? ";合法记录偏离明细:" + string.Join(";", orderedDeviationItems.Select(p => $"空头浮动端记录ID为{p.FloatPositionId},标的{p.UnderlyingCode},借贷费率查询标的{p.LendingRateUnderlyingCode},普通利息端记录ID为{p.InterestPositionId},基准利率{p.FloatRateCode}为{p.FloatRate * 100m:0.#########}%,点差为{p.InterestRateDefault * 100m:0.#########}%,利息端利率为{p.InterestRatePercent:0.#########}%,借贷加权费率为{p.LendingRatePercent:0.#########}%,偏离绝对值为{p.Deviation:0.#########}%,收支方向为{(p.InterestDirection == 1 ? "收取" : p.InterestDirection == 2 ? "支付" : "未知:" + p.InterestDirection)}")) + : string.Empty; + + throw new Exception($"规则17保护触发:空头浮动端标的仅允许数字.IB或数字.BC格式,普通利息端基准利率必须为FR007。{invalidShortFloatMessage}{invalidFloatRateMessage}{validDeviationMessage}"); +} + +var maxDeviationItem = orderedDeviationItems.First(); + +var detailItems = orderedDeviationItems + .Select(p => new RiskVariableValueDetailItem( + p.Deviation, + $"空头浮动端记录ID为{p.FloatPositionId},标的{p.UnderlyingCode},借贷费率查询标的{p.LendingRateUnderlyingCode},普通利息端记录ID为{p.InterestPositionId},基准利率{p.FloatRateCode}为{p.FloatRate * 100m:0.#########}%,点差为{p.InterestRateDefault * 100m:0.#########}%,利息端利率为{p.InterestRatePercent:0.#########}%,借贷加权费率为{p.LendingRatePercent:0.#########}%,偏离绝对值为{p.Deviation:0.#########}%,收支方向为{(p.InterestDirection == 1 ? "收取" : p.InterestDirection == 2 ? "支付" : "未知:" + p.InterestDirection)}")) + .ToList(); + +return new RiskVariableValueDetail( + maxDeviationItem.Deviation, + $"交易ID {TradeId},空头浮动端共{shortFloatItems.Count}条,普通利息端共{interestItems.Count}条,最大偏离绝对值为{maxDeviationItem.Deviation:0.#########}%", + detailItems); +``` + +汇总 SQL:先确认存在空头浮动端,再按合法空头浮动端和同次录入普通利息端组合及对应行情数据计算最大偏离值;借贷费率查询标的取空头浮动端 UnderlyingCode 点号前数字;若存在空头浮动端标的非法或基准利率不是 FR007 的记录则实际执行时会抛异常触发风控报警,SQL 中单独列出此类记录数供排查。 + +```sql +SET @TradeId = 3001699; +SET @Threshold = 5; + +SELECT + CASE + WHEN COUNT(DISTINCT fp.id) = 0 THEN NULL + ELSE MAX(CASE WHEN fp.UnderlyingCode REGEXP '^[0-9]+[.](IB|BC)$' AND ip.FloatRateUnderlyingCode = 'FR007' THEN ABS((IFNULL(fr.ReferencePrice, 0) + ip.InterestRateDefault) * 100 - blr.WeightedAvgRate) END) + END AS MaxDeviation, + CASE + WHEN COUNT(DISTINCT fp.id) = 0 THEN 0 + WHEN MAX(CASE WHEN fp.UnderlyingCode REGEXP '^[0-9]+[.](IB|BC)$' AND ip.FloatRateUnderlyingCode = 'FR007' THEN ABS((IFNULL(fr.ReferencePrice, 0) + ip.InterestRateDefault) * 100 - blr.WeightedAvgRate) END) > @Threshold THEN 1 + ELSE 0 + END AS IsGreaterThanThreshold, + COUNT(DISTINCT CASE WHEN fp.id IS NOT NULL AND (fp.UnderlyingCode IS NULL OR fp.UnderlyingCode = '' OR NOT (fp.UnderlyingCode REGEXP '^[0-9]+[.](IB|BC)$')) THEN fp.id END) AS InvalidShortUnderlyingCodeCount, + COUNT(DISTINCT CASE WHEN ip.id IS NOT NULL AND IFNULL(ip.FloatRateUnderlyingCode, '') <> 'FR007' THEN ip.id END) AS InvalidFloatRateCodeCount +FROM swap_position fp +LEFT JOIN swap_position ip + ON ip.SwapTradeId = fp.SwapTradeId + AND ip.IsInitial = fp.IsInitial + AND ip.Invalid = fp.Invalid + AND ip.PosiDirection = 0 + AND ip.PositionType = 0 + AND (ip.UnderlyingCode IS NULL OR ip.UnderlyingCode = '') + AND ip.InterestMode = 9 +LEFT JOIN trade t + ON t.id = fp.SwapTradeId +LEFT JOIN eod_commodity_future_price fr + ON fr.FutureContractId = ip.FloatRateUnderlyingCode + AND fr.ValueDate = ( + SELECT MAX(e.ValueDate) + FROM eod_commodity_future_price e + WHERE e.FutureContractId = ip.FloatRateUnderlyingCode + AND e.ValueDate <= t.TradeDate + ) +LEFT JOIN eod_bond_lending_rate blr + ON blr.UnderlyingSecurityId = SUBSTRING_INDEX(fp.UnderlyingCode, '.', 1) + AND fp.UnderlyingCode REGEXP '^[0-9]+[.](IB|BC)$' + AND blr.ValueDate = ( + SELECT MAX(b.ValueDate) + FROM eod_bond_lending_rate b + WHERE b.UnderlyingSecurityId = SUBSTRING_INDEX(fp.UnderlyingCode, '.', 1) + AND fp.UnderlyingCode REGEXP '^[0-9]+[.](IB|BC)$' + AND b.ValueDate <= t.TradeDate + ) +WHERE fp.SwapTradeId = @TradeId + AND fp.IsInitial = 1 + AND fp.Invalid = 0 + AND fp.PosiDirection > 0 + AND fp.PositionType = 2; +``` + +明细 SQL:用于排查空头浮动端及其同次录入普通利息端记录、基准利率、借贷加权费率;展示空头浮动端原始标的、借贷费率查询标的和保护标记,基准利率不是 FR007 或空头浮动端标的非法的记录会标记保护字段,实际执行时抛异常触发风控报警。 + +```sql +SET @TradeId = 3001699; +SET @Threshold = 5; + +SELECT + fp.id AS FloatPositionId, + ip.id AS InterestPositionId, + fp.SwapTradeId, + fp.IsInitial, + fp.Invalid, + fp.PosiDirection, + fp.PositionType, + fp.UnderlyingCode, + CASE + WHEN fp.UnderlyingCode REGEXP '^[0-9]+[.](IB|BC)$' THEN SUBSTRING_INDEX(fp.UnderlyingCode, '.', 1) + ELSE NULL + END AS LendingRateUnderlyingCode, + CASE + WHEN fp.UnderlyingCode IS NULL OR fp.UnderlyingCode = '' OR NOT (fp.UnderlyingCode REGEXP '^[0-9]+[.](IB|BC)$') THEN 1 + ELSE 0 + END AS IsShortUnderlyingProtectionTrigger, + ip.InterestMode, + ip.InterestDirection, + CASE ip.InterestDirection + WHEN 1 THEN '收取' + WHEN 2 THEN '支付' + ELSE CONCAT('未知:', ip.InterestDirection) + END AS InterestDirectionName, + ip.FloatRateUnderlyingCode, + CASE + WHEN ip.id IS NOT NULL AND IFNULL(ip.FloatRateUnderlyingCode, '') <> 'FR007' THEN 1 + ELSE 0 + END AS IsProtectionTrigger, + ip.InterestRateDefault, + ip.InterestRateDefault * 100 AS InterestRateDefault_100, + fr.ReferencePrice AS FloatRateReferencePrice, + fr.ValueDate AS FloatRateValueDate, + (IFNULL(fr.ReferencePrice, 0) + ip.InterestRateDefault) * 100 AS InterestRatePercent, + blr.WeightedAvgRate AS LendingWeightedAvgRate, + blr.ValueDate AS LendingValueDate, + CASE WHEN fp.UnderlyingCode REGEXP '^[0-9]+[.](IB|BC)$' AND ip.FloatRateUnderlyingCode = 'FR007' THEN ABS((IFNULL(fr.ReferencePrice, 0) + ip.InterestRateDefault) * 100 - blr.WeightedAvgRate) END AS DiffAbs, + CASE + WHEN fp.UnderlyingCode REGEXP '^[0-9]+[.](IB|BC)$' AND ip.FloatRateUnderlyingCode = 'FR007' AND ABS((IFNULL(fr.ReferencePrice, 0) + ip.InterestRateDefault) * 100 - blr.WeightedAvgRate) > @Threshold THEN 1 + ELSE 0 + END AS IsGreaterThanThreshold +FROM swap_position fp +LEFT JOIN swap_position ip + ON ip.SwapTradeId = fp.SwapTradeId + AND ip.IsInitial = fp.IsInitial + AND ip.Invalid = fp.Invalid + AND ip.PosiDirection = 0 + AND ip.PositionType = 0 + AND (ip.UnderlyingCode IS NULL OR ip.UnderlyingCode = '') + AND ip.InterestMode = 9 +LEFT JOIN trade t + ON t.id = fp.SwapTradeId +LEFT JOIN eod_commodity_future_price fr + ON fr.FutureContractId = ip.FloatRateUnderlyingCode + AND fr.ValueDate = ( + SELECT MAX(e.ValueDate) + FROM eod_commodity_future_price e + WHERE e.FutureContractId = ip.FloatRateUnderlyingCode + AND e.ValueDate <= t.TradeDate + ) +LEFT JOIN eod_bond_lending_rate blr + ON blr.UnderlyingSecurityId = SUBSTRING_INDEX(fp.UnderlyingCode, '.', 1) + AND fp.UnderlyingCode REGEXP '^[0-9]+[.](IB|BC)$' + AND blr.ValueDate = ( + SELECT MAX(b.ValueDate) + FROM eod_bond_lending_rate b + WHERE b.UnderlyingSecurityId = SUBSTRING_INDEX(fp.UnderlyingCode, '.', 1) + AND fp.UnderlyingCode REGEXP '^[0-9]+[.](IB|BC)$' + AND b.ValueDate <= t.TradeDate + ) +WHERE fp.SwapTradeId = @TradeId + AND fp.IsInitial = 1 + AND fp.Invalid = 0 + AND fp.PosiDirection > 0 + AND fp.PositionType = 2 +ORDER BY DiffAbs DESC, fp.id ASC, ip.id ASC; +``` + +--- + +## 29. 规则 18 查询结果排查 SQL + +规则 18:账户授权收支方向不匹配(本地)。用于核对账户授权的收支方向与交易浮动端多空方向是否匹配;不符合允许场景时命中。 + +校验内容: + +```text +只允许利息端收取且浮动端空头或者利息端支付且浮动端多头。 +不符合以下场景则不通过: +1. 利息端 = 收取,且浮动端 = 空头 +2. 利息端 = 支付,且浮动端 = 多头 +``` + +取数流程: + +```text +1. 根据 TradeId 查 swap_position。 +2. 限定 IsInitial=1、Invalid=0、PosiDirection>0,取浮动端初始持仓记录,取 PositionType 作为浮动端多空方向,1=多头,2=空头。 +3. 参考规则16、17的同次录入口径,再取同一 TradeId、同为初始有效、PosiDirection=0、PositionType=0、UnderlyingCode 为空、InterestMode=9 的普通利息端记录,取 InterestDirection 作为利息端收支方向,1=收取,2=支付。 +4. 利息端可能有多条,需将所有浮动端与所有普通利息端逐条组合检查。 +5. 任一组合不是“利息端收取且浮动端空头”,也不是“利息端支付且浮动端多头”,则命中。 +``` + +规则公式: + +```text +NOT ((InterestDirection = 1 AND PositionType = 2) OR (InterestDirection = 2 AND PositionType = 1)) +``` + +规则字段口径: + +```text +利息端收支方向:普通利息端 swap_position.InterestDirection,1=收取,2=支付 +浮动端多空方向:浮动端 swap_position.PositionType,1=多头,2=空头 +浮动端记录:swap_position.IsInitial=1、Invalid=0、PosiDirection>0 +普通利息端记录:swap_position.IsInitial=1、Invalid=0、PosiDirection=0、PositionType=0、UnderlyingCode 为空、InterestMode=9 +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000018, +// RuleName = "账户授权收支方向不匹配(本地)", +// RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、PosiDirection>0 的浮动端初始持仓记录,PositionType 表示浮动端多空方向,1=多头、2=空头;参考规则16、17的同次录入口径,再取同一 TradeId、同为初始有效、PosiDirection=0、PositionType=0、UnderlyingCode 为空、InterestMode=9 的普通利息端记录,InterestDirection 表示利息端收支方向,1=收取、2=支付。计算逻辑:将所有浮动端与所有普通利息端逐条组合检查,仅允许利息端=收取且浮动端=空头,或利息端=支付且浮动端=多头;任一组合不符合则触发审批。", +// RuleExpr = "", +// Version = 1, +// Status = RiskRuleStatus.Active, +// OptId = 0, +// OptName = "system", +// OptDate = DateTime.Now, +// UpdateOptId = 0, +// UpdateOptName = "system", +// UpdateDate = DateTime.Now +//}); +``` + +变量形式: + +变量名:账户授权收支方向不匹配 +DataType:Boolean +ValueDomain:true/false +规则配置:账户授权收支方向不匹配 为是 + +变量取值表达式: + +```csharp +var floatItems = DbContext.swap_position + .Where(p => p.SwapTradeId == TradeId + && p.IsInitial + && !p.Invalid + && p.PosiDirection > 0) + .Select(p => new + { + p.id, + p.PosiDirection, + p.PositionType + }) + .ToList(); + +if (!floatItems.Any()) + throw new Exception("浮动端初始持仓记录不存在"); + +var interestItems = DbContext.swap_position + .Where(p => p.SwapTradeId == TradeId + && p.IsInitial + && !p.Invalid + && p.PosiDirection == 0 + && p.PositionType == 0 + && string.IsNullOrEmpty(p.UnderlyingCode) + && p.InterestMode == 9) + .Select(p => new + { + p.id, + p.InterestMode, + p.InterestDirection + }) + .ToList(); + +if (!interestItems.Any()) + throw new Exception("同次录入的普通利息端记录不存在"); + +var directionItems = floatItems + .SelectMany(floatItem => interestItems.Select(interestItem => new + { + FloatPositionId = floatItem.id, + floatItem.PosiDirection, + floatItem.PositionType, + PositionTypeName = floatItem.PositionType == 1 ? "多头" : floatItem.PositionType == 2 ? "空头" : "未知:" + floatItem.PositionType, + InterestPositionId = interestItem.id, + interestItem.InterestMode, + interestItem.InterestDirection, + InterestDirectionName = interestItem.InterestDirection == 1 ? "收取" : interestItem.InterestDirection == 2 ? "支付" : "未知:" + interestItem.InterestDirection, + IsMismatch = !((interestItem.InterestDirection == 1 && floatItem.PositionType == 2) || (interestItem.InterestDirection == 2 && floatItem.PositionType == 1)) + })) + .OrderByDescending(p => p.IsMismatch) + .ThenBy(p => p.FloatPositionId) + .ThenBy(p => p.InterestPositionId) + .ToList(); + +var mismatchItems = directionItems + .Where(p => p.IsMismatch) + .ToList(); + +var detailItems = directionItems + .Select(p => new RiskVariableValueDetailItem( + p.IsMismatch, + $"浮动端记录ID为{p.FloatPositionId},PosiDirection为{p.PosiDirection},浮动端为{p.PositionTypeName};普通利息端记录ID为{p.InterestPositionId},InterestMode为{p.InterestMode},利息端为{p.InterestDirectionName},{(p.IsMismatch ? "不符合允许场景" : "符合允许场景")}")) + .ToList(); + +if (mismatchItems.Any()) +{ + var matchedItems = directionItems + .Where(p => !p.IsMismatch) + .ToList(); + + var mismatchMessage = string.Join(";", mismatchItems + .Select(p => $"浮动端记录ID为{p.FloatPositionId},浮动端为{p.PositionTypeName},普通利息端记录ID为{p.InterestPositionId},利息端为{p.InterestDirectionName}")); + + var matchedMessage = matchedItems.Any() + ? string.Join(";", matchedItems.Select(p => $"浮动端记录ID为{p.FloatPositionId},浮动端为{p.PositionTypeName},普通利息端记录ID为{p.InterestPositionId},利息端为{p.InterestDirectionName}")) + : "无"; + + return new RiskVariableValueDetail( + true, + $"交易ID {TradeId} 存在账户授权收支方向不匹配组合。不匹配组合:{mismatchMessage}。匹配组合:{matchedMessage}。允许场景:利息端收取且浮动端空头,或利息端支付且浮动端多头", + detailItems); +} + +var allMatchedMessage = string.Join(";", directionItems + .Select(p => $"浮动端记录ID为{p.FloatPositionId},浮动端为{p.PositionTypeName},普通利息端记录ID为{p.InterestPositionId},利息端为{p.InterestDirectionName}")); + +return new RiskVariableValueDetail( + false, + $"交易ID {TradeId} 的账户授权收支方向均符合允许场景。匹配组合:{allMatchedMessage}。浮动端共{floatItems.Count}条,普通利息端共{interestItems.Count}条,已逐条组合检查", + detailItems); +``` + ```sql SET @TradeId = 3001699; SELECT - sp.id AS SwapPositionId, - sp.SwapTradeId, - sp.IsInitial, - sp.Invalid, - sp.PosiDirection, - sp.InterestDirection, - CASE sp.InterestDirection + fp.id AS FloatPositionId, + ip.id AS InterestPositionId, + fp.SwapTradeId, + fp.IsInitial, + fp.Invalid, + fp.PosiDirection AS FloatPosiDirection, + fp.PositionType AS FloatPositionType, + CASE fp.PositionType + WHEN 1 THEN '多头' + WHEN 2 THEN '空头' + ELSE '未知' + END AS FloatPositionTypeText, + ip.PosiDirection AS InterestPosiDirection, + ip.PositionType AS InterestPositionType, + ip.InterestMode, + ip.InterestDirection, + CASE ip.InterestDirection WHEN 1 THEN '收取' WHEN 2 THEN '支付' ELSE '未知' END AS InterestDirectionText, - sp.PositionType, - CASE sp.PositionType - WHEN 1 THEN '多头' - WHEN 2 THEN '空头' - ELSE '未知' - END AS PositionTypeText, CASE - WHEN (sp.InterestDirection = 1 AND sp.PositionType = 2) - OR (sp.InterestDirection = 2 AND sp.PositionType = 1) THEN 0 + WHEN (ip.InterestDirection = 1 AND fp.PositionType = 2) + OR (ip.InterestDirection = 2 AND fp.PositionType = 1) THEN 0 ELSE 1 END AS IsDirectionMismatch -FROM swap_position sp -WHERE sp.SwapTradeId = @TradeId - AND sp.IsInitial = 1 - AND sp.Invalid = 0 - AND sp.PosiDirection > 0; +FROM swap_position fp +INNER JOIN swap_position ip + ON ip.SwapTradeId = fp.SwapTradeId + AND ip.IsInitial = fp.IsInitial + AND ip.Invalid = fp.Invalid + AND ip.PosiDirection = 0 + AND ip.PositionType = 0 + AND (ip.UnderlyingCode IS NULL OR ip.UnderlyingCode = '') + AND ip.InterestMode = 9 +WHERE fp.SwapTradeId = @TradeId + AND fp.IsInitial = 1 + AND fp.Invalid = 0 + AND fp.PosiDirection > 0 +ORDER BY + IsDirectionMismatch DESC, + fp.id, + ip.id; ``` diff --git a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs index b81e4219..21b2a4c3 100644 --- a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs +++ b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs @@ -6892,7 +6892,8 @@ namespace YLErp.Modules.RiskModule return true; } /// - /// 保存试算结果 + /// 保存试算结果。 + /// 新试算对象按历史快照新增;传入已有ID时仅更新该次试算的说明、来源和操作信息,不承担审批状态流转职责。 /// /// 试算结果 public void SaveQuotaTrial(QuotaTrial obj) @@ -6922,6 +6923,40 @@ namespace YLErp.Modules.RiskModule DbContext.SaveChanges(); } + /// + /// 保存平仓风控试算说明,并保留首次试算时间作为二次确认超时起点。 + /// + /// 平仓风控试算记录ID。 + /// 用户填写的特批说明。 + /// 更新后的平仓风控试算记录。 + public QuotaTrial SaveCloseRiskTrialRemark(int trialDataId, string remark) + { + if (trialDataId <= 0) + { + throw new ServiceException("无效的平仓风控试算记录"); + } + if (string.IsNullOrWhiteSpace(remark)) + { + throw new ServiceException("必须填写说明内容,才可以提交平仓"); + } + + const int closeTrialSource = 2; + var trial = DbContext.quotaTrial.FirstOrDefault(item => item.id == trialDataId); + if (trial == null + || trial.TrialSource != closeTrialSource + || trial.TrialStatus != QuotaTrialStatusEnum.RiskWarning) + { + throw new ServiceException("未找到有效的平仓风控审批记录"); + } + + // 仅保存说明和操作人,不更新OptDate,避免重置首次试算的确认超时时间。 + trial.Remark = remark.Trim(); + trial.OptId = UserId; + trial.OptName = UserName; + DbContext.SaveChanges(); + return trial; + } + } } diff --git a/YLErpDAL/Modules/SwapModule/Margin/SwapAdditionalMarginService.cs b/YLErpDAL/Modules/SwapModule/Margin/SwapAdditionalMarginService.cs index 08e26da3..f6540009 100644 --- a/YLErpDAL/Modules/SwapModule/Margin/SwapAdditionalMarginService.cs +++ b/YLErpDAL/Modules/SwapModule/Margin/SwapAdditionalMarginService.cs @@ -8,7 +8,9 @@ namespace YLErp.Modules.SwapModule.Margin { /// /// R3 阶段四 §4.1:合约维度(MarginWatchRule==0)规则15 交易日终结算产生"追加保证金"资金记录。 - /// 交易维度追加保证金 = 维持保证金(阶段三引擎 trade_span 产出)− 累计保证金(应付预付金+追加保证金 流水净额 + 追加授信占用); + /// 交易维度追加保证金 = 维持保证金(阶段三引擎 trade_span 产出)− 已缴保证金净额 + /// (应付预付金现金净收额 + 初始授信占用净额 + 追加保证金现金累计 + 追加授信占用累计—— + /// 2026-08-27 修正:授信垫付的初始预付金不产生应付预付金流水,此前未计入已缴导致每个结算日按维持全额重复开追加); /// 现金部分为逐结算日增量记录(BUG-03 修正:每结算日一条、Money=−increment,键 TradeId+Action+Deal+HappenDate 幂等), /// 需求上升只增不减;授信优先(阶段二规则):授信部分只写授信出入表(remark 前缀=追加保证金,position_id 空、冗余 trade_id)。 /// 由 EOD 在客户资金计算之前调用:当日新记录计入当日出入金窗口并翻"已结算",重跑时 目标/已补足 不变 → 新增为 0 不重复写。 @@ -107,6 +109,16 @@ namespace YLErp.Modules.SwapModule.Margin .Select(g => new { TradeId = g.Key ?? 0, Funded = g.Sum(x => x.amount) }) .ToDictionary(x => x.TradeId, x => x.Funded); + //初始预付金的授信占用净额(非"追加保证金"前缀:簿记初始占用 + 平仓释放取负,Σ(amount) 即净已缴): + //授信腿不产生应付预付金流水,目标追加里只扣现金净收额会把授信垫付的初始预付金漏掉—— + //每个结算日都按维持保证金全额重复开追加(BUG:多收授信占用/现金,2026-08-27 交易2538实证:初始授信200万未扣、首日全额追加280.8万) + var initCreditByTrade = DbContext.client_credit_inout.AsNoTracking() + .Where(x => x.trade_id != null && tradeIds.Contains(x.trade_id ?? 0) + && (x.remark == null || !x.remark.StartsWith(ClientCreditInoutService.AdditionalMarginRemark))) + .GroupBy(x => x.trade_id) + .Select(g => new { TradeId = g.Key ?? 0, Funded = g.Sum(x => x.amount) }) + .ToDictionary(x => x.TradeId, x => x.Funded); + var fundTagService = new SwapFundTagService(this); var cashService = new ClientCashInCashOutService(this); var creditService = new ClientCreditInoutService(this); @@ -121,8 +133,11 @@ namespace YLErp.Modules.SwapModule.Margin { continue; } - var target = SwapAdditionalMarginCalc.CalcTarget(maintenance, - payableNetByTrade.TryGetValue(td.id, out var payableNet) ? payableNet : 0); + //目标追加 = 维持保证金 − 已缴初始保证金净额(现金应付预付金净收额 + 授信初始占用净额, + //授信垫付与现金垫付同等对待,杜绝授信初始预付金被重复追加) + var payableNet = (payableNetByTrade.TryGetValue(td.id, out var payable) ? payable : 0) + + (initCreditByTrade.TryGetValue(td.id, out var initCredit) ? initCredit : 0); + var target = SwapAdditionalMarginCalc.CalcTarget(maintenance, payableNet); if (target <= 0) { continue; diff --git a/YLErpDAL/Modules/SwapModule/Margin/SwapSpanMarginCalc.cs b/YLErpDAL/Modules/SwapModule/Margin/SwapSpanMarginCalc.cs index 93c2155c..7ae27f18 100644 --- a/YLErpDAL/Modules/SwapModule/Margin/SwapSpanMarginCalc.cs +++ b/YLErpDAL/Modules/SwapModule/Margin/SwapSpanMarginCalc.cs @@ -72,11 +72,27 @@ namespace YLErp.Modules.SwapModule.Margin return tier; } } - //未落任何层:价格已穿出最深一层边界(低于多头最深层下界/高于空头最深层上界),按最深层计; - //最深层按边界值取(多头=最小下界、空头=最大上界),不依赖配置数组顺序(BUG-25 引擎侧防御) - return isCustomerLong + //未落任何层分两种情形: + //① 价格穿出最深一层边界(多头低于最深层下界/空头高于最深层上界)→ 按最深层计(追保金额不再上升); + // 最深层按边界值取(多头=最小下界、空头=最大上界),不依赖配置数组顺序(BUG-25 引擎侧防御)。 + //② 层间空隙(如空头 (0.99,1.00]——价格在期初附近小幅波动、未触发追保的区间)→ 返回 null,追加保证金按 0。 + // 此前兜底不分情形一律按最深层计,空隙价格被错误收取最深档追保 + // (2026-08-27 交易2538实证:08-24净价100→ratio 0.99999 落空头(0.99,1.00]空档,被按0.04最深档收80.8万)。 + var deepest = isCustomerLong ? valid.OrderBy(t => t.Lower ?? double.MinValue).First() : valid.OrderByDescending(t => t.Upper ?? double.MaxValue).First(); + if (isCustomerLong) + { + if (priceRatio < (deepest.Lower ?? double.MinValue)) + { + return deepest; + } + } + else if (priceRatio > (deepest.Upper ?? double.MaxValue)) + { + return deepest; + } + return null; } /// diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 7c6d41c8..66578dc1 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -2001,6 +2001,33 @@ namespace YLErp.Modules.SwapModule SwapCalcTrace.Write(interestTrace); } + /// + /// 单标的平仓 + /// + /// + /// 新风控二次确认信息;首次提交传空。 + /// 本次平仓的新风控试算结果。 + /// + public YLErp.Modules.RiskEngine.RiskTrialResult SwapUnwind(UnwindData unwindData, YLErp.Modules.RiskEngine.RiskTrialConfirmation confirmation) + { + if (unwindData == null) + { + throw new ServiceException("平仓信息不能为空"); + } + + if (FindTrade(unwindData.SwapTradeId) == null) + { + throw new ServiceException("未找到交易信息"); + } + var riskTrialResult = CheckCloseRisk(unwindData, confirmation); + if (!riskTrialResult.Passed) + { + return riskTrialResult; + } + SwapUnwind(unwindData); + return riskTrialResult; + } + /// /// 单标的平仓 /// @@ -2421,6 +2448,29 @@ namespace YLErp.Modules.SwapModule SaveAllChanges(); } + /// + /// 互换/平仓提交审核 + /// + /// + /// + /// 新风控二次确认信息;首次提交传空。 + /// 平仓事件返回新风控试算结果,互换事件返回空。 + /// + public YLErp.Modules.RiskEngine.RiskTrialResult ApplySwapTrade(UnwindData unwindData, int eventType, YLErp.Modules.RiskEngine.RiskTrialConfirmation confirmation) + { + YLErp.Modules.RiskEngine.RiskTrialResult riskTrialResult = null; + if (eventType == (int)SwapEventTypeEnum.平仓) + { + riskTrialResult = CheckCloseRisk(unwindData, confirmation); + if (!riskTrialResult.Passed) + { + return riskTrialResult; + } + } + ApplySwapTrade(unwindData, eventType); + return riskTrialResult; + } + /// /// 互换/平仓提交审核 /// @@ -2476,6 +2526,33 @@ namespace YLErp.Modules.SwapModule } }); } + + /// + /// 执行互换交易平仓审核时点的新风控试算。 + /// + /// 包含本次平仓日期和交易ID的平仓请求。 + /// 新风控二次确认信息;首次提交传空。 + /// 包含阻断、审批、提示及试算记录ID的新风控结果。 + /// 平仓请求为空。 + private YLErp.Modules.RiskEngine.RiskTrialResult CheckCloseRisk(UnwindData unwindData, YLErp.Modules.RiskEngine.RiskTrialConfirmation confirmation) + { + if (unwindData == null) + { + throw new ServiceException("平仓信息不能为空"); + } + + const string triggerPoint = "CLOSE_REVIEW"; + const int closeTrialSource = 2; + var riskContext = new YLErp.Modules.RiskEngine.RiskContext + { + TradeId = unwindData.SwapTradeId, + TriggerPoint = triggerPoint, + UnwindDate = unwindData.UnwindDate, + PayDate = unwindData.PayDate + }; + return new YLErp.Modules.RiskEngine.RiskTrialService(this).CheckRisk(riskContext, closeTrialSource, confirmation); + } + private void ValidateIncomeValueDate(UnwindData unwindData, trade td) { var maxIncomeValueDate = GetMaxIncomeValueDate(td).Date; diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs index b6503aa9..632644a7 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs @@ -46,7 +46,7 @@ namespace YLErp.Modules.TradeModule.DealModule /// /// 2021-02-04改造:批量确认因为耗时太长,用户多页面(交易列表,今日交易)并发确认导致生成多条期权费资金记录 /// - public TradeConfirmResultModel tradeConfirm(IEnumerable tradeids, bool ignoreMoneyCheck = false, bool isSkipApproval = false, bool ignoreRiskWarning = false, IEnumerable ignoreRiskRuleIds = null) + public TradeConfirmResultModel tradeConfirm(IEnumerable tradeids, bool ignoreMoneyCheck = false, bool isSkipApproval = false, bool ignoreRiskWarning = false, IEnumerable ignoreRiskRuleIds = null, bool skipNewRiskCheck = false) { if (tradeids is null || !tradeids.Any(n => n > 0)) { @@ -102,7 +102,8 @@ namespace YLErp.Modules.TradeModule.DealModule changeConfirmPaths: result.changeConfirmPaths, continueQuotaCheck: idCache.Contains(pid), ignoreRiskWarning: ignoreRiskWarning, - ignoreRiskRuleIds: ignoreRiskRuleIds); + ignoreRiskRuleIds: ignoreRiskRuleIds, + skipNewRiskCheck: skipNewRiskCheck); if (pid > 0) { idCache.Add(pid); @@ -166,9 +167,10 @@ namespace YLErp.Modules.TradeModule.DealModule /// /// /// + /// 仅用于特批批量确认,true 表示老风控和新风控都跳过;单笔老风控特批保持 false,仍执行新风控。 /// private TradeOpenResult ConfirmTrade(int tid, bool isBatch, bool hasTradeProcess, - bool ignoreMoneyCheck, bool isSkipApproval, List confirmedTradeIds, List changeConfirmPaths, bool continueQuotaCheck = false, bool ignoreRiskWarning = false, IEnumerable ignoreRiskRuleIds = null) + bool ignoreMoneyCheck, bool isSkipApproval, List confirmedTradeIds, List changeConfirmPaths, bool continueQuotaCheck = false, bool ignoreRiskWarning = false, IEnumerable ignoreRiskRuleIds = null, bool skipNewRiskCheck = false) { var td = DbContext.trade.FirstOrDefault(t => t.id == tid); var result = new TradeOpenResult(td ?? new trade()); @@ -211,8 +213,8 @@ namespace YLErp.Modules.TradeModule.DealModule var trialService = new QuotaMonitorService(this); if (ignoreMoneyCheck) { - // ignoreMoneyCheck 表示老风控资金/限额检查已被特批放行,只跳过老风控;新风控仍需独立执行。 - if (!trialService.CheckTradeConfirmNewRisk(ref result, ignoreRiskWarning, ignoreRiskRuleIds)) + // 单笔老风控特批仍需执行新风控;特批批量确认通过 skipNewRiskCheck 显式跳过新风控。 + if (!skipNewRiskCheck && !trialService.CheckTradeConfirmNewRisk(ref result, ignoreRiskWarning, ignoreRiskRuleIds)) { return result; } diff --git a/YLErpDAL/Modules/TradeModule/DocGenerateModule/SwapSettlementBillRowBuilder.cs b/YLErpDAL/Modules/TradeModule/DocGenerateModule/SwapSettlementBillRowBuilder.cs index 36fe722e..d6063274 100644 --- a/YLErpDAL/Modules/TradeModule/DocGenerateModule/SwapSettlementBillRowBuilder.cs +++ b/YLErpDAL/Modules/TradeModule/DocGenerateModule/SwapSettlementBillRowBuilder.cs @@ -64,7 +64,6 @@ public static class SwapSettlementBillRowBuilder var positions = input.Positions ?? Array.Empty(); var eventFlows = input.EventFlows ?? Array.Empty(); - // 三个业务日期是结算单和文件命名的必填项,缺失时直接阻止生成不完整附件。 var startDate = input.Trade.StartDate ?? throw new ServiceException("结算单缺少交易起始日"); var eventDate = input.CloseFlow.UnwindDate @@ -72,7 +71,7 @@ public static class SwapSettlementBillRowBuilder var payDate = input.CloseFlow.PayDate ?? throw new ServiceException("结算单缺少支付日"); - // 将同一事件的普通利息与预付金利息分开;预付金本金仅统计结算日前已生效的腿。 + var settlementPosition = positions.FirstOrDefault(x => x.id == input.CloseFlow.PositionId); var interestEvents = eventFlows .Where(x => ConsTrade.InterestModels.Contains(x.InterestMode)) @@ -91,9 +90,13 @@ public static class SwapSettlementBillRowBuilder var fee = -(input.CloseFlow.TradingFee + input.CloseFlow.TradingFeePending); var marginInterest = -marginEvents.Sum(x => x.InterestClosePnL); var initialMargin = SumMargin(effectiveMargins, InterestModeEnum.初始预付金); - var additionalMargin = SumMargin(effectiveMargins, InterestModeEnum.追加预付金); + + var additionalMarginPositions = positions + .Where(x => x.InterestMode == (int)InterestModeEnum.追加预付金) + .ToList(); + var additionalMargin = SumMargin(additionalMarginPositions, InterestModeEnum.追加预付金); + - // 净额结算仅包含全部事件利息和浮动盈亏,不包含预付金返还本金。 var netSettlementAmount = -eventFlows.Sum(x => x.InterestClosePnL) - input.CloseFlow.FloatPnlSum; var maturitySettlementAmount = netSettlementAmount + initialMargin + additionalMargin; @@ -101,10 +104,10 @@ public static class SwapSettlementBillRowBuilder ? 0m : interestAmount / input.CloseNotionalValue; - // 日终持仓的当日浮动端分红按标的类型拆分:债券展示期间付息,非债券展示期间分红。 + var isCashBond = ConsGlobal.InstrumentType.IsBond(input.UnderlyingInstrumentType); - // 此处集中完成模板字段映射和展示精度处理,生成器只负责组装原始业务数据。 + return new ExcelReportModel { TradeNumber = input.ConfirmNo, @@ -141,7 +144,9 @@ public static class SwapSettlementBillRowBuilder .ToString("0.0000%"), MarginInterestAmount = marginInterest.ToString("0.00"), InitialMargin = initialMargin.ToString("0.00"), - AdditionalMargin = additionalMargin.ToString("0.00"), + AdditionalMargin = additionalMarginPositions.Count > 0 + ? additionalMargin.ToString("0.00") + : string.Empty, MarginAmout = Math.Abs(initialMargin).ToString("0.00"), MarkClosePnl = (-input.CloseFlow.FloatPnlSum).ToString("0.00"), NetSettleAmout = netSettlementAmount.ToString("0.00"), diff --git a/YLErpWeb/Controllers/SwapTrade2Controller.cs b/YLErpWeb/Controllers/SwapTrade2Controller.cs index ef6cfdeb..b1e35303 100644 --- a/YLErpWeb/Controllers/SwapTrade2Controller.cs +++ b/YLErpWeb/Controllers/SwapTrade2Controller.cs @@ -13,6 +13,8 @@ using YLErp.DBModels; using YLErp.DBModels.Consts; using YLErp.Enums; using YLErp.Model.Enum; +using YLErp.Modules.RiskEngine; +using YLErp.Modules.RiskModule; using YLErp.Modules.SalesModule; using YLErp.Modules.SwapModule; using YLErp.Modules.SwapModule.Dto; @@ -447,12 +449,14 @@ namespace YLErp.Web.Controllers /// ///单标的 平仓 /// - /// - /// - public JsonResult SwapUnwindJson(UnwindData unwindData) + /// 本次单标的平仓数据。 + /// 首次新风控试算记录ID;二次确认时传入。 + /// 首次试算返回的需忽略规则ID;二次确认时传入。 + /// 平仓结果或新风控确认信息。 + public JsonResult SwapUnwindJson(UnwindData unwindData, int? trialDataId = null, string ignoreRiskRuleIds = null) { - new SwapDealService(CurUser).SwapUnwind(unwindData); - return JsonSuccess("平仓成功"); + var result = new SwapDealService(CurUser).SwapUnwind(unwindData, CreateRiskTrialConfirmation(trialDataId, ignoreRiskRuleIds)); + return BuildCloseRiskResult(result, "平仓成功"); } /// /// 互换 @@ -467,13 +471,112 @@ namespace YLErp.Web.Controllers /// /// 互换/平仓提交申请 /// - /// - /// - /// - public JsonResult ApplyUnwind(UnwindData unwindData, int eventType) + /// 本次互换或平仓数据。 + /// 事件类型。 + /// 首次新风控试算记录ID;平仓二次确认时传入。 + /// 首次试算返回的需忽略规则ID;二次确认时传入。 + /// 提交结果或新风控确认信息。 + public JsonResult ApplyUnwind(UnwindData unwindData, int eventType, int? trialDataId = null, string ignoreRiskRuleIds = null) { - new SwapDealService(CurUser).ApplySwapTrade(unwindData, eventType); - return JsonSuccess("提交成功"); + var result = new SwapDealService(CurUser).ApplySwapTrade(unwindData, eventType, CreateRiskTrialConfirmation(trialDataId, ignoreRiskRuleIds)); + return BuildCloseRiskResult(result, "提交成功"); + } + + /// + /// 保存平仓风控特批说明,不改变首次试算时间。 + /// 平仓二次确认会重新执行风控并生成新的 quotaTrial 快照,本接口只补充首次试算记录的用户说明和审计日志。 + /// + /// 平仓风控试算记录ID。 + /// 用户填写的特批说明。 + /// 保存结果。 + public JsonResult SaveCloseRiskTrialRemark(int trialDataId, string remark) + { + try + { + var trial = new QuotaMonitorService(CurUser) + .SaveCloseRiskTrialRemark(trialDataId, remark); + new TradeRiskCheckLogService(CurUser).AddLog(trial); + return JsonSuccess(); + } + catch (Exception ex) + { + LogFactory.GetLogger("SaveCloseRiskTrialRemark").Error(ex); + return JsonError("保存平仓风控试算说明失败!"); + } + } + + /// + /// 根据首次试算记录ID创建平仓新风控二次确认参数。 + /// + /// 首次新风控试算记录ID。 + /// 首次试算返回的需忽略规则ID,多个ID使用逗号分隔。 + /// 首次提交返回空,二次确认返回包含有效期和需忽略规则ID的确认参数。 + private static RiskTrialConfirmation CreateRiskTrialConfirmation(int? trialDataId, string ignoreRiskRuleIds) + { + if (!trialDataId.HasValue) + { + return null; + } + + var expireSeconds = 300; + var expireSecondsConfig = AppManager.GetAppConfigValue("ProjectConfig", "Trade.RiskWarningConfirmExpireSeconds"); + if (!string.IsNullOrWhiteSpace(expireSecondsConfig) + && int.TryParse(expireSecondsConfig, out var configuredExpireSeconds) + && configuredExpireSeconds > 0) + { + expireSeconds = configuredExpireSeconds; + } + return new RiskTrialConfirmation + { + TrialDataId = trialDataId.Value, + ExpireSeconds = expireSeconds, + IgnoreRiskRuleIds = ParseIgnoreRiskRuleIds(ignoreRiskRuleIds) + }; + } + + private static List ParseIgnoreRiskRuleIds(string ignoreRiskRuleIds) + { + return (ignoreRiskRuleIds ?? string.Empty) + .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(ruleId => ruleId.Trim()) + .Where(ruleId => !string.IsNullOrWhiteSpace(ruleId)) + .Distinct() + .ToList(); + } + + /// + /// 将平仓新风控结果转换为控制器统一JSON响应。 + /// + /// 新风控试算结果;互换事件不执行平仓风控时为空。 + /// 业务执行成功后的提示。 + /// 阻断、二次确认或业务成功响应。 + private JsonResult BuildCloseRiskResult(RiskTrialResult result, string successMessage) + { + if (result == null || result.Passed) + { + return JsonSuccess(successMessage, result?.ShowTip == true ? new { result.TrialDataId, result.Message } : null); + } + if (result.Blocked) + { + // 风控阻断属于可预期的业务结果,返回成功协议供前端打开只读试算详情,不进入通用请求失败分支。 + return JsonSuccessData(new + { + proccessType = "QuotaTrialError", + result.TrialDataId, + message = string.IsNullOrWhiteSpace(result.Message) + ? "平仓风控校验未通过" + : result.Message + }); + } + return JsonSuccessData(new + { + proccessType = "AdditionalProcessing", + source = "RiskWarning", + type = "RiskWarningConfirm", + result.TrialDataId, + ignoreRiskRuleIds = result.ApprovalRuleIds, + message = result.ConfirmationExpired ? $"原风控确认已超时,请重新确认。{result.Message}" : result.Message + }); } /// /// 框架合约保存 diff --git a/YLErpWeb/Controllers/clientController.cs b/YLErpWeb/Controllers/clientController.cs index f1aaae6d..2c7dd6d7 100644 --- a/YLErpWeb/Controllers/clientController.cs +++ b/YLErpWeb/Controllers/clientController.cs @@ -671,7 +671,8 @@ namespace YLErp.Web.Controllers x.PayableMargin = clientBalance.PayableMargin; x.TwoSideMargin = clientBalance.TwoSideMargin; x.OtherSideMargin = -clientBalance.OtherSideMargin; - x.MySideMargin = clientBalance.MySideMargin; + //初始保证金金额=应付预付金流水收付净额(与每日估值报告"初始保证金金额"同口径,SwapInitMargin) + x.MySideMargin = clientBalance.SwapInitMargin; x.DeltaMargin = -clientBalance.DeltaMargin; x.IsPayableMarginManual = clientBalance?.IsPayableMarginManual; x.AvailableAmount = clientBalance.AvailableAmount; @@ -689,7 +690,9 @@ namespace YLErp.Web.Controllers x.EndDesirableFund = x.NetFund + x.SettlementBalance + clientBalance.EndPremiumSum; x.SwapMarketAmount= clientBalance.SwapMarketAmount; x.SwapMarketAmountPercent=clientBalance.SwapMarketAmountPercent; - x.MaintenanceMargin=clientBalance.MaintenanceMargin; + //维持保证金金额=−MySideMargin(client_span 维持保证金反号聚合,与每日估值报告"维持保证金金额"同口径; + //原取 client_span.VariationMargin——该列全库无写入方恒为0) + x.MaintenanceMargin = -(clientBalance.MySideMargin); x.NeedAddMargin=clientBalance.NeedAddMargin; x.LastDayRemainFund=clientBalance.LastDayRemainFund; if (clientBalance.MarginOccupation == 0) diff --git a/YLErpWeb/Controllers/tradeController.cs b/YLErpWeb/Controllers/tradeController.cs index cbe29186..ce50fdab 100644 --- a/YLErpWeb/Controllers/tradeController.cs +++ b/YLErpWeb/Controllers/tradeController.cs @@ -2469,7 +2469,6 @@ namespace YLErp.Web.Controllers var ignoreMoneyCheck = tradeidArr.Count() == 1 && (additionalProcessing == tradeBLL.LackOfMoney || isOldNewRiskConfirm); var ignoreRiskWarning = tradeidArr.Count() == 1 && (additionalProcessing == tradeBLL.RiskWarningConfirm || isOldNewRiskConfirm); var ignoreRiskRuleIdArr = StringHelper.ConvertCommaValuesToStringArray(ignoreRiskRuleIds); - _logger.Info($"TradeConfirmRisk.Request tradeids:{tradeids}, additionalProcessing:{additionalProcessing}, trialDataId:{trialDataId}, ignoreMoneyCheck:{ignoreMoneyCheck}, ignoreRiskWarning:{ignoreRiskWarning}, ignoreRiskRuleIds:{ignoreRiskRuleIds}, isSkipCheck:{isSkipCheck}, userId:{CurUser?.UserId}"); // 老风控交易特批、新风控审批及组合特批统一基于 trialDataId 做超时校验,超时后清空放行标记并重新校验。 // tradeview.js 和 swapTradeView.js 会回传 trialDataId;未回传的旧入口保持原有行为,不进入超时校验。 if (ignoreMoneyCheck || ignoreRiskWarning) @@ -2489,13 +2488,20 @@ namespace YLErp.Web.Controllers } } var isSkipApproval = false; + var skipNewRiskCheck = false; + // 特批批量确认的语义是直接确认,跳过审批、老风控和新风控;单笔老风控特批不设置该标记。 if (CurUser.交易管理_特批批量确认 && isSkipCheck) { ignoreMoneyCheck = true; isSkipApproval = true; + skipNewRiskCheck = true; + _logger.Info($"TradeConfirmRisk.SkipNewRisk tradeids:{tradeids}, isSkipCheck:{isSkipCheck}, ignoreMoneyCheck:{ignoreMoneyCheck}, isSkipApproval:{isSkipApproval}, skipNewRiskCheck:{skipNewRiskCheck}, userId:{CurUser?.UserId}"); + } + var result = new TradeConfirmService(CurUser).tradeConfirm(tradeidArr, ignoreMoneyCheck, isSkipApproval, ignoreRiskWarning, ignoreRiskRuleIdArr, skipNewRiskCheck); + if (result.NewRiskBlocked || result.NewRiskNeedApproval || result.OldRiskNeedSpecialApproval || result.type == TradeOpenRetCode.QuotaTrialError.ToString() || result.type == TradeOpenRetCode.RiskWarning.ToString()) + { + _logger.Info($"TradeConfirmRisk.Result tradeids:{tradeids}, lackOfMoney:{result.LackOfMoney}, type:{result.type}, trialDataId:{result.TrialDataId}, oldRiskNeedSpecialApproval:{result.OldRiskNeedSpecialApproval}, newRiskNeedApproval:{result.NewRiskNeedApproval}, newRiskBlocked:{result.NewRiskBlocked}, skipNewRiskCheck:{skipNewRiskCheck}, ignoreRiskRuleIds:{string.Join(",", result.ignoreRiskRuleIds ?? new List())}, errorMsgLength:{result.errorMsg?.Length ?? 0}, tipMsgLength:{result.tipMsg?.Length ?? 0}, userId:{CurUser?.UserId}"); } - var result = new TradeConfirmService(CurUser).tradeConfirm(tradeidArr, ignoreMoneyCheck, isSkipApproval, ignoreRiskWarning, ignoreRiskRuleIdArr); - _logger.Info($"TradeConfirmRisk.Result tradeids:{tradeids}, lackOfMoney:{result.LackOfMoney}, type:{result.type}, trialDataId:{result.TrialDataId}, oldRiskNeedSpecialApproval:{result.OldRiskNeedSpecialApproval}, newRiskNeedApproval:{result.NewRiskNeedApproval}, newRiskBlocked:{result.NewRiskBlocked}, ignoreRiskRuleIds:{string.Join(",", result.ignoreRiskRuleIds ?? new List())}, errorMsgLength:{result.errorMsg?.Length ?? 0}, tipMsgLength:{result.tipMsg?.Length ?? 0}, userId:{CurUser?.UserId}"); //如果需要前端确认信息,触发新老风控 if (result.LackOfMoney) diff --git a/YLErpWeb/Views/margin_template_v2/margin_template_v2ClientEdit.cshtml b/YLErpWeb/Views/margin_template_v2/margin_template_v2ClientEdit.cshtml index 851f637c..593f2612 100644 --- a/YLErpWeb/Views/margin_template_v2/margin_template_v2ClientEdit.cshtml +++ b/YLErpWeb/Views/margin_template_v2/margin_template_v2ClientEdit.cshtml @@ -1,4 +1,4 @@ -@using YLErp.Enums; +@using YLErp.Enums; @model margin_template_v2 @{ @@ -216,7 +216,6 @@  ETF 子类: (按期限分档:≤5y、(5y-10y]、(10y-30y]、>30y,每个档位独立设置)

-

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

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