diff --git a/Framework/YLErp.Core/DBModels/QuotaTrial.cs b/Framework/YLErp.Core/DBModels/QuotaTrial.cs index 650af814..8daec72a 100644 --- a/Framework/YLErp.Core/DBModels/QuotaTrial.cs +++ b/Framework/YLErp.Core/DBModels/QuotaTrial.cs @@ -69,5 +69,17 @@ namespace YLErp.DBModels [NotMapped] public QuotaTrialStatusEnum? OldRiskTrialStatus { get; set; } + + /// + /// 新风控是否命中禁止规则 + /// + [NotMapped] + public bool NewRiskBlocked { get; set; } + + /// + /// 新风控是否命中需审批规则 + /// + [NotMapped] + public bool NewRiskNeedApproval { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs index d827a6db..c8b74717 100644 --- a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs +++ b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs @@ -360,7 +360,6 @@ namespace YLErp.Modules.RiskEngine .WithReferences( typeof(RiskContext).Assembly, typeof(RiskCalendarHelper).Assembly, - typeof(RiskMarketDeviationHelper).Assembly, typeof(YLContext).Assembly, typeof(YLErp.DBModels.trade).Assembly, typeof(QdpCalendarHelper).Assembly, diff --git a/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs b/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs index c2b4758c..5109aee0 100644 --- a/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs +++ b/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs @@ -3,16 +3,23 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Runtime.CompilerServices; using YLErp.BLL; namespace YLErp.Modules.RiskEngine { /// /// 风控规则专用日历辅助类。 - /// 当前主要用于债券类规则按银行间日历确认“上一收盘日”,避免简单按估值表倒序取最近日期导致口径偏差。 + /// 用于按规则对应的市场日历确认“上一收盘日”,避免简单按行情表倒序取最近日期导致口径偏差。 /// public static class RiskCalendarHelper { + /// + /// 按当前风控执行使用的数据库上下文保存日历缓存,同一次风控检查内复用,数据库上下文释放后不阻止缓存被回收。 + /// + private static readonly ConditionalWeakTable>> HolidayCaches = + new ConditionalWeakTable>>(); + /// /// 获取指定日期的上一银行间交易日。 /// @@ -22,27 +29,38 @@ namespace YLErp.Modules.RiskEngine /// 数据库上下文为空。 /// 缺少银行间日历、日历内容异常或在保护范围内找不到上一交易日。 public static DateTime GetPreviousInterbankTradingDay(YLContext dbContext, DateTime date) + { + return GetPreviousTradingDay(dbContext, date, "IB", "银行间"); + } + + public static DateTime GetPreviousExchangeTradingDay(YLContext dbContext, DateTime date) + { + return GetPreviousTradingDay(dbContext, date, "CHN", "交易所"); + } + + private static DateTime GetPreviousTradingDay(YLContext dbContext, DateTime date, string country, string calendarName) { if (dbContext == null) throw new ArgumentNullException(nameof(dbContext)); - var holidayCache = new Dictionary>(); + // 以本次风控检查的数据库上下文为缓存边界,避免不同规则重复读取和解析相同市场日历。 + var holidayCache = HolidayCaches.GetOrCreateValue(dbContext); var currentDate = date.Date.AddDays(-1); // 最多向前查 370 天,既覆盖跨年和长假场景,也避免日历配置异常时出现无限循环。 for (var i = 0; i < 370; i++) { - var holidays = GetInterbankHolidays(dbContext, currentDate.Year, holidayCache); + var holidays = GetHolidays(dbContext, currentDate.Year, country, calendarName, holidayCache); var currentDateText = currentDate.ToString("yyyy,MM,dd", CultureInfo.InvariantCulture); - // calendar.HolidayJson 存的是非交易日;不在非交易日集合内,即认为是银行间交易日。 + // calendar.HolidayJson 存的是非交易日;不在非交易日集合内,即认为是对应市场的交易日。 if (!holidays.Contains(currentDateText)) return currentDate; currentDate = currentDate.AddDays(-1); } - throw new Exception($"未找到{date:yyyy-MM-dd}的上一银行间交易日"); + throw new Exception($"未找到{date:yyyy-MM-dd}的上一{calendarName}交易日"); } /// @@ -50,24 +68,26 @@ namespace YLErp.Modules.RiskEngine /// /// 当前风控执行使用的数据库上下文。 /// 日历年份。 - /// 单次查询过程内的年份级缓存,跨年查找时避免重复读取同一年日历。 + /// 单次风控检查内按市场和年份共享的非交易日缓存。 /// 格式为 yyyy,MM,dd 的非交易日集合。 - private static HashSet GetInterbankHolidays(YLContext dbContext, int year, Dictionary> holidayCache) + private static HashSet GetHolidays(YLContext dbContext, int year, string country, string calendarName, Dictionary> holidayCache) { - if (holidayCache.TryGetValue(year, out var holidays)) + // 缓存需要同时区分市场和年份,避免银行间与交易所同一年日历相互串用。 + var cacheKey = $"{country.ToUpperInvariant()}:{year}"; + if (holidayCache.TryGetValue(cacheKey, out var holidays)) return holidays; - // 同一年可能存在多种市场日历;规则 12 明确使用 Country=IB 的银行间日历。 + // 同一年可能存在多种市场日历,按规则对应的市场代码读取非交易日。 var calendar = dbContext.calendar .Where(c => c.Year == year && (c.ValidState == null || c.ValidState != ConsGlobal.InValid)) .ToList() - .FirstOrDefault(c => string.Equals(c.Country, "IB", StringComparison.OrdinalIgnoreCase)); + .FirstOrDefault(c => string.Equals(c.Country, country, StringComparison.OrdinalIgnoreCase)); if (calendar == null) - throw new Exception($"未找到{year}年银行间日历"); + throw new Exception($"未找到{year}年{calendarName}日历"); if (string.IsNullOrWhiteSpace(calendar.HolidayJson)) - throw new Exception($"{year}年银行间日历HolidayJson为空"); + throw new Exception($"{year}年{calendarName}日历HolidayJson为空"); List holidayList; try @@ -76,11 +96,11 @@ namespace YLErp.Modules.RiskEngine } catch (Exception ex) { - throw new Exception($"{year}年银行间日历HolidayJson解析失败", ex); + throw new Exception($"{year}年{calendarName}日历HolidayJson解析失败", ex); } holidays = new HashSet(holidayList ?? new List()); - holidayCache[year] = holidays; + holidayCache[cacheKey] = holidays; return holidays; } } diff --git a/YLErpDAL/Modules/RiskEngine/Helper/RiskMarketDeviationHelper.cs b/YLErpDAL/Modules/RiskEngine/Helper/RiskMarketDeviationHelper.cs deleted file mode 100644 index 0af2c96f..00000000 --- a/YLErpDAL/Modules/RiskEngine/Helper/RiskMarketDeviationHelper.cs +++ /dev/null @@ -1,340 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using YLErp.BLL; - -namespace YLErp.Modules.RiskEngine -{ - /// - /// 风控行情偏离类变量辅助方法,统一封装债券中债估值偏离和非债券行情价格偏离的取数、计算和命中明细生成逻辑。 - /// - public static class RiskMarketDeviationHelper - { - /// - /// 计算当前交易所有浮动支付端的债券类净价偏离值,返回最大偏离值用于规则比较。 - /// - /// 数据库上下文。 - /// 当前交易ID。 - /// 包含最大净价偏离值和逐笔偏离明细的变量返回值。 - public static RiskVariableValueDetail GetBondNetPriceDeviation(YLContext dbContext, int tradeId) - { - return GetBondValuationDeviation( - dbContext, - tradeId, - "债券类净价偏离", - "期初交割净价", - "中债估值净价", - p => p.PosiNetNoFeePrice, - v => v.net_price); - } - - /// - /// 计算当前交易所有浮动支付端的债券类收益率偏离值,返回最大偏离值用于规则比较。 - /// - /// 数据库上下文。 - /// 当前交易ID。 - /// 包含最大收益率偏离值和逐笔偏离明细的变量返回值。 - public static RiskVariableValueDetail GetBondYieldDeviation(YLContext dbContext, int tradeId) - { - return GetBondValuationDeviation( - dbContext, - tradeId, - "债券类收益率偏离", - "期初成交收益率", - "中债估值收益率", - p => p.InitYtm, - v => v.yield); - } - - /// - /// 计算当前交易所有浮动支付端的非债券类价格偏离值,返回最大偏离值用于规则比较。 - /// - /// 数据库上下文。 - /// 当前交易ID。 - /// 包含最大价格偏离值和逐笔偏离明细的变量返回值。 - public static RiskVariableValueDetail GetNonBondPriceDeviation(YLContext dbContext, int tradeId) - { - if (dbContext == null) - throw new ArgumentNullException(nameof(dbContext)); - - var tradeDate = GetTradeDate(dbContext, tradeId); - var floatingPositions = GetFloatingPaymentPositions(dbContext, tradeId) - .Select(p => new - { - p.id, - p.UnderlyingCode, - p.PosiGrossPrice - }) - .ToList(); - - if (!floatingPositions.Any()) - throw new Exception("浮动支付端记录不存在"); - - var underlyingCodes = floatingPositions - .Select(p => p.UnderlyingCode) - .Distinct() - .ToList(); - - // 非债券类取交易日前最近一条行情,不使用银行间日历,也不要求行情日等于上一银行间交易日。 - var eodRows = dbContext.eod_commodity_future_price - .Where(e => underlyingCodes.Contains(e.UnderlyingCode) - && e.ValueDate < tradeDate) - .Select(e => new - { - e.id, - e.UnderlyingCode, - e.ValueDate, - e.ClosePrice - }) - .ToList(); - - // 先按标的批量查出行情,再在内存中分组取最近日,避免每条浮动支付端单独访问数据库。 - var eodByUnderlyingCode = eodRows - .GroupBy(e => e.UnderlyingCode) - .ToDictionary( - g => g.Key, - g => g.OrderByDescending(e => e.ValueDate).ThenBy(e => e.id).First()); - - var valuationItems = floatingPositions - .Select(p => new - { - Position = p, - Eod = eodByUnderlyingCode.ContainsKey(p.UnderlyingCode) ? eodByUnderlyingCode[p.UnderlyingCode] : null - }) - .ToList(); - - var missingEodItems = valuationItems - .Where(x => x.Eod == null) - .Select(x => $"记录ID {x.Position.id},标的{x.Position.UnderlyingCode}") - .ToList(); - - if (missingEodItems.Any()) - throw new Exception($"未找到交易日前行情收盘价:" + string.Join(";", missingEodItems)); - - var diffItems = valuationItems - .Select(x => new - { - PositionId = x.Position.id, - UnderlyingCode = x.Position.UnderlyingCode, - PositionPrice = x.Position.PosiGrossPrice * 100m, - MarketDate = x.Eod.ValueDate, - MarketPrice = Convert.ToDecimal(x.Eod.ClosePrice), - DiffAbs = Math.Abs(x.Position.PosiGrossPrice * 100m - Convert.ToDecimal(x.Eod.ClosePrice)) - }) - .ToList(); - - return BuildDeviationDetail( - diffItems.Select(x => new DeviationItem - { - PositionId = x.PositionId, - UnderlyingCode = x.UnderlyingCode, - PositionValue = x.PositionPrice, - MarketDate = x.MarketDate, - MarketValue = x.MarketPrice, - DiffAbs = x.DiffAbs - }).ToList(), - "非债券类价格偏离", - "期初标的价格", - "上一行情收盘价"); - } - - /// - /// 债券类中债估值偏离的公共计算入口,净价偏离和收益率偏离仅通过字段选择器区分取值字段。 - /// - /// 数据库上下文。 - /// 当前交易ID。 - /// 偏离规则名称,用于生成命中明细。 - /// 交易侧取值名称,用于生成命中明细。 - /// 市场估值取值名称,用于生成命中明细。 - /// 交易侧字段选择器。 - /// 中债估值字段选择器。 - /// 包含最大偏离值和逐笔偏离明细的变量返回值。 - private static RiskVariableValueDetail GetBondValuationDeviation( - YLContext dbContext, - int tradeId, - string deviationName, - string positionValueName, - string marketValueName, - Func positionValueSelector, - Func valuationValueSelector) - { - if (dbContext == null) - throw new ArgumentNullException(nameof(dbContext)); - - var tradeDate = GetTradeDate(dbContext, tradeId); - var previousTradingDay = RiskCalendarHelper.GetPreviousInterbankTradingDay(dbContext, tradeDate); - var nextTradingDate = previousTradingDay.AddDays(1); - var floatingPositions = GetFloatingPaymentPositions(dbContext, tradeId).ToList(); - - if (!floatingPositions.Any()) - throw new Exception("浮动支付端记录不存在"); - - var positionItems = floatingPositions - .Select(p => new - { - p.id, - p.UnderlyingCode, - PositionValue = positionValueSelector(p) - }) - .ToList(); - - var missingPositionValueIds = positionItems - .Where(p => !p.PositionValue.HasValue) - .Select(p => p.id.ToString()) - .ToList(); - - if (missingPositionValueIds.Any()) - throw new Exception($"浮动支付端{positionValueName}为空,记录ID:" + string.Join("、", missingPositionValueIds)); - - var underlyingCodes = positionItems - .Select(p => p.UnderlyingCode) - .Distinct() - .ToList(); - - // 债券类必须严格匹配上一银行间交易日当天的中债估值,不能简单取交易日前最近估值日。 - var valuationRows = dbContext.china_bond_valuation - .Where(v => underlyingCodes.Contains(v.bond_id) - && v.valuation_date >= previousTradingDay - && v.valuation_date < nextTradingDate) - .ToList() - .Select(v => new - { - v.id, - v.bond_id, - v.valuation_date, - v.credibility, - ValuationValue = valuationValueSelector(v) - }) - .Where(v => v.ValuationValue.HasValue) - .ToList(); - - // 同一标的同一估值日可能有多条来源,按可信度优先,ID兜底稳定排序。 - var valuationByBondId = valuationRows - .GroupBy(v => v.bond_id) - .ToDictionary( - g => g.Key, - g => g.OrderBy(v => v.credibility).ThenBy(v => v.id).First()); - - var valuationItems = positionItems - .Select(p => new - { - Position = p, - Valuation = valuationByBondId.ContainsKey(p.UnderlyingCode) ? valuationByBondId[p.UnderlyingCode] : null - }) - .ToList(); - - var missingValuationItems = valuationItems - .Where(x => x.Valuation == null) - .Select(x => $"记录ID {x.Position.id},标的{x.Position.UnderlyingCode}") - .ToList(); - - if (missingValuationItems.Any()) - throw new Exception($"未找到上一银行间交易日{previousTradingDay:yyyy-MM-dd}的{marketValueName}:" + string.Join(";", missingValuationItems)); - - var diffItems = valuationItems - .Select(x => new DeviationItem - { - PositionId = x.Position.id, - UnderlyingCode = x.Position.UnderlyingCode, - PositionValue = x.Position.PositionValue.Value * 100m, - MarketDate = x.Valuation.valuation_date, - MarketValue = x.Valuation.ValuationValue.Value, - DiffAbs = Math.Abs(x.Position.PositionValue.Value * 100m - x.Valuation.ValuationValue.Value) - }) - .ToList(); - - return BuildDeviationDetail(diffItems, deviationName, positionValueName, marketValueName); - } - - /// - /// 获取当前交易的交易日,所有行情偏离规则都以交易日作为市场数据取数基准。 - /// - /// 数据库上下文。 - /// 当前交易ID。 - /// 交易日日期部分。 - private static DateTime GetTradeDate(YLContext dbContext, int tradeId) - { - var tradeDate = dbContext.trade - .Where(t => t.id == tradeId) - .Select(t => t.TradeDate) - .FirstOrDefault(); - - if (!tradeDate.HasValue) - throw new Exception("交易日为空"); - - return tradeDate.Value.Date; - } - - /// - /// 获取当前交易下全部浮动支付端记录,行情偏离类规则需要遍历同一TradeId下所有浮动支付端。 - /// - /// 数据库上下文。 - /// 当前交易ID。 - /// 浮动支付端记录查询对象。 - private static IQueryable GetFloatingPaymentPositions(YLContext dbContext, int tradeId) - { - return dbContext.swap_position - .Where(p => p.SwapTradeId == tradeId - && p.IsInitial - && !p.Invalid - && p.PosiDirection == 2 - && !string.IsNullOrEmpty(p.UnderlyingCode)); - } - - /// - /// 统一生成行情偏离类变量返回值,变量值取最大偏离值,命中说明保留逐笔偏离明细。 - /// - /// 逐笔偏离结果。 - /// 偏离规则名称。 - /// 交易侧取值名称。 - /// 市场侧取值名称。 - /// 包含最大偏离值和逐笔偏离明细的变量返回值。 - private static RiskVariableValueDetail BuildDeviationDetail( - List diffItems, - string deviationName, - string positionValueName, - string marketValueName) - { - var maxDiffItem = diffItems - .OrderByDescending(x => x.DiffAbs) - .ThenBy(x => x.PositionId) - .First(); - - var deviatedItems = diffItems - .Where(x => x.DiffAbs > 0m) - .OrderByDescending(x => x.DiffAbs) - .ThenBy(x => x.PositionId) - .Select(x => $"记录ID {x.PositionId},标的{x.UnderlyingCode}:{positionValueName}{FormatDecimal(x.PositionValue)},{x.MarketDate:yyyy-MM-dd}{marketValueName}{FormatDecimal(x.MarketValue)},偏离{FormatDecimal(x.DiffAbs)}") - .ToList(); - - string diffMessage = deviatedItems.Any() - ? $"存在{deviationName}的浮动支付端记录:" + string.Join(";", deviatedItems) - : $"未发现{deviationName}记录"; - - return new RiskVariableValueDetail(maxDiffItem.DiffAbs, diffMessage); - } - - /// - /// 格式化风控命中说明中的数值,避免展示过长小数。 - /// - /// 待格式化数值。 - /// 最多9位小数的展示文本。 - private static string FormatDecimal(decimal value) - { - return value.ToString("0.#########"); - } - - /// - /// 行情偏离计算的中间结果模型,用于把债券和非债券两类计算结果统一交给明细构建逻辑。 - /// - private class DeviationItem - { - public long PositionId { get; set; } - public string UnderlyingCode { get; set; } - public decimal PositionValue { get; set; } - public DateTime MarketDate { get; set; } - public decimal MarketValue { get; set; } - public decimal DiffAbs { get; set; } - } - } -} diff --git a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs index 58e95978..21949423 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs @@ -393,6 +393,35 @@ namespace YLErp.Modules.RiskEngine _logger.Info($"[风控引擎] 匹配 TriggerPoint 的应用数: {matchedApplications.Count}"); + // 汇总本次实际命中应用关联的结构化规则,变量定义只在本次检查开始时批量查询一次。 + // 同一规则可能被多个应用引用,先对规则 ID 去重,避免重复处理规则定义。 + var matchedRuleIds = matchedApplications + .SelectMany(application => ParseRuleIds(application.RuleIds)) + .Distinct(); + // 只保留当前规则缓存中存在的规则,配置中无效或已删除的规则 ID 仍由后续原有流程记录日志并跳过。 + var matchedRules = matchedRuleIds + .Where(ruleMap.ContainsKey) + .Select(ruleId => ruleMap[ruleId]); + // 自由文本规则不依赖变量定义;条件缓存为空的结构化规则继续在执行阶段按原逻辑报错。 + var structuredRules = matchedRules + .Where(rule => rule.Status == RiskRuleStatus.Active) + .Where(rule => !string.IsNullOrWhiteSpace(rule.ConditionJson)) + .Where(rule => rule.ParsedConditions != null && rule.ParsedConditions.Count > 0); + // 条件中的主变量和变量阈值都需要加载,多条规则引用同一变量时只保留一个 ID。 + var requiredVariableIds = structuredRules + .SelectMany(rule => RuleConditionExpressionBuilder.GetReferencedVariableIds(rule.ParsedConditions)) + .Distinct() + .ToList(); + + if (requiredVariableIds.Any()) + { + // 仅加载本次检查需要的变量,避免按规则逐次查询,也避免读取整个变量池。 + variables = ruleDbContext.glms_risk_variable + .AsNoTracking() + .Where(variable => requiredVariableIds.Contains(variable.id)) + .ToDictionary(variable => (long)variable.id); + } + // ============================================================ // Step 3: 遍历匹配的应用,通过 RuleIds 关联规则并执行预编译委托 // ============================================================ @@ -444,19 +473,6 @@ namespace YLErp.Modules.RiskEngine throw new InvalidOperationException("结构化规则条件缓存为空"); var referencedVariableIds = RuleConditionExpressionBuilder.GetReferencedVariableIds(conditions); - var missingVariableIds = referencedVariableIds - .Where(id => !variables.ContainsKey(id)) - .ToList(); - if (missingVariableIds.Any()) - { - // 按本次结构化规则实际引用的变量懒加载,避免每次风控检查全量读取变量池。 - var loadedVariables = ruleDbContext.glms_risk_variable - .AsNoTracking() - .Where(v => missingVariableIds.Contains(v.id)) - .ToDictionary(v => v.id); - foreach (var variable in loadedVariables) - variables[variable.Key] = variable.Value; - } var ruleVariables = referencedVariableIds .Where(variables.ContainsKey) @@ -467,7 +483,7 @@ namespace YLErp.Modules.RiskEngine { var errorMessage = $"规则[{rule.RuleName}]执行异常:{executeResult.ErrorMessage}"; _logger.Error($"[风控引擎] 结构化规则执行异常,按阻断处理 - RuleId: {rule.Id}, Error: {executeResult.ErrorMessage}"); - AddBlockError(result, ruleId, rule.RuleName, rule.RuleText, errorMessage); + AddBlockError(result, ruleId, application.Id, rule.RuleName, rule.RuleText, errorMessage); continue; } @@ -478,7 +494,7 @@ namespace YLErp.Modules.RiskEngine { var errorMessage = $"规则[{rule.RuleName}]执行异常:{ex.Message}"; _logger.Error($"[风控引擎] 结构化规则执行异常,按阻断处理 - RuleId: {rule.Id}, Error: {ex.Message}"); - AddBlockError(result, ruleId, rule.RuleName, rule.RuleText, errorMessage); + AddBlockError(result, ruleId, application.Id, rule.RuleName, rule.RuleText, errorMessage); continue; } } @@ -496,7 +512,7 @@ namespace YLErp.Modules.RiskEngine { var errorMessage = $"规则[{rule.RuleName}]编译失败,已按阻断处理,请检查规则表达式配置:{compileResult.ErrorMessage}"; _logger.Info($"[风控引擎] 规则编译失败,按阻断处理 - RuleId: {rule.Id}, Error: {compileResult.ErrorMessage}"); - AddBlockError(result, rule.Id.ToString(), rule.RuleName, rule.RuleText, errorMessage); + AddBlockError(result, rule.Id.ToString(), application.Id, rule.RuleName, rule.RuleText, errorMessage); continue; } @@ -515,7 +531,7 @@ namespace YLErp.Modules.RiskEngine { var errorMessage = $"规则[{rule.RuleName}]执行异常:{ex.Message}"; _logger.Error($"[风控引擎] 规则执行异常,按阻断处理 - RuleId: {rule.Id}, Error: {ex.Message}"); - AddBlockError(result, ruleId, rule.RuleName, rule.RuleText, errorMessage); + AddBlockError(result, ruleId, application.Id, rule.RuleName, rule.RuleText, errorMessage); continue; } } @@ -537,6 +553,7 @@ namespace YLErp.Modules.RiskEngine result.TriggeredRules.Add(new TriggeredRuleInfo { RuleId = rule.Id.ToString(), + ApplicationId = application.Id, RuleName = rule.RuleName, ControlStrategy = RiskControlStrategy.Block, RuleText = rule.RuleText, @@ -550,6 +567,7 @@ namespace YLErp.Modules.RiskEngine result.TriggeredRules.Add(new TriggeredRuleInfo { RuleId = ruleId, + ApplicationId = application.Id, RuleName = rule.RuleName, ControlStrategy = RiskControlStrategy.Approval, RuleText = rule.RuleText, @@ -562,6 +580,7 @@ namespace YLErp.Modules.RiskEngine result.TriggeredRules.Add(new TriggeredRuleInfo { RuleId = ruleId, + ApplicationId = application.Id, RuleName = rule.RuleName, ControlStrategy = RiskControlStrategy.ShowTip, RuleText = rule.RuleText, @@ -598,7 +617,7 @@ namespace YLErp.Modules.RiskEngine catch (Exception ex) { var errorMessage = $"风控引擎异常:{ex.Message}"; - AddBlockError(result, "ENGINE_ERROR", "风控引擎执行异常", ex.Message, errorMessage); + AddBlockError(result, "ENGINE_ERROR", null, "风控引擎执行异常", ex.Message, errorMessage); _logger.Error($"[风控引擎] EvaluateRisk 异常 - TradeId: {context?.TradeId}, Error: {ex.Message}"); } @@ -612,7 +631,7 @@ namespace YLErp.Modules.RiskEngine : $"{baseMessage}。{detailMessage}"; } - private static void AddBlockError(RiskResult result, string ruleId, string ruleName, string ruleText, string errorMessage) + private static void AddBlockError(RiskResult result, string ruleId, long? applicationId, string ruleName, string ruleText, string errorMessage) { result.Blocked = true; result.Passed = false; @@ -622,6 +641,7 @@ namespace YLErp.Modules.RiskEngine result.TriggeredRules.Add(new TriggeredRuleInfo { RuleId = ruleId, + ApplicationId = applicationId, RuleName = ruleName, ControlStrategy = RiskControlStrategy.Block, RuleText = ruleText, @@ -671,313 +691,6 @@ namespace YLErp.Modules.RiskEngine } return rules; - //#region 测试本地规则 - //rules.Add(new RiskRule - //{ - // Id = 1000001, - // RuleName = "挂钩标的集中度校验(本地)", - // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易;分子查询 trade 表同一标的存续/审批中交易 StockEqvNotional 汇总;分母查询 underlying_manager.ExJson 中债券 IssueSize(亿)。计算逻辑:同一标的总名义本金 ÷ 发行量 × 100%,发行量乘 100000000 还原为元,结果大于 30% 时触发审批。", - // RuleExpr = "Convert.ToDecimal(DbContext.trade.Where(t => t.ValidState != \"InValid\" && t.UnderlyingId == DbContext.trade.First(x => x.id == TradeId).UnderlyingId && t.ParentTradeId == 0 && (ConsTrade.NeedMarginTradeStatusList.Contains(t.TradeStatus) || t.TradeStatus == \"审批中\")).Sum(t => (double?)t.StockEqvNotional) ?? 0d) / (JsonConvert.DeserializeObject(DbContext.underlying_manager.Where(u => u.UnderlyingCode == DbContext.trade.First(x => x.id == TradeId).UnderlyingCode).Select(u => u.ExJson).FirstOrDefault()).IssueSize.Value * 100000000m) * 100m > 30m", - // Version = 1, - // Status = RiskRuleStatus.Active, - // OptId = 0, - // OptName = "system", - // OptDate = DateTime.Now, - // UpdateOptId = 0, - // UpdateOptName = "system", - // UpdateDate = DateTime.Now - //}); - - //rules.Add(new RiskRule - //{ - // Id = 1000003, - // RuleName = "名义本金超阈值(本地)", - // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 StockEqvNotional,对应 trade 表名义本金字段。计算逻辑:StockEqvNotional 大于 100000000 时触发审批。", - // RuleExpr = "DbContext.trade.First(t => t.id == TradeId).StockEqvNotional > 100000000", - // Version = 1, - // Status = RiskRuleStatus.Active, - // OptId = 0, - // OptName = "system", - // OptDate = DateTime.Now, - // UpdateOptId = 0, - // UpdateOptName = "system", - // UpdateDate = DateTime.Now - //}); - - //rules.Add(new RiskRule - //{ - // Id = 1000004, - // RuleName = "保证金支付比例超阈值(本地)", - // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 MarginRate,对应 trade 表保证金率字段。计算逻辑:本地测试按数值型比例直接比较,MarginRate 大于 0.5 视为超过 50%,触发审批。", - // RuleExpr = "DbContext.trade.First(t => t.id == TradeId).MarginRate > 0.5", - // Version = 1, - // Status = RiskRuleStatus.Active, - // OptId = 0, - // OptName = "system", - // OptDate = DateTime.Now, - // UpdateOptId = 0, - // UpdateOptName = "system", - // UpdateDate = DateTime.Now - //}); - - //rules.Add(new RiskRule - //{ - // Id = 1000005, - // RuleName = "保证金利率偏离(本地)", - // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 MarginRate,作为保证金利率本地测试字段。计算逻辑:若 MarginRate 小于 0.02 或大于 0.05,则触发审批。", - // RuleExpr = "DbContext.trade.First(t => t.id == TradeId).MarginRate < 0.02 || DbContext.trade.First(t => t.id == TradeId).MarginRate > 0.05", - // Version = 1, - // Status = RiskRuleStatus.Active, - // OptId = 0, - // OptName = "system", - // OptDate = DateTime.Now, - // UpdateOptId = 0, - // UpdateOptName = "system", - // UpdateDate = DateTime.Now - //}); - - //rules.Add(new RiskRule - //{ - // Id = 1000006, - // RuleName = "保证金收取比例低于最低标准(本地)", - // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 MarginRate 做本地测试比较。计算逻辑:先以 20% 作为本地测试最低标准,MarginRate 小于 0.2 时触发审批,后续接入正式配置后再替换阈值来源。", - // RuleExpr = "DbContext.trade.First(t => t.id == TradeId).MarginRate < 0.2", - // Version = 1, - // Status = RiskRuleStatus.Active, - // OptId = 0, - // OptName = "system", - // OptDate = DateTime.Now, - // UpdateOptId = 0, - // UpdateOptName = "system", - // UpdateDate = DateTime.Now - //}); - - //rules.Add(new RiskRule - //{ - // Id = 1000007, - // RuleName = "起息日早于当前日期(本地)", - // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 StartDate,对应 trade 表开始日。计算逻辑:StartDate 有值且日期早于系统当天 DateTime.Today 时触发审批。", - // RuleExpr = "DbContext.trade.First(t => t.id == TradeId).StartDate.HasValue && DbContext.trade.First(t => t.id == TradeId).StartDate.Value.Date < DateTime.Today", - // Version = 1, - // Status = RiskRuleStatus.Active, - // OptId = 0, - // OptName = "system", - // OptDate = DateTime.Now, - // UpdateOptId = 0, - // UpdateOptName = "system", - // UpdateDate = DateTime.Now - //}); - - //rules.Add(new RiskRule - //{ - // Id = 1000008, - // RuleName = "支付日为银行间交易日(本地)", - // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 SettlementDate,对应 trade 表结算日期;调用 QdpCalendarHelper.GetNonHolidayDefore 做交易日校验。计算逻辑:若 SettlementDate 有值,且向前修正到最近交易日后的结果不等于原日期,则说明原日期不是银行间交易日,触发审批。", - // RuleExpr = "DbContext.trade.First(t => t.id == TradeId).SettlementDate.HasValue && QdpCalendarHelper.GetNonHolidayDefore(DbContext.trade.First(t => t.id == TradeId).SettlementDate.Value.Date) != DbContext.trade.First(t => t.id == TradeId).SettlementDate.Value.Date", - // Version = 1, - // Status = RiskRuleStatus.Active, - // OptId = 0, - // OptName = "system", - // OptDate = DateTime.Now, - // UpdateOptId = 0, - // UpdateOptName = "system", - // UpdateDate = DateTime.Now - //}); - - //rules.Add(new RiskRule - //{ - // Id = 1000009, - // RuleName = "到期日为银行间交易日(本地)", - // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 ExerciseDate,对应当前交易里更接近业务到期/行权日的字段;调用 QdpCalendarHelper.GetNonHolidayDefore 做交易日校验。计算逻辑:ExerciseDate 有值且向前修正到最近交易日后的结果不等于原日期时,视为不是银行间交易日,触发审批。", - // RuleExpr = "DbContext.trade.First(t => t.id == TradeId).ExerciseDate.HasValue && QdpCalendarHelper.GetNonHolidayDefore(DbContext.trade.First(t => t.id == TradeId).ExerciseDate.Value.Date) != DbContext.trade.First(t => t.id == TradeId).ExerciseDate.Value.Date", - // Version = 1, - // Status = RiskRuleStatus.Active, - // OptId = 0, - // OptName = "system", - // OptDate = DateTime.Now, - // UpdateOptId = 0, - // UpdateOptName = "system", - // UpdateDate = DateTime.Now - //}); - - //rules.Add(new RiskRule - //{ - // Id = 1000010, - // RuleName = "平仓日为银行间交易日(本地)", - // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 UnWindDate,对应 trade 表平仓日;调用 QdpCalendarHelper.GetNonHolidayDefore 做交易日校验。计算逻辑:UnWindDate 有值且向前修正到最近交易日后的结果不等于原日期时,视为不是银行间交易日,触发审批。", - // RuleExpr = "DbContext.trade.First(t => t.id == TradeId).UnWindDate.HasValue && QdpCalendarHelper.GetNonHolidayDefore(DbContext.trade.First(t => t.id == TradeId).UnWindDate.Value.Date) != DbContext.trade.First(t => t.id == TradeId).UnWindDate.Value.Date", - // Version = 1, - // Status = RiskRuleStatus.Active, - // OptId = 0, - // OptName = "system", - // OptDate = DateTime.Now, - // UpdateOptId = 0, - // UpdateOptName = "system", - // UpdateDate = DateTime.Now - //}); - - //rules.Add(new RiskRule - //{ - // Id = 1000011, - // RuleName = "合约期限超阈值(本地)", - // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 StartDate 和 ExerciseDate。计算逻辑:当 StartDate 和 ExerciseDate 都有值时,用 ExerciseDate.Date 减 StartDate.Date 的总天数,若大于 365 天则触发审批。", - // RuleExpr = "DbContext.trade.First(t => t.id == TradeId).StartDate.HasValue && DbContext.trade.First(t => t.id == TradeId).ExerciseDate.HasValue && (DbContext.trade.First(t => t.id == TradeId).ExerciseDate.Value.Date - DbContext.trade.First(t => t.id == TradeId).StartDate.Value.Date).TotalDays > 365d", - // Version = 1, - // Status = RiskRuleStatus.Active, - // OptId = 0, - // OptName = "system", - // OptDate = DateTime.Now, - // UpdateOptId = 0, - // UpdateOptName = "system", - // UpdateDate = DateTime.Now - //}); - - //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 - //}); - - //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 - //}); - - //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 = "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 - //}); - - //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 - //}); - - //rules.Add(new RiskRule - //{ - // Id = 1000016, - // RuleName = "多头支付固定端利率偏离(本地)", - // 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, - // OptName = "system", - // OptDate = DateTime.Now, - // UpdateOptId = 0, - // UpdateOptName = "system", - // UpdateDate = DateTime.Now - //}); - - //rules.Add(new RiskRule - //{ - // Id = 1000017, - // RuleName = "空头利率减点借贷加权偏离(本地)", - // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 FixedRate,并结合 BuySell 判断空头方向。计算逻辑:当 BuySell 表示空头且 FixedRate 有值时,先以 2% 作为本地测试基准,若 ABS(FixedRate-0.02)/0.02×100% 大于 5%,则触发审批。", - // RuleExpr = "DbContext.trade.First(t => t.id == TradeId).BuySell == \"Sell\" && DbContext.trade.First(t => t.id == TradeId).FixedRate.HasValue && Math.Abs((DbContext.trade.First(t => t.id == TradeId).FixedRate.Value - 0.02d) / 0.02d) * 100d > 5d", - // Version = 1, - // Status = RiskRuleStatus.Active, - // OptId = 0, - // OptName = "system", - // OptDate = DateTime.Now, - // UpdateOptId = 0, - // UpdateOptName = "system", - // UpdateDate = DateTime.Now - //}); - - //rules.Add(new RiskRule - //{ - // Id = 1000018, - // RuleName = "账户授权收支方向不匹配(本地)", - // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 OpponentRole 与 BuySell 做本地测试占位判断。计算逻辑:当 OpponentRole 和 BuySell 都有值,且 OpponentRole 为 Pay 且 BuySell 为 Buy 时视为方向不匹配,触发禁止。", - // RuleExpr = "!string.IsNullOrWhiteSpace(DbContext.trade.First(t => t.id == TradeId).OpponentRole) && !string.IsNullOrWhiteSpace(DbContext.trade.First(t => t.id == TradeId).BuySell) && DbContext.trade.First(t => t.id == TradeId).OpponentRole == \"Pay\" && DbContext.trade.First(t => t.id == TradeId).BuySell == \"Buy\"", - // Version = 1, - // Status = RiskRuleStatus.Active, - // OptId = 0, - // OptName = "system", - // OptDate = DateTime.Now, - // UpdateOptId = 0, - // UpdateOptName = "system", - // UpdateDate = DateTime.Now - //}); - - //rules.Add(new RiskRule - //{ - // Id = 1000019, - // RuleName = "执行价偏离超阈值(本地)", - // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 Strike 和 SpotPrice,分别对应行权价与现价。计算逻辑:当 Strike 和 SpotPrice 都有值且 SpotPrice 不为 0 时,按 ABS(Strike/SpotPrice-1)×100% 计算执行价相对现价的偏离率,大于 5% 时触发审批。", - // RuleExpr = "DbContext.trade.First(t => t.id == TradeId).Strike.HasValue && DbContext.trade.First(t => t.id == TradeId).SpotPrice.HasValue && DbContext.trade.First(t => t.id == TradeId).SpotPrice.Value != 0 && Math.Abs((DbContext.trade.First(t => t.id == TradeId).Strike.Value / DbContext.trade.First(t => t.id == TradeId).SpotPrice.Value) - 1d) * 100d > 5d", - // Version = 1, - // Status = RiskRuleStatus.Active, - // OptId = 0, - // OptName = "system", - // OptDate = DateTime.Now, - // UpdateOptId = 0, - // UpdateOptName = "system", - // UpdateDate = DateTime.Now - //}); - - //rules.Add(new RiskRule - //{ - // Id = 1000021, - // RuleName = "接近/触发敲入敲出价(本地)", - // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 Strike 和 SpotPrice,近似模拟触发价与现价。计算逻辑:当 Strike 和 SpotPrice 都有值且 Strike 不为 0 时,按 ABS(SpotPrice/Strike-1)×100% 计算两者距离,距离小于等于 2% 时视为接近触发价,给出提示。", - // RuleExpr = "DbContext.trade.First(t => t.id == TradeId).Strike.HasValue && DbContext.trade.First(t => t.id == TradeId).SpotPrice.HasValue && DbContext.trade.First(t => t.id == TradeId).Strike.Value != 0 && Math.Abs((DbContext.trade.First(t => t.id == TradeId).SpotPrice.Value / DbContext.trade.First(t => t.id == TradeId).Strike.Value) - 1d) * 100d <= 2d", - // Version = 1, - // Status = RiskRuleStatus.Active, - // OptId = 0, - // OptName = "system", - // OptDate = DateTime.Now, - // UpdateOptId = 0, - // UpdateOptName = "system", - // UpdateDate = DateTime.Now - //}); - - //return rules; - //#endregion } diff --git a/YLErpDAL/Modules/RiskEngine/RiskResult.cs b/YLErpDAL/Modules/RiskEngine/RiskResult.cs index cbc12d79..4069ad52 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskResult.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskResult.cs @@ -44,6 +44,7 @@ namespace YLErp.Modules.RiskEngine public class TriggeredRuleInfo { public string RuleId { get; set; } + public long? ApplicationId { get; set; } public string RuleName { get; set; } public RiskControlStrategy ControlStrategy { get; set; } public string RuleText { get; set; } diff --git a/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs b/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs index 7ba84352..73c8cb7d 100644 --- a/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs +++ b/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs @@ -331,6 +331,7 @@ namespace YLErp.Modules.RiskEngine return ValueExecuteResult.Fail($"{label}:变量'{variable.VariableName}'(ID:{variable.id}){converted.ErrorMessage},原始值:{FormatRawValue(actualRawValue)}"); converted.DetailMessage = detail?.Message; + converted.DetailItems = detail?.Items; return converted; } @@ -425,12 +426,77 @@ namespace YLErp.Modules.RiskEngine private static string BuildCompareMessage(glms_risk_variable variable, ValueExecuteResult left, ValueExecuteResult right, string ruleOperator) { var parts = new List(); - AddDetail(parts, left.DetailMessage); - parts.Add($"{variable.VariableName}为{FormatDisplayValue(left.Value)}"); + parts.Add($"触发判断:{variable.VariableName}为{FormatDisplayValue(left.Value)},阈值为{FormatDisplayValue(right.Value)},比较关系:{GetOperatorName(ruleOperator)}"); + AddCompareDetails(parts, variable, left, right, ruleOperator); AddDetail(parts, right.DetailMessage); - parts.Add($"阈值为{FormatDisplayValue(right.Value)}"); - parts.Add($"比较关系:{GetOperatorName(ruleOperator)}"); - return string.Join(",", parts); + return string.Join(";", parts); + } + + /// + /// 按当前规则阈值重新判断变量返回的逐条明细,并将明细拆分为超过阈值和未超过阈值两组。 + /// 该逻辑只在规则已经命中且需要构造提示时执行,不参与规则主判断;未提供逐条明细时保留原始明细文本。 + /// + private static void AddCompareDetails(List parts, glms_risk_variable variable, ValueExecuteResult left, ValueExecuteResult right, string ruleOperator) + { + var detailItems = left.DetailItems; + if (detailItems == null || detailItems.Count == 0) + { + AddDetail(parts, left.DetailMessage); + return; + } + + AddDetail(parts, left.DetailMessage); + var triggeredItems = new List(detailItems.Count); + var untriggeredItems = new List(); + foreach (var detailItem in detailItems) + { + if (detailItem == null || string.IsNullOrWhiteSpace(detailItem.Message)) + continue; + if (!TryEvaluateDetailTrigger(detailItem.Value, right.Value, variable.DataType, ruleOperator, out var triggered)) + continue; + + if (triggered) + triggeredItems.Add(detailItem.Message); + else + untriggeredItems.Add(detailItem.Message); + } + + if (triggeredItems.Count > 0) + parts.Add($"{variable.VariableName}超过阈值的记录:" + string.Join(";", triggeredItems)); + if (untriggeredItems.Count > 0) + parts.Add($"{variable.VariableName}未超过阈值的其他偏离记录:" + string.Join(";", untriggeredItems)); + } + + /// + /// 复用普通比较条件的类型转换口径,判断单条变量明细是否满足当前条件。 + /// 为减少命中提示阶段的额外开销,这里只做一次明细值转换,并直接用 CompareTo 完成比较。 + /// 返回 false 表示明细值无法转换或操作符不适用于当前类型,此类异常明细不影响主规则结果。 + /// + private static bool TryEvaluateDetailTrigger(object detailValue, object thresholdValue, RiskVariableDataType dataType, string ruleOperator, out bool triggered) + { + triggered = false; + if (dataType != RiskVariableDataType.Numeric && dataType != RiskVariableDataType.Date) + return false; + + var itemValue = ConvertValue(detailValue, dataType, "明细", "明细值"); + if (!itemValue.Success) + return false; + + var compare = dataType == RiskVariableDataType.Numeric + ? ((decimal)itemValue.Value).CompareTo((decimal)thresholdValue) + : ((DateTime)itemValue.Value).CompareTo((DateTime)thresholdValue); + + triggered = ruleOperator switch + { + "gt" => compare > 0, + "lt" => compare < 0, + "gte" => compare >= 0, + "lte" => compare <= 0, + "eq" => compare == 0, + "ne" => compare != 0, + _ => false + }; + return ruleOperator is "gt" or "lt" or "gte" or "lte" or "eq" or "ne"; } /// @@ -664,10 +730,35 @@ namespace YLErp.Modules.RiskEngine /// /// 变量表达式可选返回模型。Value 参与规则比较,Message 用于正常命中时展示计算明细。 + /// 仅保证金等需要逐条判断的变量应返回 Items,普通单交易变量只需返回 Value 和当前取值来源说明。 /// public class RiskVariableValueDetail { public RiskVariableValueDetail(object value, string message) + : this(value, message, null) + { + } + + public RiskVariableValueDetail(object value, string message, IEnumerable items) + { + Value = value; + Message = message; + Items = items?.ToList() ?? new List(); + } + + public object Value { get; } + + public string Message { get; } + + public IReadOnlyList Items { get; } + } + + /// + /// 需要遍历的变量所返回的单条计算明细。Value 用于按规则阈值复判该条明细是否命中,Message 用于展示该条记录。 + /// + public class RiskVariableValueDetailItem + { + public RiskVariableValueDetailItem(object value, string message) { Value = value; Message = message; @@ -692,6 +783,11 @@ namespace YLErp.Modules.RiskEngine /// public string DetailMessage { get; set; } + /// + /// 变量表达式返回的逐条计算明细,仅用于命中提示阶段按当前阈值拆分展示。 + /// + public IReadOnlyList DetailItems { get; set; } + public string ErrorMessage { get; set; } public static ValueExecuteResult Ok(object value) diff --git a/YLErpDAL/Modules/RiskEngine/测试用例.md b/YLErpDAL/Modules/RiskEngine/测试用例.md index f3397446..2eb3b5fe 100644 --- a/YLErpDAL/Modules/RiskEngine/测试用例.md +++ b/YLErpDAL/Modules/RiskEngine/测试用例.md @@ -1379,7 +1379,7 @@ SELECT FROM trade cur INNER JOIN trade t ON t.UnderlyingId = cur.UnderlyingId - AND t.ValidState <> 'InValid' + AND (t.ValidState IS NULL OR t.ValidState <> 'InValid') AND t.ParentTradeId = 0 AND t.TradeStatus IN ('确认成交', '平仓待复核', '提前终止拒绝', '行权待复核', '互换待复核', '审批中') LEFT JOIN underlying_manager um @@ -1410,7 +1410,7 @@ SELECT FROM trade cur INNER JOIN trade t ON t.UnderlyingId = cur.UnderlyingId - AND t.ValidState <> 'InValid' + AND (t.ValidState IS NULL OR t.ValidState <> 'InValid') AND t.ParentTradeId = 0 AND t.TradeStatus IN ('确认成交', '平仓待复核', '提前终止拒绝', '行权待复核', '互换待复核', '审批中') WHERE cur.id = @TradeId @@ -1458,7 +1458,9 @@ var maturityDate = DbContext.underlying_manager if (!maturityDate.HasValue) throw new Exception("挂钩标的到期日为空"); -return maturityDate.Value.Date; +return new RiskVariableValueDetail( + maturityDate.Value.Date, + $"交易ID {TradeId},挂钩标的到期日为{maturityDate.Value.Date:yyyy-MM-dd}"); ``` 变量 2:当前交易合约到期日,DataType 为 Date。 @@ -1472,7 +1474,9 @@ var exerciseDate = DbContext.trade if (!exerciseDate.HasValue) throw new Exception("当前交易合约到期日为空"); -return exerciseDate.Value.Date; +return new RiskVariableValueDetail( + exerciseDate.Value.Date, + $"交易ID {TradeId},当前交易合约到期日为{exerciseDate.Value.Date:yyyy-MM-dd}"); ``` 规则字段口径: @@ -1517,7 +1521,7 @@ SELECT um.MaturityDate AS UnderlyingMaturityDate, CASE - WHEN um.MaturityDate < t.ExerciseDate THEN 1 + WHEN DATE(um.MaturityDate) < DATE(t.ExerciseDate) THEN 1 ELSE 0 END AS IsUnderlyingMaturityLessThanContractExerciseDate FROM trade t @@ -1558,14 +1562,14 @@ if (!openingNotionalRaw.HasValue) { return new RiskVariableValueDetail( 0m, - "开仓名义本金为空,本规则不命中"); + $"交易ID {TradeId},开仓名义本金为空,本规则不命中"); } decimal openingNotional = (decimal)openingNotionalRaw.Value; return new RiskVariableValueDetail( openingNotional, - $"开仓名义本金为{openingNotional}"); + $"交易ID {TradeId},开仓名义本金为{openingNotional}"); ``` 规则字段口径: @@ -1679,7 +1683,7 @@ string marginPaymentMessage = marginPaymentDetails.Any() return new RiskVariableValueDetail( marginPaymentRatio, - $"保证金支付总金额为{marginPaymentAmount},开仓名义本金为{openingNotional},{marginPaymentMessage}"); + $"交易ID {TradeId},保证金支付总金额为{marginPaymentAmount},开仓名义本金为{openingNotional},{marginPaymentMessage}"); ``` 规则字段口径: @@ -1698,8 +1702,8 @@ return new RiskVariableValueDetail( //{ // Id = 1000004, // RuleName = "保证金支付比例超阈值(本地)", -// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 OriginalStockEqvNotional,作为开仓名义本金;通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、InterestMode 为 5 或 6 且 InterestDirection=2 的预付金支付方向记录,汇总 InterestPrincipalFix 作为保证金支付金额。计算逻辑:保证金支付金额 ÷ 开仓名义本金 大于 50% 时触发审批。", -// RuleExpr = "DbContext.swap_position.Where(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && (p.InterestMode == 5 || p.InterestMode == 6) && p.InterestDirection == 2).Sum(p => (decimal?)p.InterestPrincipalFix).Value / Convert.ToDecimal(DbContext.trade.First(t => t.id == TradeId).OriginalStockEqvNotional) > 0.5m", +// RuleText = "变量口径:使用数值型变量“保证金支付比例”。变量通过 DbContext.trade 按 TradeId 取当前交易的 OriginalStockEqvNotional 作为开仓名义本金;通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、InterestMode 为 5 或 6 且 InterestDirection=2 的预付金支付方向记录,汇总 InterestPrincipalFix 作为保证金支付金额;变量返回 保证金支付金额 ÷ 开仓名义本金。规则配置:保证金支付比例 > 0.5 时触发审批。", +// RuleExpr = "保证金支付比例 > 0.5", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -1711,21 +1715,26 @@ return new RiskVariableValueDetail( //}); ``` -汇总 SQL: +变量值核对 SQL:用于核对变量“保证金支付比例”的返回值,`MarginPaymentRatio` 即变量值;执行前将 `@Threshold` 替换为规则配置阈值。 ```sql -SET @TradeId = 3001699; +SET @TradeId = 3001749; +SET @Threshold = 0.5; SELECT t.id AS TradeId, t.OriginalStockEqvNotional, - - SUM(IFNULL(sp.InterestPrincipalFix, 0)) AS PayMarginAmount, - SUM(IFNULL(sp.InterestPrincipalFix, 0)) / t.OriginalStockEqvNotional AS PayMarginRate, CASE - WHEN SUM(IFNULL(sp.InterestPrincipalFix, 0)) / t.OriginalStockEqvNotional > 0.5 THEN 1 + WHEN t.OriginalStockEqvNotional IS NULL THEN '开仓名义本金为空,变量会抛出异常' + WHEN t.OriginalStockEqvNotional = 0 THEN '开仓名义本金为0,变量会抛出异常' + ELSE '正常' + END AS VariableStatus, + SUM(IFNULL(sp.InterestPrincipalFix, 0)) AS MarginPaymentAmount, + SUM(IFNULL(sp.InterestPrincipalFix, 0)) / NULLIF(t.OriginalStockEqvNotional, 0) AS MarginPaymentRatio, + CASE + WHEN SUM(IFNULL(sp.InterestPrincipalFix, 0)) / NULLIF(t.OriginalStockEqvNotional, 0) > @Threshold THEN 1 ELSE 0 - END AS IsGreaterThan50Percent + END AS IsVariableGreaterThanThreshold FROM trade t LEFT JOIN swap_position sp ON sp.SwapTradeId = t.id @@ -1739,10 +1748,10 @@ GROUP BY t.OriginalStockEqvNotional; ``` -明细 SQL:用于查看纳入保证金支付金额汇总的预付金明细;是否超限以汇总 SQL 的 `PayMarginRate` 为准。 +明细 SQL:用于查看变量“保证金支付比例”纳入汇总的预付金支付明细;是否超限以变量值核对 SQL 的 `MarginPaymentRatio` 为准。 ```sql -SET @TradeId = 3001699; +SET @TradeId = 3001749; SELECT sp.id AS SwapPositionId, @@ -1787,7 +1796,7 @@ ORDER BY ABS((预付金返息率 - 1) * 100) > 阈值 ``` -变量 Roslyn 示例:变量名为“保证金利率偏离”,DataType 为 Numeric;规则前端仍配置“保证金利率偏离 > 阈值”。脚本会查询当前 TradeId 下所有符合口径的保证金记录,变量值返回最大偏离值用于判断,命中说明列出所有存在偏离的记录。`InterestRateDefault` 实体字段类型为 decimal,可直接参与 decimal 计算。 +变量 Roslyn 示例:变量名为“保证金利率偏离”,DataType 为 Numeric;规则前端仍配置“保证金利率偏离 > 阈值”。脚本会查询当前 TradeId 下所有符合口径的保证金记录,变量值返回最大偏离值用于判断;变量明细逐条返回偏离值,结构化执行器会按规则阈值优先列出真正超过阈值的记录,再列出未超过阈值的其他偏离记录。`InterestRateDefault` 实体字段类型为 decimal,可直接参与 decimal 计算。 ```csharp var marginRateItems = DbContext.swap_position @@ -1815,16 +1824,17 @@ var deviatedItems = marginRateItems .Where(p => p.InterestRateDeviation > 0m) .OrderByDescending(p => p.InterestRateDeviation) .ThenBy(p => p.id) - .Select(p => $"记录ID {p.id}:返息率{(p.InterestRateDefault * 100m).ToString("0.#########")}%,偏离{p.InterestRateDeviation.ToString("0.#########")}%") + .Select(p => new RiskVariableValueDetailItem( + p.InterestRateDeviation, + $"记录ID {p.id}:返息率{(p.InterestRateDefault * 100m).ToString("0.#########")}%,偏离{p.InterestRateDeviation.ToString("0.#########")}%")) .ToList(); -string deviationMessage = deviatedItems.Any() - ? "存在偏离的预付金记录:" + string.Join(";", deviatedItems) - : "未发现保证金利率偏离记录"; +string deviationMessage = $"交易ID {TradeId}"; return new RiskVariableValueDetail( maxDeviationItem.InterestRateDeviation, - deviationMessage); + deviationMessage, + deviatedItems); ``` 规则字段口径: @@ -1841,8 +1851,8 @@ return new RiskVariableValueDetail( //{ // Id = 1000005, // RuleName = "保证金利率偏离(本地)", -// RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、InterestMode 为 5 或 6 的预付金记录,取 InterestRateDefault 作为预付金返息率数据库原值。计算逻辑:数据库保存的是原值,公式中先和 1 比较,再乘以 100 还原为百分比偏离值,即 ABS((预付金返息率 - 1) * 100),结果大于阈值时触发审批。", -// RuleExpr = "Math.Abs((Convert.ToDecimal(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && (p.InterestMode == 5 || p.InterestMode == 6)).InterestRateDefault) - 1m) * 100m) > 阈值", +// RuleText = "变量口径:使用数值型变量“保证金利率偏离”。变量通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、InterestMode 为 5 或 6 的全部预付金记录,逐条计算 ABS((InterestRateDefault - 1) * 100),返回最大偏离值;变量明细逐条返回偏离值,结构化执行器按规则阈值优先展示超过阈值的记录。规则配置:保证金利率偏离 > 阈值 时触发审批。", +// RuleExpr = "保证金利率偏离 > 阈值", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -1854,29 +1864,31 @@ return new RiskVariableValueDetail( //}); ``` -汇总 SQL:用于查看当前交易预付金返息率偏离情况;执行前将 `@Threshold` 替换为实际阈值。 +变量值核对 SQL:用于核对变量“保证金利率偏离”的返回值,`MaxInterestRateDeviation` 即变量值;执行前将 `@Threshold` 替换为规则配置阈值。 ```sql SET @TradeId = 3001699; SET @Threshold = 0.5; SELECT - sp.SwapTradeId AS TradeId, - MAX(ABS((IFNULL(sp.InterestRateDefault, 0) - 1) * 100)) AS MaxInterestRateDeviation, + @TradeId AS TradeId, CASE - WHEN MAX(ABS((IFNULL(sp.InterestRateDefault, 0) - 1) * 100)) > @Threshold THEN 1 + WHEN COUNT(*) = 0 THEN '预付金记录不存在,变量会抛出异常' + ELSE '正常' + END AS VariableStatus, + MAX(ABS((sp.InterestRateDefault - 1) * 100)) AS MaxInterestRateDeviation, + CASE + WHEN MAX(ABS((sp.InterestRateDefault - 1) * 100)) > @Threshold THEN 1 ELSE 0 - END AS IsInterestRateDeviationGreaterThanThreshold + END AS IsVariableGreaterThanThreshold FROM swap_position sp WHERE sp.SwapTradeId = @TradeId AND sp.IsInitial = 1 AND sp.Invalid = 0 - AND sp.InterestMode IN (5, 6) -GROUP BY - sp.SwapTradeId; + AND sp.InterestMode IN (5, 6); ``` -明细 SQL:用于查看每条预付金记录的返息率原值、乘以 100 后的页面口径及偏离值。 +明细 SQL:用于查看变量“保证金利率偏离”纳入计算的每条预付金记录返息率原值、乘以 100 后的页面口径及偏离值。 ```sql SET @TradeId = 3001699; @@ -1892,9 +1904,9 @@ SELECT sp.InterestPrincipalFix, sp.InterestRateDefault AS InterestRateRawValue, sp.InterestRateDefault * 100 AS InterestRatePercentValue, - ABS((IFNULL(sp.InterestRateDefault, 0) - 1) * 100) AS InterestRateDeviation, + ABS((sp.InterestRateDefault - 1) * 100) AS InterestRateDeviation, CASE - WHEN ABS((IFNULL(sp.InterestRateDefault, 0) - 1) * 100) > @Threshold THEN 1 + WHEN ABS((sp.InterestRateDefault - 1) * 100) > @Threshold THEN 1 ELSE 0 END AS IsGreaterThanThreshold, sp.HappenDate @@ -1978,7 +1990,7 @@ string marginReceiveMessage = marginReceiveDetails.Any() return new RiskVariableValueDetail( marginReceiveRatio, - $"保证金收取总金额为{marginReceiveAmount},开仓名义本金为{openingNotional},{marginReceiveMessage}"); + $"交易ID {TradeId},保证金收取总金额为{marginReceiveAmount},开仓名义本金为{openingNotional},{marginReceiveMessage}"); ``` 规则字段口径: @@ -1997,8 +2009,8 @@ return new RiskVariableValueDetail( //{ // Id = 1000006, // RuleName = "保证金收取比例低于最低标准(本地)", -// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 OriginalStockEqvNotional,作为开仓名义本金;通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、InterestMode 为 5 或 6 且 InterestDirection=1 的预付金收取方向记录,汇总 InterestPrincipalFix 作为保证金收取金额。计算逻辑:保证金收取金额 ÷ 开仓名义本金 小于 20% 时触发审批。", -// RuleExpr = "DbContext.swap_position.Where(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && (p.InterestMode == 5 || p.InterestMode == 6) && p.InterestDirection == 1).Sum(p => (decimal?)p.InterestPrincipalFix).Value / Convert.ToDecimal(DbContext.trade.First(t => t.id == TradeId).OriginalStockEqvNotional) < 0.2m", +// RuleText = "变量口径:使用数值型变量“保证金收取比例”。变量通过 DbContext.trade 按 TradeId 取当前交易的 OriginalStockEqvNotional 作为开仓名义本金;通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、InterestMode 为 5 或 6 且 InterestDirection=1 的预付金收取方向记录,汇总 InterestPrincipalFix 作为保证金收取金额;变量返回 保证金收取金额 ÷ 开仓名义本金。规则配置:保证金收取比例 < 0.2 时触发审批。", +// RuleExpr = "保证金收取比例 < 0.2", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -2010,21 +2022,26 @@ return new RiskVariableValueDetail( //}); ``` -汇总 SQL: +变量值核对 SQL:用于核对变量“保证金收取比例”的返回值,`MarginReceiveRatio` 即变量值;执行前将 `@Threshold` 替换为规则配置阈值。 ```sql SET @TradeId = 3001699; +SET @Threshold = 0.2; SELECT t.id AS TradeId, t.OriginalStockEqvNotional, - - SUM(IFNULL(sp.InterestPrincipalFix, 0)) AS ReceiveMarginAmount, - SUM(IFNULL(sp.InterestPrincipalFix, 0)) / t.OriginalStockEqvNotional AS ReceiveMarginRate, CASE - WHEN SUM(IFNULL(sp.InterestPrincipalFix, 0)) / t.OriginalStockEqvNotional < 0.2 THEN 1 + WHEN t.OriginalStockEqvNotional IS NULL THEN '开仓名义本金为空,变量会抛出异常' + WHEN t.OriginalStockEqvNotional = 0 THEN '开仓名义本金为0,变量会抛出异常' + ELSE '正常' + END AS VariableStatus, + SUM(IFNULL(sp.InterestPrincipalFix, 0)) AS MarginReceiveAmount, + SUM(IFNULL(sp.InterestPrincipalFix, 0)) / NULLIF(t.OriginalStockEqvNotional, 0) AS MarginReceiveRatio, + CASE + WHEN SUM(IFNULL(sp.InterestPrincipalFix, 0)) / NULLIF(t.OriginalStockEqvNotional, 0) < @Threshold THEN 1 ELSE 0 - END AS IsLessThan20Percent + END AS IsVariableLessThanThreshold FROM trade t LEFT JOIN swap_position sp ON sp.SwapTradeId = t.id @@ -2038,7 +2055,7 @@ GROUP BY t.OriginalStockEqvNotional; ``` -明细 SQL:用于查看纳入保证金收取金额汇总的预付金明细;是否低于最低标准以汇总 SQL 的 `ReceiveMarginRate` 为准。 +明细 SQL:用于查看变量“保证金收取比例”纳入汇总的预付金收取明细;是否低于最低标准以变量值核对 SQL 的 `MarginReceiveRatio` 为准。 ```sql SET @TradeId = 3001699; @@ -2099,12 +2116,12 @@ if (!startDate.HasValue) { return new RiskVariableValueDetail( DateTime.Today, - "起息日为空,本规则不命中"); + $"交易ID {TradeId},起息日为空,本规则不命中"); } return new RiskVariableValueDetail( startDate.Value.Date, - $"起息日为{startDate.Value.Date:yyyy-MM-dd}"); + $"交易ID {TradeId},起息日为{startDate.Value.Date:yyyy-MM-dd}"); ``` 变量 2:变量名为“今日”,DataType 为 Date。 @@ -2112,7 +2129,7 @@ return new RiskVariableValueDetail( ```csharp return new RiskVariableValueDetail( DateTime.Today, - $"当前日期为{DateTime.Today:yyyy-MM-dd}"); + $"交易ID {TradeId},当前日期为{DateTime.Today:yyyy-MM-dd}"); ``` 规则字段口径: @@ -2463,7 +2480,7 @@ if (!tradeInfo.StartDate.HasValue || !tradeInfo.ExerciseDate.HasValue) { return new RiskVariableValueDetail( 0m, - $"起始日或到期日为空,起始日为{tradeInfo.StartDate?.ToString("yyyy-MM-dd") ?? "空"},到期日为{tradeInfo.ExerciseDate?.ToString("yyyy-MM-dd") ?? "空"},本规则不命中"); + $"交易ID {TradeId},起始日或到期日为空,起始日为{tradeInfo.StartDate?.ToString("yyyy-MM-dd") ?? "空"},到期日为{tradeInfo.ExerciseDate?.ToString("yyyy-MM-dd") ?? "空"},本规则不命中"); } var tradeExtend = DbContext.trade_extend @@ -2503,7 +2520,7 @@ string interestCalcModeDescription = interestCalcMode == "00" return new RiskVariableValueDetail( contractNaturalDays, - $"起始日为{startDate:yyyy-MM-dd},到期日为{exerciseDate:yyyy-MM-dd},计息方式为{interestCalcModeText},{interestCalcModeDescription}"); + $"交易ID {TradeId},起始日为{startDate:yyyy-MM-dd},到期日为{exerciseDate:yyyy-MM-dd},计息方式为{interestCalcModeText},{interestCalcModeDescription}"); ``` 规则字段口径: @@ -2539,64 +2556,61 @@ return new RiskVariableValueDetail( SET @TradeId = 3001699; SET @Threshold = 365; +WITH trade_info AS ( + SELECT + t.id AS TradeId, + t.StartDate, + t.ExerciseDate, + CASE + WHEN TRIM(JSON_UNQUOTE(JSON_EXTRACT(te.ExtendJson, '$.InterestCalcMode'))) = '' THEN '11' + ELSE COALESCE(JSON_UNQUOTE(JSON_EXTRACT(te.ExtendJson, '$.InterestCalcMode')), '11') + END AS InterestCalcMode + FROM trade t + LEFT JOIN trade_extend te + ON te.TradeId = t.id + WHERE t.id = @TradeId +) SELECT - t.id AS TradeId, - t.StartDate, - t.ExerciseDate, - COALESCE(JSON_UNQUOTE(JSON_EXTRACT(te.ExtendJson, '$.InterestCalcMode')), '11') AS InterestCalcMode, - DATEDIFF(DATE(t.ExerciseDate), DATE(t.StartDate)) AS BaseNaturalDays, + ti.TradeId, + ti.StartDate, + ti.ExerciseDate, + ti.InterestCalcMode, CASE - WHEN LEFT(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(te.ExtendJson, '$.InterestCalcMode')), '11'), 1) = '1' THEN 1 - ELSE 0 - END AS CalcFirstDays, + WHEN ti.InterestCalcMode IN ('00', '01', '10', '11') THEN '正常' + ELSE '计息方式不合法,变量会抛出异常' + END AS VariableStatus, + DATEDIFF(DATE(ti.ExerciseDate), DATE(ti.StartDate)) AS BaseNaturalDays, + CASE WHEN LEFT(ti.InterestCalcMode, 1) = '1' THEN 1 ELSE 0 END AS CalcFirstDays, + CASE WHEN RIGHT(ti.InterestCalcMode, 1) = '1' THEN 0 ELSE -1 END AS NotCalcLastDays, + DATEDIFF(DATE(ti.ExerciseDate), DATE(ti.StartDate)) + + CASE WHEN LEFT(ti.InterestCalcMode, 1) = '1' THEN 1 ELSE 0 END + + CASE WHEN RIGHT(ti.InterestCalcMode, 1) = '1' THEN 0 ELSE -1 END AS ContractNaturalDays, CASE - WHEN RIGHT(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(te.ExtendJson, '$.InterestCalcMode')), '11'), 1) = '1' THEN 0 - ELSE -1 - END AS NotCalcLastDays, - DATEDIFF(DATE(t.ExerciseDate), DATE(t.StartDate)) - + CASE - WHEN LEFT(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(te.ExtendJson, '$.InterestCalcMode')), '11'), 1) = '1' THEN 1 - ELSE 0 - END - + CASE - WHEN RIGHT(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(te.ExtendJson, '$.InterestCalcMode')), '11'), 1) = '1' THEN 0 - ELSE -1 - END AS ContractNaturalDays, - CASE - WHEN DATEDIFF(DATE(t.ExerciseDate), DATE(t.StartDate)) - + CASE - WHEN LEFT(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(te.ExtendJson, '$.InterestCalcMode')), '11'), 1) = '1' THEN 1 - ELSE 0 - END - + CASE - WHEN RIGHT(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(te.ExtendJson, '$.InterestCalcMode')), '11'), 1) = '1' THEN 0 - ELSE -1 - END > @Threshold THEN 1 + WHEN ti.InterestCalcMode NOT IN ('00', '01', '10', '11') THEN NULL + WHEN DATEDIFF(DATE(ti.ExerciseDate), DATE(ti.StartDate)) + + CASE WHEN LEFT(ti.InterestCalcMode, 1) = '1' THEN 1 ELSE 0 END + + CASE WHEN RIGHT(ti.InterestCalcMode, 1) = '1' THEN 0 ELSE -1 END > @Threshold THEN 1 ELSE 0 END AS IsContractTermGreaterThanThreshold -FROM trade t -LEFT JOIN trade_extend te - ON te.TradeId = t.id -WHERE t.id = @TradeId; +FROM trade_info ti; ``` --- ## 23. 规则 12 查询结果排查 SQL -规则 12:债券类净价偏离(本地)。用于核对浮动支付端期初交割净价和交易日前中债估值净价;同一标的同一估值日存在不同可信度时,优先取 credibility=1。 +规则 12:债券类净价偏离(本地)。用于核对浮动支付端期初交割净价和中债估值净价;同一标的同一估值日存在不同可信度时,优先取 credibility=1。 取数流程: ```text -1. 根据 TradeId 查 trade.TradeDate。 -2. 通过银行间日历 Country=IB 计算交易日的上一银行间交易日,查不到日历或上一交易日时报异常。 -3. 根据 TradeId 查 swap_position。 -4. 限定 IsInitial=1、Invalid=0、PosiDirection=2、UnderlyingCode 非空,取所有浮动支付端。 -5. 从浮动支付端取 PosiNetNoFeePrice 和 UnderlyingCode。 -6. 用 swap_position.UnderlyingCode 关联 china_bond_valuation.bond_id。 -7. 限定 valuation_date 为上一银行间交易日当天,优先取 credibility=1。 -8. 计算 ABS(PosiNetNoFeePrice * 100 - net_price),大于 5 则命中。 +1. 根据 TradeId 查 trade.StartDate 和 trade.TradeDate。 +2. 通过银行间日历 Country=IB 分别计算开始日期和成交日期的上一银行间交易日。 +3. 根据 TradeId 查所有符合条件的浮动支付端,取 PosiNetNoFeePrice 和 UnderlyingCode。 +4. 用 swap_position.UnderlyingCode 关联 china_bond_valuation.bond_id。 +5. 对每条浮动支付端记录独立匹配行情:优先取开始日期上一交易日的 net_price;该日无数据时,改取成交日期上一交易日的数据;两日均无数据才报错。 +6. 每条浮动支付端对应的同一标的、同一估值日存在多条行情时,按 credibility ASC、ID ASC 取优先记录;相同标的的多条浮动支付端不会合并。 +7. 逐条计算 ABS(PosiNetNoFeePrice * 100 - net_price),变量返回全部记录中的最大偏离值,命中说明只展示产生最大偏离值的记录。 ``` 规则公式: @@ -2605,10 +2619,97 @@ WHERE t.id = @TradeId; ABS(浮动支付端.PosiNetNoFeePrice * 100 - 上一收盘日中债估值.net_price) > 5 ``` -变量 Roslyn 示例:变量名为“债券类净价偏离值”,DataType 为 Numeric;规则前端配置“债券类净价偏离值 > 阈值”。具体取数、上一银行间交易日确认、估值匹配、债券/非债券差异处理统一放在 `RiskMarketDeviationHelper` 中,变量公式只保留公共方法调用。 +变量 Roslyn 示例:变量名为“债券类净价偏离值”,DataType 为 Numeric;规则前端配置“债券类净价偏离值 > 阈值”。变量公式不依赖 `RiskMarketDeviationHelper`。 ```csharp -return YLErp.Modules.RiskEngine.RiskMarketDeviationHelper.GetBondNetPriceDeviation(DbContext, TradeId); +var tradeDates = DbContext.trade + .Where(t => t.id == TradeId) + .Select(t => new { t.StartDate, t.TradeDate }) + .FirstOrDefault(); + +if (tradeDates == null) + throw new Exception("交易不存在"); +if (!tradeDates.TradeDate.HasValue) + throw new Exception("成交日期为空"); + +DateTime? startPreviousTradingDay = tradeDates.StartDate.HasValue + ? YLErp.Modules.RiskEngine.RiskCalendarHelper.GetPreviousInterbankTradingDay(DbContext, tradeDates.StartDate.Value.Date) + : (DateTime?)null; +DateTime tradePreviousTradingDay = YLErp.Modules.RiskEngine.RiskCalendarHelper.GetPreviousInterbankTradingDay(DbContext, tradeDates.TradeDate.Value.Date); +DateTime primaryValuationDate = startPreviousTradingDay ?? tradePreviousTradingDay; +DateTime primaryValuationDateExclusive = primaryValuationDate.AddDays(1); +DateTime tradeValuationDateExclusive = tradePreviousTradingDay.AddDays(1); + +var positionItems = DbContext.swap_position + .Where(p => p.SwapTradeId == TradeId + && p.IsInitial + && !p.Invalid + && p.PosiDirection == 2 + && !string.IsNullOrEmpty(p.UnderlyingCode)) + .Select(p => new { p.id, p.UnderlyingCode, p.PosiNetNoFeePrice }) + .ToList(); + +if (!positionItems.Any()) + throw new Exception("浮动支付端记录不存在"); + +var missingPositionValueIds = positionItems + .Where(p => !p.PosiNetNoFeePrice.HasValue) + .Select(p => p.id.ToString()) + .ToList(); +if (missingPositionValueIds.Any()) + throw new Exception("浮动支付端期初交割净价为空,记录ID:" + string.Join("、", missingPositionValueIds)); + +var underlyingCodes = positionItems.Select(p => p.UnderlyingCode).Distinct().ToList(); +var valuationRows = DbContext.china_bond_valuation + .Where(v => underlyingCodes.Contains(v.bond_id) + && v.net_price.HasValue + && ((v.valuation_date >= primaryValuationDate && v.valuation_date < primaryValuationDateExclusive) + || (v.valuation_date >= tradePreviousTradingDay && v.valuation_date < tradeValuationDateExclusive))) + .Select(v => new { v.id, v.bond_id, v.valuation_date, v.credibility, v.net_price }) + .ToList(); + +var selectedItems = positionItems + .Select(p => new + { + Position = p, + Valuation = valuationRows + .Where(v => v.bond_id == p.UnderlyingCode) + .OrderBy(v => v.valuation_date.Date == primaryValuationDate ? 0 : 1) + .ThenBy(v => v.credibility) + .ThenBy(v => v.id) + .FirstOrDefault() + }) + .ToList(); + +var missingValuationItems = selectedItems + .Where(x => x.Valuation == null) + .Select(x => $"记录ID {x.Position.id},标的{x.Position.UnderlyingCode}") + .ToList(); +if (missingValuationItems.Any()) + throw new Exception($"未找到开始日期上一交易日{primaryValuationDate:yyyy-MM-dd}及成交日期上一交易日{tradePreviousTradingDay:yyyy-MM-dd}的中债估值净价:" + string.Join(";", missingValuationItems)); + +var deviationItems = selectedItems + .Select(x => new + { + x.Position.id, + x.Position.UnderlyingCode, + PositionValue = x.Position.PosiNetNoFeePrice.Value * 100m, + MarketDate = x.Valuation.valuation_date, + MarketDateSource = startPreviousTradingDay.HasValue + && x.Valuation.valuation_date.Date == startPreviousTradingDay.Value + ? "开始日期上一交易日" + : "成交日期上一交易日", + MarketValue = x.Valuation.net_price.Value, + DiffAbs = Math.Abs(x.Position.PosiNetNoFeePrice.Value * 100m - x.Valuation.net_price.Value) + }) + .OrderByDescending(x => x.DiffAbs) + .ThenBy(x => x.id) + .ToList(); + +var maxDeviationItem = deviationItems.First(); +string detailMessage = $"交易ID {TradeId},浮动支付端记录ID {maxDeviationItem.id},标的{maxDeviationItem.UnderlyingCode}:期初交割净价{maxDeviationItem.PositionValue:0.#########},按{maxDeviationItem.MarketDateSource}取{maxDeviationItem.MarketDate:yyyy-MM-dd}中债估值净价{maxDeviationItem.MarketValue:0.#########},最大偏离{maxDeviationItem.DiffAbs:0.#########}"; + +return new RiskVariableValueDetail(maxDeviationItem.DiffAbs, detailMessage); ``` @@ -2633,100 +2734,121 @@ return YLErp.Modules.RiskEngine.RiskMarketDeviationHelper.GetBondNetPriceDeviati ``` ```sql -SET @TradeId = 3001699; +SET @TradeId = 3001744; +SET @Threshold = 5; -WITH RECURSIVE candidate_dates AS ( - SELECT DATE(t.TradeDate) - INTERVAL 1 DAY AS CandidateDate - FROM trade t - WHERE t.id = @TradeId - - UNION ALL - - SELECT CandidateDate - INTERVAL 1 DAY - FROM candidate_dates - WHERE CandidateDate > DATE_SUB((SELECT DATE(TradeDate) FROM trade WHERE id = @TradeId), INTERVAL 370 DAY) +WITH RECURSIVE trade_dates AS ( + SELECT id, DATE(StartDate) AS StartDate, DATE(TradeDate) AS TradeDate + FROM trade + WHERE id = @TradeId ), -previous_trading_day AS ( - SELECT cd.CandidateDate +candidate_dates AS ( + SELECT 'StartDate' AS DateType, StartDate AS BaseDate, StartDate - INTERVAL 1 DAY AS CandidateDate + FROM trade_dates + WHERE StartDate IS NOT NULL + UNION ALL + SELECT 'TradeDate', TradeDate, TradeDate - INTERVAL 1 DAY + FROM trade_dates + WHERE TradeDate IS NOT NULL + UNION ALL + SELECT DateType, BaseDate, CandidateDate - INTERVAL 1 DAY + FROM candidate_dates + WHERE CandidateDate > DATE_SUB(BaseDate, INTERVAL 370 DAY) +), +trading_day_ranked AS ( + SELECT cd.DateType, cd.CandidateDate, + ROW_NUMBER() OVER (PARTITION BY cd.DateType ORDER BY cd.CandidateDate DESC) AS RowNo FROM candidate_dates cd INNER JOIN calendar c ON c.Year = YEAR(cd.CandidateDate) AND UPPER(c.Country) = 'IB' AND (c.ValidState IS NULL OR c.ValidState <> 'InValid') - WHERE NOT JSON_CONTAINS(c.HolidayJson, JSON_QUOTE(DATE_FORMAT(cd.CandidateDate, '%Y,%m,%d'))) - ORDER BY cd.CandidateDate DESC - LIMIT 1 + WHERE JSON_VALID(c.HolidayJson) = 1 + AND JSON_TYPE(c.HolidayJson) = 'ARRAY' + AND JSON_CONTAINS(c.HolidayJson, JSON_QUOTE(DATE_FORMAT(cd.CandidateDate, '%Y,%m,%d'))) = 0 ), -bond_valuation_ranked AS ( +reference_days AS ( SELECT + MAX(CASE WHEN DateType = 'StartDate' AND RowNo = 1 THEN CandidateDate END) AS StartPreviousTradingDay, + MAX(CASE WHEN DateType = 'TradeDate' AND RowNo = 1 THEN CandidateDate END) AS TradePreviousTradingDay + FROM trading_day_ranked +), +position_items AS ( + SELECT sp.* + FROM swap_position sp + WHERE sp.SwapTradeId = @TradeId + AND sp.IsInitial = 1 + AND sp.Invalid = 0 + AND sp.PosiDirection = 2 + AND sp.UnderlyingCode IS NOT NULL + AND sp.UnderlyingCode <> '' +), +valuation_ranked AS ( + SELECT + sp.id AS SwapPositionId, bv.*, - ROW_NUMBER() OVER (PARTITION BY bv.bond_id ORDER BY bv.credibility ASC, bv.id ASC) AS RowNo - FROM china_bond_valuation bv - INNER JOIN previous_trading_day ptd - ON bv.valuation_date >= ptd.CandidateDate - AND bv.valuation_date < DATE_ADD(ptd.CandidateDate, INTERVAL 1 DAY) - WHERE bv.net_price IS NOT NULL + CASE WHEN rd.StartPreviousTradingDay IS NOT NULL AND DATE(bv.valuation_date) = rd.StartPreviousTradingDay THEN '开始日期上一交易日' ELSE '成交日期上一交易日' END AS DataSource, + ROW_NUMBER() OVER ( + PARTITION BY sp.id + ORDER BY + CASE WHEN rd.StartPreviousTradingDay IS NOT NULL AND DATE(bv.valuation_date) = rd.StartPreviousTradingDay THEN 0 ELSE 1 END, + bv.credibility ASC, + bv.id ASC + ) AS RowNo + FROM position_items sp + CROSS JOIN reference_days rd + INNER JOIN china_bond_valuation bv + ON bv.bond_id = sp.UnderlyingCode + AND bv.net_price IS NOT NULL + AND DATE(bv.valuation_date) IN (COALESCE(rd.StartPreviousTradingDay, rd.TradePreviousTradingDay), rd.TradePreviousTradingDay) ) SELECT - t.id AS TradeId, - t.TradeDate, - ptd.CandidateDate AS PreviousInterbankTradingDay, - + td.id AS TradeId, + td.StartDate, + td.TradeDate, + rd.StartPreviousTradingDay, + rd.TradePreviousTradingDay, + CASE + WHEN td.TradeDate IS NULL THEN '成交日期为空,变量会抛出异常' + WHEN rd.TradePreviousTradingDay IS NULL THEN '银行间日历缺失或HolidayJson无效,变量会抛出异常' + WHEN sp.PosiNetNoFeePrice IS NULL THEN '浮动支付端期初交割净价为空,变量会抛出异常' + WHEN bv.id IS NULL THEN '开始日期及成交日期的上一交易日均无行情,变量会抛出异常' + ELSE '正常' + END AS VariableStatus, 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, - + bv.DataSource, 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 -CROSS JOIN previous_trading_day ptd -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 bond_valuation_ranked bv - ON bv.bond_id = sp.UnderlyingCode - AND bv.RowNo = 1 -WHERE t.id = @TradeId -ORDER BY - ABS(sp.PosiNetNoFeePrice * 100 - bv.net_price) DESC, - sp.id ASC; + CASE WHEN ABS(sp.PosiNetNoFeePrice * 100 - bv.net_price) > @Threshold THEN 1 ELSE 0 END AS IsGreaterThanThreshold +FROM trade_dates td +CROSS JOIN reference_days rd +INNER JOIN position_items sp ON 1 = 1 +LEFT JOIN valuation_ranked bv ON bv.SwapPositionId = sp.id AND bv.RowNo = 1 +ORDER BY DiffAbs DESC, sp.id ASC; ``` --- ## 24. 规则 13 查询结果排查 SQL -规则 13:债券类收益率偏离(本地)。用于核对浮动支付端期初成交收益率和交易日前中债估值收益率;同一标的同一估值日存在不同可信度时,优先取 credibility=1。 +规则 13:债券类收益率偏离(本地)。用于核对浮动支付端期初成交收益率和中债估值收益率;同一标的同一估值日存在不同可信度时,优先取 credibility=1。 取数流程: ```text -1. 根据 TradeId 查 swap_position。 -2. 限定 IsInitial=1、Invalid=0、PosiDirection=2、UnderlyingCode 非空,取浮动支付端。 -3. 从浮动支付端取 InitYtm 和 UnderlyingCode。 +1. 根据 TradeId 查 trade.StartDate 和 trade.TradeDate。 +2. 通过银行间日历 Country=IB 分别计算开始日期和成交日期的上一银行间交易日。 +3. 根据 TradeId 查所有符合条件的浮动支付端,取 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 则命中。 +5. 对每条浮动支付端记录独立匹配行情:优先取开始日期上一交易日的 yield;该日无数据时,改取成交日期上一交易日的数据;两日均无数据才报错。 +6. 每条浮动支付端对应的同一标的、同一估值日存在多条行情时,按 credibility ASC、ID ASC 取优先记录;相同标的的多条浮动支付端不会合并。 +7. 逐条计算 ABS(InitYtm * 100 - yield),变量返回全部记录中的最大偏离值,命中说明只展示产生最大偏离值的记录。 ``` 规则公式: @@ -2735,6 +2857,105 @@ ORDER BY ABS(浮动支付端.InitYtm * 100 - 上一收盘日中债估值.yield) > 1 ``` +变量形式: + +变量名:债券类收益率偏离值 +DataType:Numeric +规则配置:债券类收益率偏离值 > 阈值(示例 1) + +变量取值表达式: + +```csharp +var tradeDates = DbContext.trade + .Where(t => t.id == TradeId) + .Select(t => new { t.StartDate, t.TradeDate }) + .FirstOrDefault(); + +if (tradeDates == null) + throw new Exception("交易不存在"); +if (!tradeDates.TradeDate.HasValue) + throw new Exception("成交日期为空"); + +DateTime? startPreviousTradingDay = tradeDates.StartDate.HasValue + ? YLErp.Modules.RiskEngine.RiskCalendarHelper.GetPreviousInterbankTradingDay(DbContext, tradeDates.StartDate.Value.Date) + : (DateTime?)null; +DateTime tradePreviousTradingDay = YLErp.Modules.RiskEngine.RiskCalendarHelper.GetPreviousInterbankTradingDay(DbContext, tradeDates.TradeDate.Value.Date); +DateTime primaryValuationDate = startPreviousTradingDay ?? tradePreviousTradingDay; +DateTime primaryValuationDateExclusive = primaryValuationDate.AddDays(1); +DateTime tradeValuationDateExclusive = tradePreviousTradingDay.AddDays(1); + +var positionItems = DbContext.swap_position + .Where(p => p.SwapTradeId == TradeId + && p.IsInitial + && !p.Invalid + && p.PosiDirection == 2 + && !string.IsNullOrEmpty(p.UnderlyingCode)) + .Select(p => new { p.id, p.UnderlyingCode, p.InitYtm }) + .ToList(); + +if (!positionItems.Any()) + throw new Exception("浮动支付端记录不存在"); + +var missingPositionValueIds = positionItems + .Where(p => !p.InitYtm.HasValue) + .Select(p => p.id.ToString()) + .ToList(); +if (missingPositionValueIds.Any()) + throw new Exception("浮动支付端期初成交收益率为空,记录ID:" + string.Join("、", missingPositionValueIds)); + +var underlyingCodes = positionItems.Select(p => p.UnderlyingCode).Distinct().ToList(); +var valuationRows = DbContext.china_bond_valuation + .Where(v => underlyingCodes.Contains(v.bond_id) + && v.yield.HasValue + && ((v.valuation_date >= primaryValuationDate && v.valuation_date < primaryValuationDateExclusive) + || (v.valuation_date >= tradePreviousTradingDay && v.valuation_date < tradeValuationDateExclusive))) + .Select(v => new { v.id, v.bond_id, v.valuation_date, v.credibility, v.yield }) + .ToList(); + +var selectedItems = positionItems + .Select(p => new + { + Position = p, + Valuation = valuationRows + .Where(v => v.bond_id == p.UnderlyingCode) + .OrderBy(v => v.valuation_date.Date == primaryValuationDate ? 0 : 1) + .ThenBy(v => v.credibility) + .ThenBy(v => v.id) + .FirstOrDefault() + }) + .ToList(); + +var missingValuationItems = selectedItems + .Where(x => x.Valuation == null) + .Select(x => $"记录ID {x.Position.id},标的{x.Position.UnderlyingCode}") + .ToList(); +if (missingValuationItems.Any()) + throw new Exception($"未找到开始日期上一交易日{primaryValuationDate:yyyy-MM-dd}及成交日期上一交易日{tradePreviousTradingDay:yyyy-MM-dd}的中债估值收益率:" + string.Join(";", missingValuationItems)); + +var deviationItems = selectedItems + .Select(x => new + { + x.Position.id, + x.Position.UnderlyingCode, + PositionValue = x.Position.InitYtm.Value * 100m, + MarketDate = x.Valuation.valuation_date, + MarketDateSource = startPreviousTradingDay.HasValue + && x.Valuation.valuation_date.Date == startPreviousTradingDay.Value + ? "开始日期上一交易日" + : "成交日期上一交易日", + MarketValue = x.Valuation.yield.Value, + DiffAbs = Math.Abs(x.Position.InitYtm.Value * 100m - x.Valuation.yield.Value) + }) + .OrderByDescending(x => x.DiffAbs) + .ThenBy(x => x.id) + .ToList(); + +var maxDeviationItem = deviationItems.First(); +string detailMessage = $"交易ID {TradeId},浮动支付端记录ID {maxDeviationItem.id},标的{maxDeviationItem.UnderlyingCode}:期初成交收益率{maxDeviationItem.PositionValue:0.#########}%,按{maxDeviationItem.MarketDateSource}取{maxDeviationItem.MarketDate:yyyy-MM-dd}中债估值收益率{maxDeviationItem.MarketValue:0.#########}%,最大偏离{maxDeviationItem.DiffAbs:0.#########}%"; + +return new RiskVariableValueDetail(maxDeviationItem.DiffAbs, detailMessage); +``` + 注释规则定义: ```csharp @@ -2756,71 +2977,216 @@ ABS(浮动支付端.InitYtm * 100 - 上一收盘日中债估值.yield) > 1 ``` ```sql -SET @TradeId = 3001699; +SET @TradeId = 3001744; +SET @Threshold = 3; +WITH RECURSIVE trade_dates AS ( + SELECT id, DATE(StartDate) AS StartDate, DATE(TradeDate) AS TradeDate + FROM trade + WHERE id = @TradeId +), +candidate_dates AS ( + SELECT 'StartDate' AS DateType, StartDate AS BaseDate, StartDate - INTERVAL 1 DAY AS CandidateDate + FROM trade_dates + WHERE StartDate IS NOT NULL + UNION ALL + SELECT 'TradeDate', TradeDate, TradeDate - INTERVAL 1 DAY + FROM trade_dates + WHERE TradeDate IS NOT NULL + UNION ALL + SELECT DateType, BaseDate, CandidateDate - INTERVAL 1 DAY + FROM candidate_dates + WHERE CandidateDate > DATE_SUB(BaseDate, INTERVAL 370 DAY) +), +trading_day_ranked AS ( + SELECT cd.DateType, cd.CandidateDate, + ROW_NUMBER() OVER (PARTITION BY cd.DateType ORDER BY cd.CandidateDate DESC) AS RowNo + FROM candidate_dates cd + INNER JOIN calendar c + ON c.Year = YEAR(cd.CandidateDate) + AND UPPER(c.Country) = 'IB' + AND (c.ValidState IS NULL OR c.ValidState <> 'InValid') + WHERE JSON_VALID(c.HolidayJson) = 1 + AND JSON_TYPE(c.HolidayJson) = 'ARRAY' + AND JSON_CONTAINS(c.HolidayJson, JSON_QUOTE(DATE_FORMAT(cd.CandidateDate, '%Y,%m,%d'))) = 0 +), +reference_days AS ( + SELECT + MAX(CASE WHEN DateType = 'StartDate' AND RowNo = 1 THEN CandidateDate END) AS StartPreviousTradingDay, + MAX(CASE WHEN DateType = 'TradeDate' AND RowNo = 1 THEN CandidateDate END) AS TradePreviousTradingDay + FROM trading_day_ranked +), +position_items AS ( + SELECT sp.* + FROM swap_position sp + WHERE sp.SwapTradeId = @TradeId + AND sp.IsInitial = 1 + AND sp.Invalid = 0 + AND sp.PosiDirection = 2 + AND sp.UnderlyingCode IS NOT NULL + AND sp.UnderlyingCode <> '' +), +valuation_ranked AS ( + SELECT + sp.id AS SwapPositionId, + bv.*, + CASE WHEN rd.StartPreviousTradingDay IS NOT NULL AND DATE(bv.valuation_date) = rd.StartPreviousTradingDay THEN '开始日期上一交易日' ELSE '成交日期上一交易日' END AS DataSource, + ROW_NUMBER() OVER ( + PARTITION BY sp.id + ORDER BY + CASE WHEN rd.StartPreviousTradingDay IS NOT NULL AND DATE(bv.valuation_date) = rd.StartPreviousTradingDay THEN 0 ELSE 1 END, + bv.credibility ASC, + bv.id ASC + ) AS RowNo + FROM position_items sp + CROSS JOIN reference_days rd + INNER JOIN china_bond_valuation bv + ON bv.bond_id = sp.UnderlyingCode + AND bv.yield IS NOT NULL + AND DATE(bv.valuation_date) IN (COALESCE(rd.StartPreviousTradingDay, rd.TradePreviousTradingDay), rd.TradePreviousTradingDay) +) SELECT - t.id AS TradeId, - t.TradeDate, - + td.id AS TradeId, + td.StartDate, + td.TradeDate, + rd.StartPreviousTradingDay, + rd.TradePreviousTradingDay, + CASE + WHEN td.TradeDate IS NULL THEN '成交日期为空,变量会抛出异常' + WHEN rd.TradePreviousTradingDay IS NULL THEN '银行间日历缺失或HolidayJson无效,变量会抛出异常' + WHEN sp.InitYtm IS NULL THEN '浮动支付端期初成交收益率为空,变量会抛出异常' + WHEN bv.id IS NULL THEN '开始日期及成交日期的上一交易日均无行情,变量会抛出异常' + ELSE '正常' + END AS VariableStatus, 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, - + bv.DataSource, 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; + CASE WHEN ABS(sp.InitYtm * 100 - bv.yield) > @Threshold THEN 1 ELSE 0 END AS IsGreaterThanThreshold +FROM trade_dates td +CROSS JOIN reference_days rd +INNER JOIN position_items sp ON 1 = 1 +LEFT JOIN valuation_ranked bv ON bv.SwapPositionId = sp.id AND bv.RowNo = 1 +ORDER BY DiffAbs DESC, sp.id ASC; ``` --- -## 22. 规则 14 查询结果排查 SQL +## 25. 规则 14 查询结果排查 SQL -规则 14:非债券类价格偏离(本地)。用于核对普通收益互换页面填写的期初标的价格和交易日前上一日收盘价。 +规则 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 +1. 根据 TradeId 查 trade.StartDate 和 trade.TradeDate。 +2. 通过交易所日历 Country=CHN 分别计算开始日期和成交日期的上一交易日。 +3. 根据 TradeId 查所有符合条件的浮动支付端,取 PosiGrossPrice 和 UnderlyingCode。 +4. 用 swap_position.UnderlyingCode 关联 eod_commodity_future_price.UnderlyingCode;该模型属性映射数据库列 FutureContractId。 +5. 每个标的优先取开始日期上一交易日的 ClosePrice;该日无数据时,改取成交日期上一交易日的数据;两日均无数据才报错。 +6. 逐条计算 ABS(PosiGrossPrice - ClosePrice),变量返回全部记录中的最大偏离值,命中说明只展示产生最大偏离值的记录。 +``` + 规则公式: ```text -ABS(浮动支付端.PosiGrossPrice * 100 - 上一日行情.ClosePrice) > 5 +ABS(浮动支付端.PosiGrossPrice - 上一日行情.ClosePrice) > 5 +``` + +变量形式: + +变量名:非债券类价格偏离值 +DataType:Numeric +规则配置:非债券类价格偏离值 > 阈值(示例 5) + +变量取值表达式: + +```csharp +var tradeDates = DbContext.trade + .Where(t => t.id == TradeId) + .Select(t => new { t.StartDate, t.TradeDate }) + .FirstOrDefault(); + +if (tradeDates == null) + throw new Exception("交易不存在"); +if (!tradeDates.TradeDate.HasValue) + throw new Exception("成交日期为空"); + +DateTime? startPreviousTradingDay = tradeDates.StartDate.HasValue + ? YLErp.Modules.RiskEngine.RiskCalendarHelper.GetPreviousExchangeTradingDay(DbContext, tradeDates.StartDate.Value.Date) + : (DateTime?)null; +DateTime tradePreviousTradingDay = YLErp.Modules.RiskEngine.RiskCalendarHelper.GetPreviousExchangeTradingDay(DbContext, tradeDates.TradeDate.Value.Date); +DateTime primaryMarketDate = startPreviousTradingDay ?? tradePreviousTradingDay; +DateTime primaryMarketDateExclusive = primaryMarketDate.AddDays(1); +DateTime tradeMarketDateExclusive = tradePreviousTradingDay.AddDays(1); + +var positionItems = DbContext.swap_position + .Where(p => p.SwapTradeId == TradeId + && p.IsInitial + && !p.Invalid + && p.PosiDirection == 2 + && !string.IsNullOrEmpty(p.UnderlyingCode)) + .Select(p => new { p.id, p.UnderlyingCode, p.PosiGrossPrice }) + .ToList(); + +if (!positionItems.Any()) + throw new Exception("浮动支付端记录不存在"); + +var underlyingCodes = positionItems.Select(p => p.UnderlyingCode).Distinct().ToList(); +var marketRows = DbContext.eod_commodity_future_price + .Where(e => underlyingCodes.Contains(e.UnderlyingCode) + && ((e.ValueDate >= primaryMarketDate && e.ValueDate < primaryMarketDateExclusive) + || (e.ValueDate >= tradePreviousTradingDay && e.ValueDate < tradeMarketDateExclusive))) + .Select(e => new { e.id, e.UnderlyingCode, e.ValueDate, e.ClosePrice }) + .ToList(); + +var selectedItems = positionItems + .Select(p => new + { + Position = p, + Market = marketRows + .Where(e => e.UnderlyingCode == p.UnderlyingCode) + .OrderBy(e => e.ValueDate.Date == primaryMarketDate ? 0 : 1) + .ThenBy(e => e.id) + .FirstOrDefault() + }) + .ToList(); + +var missingMarketItems = selectedItems + .Where(x => x.Market == null) + .Select(x => $"记录ID {x.Position.id},标的{x.Position.UnderlyingCode}") + .ToList(); +if (missingMarketItems.Any()) + throw new Exception($"未找到开始日期上一交易日{primaryMarketDate:yyyy-MM-dd}及成交日期上一交易日{tradePreviousTradingDay:yyyy-MM-dd}的标的收盘价:" + string.Join(";", missingMarketItems)); + +var deviationItems = selectedItems + .Select(x => new + { + x.Position.id, + x.Position.UnderlyingCode, + PositionValue = x.Position.PosiGrossPrice, + MarketDate = x.Market.ValueDate, + MarketDateSource = startPreviousTradingDay.HasValue + && x.Market.ValueDate.Date == startPreviousTradingDay.Value + ? "开始日期上一交易日" + : "成交日期上一交易日", + MarketValue = Convert.ToDecimal(x.Market.ClosePrice), + DiffAbs = Math.Abs(x.Position.PosiGrossPrice - Convert.ToDecimal(x.Market.ClosePrice)) + }) + .OrderByDescending(x => x.DiffAbs) + .ThenBy(x => x.id) + .ToList(); + +var maxDeviationItem = deviationItems.First(); +string detailMessage = $"交易ID {TradeId},浮动支付端记录ID {maxDeviationItem.id},标的{maxDeviationItem.UnderlyingCode}:期初标的价格{maxDeviationItem.PositionValue:0.#########},按{maxDeviationItem.MarketDateSource}取{maxDeviationItem.MarketDate:yyyy-MM-dd}收盘价{maxDeviationItem.MarketValue:0.#########},最大偏离{maxDeviationItem.DiffAbs:0.#########}"; + +return new RiskVariableValueDetail(maxDeviationItem.DiffAbs, detailMessage); ``` 注释规则定义: @@ -2830,9 +3196,9 @@ ABS(浮动支付端.PosiGrossPrice * 100 - 上一日行情.ClosePrice) > 5 //{ // 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 时触发审批。", +// RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、PosiDirection=2 且有标的代码的浮动支付端 PosiGrossPrice 和 UnderlyingCode,PosiGrossPrice 对应普通收益互换页面填写的期初标的价格,库内为原值;通过 DbContext.eod_commodity_future_price 按该浮动支付端标的和交易日前日期取上一日收盘价 ClosePrice。注意:eod_commodity_future_price 模型属性 UnderlyingCode 实际映射数据库列 FutureContractId,数据库排查时应使用 FutureContractId 与 swap_position.UnderlyingCode 关联。计算逻辑:按 ABS(PosiGrossPrice-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", +// RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).PosiGrossPrice - 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, @@ -2845,63 +3211,114 @@ ABS(浮动支付端.PosiGrossPrice * 100 - 上一日行情.ClosePrice) > 5 ``` ```sql -SET @TradeId = 3001699; +SET @TradeId = 3001749; +SET @Threshold = 5; +WITH RECURSIVE trade_dates AS ( + SELECT id, DATE(StartDate) AS StartDate, DATE(TradeDate) AS TradeDate + FROM trade + WHERE id = @TradeId +), +candidate_dates AS ( + SELECT 'StartDate' AS DateType, StartDate AS BaseDate, StartDate - INTERVAL 1 DAY AS CandidateDate + FROM trade_dates + WHERE StartDate IS NOT NULL + UNION ALL + SELECT 'TradeDate', TradeDate, TradeDate - INTERVAL 1 DAY + FROM trade_dates + WHERE TradeDate IS NOT NULL + UNION ALL + SELECT DateType, BaseDate, CandidateDate - INTERVAL 1 DAY + FROM candidate_dates + WHERE CandidateDate > DATE_SUB(BaseDate, INTERVAL 370 DAY) +), +trading_day_ranked AS ( + SELECT cd.DateType, cd.CandidateDate, + ROW_NUMBER() OVER (PARTITION BY cd.DateType ORDER BY cd.CandidateDate DESC) AS RowNo + FROM candidate_dates cd + INNER JOIN calendar c + ON c.Year = YEAR(cd.CandidateDate) + AND UPPER(c.Country) = 'CHN' + AND (c.ValidState IS NULL OR c.ValidState <> 'InValid') + WHERE JSON_VALID(c.HolidayJson) = 1 + AND JSON_TYPE(c.HolidayJson) = 'ARRAY' + AND JSON_CONTAINS(c.HolidayJson, JSON_QUOTE(DATE_FORMAT(cd.CandidateDate, '%Y,%m,%d'))) = 0 +), +reference_days AS ( + SELECT + MAX(CASE WHEN DateType = 'StartDate' AND RowNo = 1 THEN CandidateDate END) AS StartPreviousTradingDay, + MAX(CASE WHEN DateType = 'TradeDate' AND RowNo = 1 THEN CandidateDate END) AS TradePreviousTradingDay + FROM trading_day_ranked +), +position_items AS ( + SELECT sp.* + FROM swap_position sp + WHERE sp.SwapTradeId = @TradeId + AND sp.IsInitial = 1 + AND sp.Invalid = 0 + AND sp.PosiDirection = 2 + AND sp.UnderlyingCode IS NOT NULL + AND sp.UnderlyingCode <> '' +), +market_ranked AS ( + SELECT + sp.id AS SwapPositionId, + e.*, + CASE WHEN rd.StartPreviousTradingDay IS NOT NULL AND DATE(e.ValueDate) = rd.StartPreviousTradingDay THEN '开始日期上一交易日' ELSE '成交日期上一交易日' END AS PriceDateSource, + ROW_NUMBER() OVER ( + PARTITION BY sp.id + ORDER BY + CASE WHEN rd.StartPreviousTradingDay IS NOT NULL AND DATE(e.ValueDate) = rd.StartPreviousTradingDay THEN 0 ELSE 1 END, + e.id ASC + ) AS RowNo + FROM position_items sp + CROSS JOIN reference_days rd + INNER JOIN eod_commodity_future_price e + ON e.FutureContractId = sp.UnderlyingCode + AND DATE(e.ValueDate) IN (COALESCE(rd.StartPreviousTradingDay, rd.TradePreviousTradingDay), rd.TradePreviousTradingDay) +) SELECT - t.id AS TradeId, - t.UnderlyingCode AS TradeUnderlyingCode, - t.TradeDate, - + td.id AS TradeId, + td.StartDate, + td.TradeDate, + rd.StartPreviousTradingDay, + rd.TradePreviousTradingDay, + CASE + WHEN td.TradeDate IS NULL THEN '成交日期为空,变量会抛出异常' + WHEN rd.TradePreviousTradingDay IS NULL THEN '交易所日历缺失或HolidayJson无效,变量会抛出异常' + WHEN e.id IS NULL THEN '开始日期及成交日期的上一交易日均无行情,变量会抛出异常' + ELSE '正常' + END AS VariableStatus, 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; + e.PriceDateSource, + ABS(sp.PosiGrossPrice - e.ClosePrice) AS DiffAbs, + CASE WHEN ABS(sp.PosiGrossPrice - e.ClosePrice) > @Threshold THEN 1 ELSE 0 END AS IsGreaterThanThreshold +FROM trade_dates td +CROSS JOIN reference_days rd +INNER JOIN position_items sp ON 1 = 1 +LEFT JOIN market_ranked e ON e.SwapPositionId = sp.id AND e.RowNo = 1 +ORDER BY DiffAbs DESC, sp.id ASC; ``` --- -## 23. 规则 15 查询结果排查 SQL +## 26. 规则 15 查询结果排查 SQL -规则 15:单一交易对手累计标的数量超阈值(本地)。用于核对同一交易对手实时存续持仓下的去重标的数量是否超过 10 个。 +规则 15:单一交易对手累计标的数量超阈值(本地)。用于核对同一交易对手所有有效交易的实时存续持仓下去重标的数量是否超过 10 个;当前交易如果已满足有效交易和实时存续持仓口径,则会一并计入。 取数流程: ```text 1. 根据当前 TradeId 查 trade,取当前交易对手 ClientId。 -2. 查询同一 ClientId 下的交易。 +2. 查询同一 ClientId 下的全部交易,包含当前交易本身。 3. 限定交易有效:ValidState != 'InValid'。 -4. 关联 swap_position,按实时持仓判断是否存续:IsInitial=0、PosiQuantity>0、Invalid=0、PosiDirection>0。 +4. 关联 swap_position,按实时持仓判断是否存续:IsInitial=0、PosiQuantity>0、Invalid=0、PosiDirection>0;当前交易只有存在符合该口径的持仓行时才会贡献标的数量。 5. 限定 UnderlyingCode 非空。 6. 对 swap_position.UnderlyingCode 去重计数。 7. 去重数量 > 10 时命中。 @@ -2920,7 +3337,7 @@ COUNT(DISTINCT swap_position.UnderlyingCode) > 10 //{ // Id = 1000015, // RuleName = "单一交易对手累计标的数量超阈值(本地)", -// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易对手 ClientId,再查询同一交易对手有效交易对应的实时存续持仓 swap_position.UnderlyingCode 去重数量。实时存续持仓口径:IsInitial=false、PosiQuantity>0、Invalid=false、PosiDirection>0 且 UnderlyingCode 非空。计算逻辑:同一交易对手累计标的数量超过 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, @@ -2972,7 +3389,7 @@ string underlyingList = underlyingCodes.Any() return new RiskVariableValueDetail( distinctCount, - $"交易对手ID为{currentClientId},存续标的共{distinctCount}个:{underlyingList}"); + $"交易ID {TradeId},交易对手ID为{currentClientId},存续标的共{distinctCount}个:{underlyingList}"); ``` 汇总 SQL: @@ -2989,10 +3406,10 @@ SELECT ELSE 0 END AS IsGreaterThan10 FROM trade cur -INNER JOIN trade t +LEFT JOIN trade t ON t.ClientId = cur.ClientId - AND t.ValidState <> 'InValid' -INNER JOIN swap_position sp + AND (t.ValidState IS NULL OR t.ValidState <> 'InValid') +LEFT JOIN swap_position sp ON sp.SwapTradeId = t.id AND sp.IsInitial = 0 AND sp.PosiQuantity > 0 @@ -3022,7 +3439,7 @@ SELECT FROM trade cur INNER JOIN trade t ON t.ClientId = cur.ClientId - AND t.ValidState <> 'InValid' + AND (t.ValidState IS NULL OR t.ValidState <> 'InValid') INNER JOIN swap_position sp ON sp.SwapTradeId = t.id AND sp.IsInitial = 0 @@ -3042,9 +3459,9 @@ ORDER BY --- -## 24. 规则 16 查询结果排查 SQL +## 27. 规则 16 查询结果排查 SQL -规则 16:多头支付固定端利率偏离(本地)。用于核对利息端收入固定利息方向的点差百分比绝对值是否低于阈值。 +规则 16:多头支付固定端利率偏离(本地)。用于核对利息端收入固定利息方向的点差百分比绝对值是否大于阈值。 取数流程: @@ -3082,27 +3499,77 @@ ABS(利息端.InterestRateDefault * 100) > 5 //}); ``` +变量形式: + +变量名:固定端利率偏离绝对值 +DataType:Numeric +规则配置:固定端利率偏离绝对值 > 阈值(示例 5) + +变量取值表达式: + +```csharp +var interestItems = DbContext.swap_position + .Where(p => p.SwapTradeId == TradeId + && p.InterestDirection == 1) + .Select(p => new + { + p.id, + p.InterestRateDefault, + InterestRateDeviation = Math.Abs(p.InterestRateDefault * 100m) + }) + .ToList(); + +if (!interestItems.Any()) + throw new Exception("利息端收取方向记录不存在"); + +var maxDeviationItem = interestItems + .OrderByDescending(p => p.InterestRateDeviation) + .ThenBy(p => p.id) + .First(); + +return new RiskVariableValueDetail( + maxDeviationItem.InterestRateDeviation, + $"交易ID {TradeId},利息端记录ID为{maxDeviationItem.id},点差为{maxDeviationItem.InterestRateDefault * 100m:0.#########}%,最大偏离绝对值为{maxDeviationItem.InterestRateDeviation:0.#########}%"); +``` + +汇总 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 + ELSE 0 + END AS IsGreaterThanThreshold +FROM swap_position sp +WHERE sp.SwapTradeId = @TradeId + AND sp.InterestDirection = 1; +``` + +明细 SQL:用于排查全部利息端记录;变量命中说明只展示产生最大偏离值的记录。 + +```sql +SET @TradeId = 3001699; +SET @Threshold = 5; 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 + WHEN ABS(sp.InterestRateDefault * 100) > @Threshold THEN 1 ELSE 0 - END AS IsGreaterThan + END AS IsGreaterThanThreshold FROM swap_position sp WHERE sp.SwapTradeId = @TradeId - AND sp.InterestDirection = 1; + AND sp.InterestDirection = 1 +ORDER BY DiffAbs DESC, sp.id ASC; ``` --- diff --git a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs index e090ee27..e2f7c1f4 100644 --- a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs +++ b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs @@ -4491,6 +4491,7 @@ namespace YLErp.Modules.RiskModule } var tradeId = (res.Trade.ParentTradeId == 0 || res.Trade.IsGroup != 0) ? res.Trade.id : res.Trade.ParentTradeId; + _logger.Info($"TradeConfirmRisk.RunFullRisk tradeId:{tradeId}, sourceTradeId:{res.Trade.id}, tradeNumber:{res.Trade.TradeNumber}, ignoreRiskWarning:{ignoreRiskWarning}, ignoreRiskRuleIds:{string.Join(",", ignoreRiskRuleIds ?? Enumerable.Empty())}, userId:{OptUser?.UserId}"); var trialService = new QuotaMonitorService(this); var quotaObj = trialService.RunQuotaTrial(tradeId, 1); return ApplyQuotaCheckResult(ref res, quotaObj, ignoreRiskWarning, ignoreRiskRuleIds); @@ -4513,6 +4514,7 @@ namespace YLErp.Modules.RiskModule } var tradeId = (res.Trade.ParentTradeId == 0 || res.Trade.IsGroup != 0) ? res.Trade.id : res.Trade.ParentTradeId; + _logger.Info($"TradeConfirmRisk.RunNewRisk tradeId:{tradeId}, sourceTradeId:{res.Trade.id}, tradeNumber:{res.Trade.TradeNumber}, ignoreRiskWarning:{ignoreRiskWarning}, ignoreRiskRuleIds:{string.Join(",", ignoreRiskRuleIds ?? Enumerable.Empty())}, userId:{OptUser?.UserId}"); var trialService = new QuotaMonitorService(this); var quotaObj = trialService.RunNewRiskTrial(tradeId, 1); return ApplyQuotaCheckResult(ref res, quotaObj, ignoreRiskWarning, ignoreRiskRuleIds); @@ -4524,6 +4526,7 @@ namespace YLErp.Modules.RiskModule /// private bool ApplyQuotaCheckResult(ref TradeOpenResult res, QuotaTrial quotaObj, bool ignoreRiskWarning, IEnumerable ignoreRiskRuleIds = null) { + _logger.Info($"TradeConfirmRisk.TrialResult tradeId:{res.Trade?.id}, tradeNumber:{res.Trade?.TradeNumber}, trialDataId:{quotaObj.id}, trialStatus:{quotaObj.TrialStatus}, oldRiskTrialStatus:{quotaObj.OldRiskTrialStatus}, newRiskNeedApproval:{quotaObj.NewRiskNeedApproval}, newRiskBlocked:{quotaObj.NewRiskBlocked}, approvalRuleIds:{string.Join(",", quotaObj.ApprovalRuleIds ?? new List())}, ignoreRiskWarning:{ignoreRiskWarning}, ignoreRiskRuleIds:{string.Join(",", ignoreRiskRuleIds ?? Enumerable.Empty())}, fundCheck:{!string.IsNullOrWhiteSpace(quotaObj.FundCheckDetails)}, quotaCheck:{!string.IsNullOrWhiteSpace(quotaObj.QuotaCheckDetails)}, riskWarning:{!string.IsNullOrWhiteSpace(quotaObj.RiskWarningDetails)}, quotaWarning:{!string.IsNullOrWhiteSpace(quotaObj.QuotaWarningDetails)}, userId:{OptUser?.UserId}"); if (quotaObj.TrialStatus == QuotaTrialStatusEnum.Success)//这次计算结果是通过,直接通过 { if (!string.IsNullOrWhiteSpace(quotaObj.AvailableForClient)) // 返回用户资金情况 @@ -4554,11 +4557,15 @@ namespace YLErp.Modules.RiskModule if (!string.IsNullOrWhiteSpace(quotaObj.QuotaWarningDetails)) detailParts.Add($"限额预警:{quotaObj.QuotaWarningDetails}"); res.ErrorMsg = string.Join("\n", detailParts); - //确认本次为需审批后,二次特批。因为只靠点击“交易特批”的ignoreRiskWarning,不能保证本次校验通过。 - var isRiskApprovalWarning = quotaObj.TrialStatus == QuotaTrialStatusEnum.RiskWarning + // 向上层透传新风控原始结果,用于区分组合特批和禁止场景。 + res.NewRiskBlocked = quotaObj.NewRiskBlocked; + res.NewRiskNeedApproval = quotaObj.NewRiskNeedApproval; + // 新风控禁止优先阻断;需审批状态独立判断,避免被老风控的Error或Warning状态覆盖。 + var isRiskApprovalWarning = quotaObj.NewRiskNeedApproval + && !quotaObj.NewRiskBlocked && !string.IsNullOrWhiteSpace(quotaObj.RiskWarningDetails) && (quotaObj.ApprovalRuleIds?.Any() ?? false); - //老风控试算不通过时,延用master逻辑进入交易特批/二次处理;新风控需审批由RiskWarning单独处理 + // 老风控试算不通过时,延用master逻辑进入交易特批;与新风控需审批同时命中时保留两边状态。 res.OldRiskNeedSpecialApproval = quotaObj.OldRiskTrialStatus.HasValue && quotaObj.OldRiskTrialStatus.Value != QuotaTrialStatusEnum.Success; if (isRiskApprovalWarning) @@ -4573,6 +4580,7 @@ namespace YLErp.Modules.RiskModule var newApprovalRuleIds = currentApprovalRuleIds.Where(o => !ignoredRuleIdSet.Contains(o)).ToList(); if (!newApprovalRuleIds.Any()) { + _logger.Info($"TradeConfirmRisk.ApprovalIgnored tradeId:{res.Trade?.id}, tradeNumber:{res.Trade?.TradeNumber}, trialDataId:{quotaObj.id}, ignoredRuleIds:{string.Join(",", ignoredRuleIdSet)}, userId:{OptUser?.UserId}"); res.ErrorMsg = string.Empty; return true; } @@ -5343,6 +5351,10 @@ namespace YLErp.Modules.RiskModule _logger.Info($"[风控引擎] 簿记交易确认 - TradeId: {tradeId}, Passed: {riskResult.Passed}, Blocked: {riskResult.Blocked}, NeedApproval: {riskResult.NeedApproval}, ShowTip: {riskResult.ShowTip}"); + // 单独保留新风控原始结果,避免与老风控状态合并后无法区分组合特批和禁止场景。 + result.NewRiskBlocked = riskResult.Blocked; + result.NewRiskNeedApproval = riskResult.NeedApproval; + // 按策略映射回 QuotaTrialStatusEnum if (riskResult.Blocked) { @@ -5402,7 +5414,8 @@ namespace YLErp.Modules.RiskModule /// private static string BuildTriggeredRuleDetail(TriggeredRuleInfo triggeredRule) { - var detail = $"规则ID:{triggeredRule.RuleId};规则名称:{triggeredRule.RuleName};规则说明:{triggeredRule.RuleText}"; + var applicationIdText = triggeredRule.ApplicationId?.ToString() ?? "-"; + var detail = $"应用ID:{applicationIdText};规则ID:{triggeredRule.RuleId};规则名称:{triggeredRule.RuleName};规则说明:{triggeredRule.RuleText}"; if (!string.IsNullOrWhiteSpace(triggeredRule.Message)) { detail += $";信息:{triggeredRule.Message}"; diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs index 018cac68..a10d96ac 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs @@ -122,6 +122,9 @@ namespace YLErp.Modules.TradeModule.DealModule { result.LackOfMoney = true; result.OldRiskNeedSpecialApproval = temp.OldRiskNeedSpecialApproval; + // 保留新风控独立状态,供上层决定展示组合特批或仅展示禁止详情。 + result.NewRiskBlocked = temp.NewRiskBlocked; + result.NewRiskNeedApproval = temp.NewRiskNeedApproval; result.TrialDataId = temp.TrialDataId; result.TradeId = temp.Trade.id; result.ignoreRiskRuleIds = temp.IgnoredRiskRuleIds; @@ -634,6 +637,17 @@ namespace YLErp.Modules.TradeModule.DealModule { public bool LackOfMoney; public bool OldRiskNeedSpecialApproval; + + /// + /// 新风控是否命中禁止规则 + /// + public bool NewRiskBlocked; + + /// + /// 新风控是否命中需审批规则 + /// + public bool NewRiskNeedApproval; + public int TrialDataId; public int TradeId; public List confirmedTradeIds; diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs index 0ec40dad..3755dc1b 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs @@ -804,6 +804,16 @@ namespace YLErp.Modules.TradeModule.DealModule public List IgnoredRiskRuleIds; public bool OldRiskNeedSpecialApproval; + /// + /// 新风控是否命中禁止规则 + /// + public bool NewRiskBlocked; + + /// + /// 新风控是否命中需审批规则 + /// + public bool NewRiskNeedApproval; + /// /// 错误信息 /// diff --git a/YLErpDAL/Modules/TradeModule/TradeBLL.cs b/YLErpDAL/Modules/TradeModule/TradeBLL.cs index e91abbae..2efe1960 100644 --- a/YLErpDAL/Modules/TradeModule/TradeBLL.cs +++ b/YLErpDAL/Modules/TradeModule/TradeBLL.cs @@ -27,6 +27,11 @@ namespace YLErp.BLL public const string RiskWarningConfirm = "RiskWarningConfirm"; + /// + /// 老风控交易特批与新风控需审批同时放行 + /// + public const string OldNewRiskConfirm = "OldNewRiskConfirm"; + public const string IgnoreBreak = "IgnoreBreak"; /// diff --git a/YLErpWeb/Controllers/tradeController.cs b/YLErpWeb/Controllers/tradeController.cs index 827bc8a6..12de7016 100644 --- a/YLErpWeb/Controllers/tradeController.cs +++ b/YLErpWeb/Controllers/tradeController.cs @@ -2464,12 +2464,14 @@ namespace YLErp.Web.Controllers { riskWarningConfirmExpireSeconds = configuredRiskWarningConfirmExpireSeconds; } - var ignoreMoneyCheck = tradeidArr.Count() == 1 && additionalProcessing == tradeBLL.LackOfMoney; - var ignoreRiskWarning = tradeidArr.Count() == 1 && additionalProcessing == tradeBLL.RiskWarningConfirm; + // 组合特批由单独类型明确表达,避免只有老风控异常时误放行新风控。 + var isOldNewRiskConfirm = additionalProcessing == tradeBLL.OldNewRiskConfirm; + var ignoreMoneyCheck = tradeidArr.Count() == 1 && (additionalProcessing == tradeBLL.LackOfMoney || isOldNewRiskConfirm); + var ignoreRiskWarning = tradeidArr.Count() == 1 && (additionalProcessing == tradeBLL.RiskWarningConfirm || isOldNewRiskConfirm); var ignoreRiskRuleIdArr = StringHelper.ConvertCommaValuesToStringArray(ignoreRiskRuleIds); - // 老风控交易特批与新风控二次审批统一基于 trialDataId 做超时校验,超时后清空放行标记并重新校验。 - // 当前仅 swapTradeView.js 会回传 trialDataId,按最近一次“触发风控”日志时间校验;查不到日志时回退 quotaTrial.OptDate。 - // tradeview.js / tradeConfirmList.js / swapTradeConfirmList.js / quotaMonitor.js 只发 LackOfMoney,不传 trialDataId,不进入超时校验。 + _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) { var trialExpired = false; @@ -2480,6 +2482,7 @@ namespace YLErp.Web.Controllers } if (trialExpired) { + _logger.Info($"TradeConfirmRisk.TrialExpired tradeids:{tradeids}, additionalProcessing:{additionalProcessing}, trialDataId:{trialDataId}, expireSeconds:{riskWarningConfirmExpireSeconds}, userId:{CurUser?.UserId}"); ignoreMoneyCheck = false; ignoreRiskWarning = false; ignoreRiskRuleIdArr = Array.Empty(); @@ -2492,20 +2495,33 @@ namespace YLErp.Web.Controllers isSkipApproval = true; } 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) { - if (result.type == TradeOpenRetCode.RiskWarning.ToString()) + // 新风控禁止优先级最高,即使老风控允许特批也只展示详情,不提供特批入口。 + if (result.NewRiskBlocked) { - return JsonSuccessData(new { proccessType = "AdditionalProcessing", source = "RiskWarning", type = tradeBLL.RiskWarningConfirm, TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type, ignoreRiskRuleIds = result.ignoreRiskRuleIds }); + _logger.Info($"TradeConfirmRisk.Response tradeids:{tradeids}, proccessType:QuotaTrialError, source:NewRiskBlocked, typecode:{result.type}, trialDataId:{result.TrialDataId}, userId:{CurUser?.UserId}"); + return JsonSuccessData(new { proccessType = "QuotaTrialError", TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type }); + } + // 老风控Error会使最终类型保持QuotaTrialError,因此组合场景必须按新风控原始审批状态识别。 + if (result.NewRiskNeedApproval || result.type == TradeOpenRetCode.RiskWarning.ToString()) + { + // 老风控异常叠加新风控需审批时,一次特批同时放行两边;否则仍只审批新风控。 + var isOldNewRiskConfirmRequired = result.OldRiskNeedSpecialApproval && result.NewRiskNeedApproval; + _logger.Info($"TradeConfirmRisk.Response tradeids:{tradeids}, proccessType:AdditionalProcessing, source:{(isOldNewRiskConfirmRequired ? "OldNewRisk" : "RiskWarning")}, type:{(isOldNewRiskConfirmRequired ? tradeBLL.OldNewRiskConfirm : tradeBLL.RiskWarningConfirm)}, typecode:{result.type}, trialDataId:{result.TrialDataId}, ignoreRiskRuleIds:{string.Join(",", result.ignoreRiskRuleIds ?? new List())}, userId:{CurUser?.UserId}"); + return JsonSuccessData(new { proccessType = "AdditionalProcessing", source = isOldNewRiskConfirmRequired ? "OldNewRisk" : "RiskWarning", type = isOldNewRiskConfirmRequired ? tradeBLL.OldNewRiskConfirm : tradeBLL.RiskWarningConfirm, TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type, ignoreRiskRuleIds = result.ignoreRiskRuleIds }); } if (result.type == TradeOpenRetCode.FundStatus.ToString()) { + _logger.Info($"TradeConfirmRisk.Response tradeids:{tradeids}, proccessType:AdditionalProcessing, source:FundStatus, typecode:{result.type}, trialDataId:{result.TrialDataId}, userId:{CurUser?.UserId}"); return JsonSuccessData(new { proccessType = "AdditionalProcessing", source = "FundStatus", type = "", TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type }); } if (result.type == TradeOpenRetCode.LackOfMoney.ToString() || ((PS.Config.ErpElement.Company == Configuration.CompanyEnum.天风 || config.SpecialOperateForTrade == 1) && string.IsNullOrWhiteSpace(result.type))) { + _logger.Info($"TradeConfirmRisk.Response tradeids:{tradeids}, proccessType:AdditionalProcessing, source:LackOfMoney, type:{tradeBLL.LackOfMoney}, typecode:{result.type}, trialDataId:{result.TrialDataId}, userId:{CurUser?.UserId}"); return JsonSuccessData(new { proccessType = "AdditionalProcessing", source = "LackOfMoney", type = tradeBLL.LackOfMoney, TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type }); } //限额试算不通过时,延用master老逻辑:老风控走AdditionalProcessing并允许前端展示“交易特批”;新风控检查失败仍只展示详情不允许审批 @@ -2513,8 +2529,11 @@ namespace YLErp.Web.Controllers { if (result.OldRiskNeedSpecialApproval) { - return JsonSuccessData(new { proccessType = "AdditionalProcessing", source = "OldRiskQuotaTrialError", type = PS.Config.ErpElement.Company == Configuration.CompanyEnum.天风 || config.SpecialOperateForTrade == 1 ? tradeBLL.LackOfMoney : "", TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type }); + var oldRiskSpecialApprovalType = PS.Config.ErpElement.Company == Configuration.CompanyEnum.天风 || config.SpecialOperateForTrade == 1 ? tradeBLL.LackOfMoney : ""; + _logger.Info($"TradeConfirmRisk.Response tradeids:{tradeids}, proccessType:AdditionalProcessing, source:OldRiskQuotaTrialError, type:{oldRiskSpecialApprovalType}, typecode:{result.type}, trialDataId:{result.TrialDataId}, userId:{CurUser?.UserId}"); + return JsonSuccessData(new { proccessType = "AdditionalProcessing", source = "OldRiskQuotaTrialError", type = oldRiskSpecialApprovalType, TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type }); } + _logger.Info($"TradeConfirmRisk.Response tradeids:{tradeids}, proccessType:QuotaTrialError, source:QuotaTrialError, typecode:{result.type}, trialDataId:{result.TrialDataId}, userId:{CurUser?.UserId}"); return JsonSuccessData(new { proccessType = "QuotaTrialError", TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type }); } } @@ -2553,6 +2572,7 @@ namespace YLErp.Web.Controllers } if (!string.IsNullOrWhiteSpace(decision)) { + _logger.Info($"TradeConfirmRisk.Decision tradeids:{tradeids}, trialDataId:{trialDataId}, decision:{decision}, userId:{CurUser?.UserId}"); new TradeRiskCheckLogService(CurUser).AddWarningDecisionLog(trialDataId.Value, decision); } } diff --git a/YLErpWeb/Views/SwapTrade/tradeEdit.cshtml b/YLErpWeb/Views/SwapTrade/tradeEdit.cshtml index 14750c6d..e18f7e9b 100644 --- a/YLErpWeb/Views/SwapTrade/tradeEdit.cshtml +++ b/YLErpWeb/Views/SwapTrade/tradeEdit.cshtml @@ -1,10 +1,11 @@ -@using YLErp.Web.Models.JsModels; +@using YLErp.Web.Models.JsModels; @model trade @{ ViewBag.Title = "交易信息 | 编辑"; Layout = "~/Views/Shared/_InfoLayout.cshtml"; var isAdd = Model.id == 0; + var isUseApproval = ViewBag.isUseApproval ?? false; var canChangeClient = isAdd || Model.TradeStatus == ConsTrade.新增待确认; var canAddNewTrader = CurUser.交易管理_新增选择交易员; @@ -594,7 +595,7 @@
- @if (ViewBag.isUseApproval) + @if (isUseApproval) { diff --git a/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml b/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml index bf22e849..f5866bda 100644 --- a/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml +++ b/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml @@ -7,6 +7,7 @@ Layout = "~/Views/Shared/_InfoLayout.cshtml"; var isAdd = Model.id == 0; + var isUseApproval = ViewBag.isUseApproval ?? false; var canChangeClient = isAdd || Model.TradeStatus == ConsTrade.新增待确认; var canAddNewTrader = CurUser.交易管理_新增选择交易员; var canEdit = Model.SwapCanEdit() && CurUser.交易管理_交易编辑; @@ -547,7 +548,7 @@
- @if (ViewBag.isUseApproval) + @if (isUseApproval) { diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js index 456633e8..38562200 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js @@ -84,7 +84,7 @@ var confirmFunc = function (id, additionalProcessing, ignoreRiskRuleIds, trialDa if (data.obj.TrialDataId) { var quotaTrialErrorLayerSetting = { type: 2, - title: "提示", + title: "风控试算详情", shadeClose: false, shade: 0.4, area: ['800px', '500px'], @@ -111,12 +111,14 @@ var confirmFunc = function (id, additionalProcessing, ignoreRiskRuleIds, trialDa if (data.obj.TrialDataId) { // 交易特批来源,优先使用后端新增的 source 字段区分老风控/新风控,同时兼容旧返回结构。 var additionalProcessingSource = data.obj.source || ""; + // 新老风控组合特批必须原样回传 OldNewRiskConfirm,二次确认时才能同时放行两套风控。 + var isOldNewRiskConfirm = data.obj.type === "OldNewRiskConfirm" || additionalProcessingSource === "OldNewRisk"; // 新风控需审批场景:source=RiskWarning 或 type=RiskWarningConfirm,二次确认时需要继续透传 ignoreRiskRuleIds。 var isRiskWarningConfirm = data.obj.type === "RiskWarningConfirm" || additionalProcessingSource === "RiskWarning"; // 老风控兼容特批场景:source=OldRiskQuotaTrialError 或 type=LackOfMoney,保持 master 原有“交易特批”按钮逻辑。 - var isOldRiskSpecialApproval = additionalProcessingSource === "OldRiskQuotaTrialError" || data.obj.type === "LackOfMoney"; - // 新风控特批走 RiskWarningConfirm,老风控特批继续走 LackOfMoney,兼容当前 confirmFunc 的入参约定。 - var additionalProcessingType = isRiskWarningConfirm ? "RiskWarningConfirm" : "LackOfMoney"; + var isOldRiskSpecialApproval = additionalProcessingSource === "OldRiskQuotaTrialError" || data.obj.type === "LackOfMoney" || isOldNewRiskConfirm; + // 后端已区分单独审批和组合特批,前端原样提交处理类型,避免组合特批降级为仅放行老风控。 + var additionalProcessingType = data.obj.type; // 新风控需审批时由后端返回本次命中的审批规则 id,前端二次确认时原样透传。 var currentIgnoreRiskRuleIds = data.obj.ignoreRiskRuleIds || []; var currentTrialDataId = data.obj.TrialDataId; @@ -134,7 +136,7 @@ var confirmFunc = function (id, additionalProcessing, ignoreRiskRuleIds, trialDa }; var layerSetting = { type: 2, - title: "提示", + title: "风控试算详情", shadeClose: false, shade: 0.4, area: ['800px', '500px'], @@ -161,7 +163,7 @@ var confirmFunc = function (id, additionalProcessing, ignoreRiskRuleIds, trialDa layer.close(layerIndex); } }; - if (data.obj.type === "LackOfMoney") { + if (data.obj.type === "LackOfMoney" || isOldNewRiskConfirm) { layerSetting.btn = [page.buttonStr, '取消']; } else if (isRiskWarningConfirm) {