diff --git a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs index 471f302f..36e9222c 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs @@ -1,4 +1,4 @@ -using Newtonsoft.Json; +using Newtonsoft.Json; using Qdp.Foundation.Utilities; using System.Reflection; using YLErp.BLL; @@ -309,11 +309,14 @@ namespace YLErp.Modules.RiskEngine try { - _logger.Info($"[风控引擎] EvaluateRisk 开始 - TradeId: {context?.TradeId}, TriggerPoint: {triggerPoint}"); - if (context != null && context.DbContext == null) - { - context.DbContext = DbContext; - } + using var ruleDbContext = DbContextFactory.GetYLDbContext(); + ruleDbContext.ChangeTracker.QueryTrackingBehavior = Microsoft.EntityFrameworkCore.QueryTrackingBehavior.NoTracking; + + context ??= new RiskContext(); + context.TriggerPoint = triggerPoint; + context.DbContext = ruleDbContext; + + _logger.Info($"[风控引擎] EvaluateRisk 开始 - TradeId: {context.TradeId}, TriggerPoint: {triggerPoint}"); // ============================================================ // Step 1: 从内存缓存读取规则定义和规则应用(启动时已预热) @@ -343,8 +346,8 @@ namespace YLErp.Modules.RiskEngine // 2. 同一维度内多选按并集处理; // 3. 不同维度之间按交集处理; // 4. 某维度留空表示该维度不限制。 - var trade = context != null && context.TradeId > 0 - ? DbContext.trade.FirstOrDefault(t => t.id == context.TradeId) + var trade = context.TradeId > 0 + ? context.DbContext.trade.AsNoTracking().FirstOrDefault(t => t.id == context.TradeId) : null; var matchedApplications = triggerMatchedApps @@ -706,8 +709,8 @@ namespace YLErp.Modules.RiskEngine //{ // Id = 1000012, // RuleName = "债券类净价偏离(本地)", - // RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 PosiNetNoFeePrice,对应债券类标的期初交割净价,库内为 1 左右原值;通过 DbContext.china_bond_valuation 按当前交易标的和交易日前日期取上一收盘日 net_price,库内为 100 左右报价。计算逻辑:按 ABS(PosiNetNoFeePrice×100-net_price) 计算绝对价差,价差大于 5 元时触发审批。", - // RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId).PosiNetNoFeePrice.Value * 100m - DbContext.china_bond_valuation.Where(v => v.bond_id == DbContext.trade.First(t => t.id == TradeId).UnderlyingCode && v.valuation_date < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderByDescending(v => v.credibility).ThenByDescending(v => v.valuation_date).First().net_price.Value) > 5m", + // RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、PosiDirection=2 且有标的代码的浮动支付端 PosiNetNoFeePrice 和 UnderlyingCode,PosiNetNoFeePrice 对应债券类标的期初交割净价,库内为 1 左右原值;通过 DbContext.china_bond_valuation 按该浮动支付端标的和交易日前日期优先取 credibility=1 的上一收盘日 net_price,库内为 100 左右报价。计算逻辑:按 ABS(PosiNetNoFeePrice×100-net_price) 计算绝对价差,价差大于 5 元时触发审批。", + // RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).PosiNetNoFeePrice.Value * 100m - DbContext.china_bond_valuation.Where(v => v.bond_id == DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).UnderlyingCode && v.valuation_date < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderBy(v => v.credibility).ThenByDescending(v => v.valuation_date).First().net_price.Value) > 5m", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -722,8 +725,8 @@ namespace YLErp.Modules.RiskEngine //{ // Id = 1000013, // RuleName = "债券类收益率偏离(本地)", - // RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 InitYtm,对应债券类标的期初成交收益率,库内为原值;通过 DbContext.china_bond_valuation 按当前交易标的和交易日前日期取上一收盘日 yield,库内为 1.5 到 2.2 左右百分数。计算逻辑:按 ABS(InitYtm×100-yield) 计算收益率绝对差,差值大于 1 时触发审批。", - // RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId).InitYtm.Value * 100m - DbContext.china_bond_valuation.Where(v => v.bond_id == DbContext.trade.First(t => t.id == TradeId).UnderlyingCode && v.valuation_date < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderByDescending(v => v.credibility).ThenByDescending(v => v.valuation_date).First().yield.Value) > 1m", + // RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、PosiDirection=2 且有标的代码的浮动支付端 InitYtm 和 UnderlyingCode,InitYtm 对应债券类标的期初成交收益率,库内为原值;通过 DbContext.china_bond_valuation 按该浮动支付端标的和交易日前日期优先取 credibility=1 的上一收盘日 yield,库内为 1.5 到 2.2 左右百分数。计算逻辑:按 ABS(InitYtm×100-yield) 计算收益率绝对差,差值大于 1 时触发审批。", + // RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).InitYtm.Value * 100m - DbContext.china_bond_valuation.Where(v => v.bond_id == DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).UnderlyingCode && v.valuation_date < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderBy(v => v.credibility).ThenByDescending(v => v.valuation_date).First().yield.Value) > 1m", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -738,8 +741,8 @@ namespace YLErp.Modules.RiskEngine //{ // Id = 1000014, // RuleName = "非债券类价格偏离(本地)", - // RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 PosiGrossPrice,对应普通收益互换页面填写的期初标的价格,库内为 1 左右原值;通过 DbContext.eod_commodity_future_price 按当前交易标的和交易日前日期取上一日收盘价 ClosePrice。计算逻辑:按 ABS(PosiGrossPrice×100-ClosePrice) 计算绝对价差,价差大于 5 时触发审批。", - // RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId).PosiGrossPrice * 100m - Convert.ToDecimal(DbContext.eod_commodity_future_price.Where(e => e.UnderlyingCode == DbContext.trade.First(t => t.id == TradeId).UnderlyingCode && e.ValueDate < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderByDescending(e => e.ValueDate).First().ClosePrice)) > 5m", + // RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、PosiDirection=2 且有标的代码的浮动支付端 PosiGrossPrice 和 UnderlyingCode,PosiGrossPrice 对应普通收益互换页面填写的期初标的价格,库内为 1 左右原值;通过 DbContext.eod_commodity_future_price 按该浮动支付端标的和交易日前日期取上一日收盘价 ClosePrice。注意:eod_commodity_future_price 模型属性 UnderlyingCode 实际映射数据库列 FutureContractId,数据库排查时应使用 FutureContractId 与 swap_position.UnderlyingCode 关联。计算逻辑:按 ABS(PosiGrossPrice×100-ClosePrice) 计算绝对价差,价差大于 5 时触发审批。", + // RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).PosiGrossPrice * 100m - Convert.ToDecimal(DbContext.eod_commodity_future_price.Where(e => e.UnderlyingCode == DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).UnderlyingCode && e.ValueDate < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderByDescending(e => e.ValueDate).First().ClosePrice)) > 5m", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -754,8 +757,8 @@ namespace YLErp.Modules.RiskEngine //{ // Id = 1000015, // RuleName = "单一交易对手累计标的数量超阈值(本地)", - // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易对手 ClientId,再查询同一交易对手存续/审批中交易对应的 swap_position.UnderlyingCode 去重数量。计算逻辑:同一交易对手累计标的数量超过 10 个时触发审批。", - // RuleExpr= DbContext.swap_position.Where(p => !string.IsNullOrEmpty(p.UnderlyingCode) && p.IsInitial && !p.Invalid && DbContext.trade.Any(t => t.id == p.SwapTradeId && t.ValidState != "InValid" && t.ClientId == DbContext.trade.First(x => x.id == TradeId).ClientId && t.ParentTradeId == 0 && (ConsTrade.NeedMarginTradeStatusList.Contains(t.TradeStatus) || t.TradeStatus == "审批中"))).Select(p => p.UnderlyingCode).Distinct().Count() > 10 + // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易对手 ClientId,再查询同一交易对手有效交易对应的实时存续持仓 swap_position.UnderlyingCode 去重数量。实时存续持仓口径:IsInitial=false、PosiQuantity>0、Invalid=false、PosiDirection>0 且 UnderlyingCode 非空。计算逻辑:同一交易对手累计标的数量超过 10 个时触发审批。", + // RuleExpr= DbContext.swap_position.Where(p => !string.IsNullOrEmpty(p.UnderlyingCode) && !p.IsInitial && p.PosiQuantity > 0 && !p.Invalid && p.PosiDirection > 0 && DbContext.trade.Any(t => t.id == p.SwapTradeId && t.ValidState != "InValid" && t.ClientId == DbContext.trade.First(x => x.id == TradeId).ClientId)).Select(p => p.UnderlyingCode).Distinct().Count() > 10 // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -770,8 +773,8 @@ namespace YLErp.Modules.RiskEngine //{ // Id = 1000016, // RuleName = "多头支付固定端利率偏离(本地)", - // RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取利息端支付方向记录的 InterestRateDefault,对应互换交易-利息端-利率文本框数值;通过 DbContext.eod_commodity_future_price 按 FutureContractId=FR007(模型属性 UnderlyingCode)和交易日前日期取最近一条 ClosePrice。计算逻辑:按 ABS(InterestRateDefault-FR007) 计算利率差,差值小于 5% 时触发审批。", - // RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.InterestDirection == 2).InterestRateDefault - Convert.ToDecimal(DbContext.eod_commodity_future_price.Where(e => e.UnderlyingCode == "FR007" && e.ValueDate < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderByDescending(e => e.ValueDate).First().ClosePrice)) < 0.05m", + // RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取利息端收入固定利息方向记录的 InterestRateDefault。InterestRateDefault 只代表利率文本框中 + 号后的点差,不包含 FR007 基准利率,库内为小数原值,界面按百分比显示。计算逻辑:按 ABS(InterestRateDefault×100) 计算点差百分比绝对值,绝对值小于 5 时触发审批。", + // RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.InterestDirection == 1).InterestRateDefault * 100m) < 5m", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, diff --git a/YLErpDAL/Modules/RiskEngine/测试用例.md b/YLErpDAL/Modules/RiskEngine/测试用例.md new file mode 100644 index 00000000..00050f5c --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/测试用例.md @@ -0,0 +1,1754 @@ +## 风控测试用例 + +### 1. 测试范围 + +覆盖交易确认链路中老风控与新风控的组合结果,重点验证最终返回给前端的处理类型是否符合优先级。 + +本用例主要覆盖: + +- 老风控通过 +- 老风控触发特批 +- 老风控绝对失败 +- 新风控不触发 +- 新风控提示 ShowTip +- 新风控需审批 NeedApproval +- 新风控禁止 Block +- 新风控多策略叠加 +- 老风控特批二次确认 +- 新风控审批二次确认 +- 新风控关闭后的 master 兼容行为 + +### 2. 状态优先级 + +最终风控状态按以下优先级处理: + +```text +Error > Warning > RiskWarning > Success +``` + +含义: + +```text +Error:绝对失败,不允许审批或特批放行,只展示详情。 +Warning:老风控触发 master 原有交易特批/二次处理入口,前端 code/typecode 可仍为 QuotaTrialError。 +RiskWarning:新风控需审批,需要前端二次确认 RiskWarningConfirm。 +Success:风控通过,可能携带 ShowTip 提示。 +``` + +### 3. 前端返回约定 + +#### 3.1 普通成功 + +```text +success = true +msg = 操作完成 +obj.TrialDataId 可有可无 +``` + +前端表现: + +```text +关闭交易确认窗口,刷新列表。 +``` + +#### 3.2 成功但有提示 + +```text +success = true +msg != 操作完成 +obj.TrialDataId = quotaTrial.id +``` + +前端表现: + +```text +交易确认成功后,打开 /trade/showQuotaTrial?id={TrialDataId}。 +按钮:关闭。 +``` + +#### 3.3 老风控交易特批入口 + +```text +success = true +obj.proccessType = AdditionalProcessing +obj.source = OldRiskQuotaTrialError +obj.typecode = QuotaTrialError +obj.type = LackOfMoney 或空字符串 +obj.TrialDataId = quotaTrial.id +``` + +前端表现: + +```text +打开 /trade/showQuotaTrial?id={TrialDataId}。 +如果 obj.type = LackOfMoney,显示交易特批按钮。 +如果 obj.type = 空字符串,只展示详情,不显示交易特批按钮。 +``` + +`obj.type` 判断规则应延用 master: + +```text +公司为天风 或 SpecialOperateForTrade = 1:type = LackOfMoney +否则:type = 空字符串 +``` + +#### 3.4 新风控需审批 + +```text +success = true +obj.proccessType = AdditionalProcessing +obj.source = RiskWarning +obj.type = RiskWarningConfirm +obj.typecode = RiskWarning +obj.ignoreRiskRuleIds = 本次命中的审批/提示规则ID +obj.TrialDataId = quotaTrial.id +``` + +前端表现: + +```text +打开 /trade/showQuotaTrial?id={TrialDataId}。 +按钮:交易特批、取消。 +二次确认时回传 additionalProcessing = RiskWarningConfirm。 +``` + +#### 3.5 绝对失败 + +```text +success = true +obj.proccessType = QuotaTrialError +obj.typecode = QuotaTrialError +obj.TrialDataId = quotaTrial.id +``` + +前端表现: + +```text +打开 /trade/showQuotaTrial?id={TrialDataId}。 +按钮:关闭。 +不允许交易特批或新风控审批。 +``` + +### 4. 老风控分类约定 + +#### 4.1 老风控通过 + +```text +OldRiskTrialStatus = Success +OldRiskNeedSpecialApproval = false +``` + +#### 4.2 老风控触发 master 特批/二次处理入口 + +```text +OldRiskTrialStatus != Success +OldRiskNeedSpecialApproval = true +``` + +说明: + +```text +这里必须覆盖 Warning 和 Error。 +不能只把 Warning 当成可进入 master 特批入口。 +master 中老风控非 Success 会进入 AdditionalProcessing,是否显示交易特批按钮由 type 决定。 +``` + +#### 4.3 老风控绝对失败 + +```text +OldRiskNeedSpecialApproval = false +RetCode = QuotaTrialError +``` + +说明: + +```text +如果当前业务完全延用 master,则老风控自身通常不单独区分这类场景。 +如果未来引入老风控绝对失败,需要有明确标志,不能仅依赖 TrialStatus = Error 判断。 +``` + +### 5. 新风控分类约定 + +新风控策略可以单独触发,也可以叠加触发。 + +```text +无触发 +ShowTip +NeedApproval +Block +ShowTip + NeedApproval +ShowTip + Block +NeedApproval + Block +ShowTip + NeedApproval + Block +``` + +处理优先级: + +```text +Block 优先于 NeedApproval。 +NeedApproval 优先于 ShowTip。 +ShowTip 只有在最终成交成功时才作为成功后提示。 +``` + +--- + +## 6. 老风控 Success 组合用例 + +### 用例 001:老风控 Success + 新风控无触发 + +前置条件: + +```text +老风控:Success +新风控:无 Block、无 NeedApproval、无 ShowTip +``` + +预期结果: + +```text +最终状态:Success +前端返回:普通成功 +前端表现:直接关闭/刷新 +``` + +不得出现: + +```text +AdditionalProcessing +QuotaTrialError +RiskWarningConfirm +ShowTip 弹窗 +``` + +### 用例 002:老风控 Success + 新风控 ShowTip + +前置条件: + +```text +老风控:Success +新风控:ShowTip +``` + +预期结果: + +```text +最终状态:Success +前端返回:成功但有提示 +前端表现:交易确认成功后,弹 showQuotaTrial 详情,只能关闭 +``` + +不得出现: + +```text +AdditionalProcessing +RiskWarningConfirm +QuotaTrialError +``` + +### 用例 003:老风控 Success + 新风控 NeedApproval + +前置条件: + +```text +老风控:Success +新风控:NeedApproval +``` + +预期结果: + +```text +最终状态:RiskWarning +RetCode:RiskWarning +OldRiskNeedSpecialApproval:false +前端返回:新风控需审批 +source:RiskWarning +type:RiskWarningConfirm +typecode:RiskWarning +ignoreRiskRuleIds:包含本次需审批规则ID +``` + +前端表现: + +```text +打开 showQuotaTrial。 +显示新风控审批按钮。 +``` + +不得出现: + +```text +OldRiskQuotaTrialError +QuotaTrialError 只关闭分支 +普通成功 +``` + +### 用例 004:老风控 Success + 新风控 Block + +前置条件: + +```text +老风控:Success +新风控:Block +``` + +预期结果: + +```text +最终状态:Error +RetCode:QuotaTrialError +OldRiskNeedSpecialApproval:false +前端返回:绝对失败 +前端表现:弹 showQuotaTrial,只能关闭 +``` + +不得出现: + +```text +RiskWarningConfirm +OldRiskQuotaTrialError +交易特批按钮 +普通成功 +``` + +### 用例 005:老风控 Success + 新风控 ShowTip + NeedApproval + +前置条件: + +```text +老风控:Success +新风控:ShowTip + NeedApproval +``` + +预期结果: + +```text +最终状态:RiskWarning +RetCode:RiskWarning +前端返回:新风控需审批 +source:RiskWarning +type:RiskWarningConfirm +typecode:RiskWarning +ignoreRiskRuleIds:包含本次需审批规则ID;当前实现也会把已展示的提示规则ID纳入 processedRiskRuleIds +``` + +说明: + +```text +NeedApproval 优先于 ShowTip。 +交易尚未确认成功,不应走成功后 ShowTip。 +``` + +不得出现: + +```text +成功后 ShowTip 弹窗 +QuotaTrialError +OldRiskQuotaTrialError +``` + +### 用例 006:老风控 Success + 新风控 ShowTip + Block + +前置条件: + +```text +老风控:Success +新风控:ShowTip + Block +``` + +预期结果: + +```text +最终状态:Error +RetCode:QuotaTrialError +前端返回:绝对失败 +前端表现:弹 showQuotaTrial,只能关闭 +``` + +说明: + +```text +Block 优先于 ShowTip。 +ShowTip 信息可以进入详情,但不能导致成功后提示。 +``` + +不得出现: + +```text +成功后 ShowTip 弹窗 +RiskWarningConfirm +OldRiskQuotaTrialError +``` + +### 用例 007:老风控 Success + 新风控 NeedApproval + Block + +前置条件: + +```text +老风控:Success +新风控:NeedApproval + Block +``` + +预期结果: + +```text +最终状态:Error +RetCode:QuotaTrialError +前端返回:绝对失败 +前端表现:弹 showQuotaTrial,只能关闭 +``` + +说明: + +```text +Block 优先于 NeedApproval。 +即使命中需审批规则,也不允许审批放行。 +``` + +不得出现: + +```text +RiskWarningConfirm +普通成功 +交易特批按钮 +``` + +### 用例 008:老风控 Success + 新风控 ShowTip + NeedApproval + Block + +前置条件: + +```text +老风控:Success +新风控:ShowTip + NeedApproval + Block +``` + +预期结果: + +```text +最终状态:Error +RetCode:QuotaTrialError +前端返回:绝对失败 +前端表现:弹 showQuotaTrial,只能关闭 +``` + +说明: + +```text +Block 最高优先级。 +NeedApproval 和 ShowTip 只能作为详情展示,不应改变失败结果。 +``` + +不得出现: + +```text +RiskWarningConfirm +成功后 ShowTip +OldRiskQuotaTrialError +``` + +--- + +## 7. 老风控触发特批组合用例 + +说明: + +```text +老风控触发特批表示 OldRiskNeedSpecialApproval = true。 +按 master 兼容逻辑,OldRiskTrialStatus != Success 即应进入该分支。 +是否显示交易特批按钮仍由 type 决定。 +``` + +### 用例 009:老风控触发特批 + 新风控无触发 + +前置条件: + +```text +老风控:触发特批 +新风控:无 Block、无 NeedApproval、无 ShowTip +``` + +预期结果: + +```text +最终优先级:Warning(老风控特批) +RetCode:QuotaTrialError +OldRiskNeedSpecialApproval:true +前端返回:老风控交易特批入口 +source:OldRiskQuotaTrialError +typecode:QuotaTrialError +type:LackOfMoney 或空字符串 +``` + +前端表现: + +```text +如果 type = LackOfMoney:显示交易特批按钮。 +如果 type = 空字符串:弹详情,但不显示交易特批按钮。 +``` + +### 用例 010:老风控触发特批 + 新风控 ShowTip + +前置条件: + +```text +老风控:触发特批 +新风控:ShowTip +``` + +预期结果: + +```text +最终优先级:Warning(老风控特批) +前端返回:老风控交易特批入口 +source:OldRiskQuotaTrialError +typecode:QuotaTrialError +``` + +说明: + +```text +老风控特批优先于成功提示。 +交易尚未确认成功,不能走成功后 ShowTip。 +``` + +不得出现: + +```text +成功后 ShowTip 弹窗 +RiskWarningConfirm +QuotaTrialError 只关闭分支 +``` + +### 用例 011:老风控触发特批 + 新风控 NeedApproval + +前置条件: + +```text +老风控:触发特批 +新风控:NeedApproval +``` + +预期结果: + +```text +最终优先级:Warning(老风控特批) +前端返回:老风控交易特批入口 +source:OldRiskQuotaTrialError +typecode:QuotaTrialError +``` + +说明: + +```text +老风控特批优先于新风控需审批。 +不能返回 RiskWarningConfirm。 +``` + +不得出现: + +```text +source = RiskWarning +type = RiskWarningConfirm +``` + +### 用例 012:老风控触发特批 + 新风控 Block + +前置条件: + +```text +老风控:触发特批 +新风控:Block +``` + +预期结果: + +```text +最终优先级:Error +前端返回:绝对失败 +前端表现:只展示详情,只能关闭 +``` + +说明: + +```text +新风控 Block 是绝对失败,优先级高于老风控特批。 +``` + +不得出现: + +```text +AdditionalProcessing + OldRiskQuotaTrialError +交易特批按钮 +RiskWarningConfirm +``` + +风险提示: + +```text +当前实现需要有明确的新风控 Block 标志。 +否则 Controller 只看到 RetCode = QuotaTrialError 且 OldRiskNeedSpecialApproval = true 时,可能误走老风控特批分支。 +``` + +### 用例 013:老风控触发特批 + 新风控 ShowTip + NeedApproval + +前置条件: + +```text +老风控:触发特批 +新风控:ShowTip + NeedApproval +``` + +预期结果: + +```text +最终优先级:Warning(老风控特批) +前端返回:老风控交易特批入口 +source:OldRiskQuotaTrialError +typecode:QuotaTrialError +``` + +说明: + +```text +老风控特批优先于新风控 NeedApproval。 +ShowTip 只进入详情,不走成功提示。 +``` + +不得出现: + +```text +RiskWarningConfirm +成功后 ShowTip +QuotaTrialError 只关闭分支 +``` + +### 用例 014:老风控触发特批 + 新风控 ShowTip + Block + +前置条件: + +```text +老风控:触发特批 +新风控:ShowTip + Block +``` + +预期结果: + +```text +最终优先级:Error +前端返回:绝对失败 +前端表现:只展示详情,只能关闭 +``` + +说明: + +```text +Block 优先于老风控特批和 ShowTip。 +``` + +不得出现: + +```text +AdditionalProcessing +RiskWarningConfirm +成功后 ShowTip +``` + +### 用例 015:老风控触发特批 + 新风控 NeedApproval + Block + +前置条件: + +```text +老风控:触发特批 +新风控:NeedApproval + Block +``` + +预期结果: + +```text +最终优先级:Error +前端返回:绝对失败 +前端表现:只展示详情,只能关闭 +``` + +说明: + +```text +Block 优先级最高。 +NeedApproval 不应被允许审批放行。 +老风控特批也不应覆盖 Block。 +``` + +不得出现: + +```text +OldRiskQuotaTrialError +RiskWarningConfirm +交易特批按钮 +``` + +### 用例 016:老风控触发特批 + 新风控 ShowTip + NeedApproval + Block + +前置条件: + +```text +老风控:触发特批 +新风控:ShowTip + NeedApproval + Block +``` + +预期结果: + +```text +最终优先级:Error +前端返回:绝对失败 +前端表现:只展示详情,只能关闭 +``` + +说明: + +```text +Block 优先级最高。 +其余策略只能进入详情展示。 +``` + +不得出现: + +```text +AdditionalProcessing +RiskWarningConfirm +成功后 ShowTip +``` + +--- + +## 8. 老风控绝对失败组合用例 + +说明: + +```text +老风控绝对失败表示 RetCode = QuotaTrialError 且 OldRiskNeedSpecialApproval = false。 +如果当前业务完全延用 master,则老风控自身通常不单独区分此类场景。 +如果未来需要支持,必须有明确标志,不能仅用 TrialStatus = Error 判断。 +``` + +### 用例 017:老风控绝对失败 + 新风控无触发 + +前置条件: + +```text +老风控:绝对失败 +新风控:无触发 +``` + +预期结果: + +```text +最终优先级:Error +RetCode:QuotaTrialError +OldRiskNeedSpecialApproval:false +前端返回:绝对失败 +前端表现:只展示详情,只能关闭 +``` + +不得出现: + +```text +AdditionalProcessing +交易特批按钮 +RiskWarningConfirm +普通成功 +``` + +### 用例 018:老风控绝对失败 + 新风控 ShowTip + +前置条件: + +```text +老风控:绝对失败 +新风控:ShowTip +``` + +预期结果: + +```text +最终优先级:Error +前端返回:绝对失败 +``` + +说明: + +```text +老风控绝对失败优先于 ShowTip。 +``` + +不得出现: + +```text +成功后 ShowTip 弹窗 +AdditionalProcessing +RiskWarningConfirm +``` + +### 用例 019:老风控绝对失败 + 新风控 NeedApproval + +前置条件: + +```text +老风控:绝对失败 +新风控:NeedApproval +``` + +预期结果: + +```text +最终优先级:Error +前端返回:绝对失败 +``` + +说明: + +```text +老风控绝对失败优先于新风控需审批。 +``` + +不得出现: + +```text +RiskWarningConfirm +AdditionalProcessing +``` + +### 用例 020:老风控绝对失败 + 新风控 Block + +前置条件: + +```text +老风控:绝对失败 +新风控:Block +``` + +预期结果: + +```text +最终优先级:Error +前端返回:绝对失败 +``` + +说明: + +```text +老风控绝对失败和新风控 Block 都是 Error。 +``` + +### 用例 021:老风控绝对失败 + 新风控 ShowTip + NeedApproval + +前置条件: + +```text +老风控:绝对失败 +新风控:ShowTip + NeedApproval +``` + +预期结果: + +```text +最终优先级:Error +前端返回:绝对失败 +``` + +不得出现: + +```text +RiskWarningConfirm +成功后 ShowTip +AdditionalProcessing +``` + +### 用例 022:老风控绝对失败 + 新风控 ShowTip + Block + +前置条件: + +```text +老风控:绝对失败 +新风控:ShowTip + Block +``` + +预期结果: + +```text +最终优先级:Error +前端返回:绝对失败 +``` + +不得出现: + +```text +成功后 ShowTip +RiskWarningConfirm +AdditionalProcessing +``` + +### 用例 023:老风控绝对失败 + 新风控 NeedApproval + Block + +前置条件: + +```text +老风控:绝对失败 +新风控:NeedApproval + Block +``` + +预期结果: + +```text +最终优先级:Error +前端返回:绝对失败 +``` + +不得出现: + +```text +RiskWarningConfirm +AdditionalProcessing +``` + +### 用例 024:老风控绝对失败 + 新风控 ShowTip + NeedApproval + Block + +前置条件: + +```text +老风控:绝对失败 +新风控:ShowTip + NeedApproval + Block +``` + +预期结果: + +```text +最终优先级:Error +前端返回:绝对失败 +``` + +不得出现: + +```text +RiskWarningConfirm +AdditionalProcessing +成功后 ShowTip +``` + +--- + +## 9. 新风控关闭专项回归用例 + +### 用例 025:新风控关闭 + 老风控 Success + +前置条件: + +```text +新风控 RULE 全部非 Active +新风控 APPLICATION 全部非 Active +老风控:Success +``` + +预期结果: + +```text +最终状态:Success +前端返回:普通成功 +表现与 master 一致 +``` + +### 用例 026:新风控关闭 + 老风控 Warning + +前置条件: + +```text +新风控 RULE 全部非 Active +新风控 APPLICATION 全部非 Active +老风控:Warning +``` + +预期结果: + +```text +OldRiskNeedSpecialApproval = true +RetCode = QuotaTrialError +前端返回:老风控交易特批入口 +source = OldRiskQuotaTrialError +typecode = QuotaTrialError +type = LackOfMoney 或空字符串 +``` + +表现要求: + +```text +是否显示交易特批按钮与 master 一致。 +判断条件:天风 || SpecialOperateForTrade = 1。 +``` + +### 用例 027:新风控关闭 + 老风控 Error + +前置条件: + +```text +新风控 RULE 全部非 Active +新风控 APPLICATION 全部非 Active +老风控:Error +``` + +预期结果: + +```text +OldRiskNeedSpecialApproval = true +RetCode = QuotaTrialError +前端返回:老风控交易特批入口 +source = OldRiskQuotaTrialError +typecode = QuotaTrialError +type = LackOfMoney 或空字符串 +``` + +表现要求: + +```text +表现与 master 一致。 +不得因为 TrialStatus = Error 而走 QuotaTrialError 只关闭分支。 +``` + +--- + +## 10. 二次确认叠加用例 + +### 用例 028:老风控特批二次确认 + 新风控无触发 + +首次前置条件: + +```text +老风控:触发特批 +新风控:无触发 +``` + +首次预期返回: + +```text +AdditionalProcessing +source = OldRiskQuotaTrialError +type = LackOfMoney +``` + +二次请求: + +```text +additionalProcessing = LackOfMoney +``` + +二次预期: + +```text +ignoreMoneyCheck = true +成交成功 +历史记录包含:交易特批-确认交易 +``` + +### 用例 029:老风控特批二次确认 + 新风控 NeedApproval + +首次前置条件: + +```text +老风控:触发特批 +新风控:无触发 +``` + +二次确认前置条件: + +```text +新风控规则变为 NeedApproval +``` + +二次请求: + +```text +additionalProcessing = LackOfMoney +``` + +预期结果需要业务确认: + +```text +如果老风控特批后仍应跑新风控:应返回 RiskWarningConfirm。 +如果沿用当前 ignoreMoneyCheck 跳过 QuotaCheck:会直接成交。 +``` + +### 用例 030:老风控特批二次确认 + 新风控 Block + +首次前置条件: + +```text +老风控:触发特批 +新风控:无触发 +``` + +二次确认前置条件: + +```text +新风控规则变为 Block +``` + +二次请求: + +```text +additionalProcessing = LackOfMoney +``` + +预期结果需要业务确认: + +```text +如果老风控特批后仍应跑新风控:应返回 QuotaTrialError,只能关闭。 +如果沿用当前 ignoreMoneyCheck 跳过 QuotaCheck:会直接成交。 +``` + +### 用例 031:新风控需审批二次确认 + 老风控仍 Success + 无新增新风控规则 + +首次前置条件: + +```text +老风控:Success +新风控:NeedApproval,命中规则 A +``` + +首次预期返回: + +```text +RiskWarningConfirm +ignoreRiskRuleIds = A +``` + +二次请求: + +```text +additionalProcessing = RiskWarningConfirm +ignoreRiskRuleIds = A +``` + +二次预期: + +```text +成交成功 +历史记录包含:风控预警确认-确认交易 +保存确认通过日志 +``` + +### 用例 032:新风控需审批二次确认 + 老风控变为触发特批 + +首次前置条件: + +```text +老风控:Success +新风控:NeedApproval +``` + +二次确认前置条件: + +```text +老风控变为非 Success +``` + +二次请求: + +```text +additionalProcessing = RiskWarningConfirm +``` + +二次预期: + +```text +老风控特批优先于新风控审批放行 +返回 AdditionalProcessing +source = OldRiskQuotaTrialError +typecode = QuotaTrialError +不应直接成交 +``` + +### 用例 033:新风控需审批二次确认 + 老风控绝对失败 + +首次前置条件: + +```text +老风控:Success +新风控:NeedApproval +``` + +二次确认前置条件: + +```text +老风控变为绝对失败 +``` + +二次请求: + +```text +additionalProcessing = RiskWarningConfirm +``` + +二次预期: + +```text +Error 优先 +返回 QuotaTrialError +只展示详情 +不应直接成交 +``` + +### 用例 034:新风控需审批二次确认 + 新增 NeedApproval 规则 + +首次前置条件: + +```text +新风控命中审批规则 A +``` + +二次确认前置条件: + +```text +仍命中 A,同时新增命中审批规则 B +``` + +二次请求: + +```text +additionalProcessing = RiskWarningConfirm +ignoreRiskRuleIds = A +``` + +二次预期: + +```text +规则 A 被忽略 +规则 B 未被忽略 +继续返回 RiskWarningConfirm +ignoreRiskRuleIds 应包含 B +不成交 +``` + +### 用例 035:新风控需审批二次确认 + 新增 Block 规则 + +首次前置条件: + +```text +新风控命中审批规则 A +``` + +二次确认前置条件: + +```text +新增命中新风控 Block 规则 C +``` + +二次请求: + +```text +additionalProcessing = RiskWarningConfirm +ignoreRiskRuleIds = A +``` + +二次预期: + +```text +Block 优先 +返回 QuotaTrialError +只展示详情 +不成交 +``` + +### 用例 036:新风控需审批二次确认 + 新增 ShowTip 规则 + +首次前置条件: + +```text +新风控命中审批规则 A +``` + +二次确认前置条件: + +```text +规则 A 被忽略 +新增 ShowTip 规则 D +无老风控失败 +无新增 NeedApproval +无 Block +``` + +二次请求: + +```text +additionalProcessing = RiskWarningConfirm +ignoreRiskRuleIds = A +``` + +二次预期: + +```text +成交成功 +成功后展示 ShowTip +``` + +--- + +## 11. 高风险回归用例 + +优先执行以下用例: + +```text +用例 027:新风控关闭 + 老风控 Error +用例 012:老风控触发特批 + 新风控 Block +用例 015:老风控触发特批 + 新风控 NeedApproval + Block +用例 030:老风控特批二次确认 + 新风控 Block +用例 032:新风控审批二次确认 + 老风控变为触发特批 +用例 035:新风控审批二次确认 + 新增 Block +``` + +这些用例重点验证: + +```text +老风控 Error 不应被误判为不可进入 master 特批入口。 +新风控 Block 不应被老风控特批覆盖。 +二次确认时不应跳过必须执行的新风控或老风控校验。 +新风控审批二次确认后,如果老风控状态变化,不应直接成交。 +``` + +--- + +## 12. 规则 12 查询结果排查 SQL + +规则 12:债券类净价偏离(本地)。用于核对浮动支付端期初交割净价和交易日前中债估值净价;同一标的同一估值日存在不同可信度时,优先取 credibility=1。 + +取数流程: + +```text +1. 根据 TradeId 查 swap_position。 +2. 限定 IsInitial=1、Invalid=0、PosiDirection=2、UnderlyingCode 非空,取浮动支付端。 +3. 从浮动支付端取 PosiNetNoFeePrice 和 UnderlyingCode。 +4. 用 swap_position.UnderlyingCode 关联 china_bond_valuation.bond_id。 +5. 限定 valuation_date < trade.TradeDate,取交易日前估值。 +6. 按 credibility ASC、valuation_date DESC 排序,优先 credibility=1,再取最近估值日。 +7. 计算 ABS(PosiNetNoFeePrice * 100 - net_price),大于 5 则命中。 +``` + +规则公式: + +```text +ABS(浮动支付端.PosiNetNoFeePrice * 100 - 上一收盘日中债估值.net_price) > 5 +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000012, +// RuleName = "债券类净价偏离(本地)", +// RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、PosiDirection=2 且有标的代码的浮动支付端 PosiNetNoFeePrice 和 UnderlyingCode,PosiNetNoFeePrice 对应债券类标的期初交割净价,库内为 1 左右原值;通过 DbContext.china_bond_valuation 按该浮动支付端标的和交易日前日期优先取 credibility=1 的上一收盘日 net_price,库内为 100 左右报价。计算逻辑:按 ABS(PosiNetNoFeePrice×100-net_price) 计算绝对价差,价差大于 5 元时触发审批。", +// RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).PosiNetNoFeePrice.Value * 100m - DbContext.china_bond_valuation.Where(v => v.bond_id == DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).UnderlyingCode && v.valuation_date < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderBy(v => v.credibility).ThenByDescending(v => v.valuation_date).First().net_price.Value) > 5m", +// Version = 1, +// Status = RiskRuleStatus.Active, +// OptId = 0, +// OptName = "system", +// OptDate = DateTime.Now, +// UpdateOptId = 0, +// UpdateOptName = "system", +// UpdateDate = DateTime.Now +//}); +``` + +```sql +SET @TradeId = 3001699; + +SELECT + t.id AS TradeId, + t.TradeDate, + + sp.id AS SwapPositionId, + sp.SwapTradeId, + sp.IsInitial, + sp.Invalid, + sp.PosiDirection, + sp.UnderlyingCode, + sp.UnderlyingInstrumentType, + sp.PosiNetNoFeePrice, + sp.PosiNetNoFeePrice * 100 AS PosiNetNoFeePrice_100, + + bv.id AS BondValuationId, + bv.bond_id, + bv.valuation_date, + bv.credibility, + bv.net_price AS BondValuationNetPrice, + + ABS(sp.PosiNetNoFeePrice * 100 - bv.net_price) AS DiffAbs, + CASE + WHEN ABS(sp.PosiNetNoFeePrice * 100 - bv.net_price) > 5 THEN 1 + ELSE 0 + END AS IsGreaterThan5 +FROM trade t +INNER JOIN swap_position sp + ON sp.SwapTradeId = t.id + AND sp.IsInitial = 1 + AND sp.Invalid = 0 + AND sp.PosiDirection = 2 + AND sp.UnderlyingCode IS NOT NULL + AND sp.UnderlyingCode <> '' +LEFT JOIN china_bond_valuation bv + ON bv.bond_id = sp.UnderlyingCode + AND bv.valuation_date < DATE(t.TradeDate) +WHERE t.id = @TradeId +ORDER BY + bv.credibility ASC, + bv.valuation_date DESC +LIMIT 1; +``` + +--- + +## 13. 规则 13 查询结果排查 SQL + +规则 13:债券类收益率偏离(本地)。用于核对浮动支付端期初成交收益率和交易日前中债估值收益率;同一标的同一估值日存在不同可信度时,优先取 credibility=1。 + +取数流程: + +```text +1. 根据 TradeId 查 swap_position。 +2. 限定 IsInitial=1、Invalid=0、PosiDirection=2、UnderlyingCode 非空,取浮动支付端。 +3. 从浮动支付端取 InitYtm 和 UnderlyingCode。 +4. 用 swap_position.UnderlyingCode 关联 china_bond_valuation.bond_id。 +5. 限定 valuation_date < trade.TradeDate,取交易日前估值。 +6. 按 credibility ASC、valuation_date DESC 排序,优先 credibility=1,再取最近估值日。 +7. 计算 ABS(InitYtm * 100 - yield),大于 1 则命中。 +``` + +规则公式: + +```text +ABS(浮动支付端.InitYtm * 100 - 上一收盘日中债估值.yield) > 1 +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000013, +// RuleName = "债券类收益率偏离(本地)", +// RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、PosiDirection=2 且有标的代码的浮动支付端 InitYtm 和 UnderlyingCode,InitYtm 对应债券类标的期初成交收益率,库内为原值;通过 DbContext.china_bond_valuation 按该浮动支付端标的和交易日前日期优先取 credibility=1 的上一收盘日 yield,库内为 1.5 到 2.2 左右百分数。计算逻辑:按 ABS(InitYtm×100-yield) 计算收益率绝对差,差值大于 1 时触发审批。", +// RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).InitYtm.Value * 100m - DbContext.china_bond_valuation.Where(v => v.bond_id == DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).UnderlyingCode && v.valuation_date < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderBy(v => v.credibility).ThenByDescending(v => v.valuation_date).First().yield.Value) > 1m", +// Version = 1, +// Status = RiskRuleStatus.Active, +// OptId = 0, +// OptName = "system", +// OptDate = DateTime.Now, +// UpdateOptId = 0, +// UpdateOptName = "system", +// UpdateDate = DateTime.Now +//}); +``` + +```sql +SET @TradeId = 3001699; + +SELECT + t.id AS TradeId, + t.TradeDate, + + sp.id AS SwapPositionId, + sp.SwapTradeId, + sp.IsInitial, + sp.Invalid, + sp.PosiDirection, + sp.UnderlyingCode, + sp.UnderlyingInstrumentType, + sp.InitYtm, + sp.InitYtm * 100 AS InitYtm_100, + + bv.id AS BondValuationId, + bv.bond_id, + bv.valuation_date, + bv.credibility, + bv.yield AS BondValuationYield, + + ABS(sp.InitYtm * 100 - bv.yield) AS DiffAbs, + CASE + WHEN ABS(sp.InitYtm * 100 - bv.yield) > 1 THEN 1 + ELSE 0 + END AS IsGreaterThan1 +FROM trade t +INNER JOIN swap_position sp + ON sp.SwapTradeId = t.id + AND sp.IsInitial = 1 + AND sp.Invalid = 0 + AND sp.PosiDirection = 2 + AND sp.UnderlyingCode IS NOT NULL + AND sp.UnderlyingCode <> '' +LEFT JOIN china_bond_valuation bv + ON bv.bond_id = sp.UnderlyingCode + AND bv.valuation_date < DATE(t.TradeDate) +WHERE t.id = @TradeId +ORDER BY + bv.credibility ASC, + bv.valuation_date DESC +LIMIT 1; +``` + +--- + +## 14. 规则 14 查询结果排查 SQL + +规则 14:非债券类价格偏离(本地)。用于核对普通收益互换页面填写的期初标的价格和交易日前上一日收盘价。 + +取数流程: + +```text +1. 根据 TradeId 查 trade。 +2. 根据 TradeId 查 swap_positionce od_commodity_flyingCode 实际映射数据库列 FutureContractId,数据库排查时看 FutureContractId。 +3. 限定 ValueDate < trade.TradeDate,取交易日前行情。 +5. 按 ValueDate DESC 排序,取最近一日收盘价。 +4. 计算 ABS(PosiGrossPrice * 100 - ClosePrice),大于 5 则命中。 +5`` +6 +规则公式: + +```text +ABS(浮动支付端.PosiGrossPrice * 100 - 上一日行情.ClosePrice) > 5 +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000014, +// RuleName = "非债券类价格偏离(本地)", +// RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、PosiDirection=2 且有标的代码的浮动支付端 PosiGrossPrice 和 UnderlyingCode,PosiGrossPrice 对应普通收益互换页面填写的期初标的价格,库内为 1 左右原值;通过 DbContext.eod_commodity_future_price 按该浮动支付端标的和交易日前日期取上一日收盘价 ClosePrice。注意:eod_commodity_future_price 模型属性 UnderlyingCode 实际映射数据库列 FutureContractId,数据库排查时应使用 FutureContractId 与 swap_position.UnderlyingCode 关联。计算逻辑:按 ABS(PosiGrossPrice×100-ClosePrice) 计算绝对价差,价差大于 5 时触发审批。", +// 字段映射:RuleExpr 中 eod_commodity_future_price.UnderlyingCode 是 C# 模型属性,实际数据库列为 FutureContractId;SQL 排查时应写 e.FutureContractId。 +// RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).PosiGrossPrice * 100m - Convert.ToDecimal(DbContext.eod_commodity_future_price.Where(e => e.UnderlyingCode == DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).UnderlyingCode && e.ValueDate < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderByDescending(e => e.ValueDate).First().ClosePrice)) > 5m", +// Version = 1, +// Status = RiskRuleStatus.Active, +// OptId = 0, +// OptName = "system", +// OptDate = DateTime.Now, +// UpdateOptId = 0, +// UpdateOptName = "system", +// UpdateDate = DateTime.Now +//}); +``` + +```sql +SET @TradeId = 3001699; + +SELECT + t.id AS TradeId, + t.UnderlyingCode AS TradeUnderlyingCode, + t.TradeDate, + + sp.id AS SwapPositionId, + sp.SwapTradeId, + sp.IsInitial, + sp.Invalid, + sp.PosiDirection, + sp.UnderlyingCode AS SwapPositionUnderlyingCode, + sp.UnderlyingInstrumentType, + sp.PosiGrossPrice, + sp.PosiGrossPrice * 100 AS PosiGrossPrice_100, + + e.id AS EodPriceId, + e.FutureContractId AS EodFutureContractId, + e.ValueDate, + e.ClosePrice, + + ABS(sp.PosiGrossPrice * 100 - e.ClosePrice) AS DiffAbs, + CASE + WHEN ABS(sp.PosiGrossPrice * 100 - e.ClosePrice) > 5 THEN 1 + ELSE 0 + END AS IsGreaterThan5 +FROM trade t +INNER JOIN swap_position sp + ON sp.SwapTradeId = t.id + AND sp.IsInitial = 1 + AND sp.Invalid = 0 + AND sp.PosiDirection = 2 + AND sp.UnderlyingCode IS NOT NULL + AND sp.UnderlyingCode <> '' +LEFT JOIN eod_commodity_future_price e + ON e.FutureContractId = sp.UnderlyingCode + AND e.ValueDate < DATE(t.TradeDate) +WHERE t.id = @TradeId +ORDER BY + e.ValueDate DESC +LIMIT 1; +``` + +--- + +## 15. 规则 15 查询结果排查 SQL + +规则 15:单一交易对手累计标的数量超阈值(本地)。用于核对同一交易对手实时存续持仓下的去重标的数量是否超过 10 个。 + +取数流程: + +```text +1. 根据当前 TradeId 查 trade,取当前交易对手 ClientId。 +2. 查询同一 ClientId 下的交易。 +3. 限定交易有效:ValidState != 'InValid'。 +4. 关联 swap_position,按实时持仓判断是否存续:IsInitial=0、PosiQuantity>0、Invalid=0、PosiDirection>0。 +5. 限定 UnderlyingCode 非空。 +6. 对 swap_position.UnderlyingCode 去重计数。 +7. 去重数量 > 10 时命中。 +``` + +规则公式: + +```text +COUNT(DISTINCT swap_position.UnderlyingCode) > 10 +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000015, +// RuleName = "单一交易对手累计标的数量超阈值(本地)", +// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易对手 ClientId,再查询同一交易对手有效交易对应的实时存续持仓 swap_position.UnderlyingCode 去重数量。实时存续持仓口径:IsInitial=false、PosiQuantity>0、Invalid=false、PosiDirection>0 且 UnderlyingCode 非空。计算逻辑:同一交易对手累计标的数量超过 10 个时触发审批。", +// RuleExpr= DbContext.swap_position.Where(p => !string.IsNullOrEmpty(p.UnderlyingCode) && !p.IsInitial && p.PosiQuantity > 0 && !p.Invalid && p.PosiDirection > 0 && DbContext.trade.Any(t => t.id == p.SwapTradeId && t.ValidState != "InValid" && t.ClientId == DbContext.trade.First(x => x.id == TradeId).ClientId)).Select(p => p.UnderlyingCode).Distinct().Count() > 10 +// Version = 1, +// Status = RiskRuleStatus.Active, +// OptId = 0, +// OptName = "system", +// OptDate = DateTime.Now, +// UpdateOptId = 0, +// UpdateOptName = "system", +// UpdateDate = DateTime.Now +//}); +``` + +汇总 SQL: + +```sql +SET @TradeId = 3001699; + +SELECT + cur.id AS CurrentTradeId, + cur.ClientId, + COUNT(DISTINCT sp.UnderlyingCode) AS DistinctUnderlyingCount, + CASE + WHEN COUNT(DISTINCT sp.UnderlyingCode) > 10 THEN 1 + ELSE 0 + END AS IsGreaterThan10 +FROM trade cur +INNER JOIN trade t + ON t.ClientId = cur.ClientId + AND t.ValidState <> 'InValid' +INNER JOIN swap_position sp + ON sp.SwapTradeId = t.id + AND sp.IsInitial = 0 + AND sp.PosiQuantity > 0 + AND sp.Invalid = 0 + AND sp.PosiDirection > 0 + AND sp.UnderlyingCode IS NOT NULL + AND sp.UnderlyingCode <> '' +WHERE cur.id = @TradeId +GROUP BY + cur.id, + cur.ClientId; +``` + +标的明细 SQL:用于查看当前交易对手实时存续持仓下到底有哪些去重标的;是否超限以汇总 SQL 的 `DistinctUnderlyingCount` 为准。 + +```sql +SET @TradeId = 3001699; + +SELECT + cur.id AS CurrentTradeId, + cur.ClientId, + sp.UnderlyingCode, + MAX(sp.UnderlyingInstrumentType) AS UnderlyingInstrumentType, + COUNT(DISTINCT t.id) AS RelatedTradeCount, + COUNT(*) AS PositionCount, + SUM(sp.PosiQuantity) AS TotalPosiQuantity +FROM trade cur +INNER JOIN trade t + ON t.ClientId = cur.ClientId + AND t.ValidState <> 'InValid' +INNER JOIN swap_position sp + ON sp.SwapTradeId = t.id + AND sp.IsInitial = 0 + AND sp.PosiQuantity > 0 + AND sp.Invalid = 0 + AND sp.PosiDirection > 0 + AND sp.UnderlyingCode IS NOT NULL + AND sp.UnderlyingCode <> '' +WHERE cur.id = @TradeId +GROUP BY + cur.id, + cur.ClientId, + sp.UnderlyingCode +ORDER BY + sp.UnderlyingCode; +``` + +--- + +## 16. 规则 16 查询结果排查 SQL + +规则 16:多头支付固定端利率偏离(本地)。用于核对利息端收入固定利息方向的点差百分比绝对值是否低于阈值。 + +取数流程: + +```text +1. 根据 TradeId 查 swap_position。 +2. 限定 InterestDirection=1,取固定利息端收取方向记录。 +3. 从该利息端记录取 InterestRateDefault。 +4. InterestRateDefault 只代表利率文本框中 + 号后的点差,不包含 FR007 基准利率,库内为小数原值,界面按百分比显示。 +5. 计算 ABS(InterestRateDefault * 100),大于 5 时命中。 +``` + +规则公式: + +```text +ABS(利息端.InterestRateDefault * 100) > 5 +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// 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", +// Version = 1, +// Status = RiskRuleStatus.Active, +// OptId = 0, +// OptName = "system", +// OptDate = DateTime.Now, +// UpdateOptId = 0, +// UpdateOptName = "system", +// UpdateDate = DateTime.Now +//}); +``` + +```sql +SET @TradeId = 3001699; + +SELECT + sp.id AS SwapPositionId, + sp.SwapTradeId, + sp.IsInitial, + sp.Invalid, + sp.PosiDirection, + sp.InterestDirection, + sp.FloatRateUnderlyingCode, + sp.InterestRateDefault, + sp.InterestRateDefault * 100 AS InterestRateDefault_100, + ABS(sp.InterestRateDefault * 100) AS DiffAbs, + CASE + WHEN ABS(sp.InterestRateDefault * 100) > 3 THEN 1 + ELSE 0 + END AS IsGreaterThan +FROM swap_position sp +WHERE sp.SwapTradeId = @TradeId + AND sp.InterestDirection = 1; +``` diff --git a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs index f2449805..5cf1e08e 100644 --- a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs +++ b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs @@ -11,6 +11,7 @@ using Qdp.Foundation.Utilities; using Qdp.Pricing.Ecosystem.Trade.FixedIncome; using Qdp.Pricing.Library.Base.Utilities; using Qdp.Pricing.Library.Common.Products.Rates; +using Snowflake.Core; using System.Collections.Generic; using System.Data; using System.Linq; @@ -57,6 +58,7 @@ namespace YLErp.Modules.RiskModule { public class QuotaMonitorService : YLBaseService { + private const string RiskCheckTriggerRemark = "触发风控"; IYcLogger _logger = LogFactory.GetLogger("QuotaMonitorService"); static QuotaMonitorService() { @@ -4500,11 +4502,6 @@ namespace YLErp.Modules.RiskModule } return true; } - var lastTrial = trialService.QueryLastQuotaTrial(tradeId, true); - if (quotaObj.TrialStatus == QuotaTrialStatusEnum.Success) - { - new TradeRiskCheckLogService(UserInfo).AddLog(quotaObj); - } //否则的情况是上次没算,这次是预警,或上次算了,结果是不通过\通过或预警,这次是预警或不通过,提示用户; res.RetCode = TradeOpenRetCode.QuotaTrialError; res.TrialDataId = quotaObj.id; @@ -4544,6 +4541,10 @@ namespace YLErp.Modules.RiskModule } } res.RetCode = isRiskApprovalWarning ? TradeOpenRetCode.RiskWarning : TradeOpenRetCode.QuotaTrialError; + var isOldRiskErrorSpecialApproval = !isRiskApprovalWarning + && quotaObj.OldRiskTrialStatus == QuotaTrialStatusEnum.Error + && res.OldRiskNeedSpecialApproval; + AddRiskCheckLog(quotaObj, isOldRiskErrorSpecialApproval); return false; } @@ -4935,8 +4936,64 @@ namespace YLErp.Modules.RiskModule return result; } + private DateTime? QueryLatestRiskCheckTriggerTime(int tradeId) + { + var tradeNumber = DbContext.trade.Where(x => x.id == tradeId).Select(x => x.TradeNumber).FirstOrDefault(); + if (string.IsNullOrWhiteSpace(tradeNumber)) + { + return null; + } + + return DbContext.trade_risk_check_log + .Where(x => x.trade_number == tradeNumber && x.remark.StartsWith(RiskCheckTriggerRemark)) + .OrderByDescending(x => x.create_time) + .Select(x => (DateTime?)x.create_time) + .FirstOrDefault(); + } + + private void AddRiskCheckLog(QuotaTrial quotaTrial, bool isOldRiskErrorSpecialApproval = false) + { + var td = DbContext.trade.FirstOrDefault(x => x.id == quotaTrial.TradeId); + var log = new trade_risk_check_log(); + if (td != null) + { + var client = DataCacheProvider.GetClientDataSource().GetData(td.ClientId); + log.client_number = client?.Number; + } + var worker = new IdWorker(1, 1); + log.id = worker.NextId(); + log.trial_result = (int)quotaTrial.TrialStatus; + log.client_name = quotaTrial.ClientName; + log.trader = td?.TraderName; + log.trade_number = quotaTrial.TradeNumber; + var riskWarning = string.Empty; + if (!string.IsNullOrWhiteSpace(quotaTrial.QuotaWarningDetails)) + { + riskWarning = "限额预警: " + quotaTrial.QuotaWarningDetails; + } + if (!string.IsNullOrWhiteSpace(quotaTrial.RiskWarningDetails)) + { + if (!string.IsNullOrWhiteSpace(riskWarning)) + { + riskWarning += Environment.NewLine; + } + riskWarning += "风险预警: " + quotaTrial.RiskWarningDetails; + } + log.risk_warning = riskWarning; + log.limit_warning = quotaTrial.QuotaCheckDetails; + log.remark = RiskCheckTriggerRemark; + if (isOldRiskErrorSpecialApproval) + { + log.remark += ";老风控Error允许交易特批"; + } + log.create_user = UserId; + log.create_time = DateTime.Now; + DbContext.trade_risk_check_log.Add(log); + DbContext.SaveChanges(); + } + /// - /// 按 quotaTrial.id 校验试算结果是否已超过配置时效 + /// 按 quotaTrial.id 校验最近一次触发风控的记录是否已超过配置时效 /// /// quotaTrial.id /// 有效时长(秒) @@ -4944,11 +5001,17 @@ namespace YLErp.Modules.RiskModule public bool IsQuotaTrialExpired(int trialDataId, int expireSeconds) { var quotaTrial = QueryQuotaTrial(trialDataId); - if (quotaTrial == null || !quotaTrial.OptDate.HasValue) + if (quotaTrial == null) { return true; } - return DateTime.Now - quotaTrial.OptDate.Value > TimeSpan.FromSeconds(expireSeconds); + var latestRiskCheckTriggerTime = QueryLatestRiskCheckTriggerTime(quotaTrial.TradeId); + var riskCheckTime = latestRiskCheckTriggerTime ?? quotaTrial.OptDate; + if (!riskCheckTime.HasValue) + { + return true; + } + return DateTime.Now - riskCheckTime.Value > TimeSpan.FromSeconds(expireSeconds); } /// diff --git a/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs b/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs index 96d6303e..687754dd 100644 --- a/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs +++ b/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs @@ -95,13 +95,13 @@ namespace YLErp.Modules.RiskModule switch ((QuotaTrialStatusEnum)item.trial_result) { case QuotaTrialStatusEnum.Warning: - exportModel.trial_result = "预警"; + exportModel.trial_result = "老风控触发特批"; break; case QuotaTrialStatusEnum.RiskWarning: - exportModel.trial_result = "风控需审批"; + exportModel.trial_result = "新风控需审批"; break; case QuotaTrialStatusEnum.Error: - exportModel.trial_result = "限额"; + exportModel.trial_result = "禁止"; break; default: exportModel.trial_result = "通过"; @@ -140,7 +140,20 @@ namespace YLErp.Modules.RiskModule log.client_name = quotaTrial.ClientName; log.trader = td?.TraderName; log.trade_number = quotaTrial.TradeNumber; - log.risk_warning = quotaTrial.QuotaWarningDetails; + var riskWarning = string.Empty; + if (!string.IsNullOrWhiteSpace(quotaTrial.QuotaWarningDetails)) + { + riskWarning = "限额预警: " + quotaTrial.QuotaWarningDetails; + } + if (!string.IsNullOrWhiteSpace(quotaTrial.RiskWarningDetails)) + { + if (!string.IsNullOrWhiteSpace(riskWarning)) + { + riskWarning += Environment.NewLine; + } + riskWarning += "风控预警: " + quotaTrial.RiskWarningDetails; + } + log.risk_warning = riskWarning; log.limit_warning = quotaTrial.QuotaCheckDetails; log.remark = quotaTrial.Remark; log.create_user = UserId; diff --git a/YLErpWeb/Controllers/RiskCheckLogController.cs b/YLErpWeb/Controllers/RiskCheckLogController.cs index 3cc1bb4d..d8c84206 100644 --- a/YLErpWeb/Controllers/RiskCheckLogController.cs +++ b/YLErpWeb/Controllers/RiskCheckLogController.cs @@ -1,4 +1,4 @@ -using YLErp.Modules.RiskModule; +using YLErp.Modules.RiskModule; using YLErp.Modules.RiskModule.Dto; using YLErp.Modules.SwapModule; @@ -41,8 +41,9 @@ namespace YLErp.Web.Controllers { new SelectListItem() {Value="-1",Text="全部",Selected=true}, new SelectListItem() {Value="0",Text="通过"}, - new SelectListItem() {Value="1",Text="预警"}, - new SelectListItem() {Value="2",Text="限额"} + new SelectListItem() {Value="1",Text="老风控触发特批"}, + new SelectListItem() {Value="2",Text="新风控需审批"}, + new SelectListItem() {Value="3",Text="禁止"} }; return list; } diff --git a/YLErpWeb/Controllers/tradeController.cs b/YLErpWeb/Controllers/tradeController.cs index 7a380c0c..0a162c3c 100644 --- a/YLErpWeb/Controllers/tradeController.cs +++ b/YLErpWeb/Controllers/tradeController.cs @@ -2467,8 +2467,8 @@ namespace YLErp.Web.Controllers var ignoreMoneyCheck = tradeidArr.Count() == 1 && additionalProcessing == tradeBLL.LackOfMoney; var ignoreRiskWarning = tradeidArr.Count() == 1 && additionalProcessing == tradeBLL.RiskWarningConfirm; var ignoreRiskRuleIdArr = StringHelper.ConvertCommaValuesToStringArray(ignoreRiskRuleIds); - // 老风控交易特批与新风控二次审批统一基于 quotaTrial 做超时校验,超时后清空放行标记并重新校验。 - // 仅 swapTradeView.js 会回传 trialDataId 并触发 RiskWarningConfirm,按 quotaTrial.OptDate 精确校验; + // 老风控交易特批与新风控二次审批统一基于 trialDataId 做超时校验,超时后清空放行标记并重新校验。 + // 当前仅 swapTradeView.js 会回传 trialDataId,按最近一次“触发风控”日志时间校验;查不到日志时回退 quotaTrial.OptDate。 // tradeview.js / tradeConfirmList.js / swapTradeConfirmList.js / quotaMonitor.js 只发 LackOfMoney,不传 trialDataId,不进入超时校验。 if (ignoreMoneyCheck || ignoreRiskWarning) { @@ -2535,6 +2535,27 @@ namespace YLErp.Web.Controllers } else { + // 特批放行只有在交易确认最终成功后才记录,避免前端点击特批但后端超时重检失败时误记通过。 + if (trialDataId.HasValue) + { + var decision = string.Empty; + if (ignoreRiskWarning && ignoreMoneyCheck) + { + decision = "新风控特批、老风控交易特批通过"; + } + else if (ignoreRiskWarning) + { + decision = "新风控特批通过"; + } + else if (ignoreMoneyCheck) + { + decision = "老风控交易特批通过"; + } + if (!string.IsNullOrWhiteSpace(decision)) + { + new TradeRiskCheckLogService(CurUser).AddWarningDecisionLog(trialDataId.Value, decision); + } + } var successMsg = string.IsNullOrWhiteSpace(result.tipMsg) ? "操作完成" : result.tipMsg; if (result.changeConfirmPaths?.Count > 0) { diff --git a/YLErpWeb/wwwroot/Scripts/app/riskchecklog/riskchecklog.js b/YLErpWeb/wwwroot/Scripts/app/riskchecklog/riskchecklog.js index 04c77a28..11d3ec49 100644 --- a/YLErpWeb/wwwroot/Scripts/app/riskchecklog/riskchecklog.js +++ b/YLErpWeb/wwwroot/Scripts/app/riskchecklog/riskchecklog.js @@ -1,4 +1,4 @@ -var query_data = {}; +var query_data = {}; const colModelGrid = function () { function dateFmt(cellValue) { @@ -15,8 +15,9 @@ const colModelGrid = function () { name: 'trial_result', label: '预警结果', index: 'trial_result', width: 80, formatter(cellValue) { if (cellValue === 0) return '通过'; - if (cellValue === 1) return '预警'; - if (cellValue === 2) return '限额'; + if (cellValue === 1) return '老风控触发特批'; + if (cellValue === 2) return '新风控需审批'; + if (cellValue === 3) return '禁止'; return cellValue; } }, @@ -45,7 +46,7 @@ var g_grid = {}; $(function () { $(".datepicker").datepicker({ changeMonth: true, changeYear: true, showButtonPanel: true, showOtherMonths: true, selectOtherMonths: true }); - $("#TrialResult").val([1, 2]); + $("#TrialResult").val([1, 2, 3]); $("#TrialResult").selectpicker('refresh'); PostData = { TrialResults: $("#TrialResult").val(), diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js index 7b832f3c..d0759efc 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js @@ -119,16 +119,6 @@ var confirmFunc = function (id, additionalProcessing, ignoreRiskRuleIds, trialDa } return saveSuccess; }; - var saveRiskWarningDecisionLog = function (quotaTrialId, decision) { - var saveSuccess = true; - main.post("/trade/SaveRiskWarningDecisionLog", { quotaTrialId: quotaTrialId, decision: decision }, { async: false }).done(function (res) { - if (!res || !res.success) { - saveSuccess = false; - main.message(res.msg); - } - }); - return saveSuccess; - }; var layerSetting = { type: 2, title: "提示", @@ -145,11 +135,6 @@ var confirmFunc = function (id, additionalProcessing, ignoreRiskRuleIds, trialDa if (!saveQuotaTrial(obj)) { return; } - if (isRiskWarningConfirm) { - if (!saveRiskWarningDecisionLog(obj.Data.id, "确认通过")) { - return; - } - } if (isOldRiskSpecialApproval) { obj.Data.Remark = (obj.Data.Remark || "").trim(); } @@ -157,11 +142,6 @@ var confirmFunc = function (id, additionalProcessing, ignoreRiskRuleIds, trialDa confirmFunc(id, additionalProcessingType, currentIgnoreRiskRuleIds, currentTrialDataId); }, cancel: function () { - var iframeWindow = window["layui-layer-iframe" + this.index]; - var pageObj = iframeWindow && iframeWindow.page; - if (isRiskWarningConfirm && pageObj && pageObj.Data && pageObj.Data.id) { - saveRiskWarningDecisionLog(pageObj.Data.id, "取消不通过"); - } if (window.parent && window.parent.reloadtrade) { window.parent.reloadtrade(); }