diff --git a/Framework/YLErp.Core/DBModels/eod_bond_lending_rate.cs b/Framework/YLErp.Core/DBModels/eod_bond_lending_rate.cs new file mode 100644 index 00000000..e1b9c54e --- /dev/null +++ b/Framework/YLErp.Core/DBModels/eod_bond_lending_rate.cs @@ -0,0 +1,66 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace YLErp.DBModels +{ + [Table("eod_bond_lending_rate")] + public class eod_bond_lending_rate + { + [Key] + public long id { get; set; } + + [DisplayName("业务唯一代码")] + public string RecordCode { get; set; } + + [DisplayName("业务日期")] + public DateTime ValueDate { get; set; } + + [DisplayName("标的债券代码")] + public string UnderlyingSecurityId { get; set; } + + [DisplayName("标的债券名称")] + public string UnderlyingSymbol { get; set; } + + [DisplayName("前收盘费率(%)")] + public decimal? PreCloseRate { get; set; } + + [DisplayName("前加权平均费率(%)")] + public decimal? PreWeightedAvgRate { get; set; } + + [DisplayName("开盘费率(%)")] + public decimal? OpenRate { get; set; } + + [DisplayName("最新费率(%)")] + public decimal? LatestRate { get; set; } + + [DisplayName("最高费率(%)")] + public decimal? HighRate { get; set; } + + [DisplayName("最低费率(%)")] + public decimal? LowRate { get; set; } + + [DisplayName("收盘费率(%)")] + public decimal? CloseRate { get; set; } + + [DisplayName("加权平均费率(%)")] + public decimal? WeightedAvgRate { get; set; } + + [DisplayName("成交量(元)")] + public decimal? TurnoverAmount { get; set; } + + [DisplayName("操作人ID")] + public int OptId { get; set; } + + [DisplayName("操作人名称")] + public string OptName { get; set; } + + [DisplayName("操作时间")] + public DateTime OptDate { get; set; } + + [DisplayName("数据来源")] + public string DataSource { get; set; } + + [DisplayName("源行情时间")] + public string SourceTime { get; set; } + } +} diff --git a/YLErpDAL/DataBase/YLContext.cs b/YLErpDAL/DataBase/YLContext.cs index dbffc149..c5ac9d33 100644 --- a/YLErpDAL/DataBase/YLContext.cs +++ b/YLErpDAL/DataBase/YLContext.cs @@ -255,6 +255,7 @@ namespace YLErp.BLL public DbSet eod_trade_risk_hedgevol_s { get; set; } public DbSet eod_trade_risk_openvol_s { get; set; } public DbSet eod_currency_rate { get; set; } + public DbSet eod_bond_lending_rate { get; set; } public DbSet Stock_BlackWhite { get; set; } public DbSet SalesCommissionInfo { get; set; } public DbSet SalesCommissionDetail { get; set; } diff --git a/YLErpDAL/Modules/DataProviderModule/EodPriceQueryService.cs b/YLErpDAL/Modules/DataProviderModule/EodPriceQueryService.cs index 23e1dd26..b6db9bed 100644 --- a/YLErpDAL/Modules/DataProviderModule/EodPriceQueryService.cs +++ b/YLErpDAL/Modules/DataProviderModule/EodPriceQueryService.cs @@ -280,6 +280,36 @@ namespace YLErp.Modules.DataProviderModule return bondPrice; } /// + /// 获取债券借贷费率最新行情 + /// + /// + /// + /// + public static eod_bond_lending_rate GetBondLendingRate(DateTime valueDate, string underlyingSecurityId) + { + if (string.IsNullOrWhiteSpace(underlyingSecurityId)) + { + return null; + } + + using var db = DbContextFactory.GetYLDbContext(); + return db.eod_bond_lending_rate + .Where(x => x.UnderlyingSecurityId == underlyingSecurityId && x.ValueDate <= valueDate.Date) + .OrderByDescending(x => x.ValueDate) + .FirstOrDefault(); + } + /// + /// 尝试获取债券借贷费率最新行情 + /// + /// + /// + /// + /// + public static bool TryGetBondLendingRate(DateTime valueDate, string underlyingSecurityId, out eod_bond_lending_rate bondLendingRate) + { + return (bondLendingRate = GetBondLendingRate(valueDate, underlyingSecurityId)) != null; + } + /// /// 获取标的收盘价格 /// /// 标的代码 diff --git a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs index c8b74717..c5a5fc68 100644 --- a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs +++ b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs @@ -26,6 +26,13 @@ namespace YLErp.Modules.RiskEngine /// public string TriggerPoint { get; set; } + /// + /// 当前平仓请求提交的平仓日期 + /// + public DateTime? UnwindDate { get; set; } + + public DateTime? PayDate { get; set; } + /// /// 数据库上下文,供规则公式直接查询数据库 /// @@ -396,6 +403,8 @@ namespace YLErp.Modules.RiskEngine { TradeId = ctx.TradeId, TriggerPoint = ctx.TriggerPoint, + UnwindDate = ctx.UnwindDate, + PayDate = ctx.PayDate, DbContext = ctx.DbContext }; return runner(globals).GetAwaiter().GetResult(); diff --git a/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs b/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs index 5109aee0..8ea8d558 100644 --- a/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs +++ b/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs @@ -14,11 +14,33 @@ namespace YLErp.Modules.RiskEngine /// public static class RiskCalendarHelper { + private const string CalendarDateFormat = "yyyy,MM,dd"; + + /// + /// 日历数据异常直接影响平仓日期校验和上一交易日取数,单独使用固定 logger 名称便于线上按模块检索。 + /// + private static readonly IYcLogger Logger = LogFactory.GetLogger("RiskCalendarHelper"); + + /// + /// calendar.HolidayJson 历史主格式是 yyyy,MM,dd;这里额外兼容常见日期格式,避免历史数据被静默当作交易日。 + /// 解析后统一转成 DateTime.Date,后续交易日判断不再依赖原始字符串格式。 + /// + private static readonly string[] SupportedCalendarDateFormats = + { + CalendarDateFormat, + "yyyy/MM/dd", + "yyyy-MM-dd", + "yyyyMMdd", + "yyyy-MM-ddTHH:mm:ss", + "yyyy-MM-ddTHH:mm:ssK", + "yyyy-MM-ddTHH:mm:ss.fffK" + }; + /// /// 按当前风控执行使用的数据库上下文保存日历缓存,同一次风控检查内复用,数据库上下文释放后不阻止缓存被回收。 /// - private static readonly ConditionalWeakTable>> HolidayCaches = - new ConditionalWeakTable>>(); + private static readonly ConditionalWeakTable>> HolidayCaches = + new ConditionalWeakTable>>(); /// /// 获取指定日期的上一银行间交易日。 @@ -33,6 +55,24 @@ namespace YLErp.Modules.RiskEngine return GetPreviousTradingDay(dbContext, date, "IB", "银行间"); } + /// + /// 判断指定日期是否为银行间交易日。 + /// + /// 当前风控执行使用的数据库上下文。 + /// 待判断的平仓日期。 + /// 日期不在银行间非交易日集合中时返回 true。 + /// 数据库上下文为空。 + /// 缺少银行间日历或日历内容异常。 + public static bool IsInterbankTradingDay(YLContext dbContext, DateTime date) + { + if (dbContext == null) + throw new ArgumentNullException(nameof(dbContext)); + + var holidayCache = HolidayCaches.GetOrCreateValue(dbContext); + var holidays = GetHolidays(dbContext, date.Year, "IB", "银行间", holidayCache); + return !holidays.Contains(date.Date); + } + public static DateTime GetPreviousExchangeTradingDay(YLContext dbContext, DateTime date) { return GetPreviousTradingDay(dbContext, date, "CHN", "交易所"); @@ -51,10 +91,9 @@ namespace YLErp.Modules.RiskEngine for (var i = 0; i < 370; i++) { var holidays = GetHolidays(dbContext, currentDate.Year, country, calendarName, holidayCache); - var currentDateText = currentDate.ToString("yyyy,MM,dd", CultureInfo.InvariantCulture); // calendar.HolidayJson 存的是非交易日;不在非交易日集合内,即认为是对应市场的交易日。 - if (!holidays.Contains(currentDateText)) + if (!holidays.Contains(currentDate)) return currentDate; currentDate = currentDate.AddDays(-1); @@ -64,30 +103,51 @@ namespace YLErp.Modules.RiskEngine } /// - /// 获取指定年份的银行间非交易日集合。 + /// 获取指定年份的非交易日集合。 /// /// 当前风控执行使用的数据库上下文。 /// 日历年份。 /// 单次风控检查内按市场和年份共享的非交易日缓存。 - /// 格式为 yyyy,MM,dd 的非交易日集合。 - private static HashSet GetHolidays(YLContext dbContext, int year, string country, string calendarName, Dictionary> holidayCache) + /// 按 Date 归一化后的非交易日集合。 + private static HashSet GetHolidays(YLContext dbContext, int year, string country, string calendarName, Dictionary> holidayCache) { - // 缓存需要同时区分市场和年份,避免银行间与交易所同一年日历相互串用。 - var cacheKey = $"{country.ToUpperInvariant()}:{year}"; + var normalizedCountry = NormalizeCountry(country); + var cacheKey = $"{normalizedCountry}:{year}"; if (holidayCache.TryGetValue(cacheKey, out var holidays)) return holidays; - // 同一年可能存在多种市场日历,按规则对应的市场代码读取非交易日。 - var calendar = dbContext.calendar - .Where(c => c.Year == year && (c.ValidState == null || c.ValidState != ConsGlobal.InValid)) - .ToList() - .FirstOrDefault(c => string.Equals(c.Country, country, StringComparison.OrdinalIgnoreCase)); + // 先按年份和有效状态缩小范围,再在内存里做 Trim + ToUpperInvariant 匹配,兼容历史 Country 存在大小写或前后空格的情况。 + // 这里没有在数据库查询里直接 Trim,是为了避免不同 EF/数据库提供方对字符串函数翻译不一致。 + var validCalendars = dbContext.calendar + .Where(c => c.Year == year + && (c.ValidState == null || c.ValidState != ConsGlobal.InValid) + && c.Country != null) + .ToList(); + var matchedCalendars = validCalendars.Where(c => NormalizeCountry(c.Country) == normalizedCountry).ToList(); + var calendar = matchedCalendars.FirstOrDefault(); if (calendar == null) - throw new Exception($"未找到{year}年{calendarName}日历"); + { + var availableCountries = string.Join(",", validCalendars.Select(c => c.Country?.Trim()).Where(c => !string.IsNullOrWhiteSpace(c)).Distinct()); + Logger.Error($"[风控日历] 未找到目标市场日历 - Year:{year}, CalendarName:{calendarName}, Country:{normalizedCountry}, AvailableCountries:{availableCountries}"); + throw new Exception($"未找到{year}年{calendarName}日历,Country={normalizedCountry},当前可用日历:{availableCountries}"); + } + if (matchedCalendars.Count > 1) + { + // 多条匹配不改变原有“取第一条”的行为,只记录数据质量问题,避免线上突然因历史重复配置中断风控。 + Logger.Info($"[风控日历] 同一年存在多个匹配市场日历,使用第一条 - Year:{year}, CalendarName:{calendarName}, Country:{normalizedCountry}, MatchedIds:{string.Join(",", matchedCalendars.Select(c => c.id))}"); + } + if (!string.Equals(calendar.Country, normalizedCountry, StringComparison.Ordinal)) + { + // Country 能通过归一化匹配说明历史数据存在大小写或空格差异,只在日历首次加载时记录一次,便于后续清洗数据。 + Logger.Info($"[风控日历] 日历Country已归一化匹配 - Year:{year}, CalendarName:{calendarName}, RawCountry:{calendar.Country}, NormalizedCountry:{normalizedCountry}, CalendarId:{calendar.id}"); + } if (string.IsNullOrWhiteSpace(calendar.HolidayJson)) + { + Logger.Error($"[风控日历] HolidayJson为空 - Year:{year}, CalendarName:{calendarName}, Country:{normalizedCountry}, CalendarId:{calendar.id}"); throw new Exception($"{year}年{calendarName}日历HolidayJson为空"); + } List holidayList; try @@ -96,12 +156,70 @@ namespace YLErp.Modules.RiskEngine } catch (Exception ex) { + Logger.Error($"[风控日历] HolidayJson解析失败 - Year:{year}, CalendarName:{calendarName}, Country:{normalizedCountry}, CalendarId:{calendar.id}, Error:{ex.Message}"); throw new Exception($"{year}年{calendarName}日历HolidayJson解析失败", ex); } - holidays = new HashSet(holidayList ?? new List()); + // 日历解析和日志统计只在缓存未命中时执行;同一次风控检查内重复判断交易日不会重复解析 HolidayJson。 + holidays = NormalizeHolidays(holidayList, year, calendarName, normalizedCountry, calendar.id); holidayCache[cacheKey] = holidays; return holidays; } + + private static string NormalizeCountry(string country) + { + return (country ?? string.Empty).Trim().ToUpperInvariant(); + } + + private static HashSet NormalizeHolidays(IEnumerable holidayList, int year, string calendarName, string normalizedCountry, int calendarId) + { + var holidays = new HashSet(); + var formatCounts = new Dictionary(); + var rawCount = 0; + var blankCount = 0; + var duplicateCount = 0; + foreach (var holidayText in holidayList ?? Enumerable.Empty()) + { + rawCount++; + if (string.IsNullOrWhiteSpace(holidayText)) + { + blankCount++; + continue; + } + + // 每条原始日期只在缓存加载阶段解析一次;正常数据只累计格式分布,避免大量节假日逐条写日志。 + var holidayDate = ParseHolidayDate(holidayText, year, calendarName, normalizedCountry, calendarId, out var matchedFormat); + if (!holidays.Add(holidayDate)) + { + duplicateCount++; + } + formatCounts[matchedFormat] = formatCounts.TryGetValue(matchedFormat, out var count) ? count + 1 : 1; + } + Logger.Info($"[风控日历] HolidayJson日期归一化完成 - Year:{year}, CalendarName:{calendarName}, Country:{normalizedCountry}, CalendarId:{calendarId}, RawCount:{rawCount}, HolidayCount:{holidays.Count}, BlankCount:{blankCount}, DuplicateCount:{duplicateCount}, Formats:{string.Join(",", formatCounts.Select(item => item.Key + ":" + item.Value))}"); + return holidays; + } + + private static DateTime ParseHolidayDate(string holidayText, int year, string calendarName, string normalizedCountry, int calendarId, out string matchedFormat) + { + var normalizedHolidayText = holidayText.Trim(); + foreach (var format in SupportedCalendarDateFormats) + { + if (DateTime.TryParseExact(normalizedHolidayText, format, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var holidayDate)) + { + matchedFormat = format; + return holidayDate.Date; + } + } + + // 支持带时区的历史数据;最终取日期部分用于非交易日集合匹配,避免字符串格式差异导致静默漏判。 + if (DateTimeOffset.TryParse(normalizedHolidayText, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var holidayDateOffset)) + { + matchedFormat = "DateTimeOffset.TryParse"; + return holidayDateOffset.Date; + } + + Logger.Error($"[风控日历] HolidayJson日期格式无法识别 - Year:{year}, CalendarName:{calendarName}, Country:{normalizedCountry}, CalendarId:{calendarId}, RawValue:{holidayText}"); + throw new Exception($"{year}年{calendarName}日历HolidayJson存在无法识别的日期格式:{holidayText}"); + } } } diff --git a/YLErpDAL/Modules/RiskEngine/RiskContext.cs b/YLErpDAL/Modules/RiskEngine/RiskContext.cs index 9686f980..7a86b0a1 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskContext.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskContext.cs @@ -17,6 +17,13 @@ namespace YLErp.Modules.RiskEngine /// public string TriggerPoint { get; set; } + /// + /// 当前平仓请求提交的平仓日期,供平仓阶段规则直接使用。 + /// + public DateTime? UnwindDate { get; set; } + + public DateTime? PayDate { get; set; } + /// /// 数据库上下文,供规则公式直接查询数据库 /// diff --git a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs index 6cd51d3e..295a5f18 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs @@ -202,8 +202,7 @@ namespace YLErp.Modules.RiskEngine "BOOK_CONFIRM", "CLOSE_REVIEW", "UPLOAD_CONFIRMATION", - "EVENT_TRIGGER", - "FUND_PAYMENT" + "EVENT_TRIGGER" }; @@ -792,11 +791,10 @@ namespace YLErp.Modules.RiskEngine private static readonly Dictionary TriggerPointCnMap = new Dictionary { - ["BOOK_CONFIRM"] = "交易录入确认", - ["CLOSE_REVIEW"] = "平仓审核", + ["BOOK_CONFIRM"] = "簿记交易确认", + ["CLOSE_REVIEW"] = "平仓审核提交", ["UPLOAD_CONFIRMATION"] = "上传确认书", - ["EVENT_TRIGGER"] = "事件触发", - ["FUND_PAYMENT"] = "资金支付" + ["EVENT_TRIGGER"] = "事件发生时" }; /// diff --git a/YLErpDAL/Modules/RiskEngine/RiskTrialService.cs b/YLErpDAL/Modules/RiskEngine/RiskTrialService.cs new file mode 100644 index 00000000..bfd85d60 --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/RiskTrialService.cs @@ -0,0 +1,303 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Qdp.Foundation.Utilities; +using YLErp.BLL; +using YLErp.DBModels; +using YLErp.Enums; + +namespace YLErp.Modules.RiskEngine +{ + /// + /// 新风控通用试算服务,负责按业务触发时点执行规则、保存试算结果并校验二次确认。 + /// + public class RiskTrialService : YLBaseService + { + private readonly IYcLogger _logger = LogFactory.GetLogger("RiskTrialService"); + + /// + /// 使用当前业务服务的用户和数据库上下文创建新风控试算服务。 + /// + /// 当前业务服务。 + public RiskTrialService(YLBaseService baseService) : base(baseService) + { + } + + /// + /// 执行一次独立于老风控的新风控试算并保存试算记录。 + /// quotaTrial 在现有交易确认和平仓链路中按“每次试算快照/历史流水”使用,不作为审批状态表;二次确认会重新试算并生成新的快照。 + /// + /// 包含交易、触发时点及业务日期的风控上下文。 + /// 试算来源,由具体业务场景约定。 + /// 二次确认信息;首次试算传空。 + /// 包含阻断、审批、提示及本次试算记录ID的统一结果。 + public RiskTrialResult CheckRisk(RiskContext context, int trialSource, RiskTrialConfirmation confirmation = null) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + if (context.TradeId <= 0) + { + throw new ArgumentException("交易ID必须大于0", nameof(context)); + } + if (string.IsNullOrWhiteSpace(context.TriggerPoint)) + { + throw new ArgumentException("风控触发时点不能为空", nameof(context)); + } + + var ignoreRiskRuleIds = new HashSet(); + var confirmationExpired = false; + if (confirmation != null) + { + confirmationExpired = !TryLoadIgnoreRiskRuleIds(context, trialSource, confirmation, ignoreRiskRuleIds); + } + + var riskResult = new RiskEngineService(this).EvaluateRisk(context, context.TriggerPoint); + // 与交易确认流程保持一致:二次确认放行范围包含“需审批”和“提示”,避免已确认过的提示规则重复弹出。 + var currentApprovalRules = riskResult.TriggeredRules + .Where(rule => rule.ControlStrategy == RiskControlStrategy.Approval) + .ToList(); + var currentTipRules = riskResult.TriggeredRules + .Where(rule => rule.ControlStrategy == RiskControlStrategy.ShowTip) + .ToList(); + // 二次提交时,若首次试算记录有效,则剔除已确认过的审批和提示规则;记录过期或上下文不匹配时重新展示全部当前命中的审批和提示规则。 + var pendingApprovalRules = confirmationExpired + ? currentApprovalRules + : currentApprovalRules.Where(rule => !ignoreRiskRuleIds.Contains(rule.RuleId)).ToList(); + var pendingTipRules = confirmationExpired + ? currentTipRules + : currentTipRules.Where(rule => !ignoreRiskRuleIds.Contains(rule.RuleId)).ToList(); + var trial = CreateQuotaTrial(context, trialSource, riskResult, pendingApprovalRules, pendingTipRules); + // 与交易确认 RunQuotaTrial/RunNewRiskTrial 保持一致:每次风控执行保存一条 quotaTrial 快照,用于详情展示、历史追溯和二次确认时效校验,不回写上一条记录为已确认。 + new Modules.RiskModule.QuotaMonitorService(this).SaveQuotaTrial(trial); + + var result = new RiskTrialResult + { + Blocked = riskResult.Blocked, + NeedApproval = pendingApprovalRules.Any(), + ShowTip = pendingTipRules.Any(), + ConfirmationExpired = confirmationExpired, + TrialDataId = trial.id, + Message = trial.RiskWarningDetails, + ApprovalRuleIds = pendingApprovalRules.Concat(pendingTipRules) + .Select(rule => rule.RuleId) + .Where(ruleId => !string.IsNullOrWhiteSpace(ruleId)) + .Distinct() + .ToList() + }; + result.Passed = !result.Blocked && !result.NeedApproval; + + _logger.Info($"[新风控试算] TradeId: {context.TradeId}, TriggerPoint: {context.TriggerPoint}, TrialSource: {trialSource}, TrialDataId: {result.TrialDataId}, Passed: {result.Passed}, Blocked: {result.Blocked}, NeedApproval: {result.NeedApproval}, ShowTip: {result.ShowTip}, ConfirmationExpired: {result.ConfirmationExpired}"); + return result; + } + + /// + /// 校验二次确认记录,并恢复首次试算中已确认的审批和提示规则ID。 + /// + private bool TryLoadIgnoreRiskRuleIds(RiskContext context, int trialSource, RiskTrialConfirmation confirmation, ISet ignoreRiskRuleIds) + { + if (confirmation.TrialDataId <= 0 || confirmation.ExpireSeconds <= 0) + { + return false; + } + + var trial = DbContext.quotaTrial.FirstOrDefault(item => item.id == confirmation.TrialDataId); + if (trial == null + || trial.TradeId != context.TradeId + || trial.TrialSource != trialSource + || !trial.OptDate.HasValue + || !HasMatchingRequestContext(trial.RiskWarningDetails, context)) + { + return false; + } + if (DateTime.Now - trial.OptDate.Value > TimeSpan.FromSeconds(confirmation.ExpireSeconds)) + { + return false; + } + + foreach (var ruleId in GetIgnoreRiskRuleIds(confirmation, trial.RiskWarningDetails)) + { + ignoreRiskRuleIds.Add(ruleId); + } + return true; + } + + /// + /// 校验二次提交与首次试算的触发点和平仓日期一致,避免复用其他业务参数生成的审批记录。 + /// + private static bool HasMatchingRequestContext(string details, RiskContext context) + { + var expectedContext = BuildRequestContext(context); + return (details ?? string.Empty) + .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .Any(line => string.Equals(line, expectedContext, StringComparison.Ordinal)); + } + + private static IEnumerable GetIgnoreRiskRuleIds(RiskTrialConfirmation confirmation, string details) + { + var ignoreRiskRuleIds = confirmation.IgnoreRiskRuleIds ?? new List(); + return ignoreRiskRuleIds.Any() + ? ignoreRiskRuleIds.Where(ruleId => !string.IsNullOrWhiteSpace(ruleId)).Select(ruleId => ruleId.Trim()).Distinct() + : ParseIgnoreRiskRuleIds(details); + } + + /// + /// 从系统生成的审批和提示规则明细中恢复规则ID,兼容未回传忽略规则ID的旧入口。 + /// + private static IEnumerable ParseIgnoreRiskRuleIds(string details) + { + const string ruleIdPrefix = "规则ID:"; + var inConfirmableSection = false; + foreach (var line in (details ?? string.Empty).Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) + { + if (line.StartsWith("[风控引擎] 规则触发:", StringComparison.Ordinal)) + { + inConfirmableSection = line.StartsWith("[风控引擎] 规则触发:需审批", StringComparison.Ordinal) + || line.StartsWith("[风控引擎] 规则触发:提示", StringComparison.Ordinal); + continue; + } + if (!inConfirmableSection) + { + continue; + } + + var ruleIdStart = line.IndexOf(ruleIdPrefix, StringComparison.Ordinal); + if (ruleIdStart < 0) + { + continue; + } + ruleIdStart += ruleIdPrefix.Length; + var ruleIdEnd = line.IndexOf(';', ruleIdStart); + var ruleId = (ruleIdEnd < 0 ? line.Substring(ruleIdStart) : line.Substring(ruleIdStart, ruleIdEnd - ruleIdStart)).Trim(); + if (!string.IsNullOrWhiteSpace(ruleId)) + { + yield return ruleId; + } + } + } + + /// + /// 将新风控引擎结果转换为现有试算记录,供详情页面和二次确认复用。 + /// + private QuotaTrial CreateQuotaTrial(RiskContext context, int trialSource, RiskResult riskResult, IReadOnlyCollection pendingApprovalRules, IReadOnlyCollection pendingTipRules) + { + // 业务入口已通过Find加载交易,优先复用当前上下文跟踪的实体,避免重复查询数据库。 + var trade = DbContext.trade.Local.FirstOrDefault(item => item.id == context.TradeId) + ?? DbContext.trade.Find(context.TradeId); + var trial = new QuotaTrial + { + TradeId = context.TradeId, + TradeNumber = trade?.TradeNumber ?? string.Empty, + ClientName = trade?.ClientName ?? string.Empty, + TrialSource = trialSource, + TrialStatus = riskResult.Blocked + ? QuotaTrialStatusEnum.Error + : pendingApprovalRules.Any() ? QuotaTrialStatusEnum.RiskWarning : QuotaTrialStatusEnum.Success, + NewRiskBlocked = riskResult.Blocked, + NewRiskNeedApproval = pendingApprovalRules.Any(), + RiskWarningDetails = BuildRiskDetails(context, riskResult, pendingApprovalRules, pendingTipRules) + }; + trial.ApprovalRuleIds = pendingApprovalRules.Concat(pendingTipRules) + .Select(rule => rule.RuleId) + .Where(ruleId => !string.IsNullOrWhiteSpace(ruleId)) + .Distinct() + .ToList(); + return trial; + } + + /// + /// 按控制策略生成与现有试算详情页面兼容的规则明细。 + /// + private static string BuildRiskDetails(RiskContext context, RiskResult riskResult, IReadOnlyCollection pendingApprovalRules, IReadOnlyCollection pendingTipRules) + { + var lines = new List + { + BuildRequestContext(context) + }; + AppendRuleDetails(lines, "禁止", riskResult.TriggeredRules.Where(rule => rule.ControlStrategy == RiskControlStrategy.Block)); + AppendRuleDetails(lines, "需审批", pendingApprovalRules); + AppendRuleDetails(lines, "提示", pendingTipRules); + return string.Join("\n", lines); + } + + /// + /// 生成首次试算的业务请求标识,供二次确认严格校验业务参数。 + /// + private static string BuildRequestContext(RiskContext context) + { + return $"[风控引擎] 请求上下文:触发点:{GetTriggerPointDisplayName(context.TriggerPoint)};平仓日期:{context.UnwindDate?.Date:yyyy-MM-dd};支付日期:{context.PayDate?.Date:yyyy-MM-dd}"; + } + + private static string GetTriggerPointDisplayName(string triggerPoint) + { + return triggerPoint switch + { + "BOOK_CONFIRM" => "簿记交易确认", + "CLOSE_REVIEW" => "平仓审核提交", + "UPLOAD_CONFIRMATION" => "上传确认书", + "EVENT_TRIGGER" => "事件发生时", + _ => triggerPoint ?? string.Empty + }; + } + + /// + /// 追加指定控制策略的规则明细。 + /// + private static void AppendRuleDetails(ICollection lines, string strategyName, IEnumerable rules) + { + var ruleList = rules.ToList(); + if (!ruleList.Any()) + { + return; + } + + lines.Add($"[风控引擎] 规则触发:{strategyName}"); + foreach (var rule in ruleList) + { + var detail = $"应用ID:{rule.ApplicationId?.ToString() ?? "-"};规则ID:{rule.RuleId};规则名称:{rule.RuleName};规则说明:{rule.RuleText}"; + if (!string.IsNullOrWhiteSpace(rule.Message)) + { + detail += $";信息:{rule.Message}"; + } + lines.Add(detail); + } + } + } + + /// + /// 新风控二次确认参数。 + /// + public class RiskTrialConfirmation + { + /// + /// 首次风控试算记录ID。 + /// + public int TrialDataId { get; set; } + + /// + /// 首次试算允许确认放行的有效时长,单位为秒。 + /// + public int ExpireSeconds { get; set; } + + /// + /// 首次试算返回并经前端回传的需忽略规则ID。 + /// + public List IgnoreRiskRuleIds { get; set; } = new List(); + } + + /// + /// 新风控通用试算结果。 + /// + public class RiskTrialResult + { + public bool Passed { get; set; } + public bool Blocked { get; set; } + public bool NeedApproval { get; set; } + public bool ShowTip { get; set; } + public bool ConfirmationExpired { get; set; } + public int TrialDataId { get; set; } + public string Message { get; set; } + public List ApprovalRuleIds { get; set; } = new List(); + } +} diff --git a/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs b/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs index 73c8cb7d..1a46ee33 100644 --- a/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs +++ b/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs @@ -519,8 +519,10 @@ namespace YLErp.Modules.RiskEngine /// private static string BuildBooleanMessage(glms_risk_variable variable, ValueExecuteResult value, bool expected) { + if (!string.IsNullOrWhiteSpace(value.DetailMessage)) + return value.DetailMessage.Trim(); + var parts = new List(); - AddDetail(parts, value.DetailMessage); parts.Add($"{variable.VariableName}为{FormatDisplayValue(value.Value)}"); parts.Add($"期望为{FormatDisplayValue(expected)}"); return string.Join(",", parts); diff --git a/YLErpDAL/Modules/RiskEngine/测试用例.md b/YLErpDAL/Modules/RiskEngine/测试用例.md index 2eb3b5fe..1d543383 100644 --- a/YLErpDAL/Modules/RiskEngine/测试用例.md +++ b/YLErpDAL/Modules/RiskEngine/测试用例.md @@ -2180,17 +2180,16 @@ WHERE t.id = @TradeId; ## 19. 规则 8 查询结果排查 SQL -规则 8:支付日为银行间交易日(本地)。用于核对交易平仓窗口的支付日期是否为银行间交易日;若支付日期未落在 IB 日历的非交易日列表中,则命中。 +规则 8:支付日为银行间交易日(本地)。用于核对互换交易-交易查看-交易平仓窗口中本次提交的支付日期是否为银行间交易日;若支付日期未落在 IB 日历的非交易日列表中,则命中。 取数流程: ```text -1. 根据 TradeId 查 swap_flow_event。 -2. 限定 SwapTradeId = TradeId、EventType = 2、DataState <> 0,取平仓事件。 -3. 取 swap_flow_event.PayDate,作为交易平仓窗口的支付日期。 -4. 根据 PayDate.Value.Year 查询 calendar 表中 Country = 'IB' 的银行间日历。 -5. calendar.HolidayJson 存储该年非交易日,格式为 yyyy,MM,dd。 -6. 若 HolidayJson 不包含支付日期对应的 yyyy,MM,dd,则说明支付日期是银行间交易日,命中规则。 +1. 用户从互换交易-交易查看进入交易平仓窗口。 +2. 取交易平仓窗口本次提交的支付日期,即 UnwindData.PayDate。 +3. 根据 PayDate.Value.Year 查询 calendar 表中 Country = 'IB' 的银行间日历。 +4. calendar.HolidayJson 存储该年非交易日,格式为 yyyy,MM,dd。 +5. 若 HolidayJson 不包含支付日期对应的 yyyy,MM,dd,则说明支付日期是银行间交易日,命中规则。 ``` 规则公式: @@ -2202,10 +2201,9 @@ WHERE t.id = @TradeId; 规则字段口径: ```text -支付日期:swap_flow_event.PayDate,对应交易平仓窗口的支付日期 -平仓事件:swap_flow_event.EventType = 2 +支付日期:UnwindData.PayDate,对应互换交易-交易查看-交易平仓窗口的支付日期 银行间日历:calendar.Country = 'IB' -日历年份:calendar.Year = swap_flow_event.PayDate.Year +日历年份:calendar.Year = UnwindData.PayDate.Year 非交易日:calendar.HolidayJson ``` @@ -2216,6 +2214,30 @@ WHERE t.id = @TradeId; ``` +变量配置:规则 8 需要同时准备日期变量和布尔判断变量,规则前端配置时使用“平仓支付日期为银行间交易日 isTrue”;如果需要与平仓日期组合比较,可使用“平仓支付日期”日期变量。 + +变量 1:平仓支付日期,Category 为 BookingElement,DataType 为 Date,ValueDomain 为 yyyy-MM-dd。该变量必须使用本次平仓请求中的 PayDate,不能查询 swap_flow_event 历史平仓事件,否则首次提交和修改支付日期后的试算会取不到当前页面值。 + +```csharp +if (!PayDate.HasValue) + throw new Exception("平仓支付日期为空"); + +return new RiskVariableValueDetail( + PayDate.Value.Date, + $"交易ID {TradeId},本次平仓支付日期为{PayDate.Value:yyyy-MM-dd}"); +``` + +变量 2:平仓支付日期为银行间交易日,Category 为 BooleanCheck,DataType 为 Boolean,ValueDomain 为 true/false。 + +```csharp +if (!PayDate.HasValue) + throw new Exception("平仓支付日期为空"); + +return new RiskVariableValueDetail( + RiskCalendarHelper.IsInterbankTradingDay(DbContext, PayDate.Value), + $"交易ID {TradeId},本次平仓支付日期为{PayDate.Value:yyyy-MM-dd}"); +``` + 注释规则定义: ```csharp @@ -2223,8 +2245,8 @@ WHERE t.id = @TradeId; //{ // Id = 1000008, // RuleName = "支付日为银行间交易日(本地)", -// RuleText = "取值字段:通过 DbContext.swap_flow_event 按 SwapTradeId=TradeId、EventType=2、DataState<>0 取平仓事件的 PayDate,PayDate 对应交易平仓窗口的支付日期;通过 DbContext.calendar 按 Country='IB' 且 Year=PayDate.Year 取银行间日历,HolidayJson 存储该年非交易日。计算逻辑:若支付日期对应的 yyyy,MM,dd 不存在于 IB 日历 HolidayJson 中,则说明支付日期是银行间交易日,触发审批。", -// RuleExpr = "DbContext.swap_flow_event.Any(e => e.SwapTradeId == TradeId && e.EventType == 2 && e.DataState != 0 && e.PayDate.HasValue) && !DbContext.calendar.First(c => c.Country == \"IB\" && c.Year == DbContext.swap_flow_event.Where(e => e.SwapTradeId == TradeId && e.EventType == 2 && e.DataState != 0 && e.PayDate.HasValue).OrderByDescending(e => e.id).First().PayDate.Value.Year && c.ValidState != \"InValid\").HolidayJson.Contains(DbContext.swap_flow_event.Where(e => e.SwapTradeId == TradeId && e.EventType == 2 && e.DataState != 0 && e.PayDate.HasValue).OrderByDescending(e => e.id).First().PayDate.Value.ToString(\"yyyy,MM,dd\"))", +// RuleText = "取值字段:取互换交易-交易查看-交易平仓窗口本次提交的 UnwindData.PayDate,PayDate 对应页面支付日期;通过 DbContext.calendar 按 Country='IB' 且 Year=PayDate.Year 取银行间日历,HolidayJson 存储该年非交易日。计算逻辑:若支付日期对应的 yyyy,MM,dd 不存在于 IB 日历 HolidayJson 中,则说明支付日期是银行间交易日,触发审批。", +// RuleExpr = "PayDate.HasValue && RiskCalendarHelper.IsInterbankTradingDay(DbContext, PayDate.Value)", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -2236,6 +2258,8 @@ WHERE t.id = @TradeId; //}); ``` +变量值核对 SQL:用于核对已落库平仓事件的支付日期是否为银行间交易日;首次提交前的页面值应以请求中的 UnwindData.PayDate 为准,不能只依赖该 SQL。 + ```sql SET @TradeId = 3001699; @@ -2274,33 +2298,79 @@ WHERE t.id = @TradeId; ## 20. 规则 9 查询结果排查 SQL -规则 9:到期日为银行间交易日(本地)。用于核对互换交易界面的到期日是否为银行间交易日;若到期日未落在 IB 日历的非交易日列表中,则命中。 +规则 9:到期日为银行间交易日(本地)。用于核对新增互换交易页面本次提交的到期日期是否为银行间交易日;若到期日期未落在 IB 日历的非交易日列表中,则命中。到期日期指 swapTradeEdit.js 中绑定的 trade.ExerciseDate。 取数流程: ```text -1. 根据 TradeId 查当前 trade。 -2. 取 trade.ExerciseDate,作为互换交易界面的到期日。 -3. 根据 ExerciseDate.Value.Year 查询 calendar 表中 Country = 'IB' 的银行间日历。 -4. calendar.HolidayJson 存储该年非交易日,格式为 yyyy,MM,dd。 -5. 若 HolidayJson 不包含到期日对应的 yyyy,MM,dd,则说明到期日是银行间交易日,命中规则。 +1. 前端互换交易编辑页 swapTradeEdit.js 使用 trade.ExerciseDate 作为到期日期。 +2. 新增互换交易提交后,后端交易模型承接为 trade.ExerciseDate。 +3. 确认成交触发风控时,初始 swap_position 已由 trade.ExerciseDate 生成,读取 swap_position.PosiMatuirityDate。 +4. 限定 SwapTradeId = TradeId、IsInitial = 1、Invalid = 0、PosiMatuirityDate 不为空,按 id 取第一条初始持仓。 +5. 根据 PosiMatuirityDate.Value.Year 查询 calendar 表中 Country = 'IB' 的银行间日历。 +6. calendar.HolidayJson 存储该年非交易日,格式为 yyyy,MM,dd。 +7. 若 HolidayJson 不包含到期日期对应的 yyyy,MM,dd,则说明到期日期是银行间交易日,命中规则。 ``` 规则公式: ```text -到期日 ∉ IB 日历非交易日 +到期日期 ∉ IB 日历非交易日 ``` 规则字段口径: ```text -到期日:trade.ExerciseDate,对应互换交易界面的到期日 +到期日期:swapTradeEdit.js 的 trade.ExerciseDate,对应新增互换交易页面的“到期日期” +确认成交风控取值:swap_position.PosiMatuirityDate,由 Trade.ExerciseDate 生成 +初始持仓:swap_position.SwapTradeId = TradeId、IsInitial = 1、Invalid = 0 银行间日历:calendar.Country = 'IB' -日历年份:calendar.Year = trade.ExerciseDate.Year +日历年份:calendar.Year = swap_position.PosiMatuirityDate.Year 非交易日:calendar.HolidayJson ``` +变量配置:规则 9 需要同时准备日期变量和布尔判断变量,规则前端配置时使用“互换到期日期为银行间交易日 isTrue”;如果需要与成交日、起始日组合比较,可使用“互换到期日期”日期变量。 + +变量 1:互换到期日期,Category 为 BookingElement,DataType 为 Date,ValueDomain 为 yyyy-MM-dd。确认成交触发点使用 swap_position.PosiMatuirityDate,需限定初始有效持仓,避免后续互换/平仓产生的非初始持仓干扰。 + +```csharp +var maturityDate = DbContext.swap_position + .Where(p => p.SwapTradeId == TradeId + && p.IsInitial + && !p.Invalid + && p.PosiMatuirityDate.HasValue) + .OrderBy(p => p.id) + .Select(p => p.PosiMatuirityDate) + .FirstOrDefault(); + +if (!maturityDate.HasValue) + throw new Exception("互换到期日期为空"); + +return new RiskVariableValueDetail( + maturityDate.Value.Date, + $"交易ID {TradeId},本次互换到期日期为{maturityDate.Value:yyyy-MM-dd}"); +``` + +变量 2:互换到期日期为银行间交易日,Category 为 BooleanCheck,DataType 为 Boolean,ValueDomain 为 true/false。 + +```csharp +var maturityDate = DbContext.swap_position + .Where(p => p.SwapTradeId == TradeId + && p.IsInitial + && !p.Invalid + && p.PosiMatuirityDate.HasValue) + .OrderBy(p => p.id) + .Select(p => p.PosiMatuirityDate) + .FirstOrDefault(); + +if (!maturityDate.HasValue) + throw new Exception("互换到期日期为空"); + +return new RiskVariableValueDetail( + RiskCalendarHelper.IsInterbankTradingDay(DbContext, maturityDate.Value), + $"交易ID {TradeId},本次互换到期日期为{maturityDate.Value:yyyy-MM-dd}"); +``` + 注释规则定义: ```csharp @@ -2308,8 +2378,8 @@ WHERE t.id = @TradeId; //{ // Id = 1000009, // RuleName = "到期日为银行间交易日(本地)", -// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 ExerciseDate,ExerciseDate 对应互换交易界面的到期日;通过 DbContext.calendar 按 Country='IB' 且 Year=ExerciseDate.Year 取银行间日历,HolidayJson 存储该年非交易日。计算逻辑:若到期日对应的 yyyy,MM,dd 不存在于 IB 日历 HolidayJson 中,则说明到期日是银行间交易日,触发审批。", -// RuleExpr = "DbContext.trade.First(t => t.id == TradeId).ExerciseDate.HasValue && !DbContext.calendar.First(c => c.Country == \"IB\" && c.Year == DbContext.trade.First(t => t.id == TradeId).ExerciseDate.Value.Year && c.ValidState != \"InValid\").HolidayJson.Contains(DbContext.trade.First(t => t.id == TradeId).ExerciseDate.Value.ToString(\"yyyy,MM,dd\"))", +// RuleText = "取值字段:确认成交触发点下初始 swap_position 已由 trade.ExerciseDate 生成,通过 DbContext.swap_position 按 SwapTradeId=TradeId、IsInitial=true、Invalid=false 取 PosiMatuirityDate,PosiMatuirityDate 对应 swapTradeEdit.js 页面上的到期日期;通过 DbContext.calendar 按 Country='IB' 且 Year=PosiMatuirityDate.Year 取银行间日历,HolidayJson 存储该年非交易日。计算逻辑:若到期日期对应的 yyyy,MM,dd 不存在于 IB 日历 HolidayJson 中,则说明到期日期是银行间交易日,触发审批。", +// RuleExpr = "DbContext.swap_position.Any(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiMatuirityDate.HasValue) && RiskCalendarHelper.IsInterbankTradingDay(DbContext, DbContext.swap_position.Where(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiMatuirityDate.HasValue).OrderBy(p => p.id).First().PosiMatuirityDate.Value)", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -2327,19 +2397,33 @@ SET @TradeId = 3001699; SELECT t.id AS TradeId, t.TradeType, - t.ExerciseDate AS MaturityDate, + t.ExerciseDate AS TradeExerciseDate, + sp.id AS SwapPositionId, + sp.IsInitial, + sp.Invalid, + sp.PosiMatuirityDate AS MaturityDate, c.Country AS CalendarCountry, c.Year AS CalendarYear, - DATE_FORMAT(t.ExerciseDate, '%Y,%m,%d') AS MaturityDateText, + DATE_FORMAT(sp.PosiMatuirityDate, '%Y,%m,%d') AS MaturityDateText, CASE - WHEN t.ExerciseDate IS NOT NULL - AND c.HolidayJson NOT LIKE CONCAT('%', DATE_FORMAT(t.ExerciseDate, '%Y,%m,%d'), '%') THEN 1 + WHEN sp.PosiMatuirityDate IS NOT NULL + AND c.HolidayJson NOT LIKE CONCAT('%', DATE_FORMAT(sp.PosiMatuirityDate, '%Y,%m,%d'), '%') THEN 1 ELSE 0 END AS IsMaturityDateIBTradingDay FROM trade t +LEFT JOIN ( + SELECT * + FROM swap_position + WHERE SwapTradeId = @TradeId + AND IsInitial = 1 + AND Invalid = 0 + AND PosiMatuirityDate IS NOT NULL + ORDER BY id + LIMIT 1 +) sp ON sp.SwapTradeId = t.id LEFT JOIN calendar c ON c.Country = 'IB' - AND c.Year = YEAR(t.ExerciseDate) + AND c.Year = YEAR(sp.PosiMatuirityDate) AND c.ValidState <> 'InValid' WHERE t.id = @TradeId; ``` @@ -2348,17 +2432,16 @@ WHERE t.id = @TradeId; ## 21. 规则 10 查询结果排查 SQL -规则 10:平仓日为银行间交易日(本地)。用于核对交易平仓窗口的平仓日期是否为银行间交易日;若平仓日期未落在 IB 日历的非交易日列表中,则命中。 +规则 10:平仓日为银行间交易日(本地)。用于核对互换交易-交易查看-交易平仓窗口中本次提交的平仓日期是否为银行间交易日;若平仓日期未落在 IB 日历的非交易日列表中,则命中。 取数流程: ```text -1. 根据 TradeId 查 swap_flow_event。 -2. 限定 SwapTradeId = TradeId、EventType = 2、DataState <> 0,取平仓事件。 -3. 取 swap_flow_event.UnwindDate,作为交易平仓窗口的平仓日期。 -4. 根据 UnwindDate.Value.Year 查询 calendar 表中 Country = 'IB' 的银行间日历。 -5. calendar.HolidayJson 存储该年非交易日,格式为 yyyy,MM,dd。 -6. 若 HolidayJson 不包含平仓日期对应的 yyyy,MM,dd,则说明平仓日期是银行间交易日,命中规则。 +1. 用户从互换交易-交易查看进入交易平仓窗口。 +2. 取交易平仓窗口本次提交的平仓日期,即 UnwindData.UnwindDate。 +3. 根据 UnwindDate.Value.Year 查询 calendar 表中 Country = 'IB' 的银行间日历。 +4. calendar.HolidayJson 存储该年非交易日,格式为 yyyy,MM,dd。 +5. 若 HolidayJson 不包含平仓日期对应的 yyyy,MM,dd,则说明平仓日期是银行间交易日,命中规则。 ``` 规则公式: @@ -2370,10 +2453,9 @@ WHERE t.id = @TradeId; 规则字段口径: ```text -平仓日期:swap_flow_event.UnwindDate,对应交易平仓窗口的平仓日期 -平仓事件:swap_flow_event.EventType = 2 +平仓日期:UnwindData.UnwindDate,对应互换交易-交易查看-交易平仓窗口的平仓日期 银行间日历:calendar.Country = 'IB' -日历年份:calendar.Year = swap_flow_event.UnwindDate.Year +日历年份:calendar.Year = UnwindData.UnwindDate.Year 非交易日:calendar.HolidayJson ``` @@ -2384,6 +2466,30 @@ WHERE t.id = @TradeId; ``` +变量配置:规则 10 需要同时准备日期变量和布尔判断变量,规则前端配置时使用“平仓日期为银行间交易日 isTrue”;如果需要与支付日期组合比较,可使用“平仓日期”日期变量。 + +变量 1:平仓日期,Category 为 BookingElement,DataType 为 Date,ValueDomain 为 yyyy-MM-dd。该变量必须使用本次平仓请求中的 UnwindDate,不能查询 swap_flow_event 历史平仓事件,否则首次提交和修改平仓日期后的试算会取不到当前页面值。 + +```csharp +if (!UnwindDate.HasValue) + throw new Exception("平仓日期为空"); + +return new RiskVariableValueDetail( + UnwindDate.Value.Date, + $"交易ID {TradeId},本次平仓日期为{UnwindDate.Value:yyyy-MM-dd}"); +``` + +变量 2:平仓日期为银行间交易日,Category 为 BooleanCheck,DataType 为 Boolean,ValueDomain 为 true/false。 + +```csharp +if (!UnwindDate.HasValue) + throw new Exception("平仓日期为空"); + +return new RiskVariableValueDetail( + RiskCalendarHelper.IsInterbankTradingDay(DbContext, UnwindDate.Value), + $"交易ID {TradeId},本次平仓日期为{UnwindDate.Value:yyyy-MM-dd}"); +``` + 注释规则定义: ```csharp @@ -2391,8 +2497,8 @@ WHERE t.id = @TradeId; //{ // Id = 1000010, // RuleName = "平仓日为银行间交易日(本地)", -// RuleText = "取值字段:通过 DbContext.swap_flow_event 按 SwapTradeId=TradeId、EventType=2、DataState<>0 取平仓事件的 UnwindDate,UnwindDate 对应交易平仓窗口的平仓日期;通过 DbContext.calendar 按 Country='IB' 且 Year=UnwindDate.Year 取银行间日历,HolidayJson 存储该年非交易日。计算逻辑:若平仓日期对应的 yyyy,MM,dd 不存在于 IB 日历 HolidayJson 中,则说明平仓日期是银行间交易日,触发审批。", -// RuleExpr = "DbContext.swap_flow_event.Any(e => e.SwapTradeId == TradeId && e.EventType == 2 && e.DataState != 0 && e.UnwindDate.HasValue) && !DbContext.calendar.First(c => c.Country == \"IB\" && c.Year == DbContext.swap_flow_event.Where(e => e.SwapTradeId == TradeId && e.EventType == 2 && e.DataState != 0 && e.UnwindDate.HasValue).OrderByDescending(e => e.id).First().UnwindDate.Value.Year && c.ValidState != \"InValid\").HolidayJson.Contains(DbContext.swap_flow_event.Where(e => e.SwapTradeId == TradeId && e.EventType == 2 && e.DataState != 0 && e.UnwindDate.HasValue).OrderByDescending(e => e.id).First().UnwindDate.Value.ToString(\"yyyy,MM,dd\"))", +// RuleText = "取值字段:取互换交易-交易查看-交易平仓窗口本次提交的 UnwindData.UnwindDate,UnwindDate 对应页面平仓日期;通过 DbContext.calendar 按 Country='IB' 且 Year=UnwindDate.Year 取银行间日历,HolidayJson 存储该年非交易日。计算逻辑:若平仓日期对应的 yyyy,MM,dd 不存在于 IB 日历 HolidayJson 中,则说明平仓日期是银行间交易日,触发审批。", +// RuleExpr = "UnwindDate.HasValue && RiskCalendarHelper.IsInterbankTradingDay(DbContext, UnwindDate.Value)", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -2404,6 +2510,8 @@ WHERE t.id = @TradeId; //}); ``` +变量值核对 SQL:用于核对已落库平仓事件的平仓日期是否为银行间交易日;首次提交前的页面值应以请求中的 UnwindData.UnwindDate 为准,不能只依赖该 SQL。 + ```sql SET @TradeId = 3001699; @@ -3461,22 +3569,27 @@ ORDER BY ## 27. 规则 16 查询结果排查 SQL -规则 16:多头支付固定端利率偏离(本地)。用于核对利息端收入固定利息方向的点差百分比绝对值是否大于阈值。 +规则 16:多头支付固定端利率偏离(本地)。用于核对多头浮动端对应利息端点差百分比绝对值是否大于阈值,利息端不按 InterestDirection 区分收取或支付。 取数流程: ```text 1. 根据 TradeId 查 swap_position。 -2. 限定 InterestDirection=1,取固定利息端收取方向记录。 -3. 从该利息端记录取 InterestRateDefault。 -4. InterestRateDefault 只代表利率文本框中 + 号后的点差,不包含 FR007 基准利率,库内为小数原值,界面按百分比显示。 -5. 计算 ABS(InterestRateDefault * 100),大于 5 时命中。 +2. 先限定 IsInitial=1、Invalid=0、PosiDirection>0、PositionType=1,取多头浮动端记录。 +3. 若不存在多头浮动端记录,说明当前 trade 不适用规则16,变量返回 0,不触发风控。 +4. 再取同一 TradeId、同为初始有效、PosiDirection=0、PositionType=0、UnderlyingCode 为空、InterestMode=9 的利息端记录,不按 InterestDirection 区分收取或支付。 +5. 该识别口径来自当前建仓保存逻辑:浮动端记录会赋值 PosiDirection、PositionType、UnderlyingCode;普通利息端记录单独创建,InterestMode 设置为 9(标的期初全价),只赋值 InterestDirection、InterestRateDefault、InterestMode、FloatRateUnderlyingCode 等利息字段,未赋值浮动端字段时落库为默认的 0、0、空;保证金/预付金记录用 InterestMode=5 或 6,不纳入规则16利息端。 +6. 从该利息端记录取 InterestRateDefault 和 FloatRateUnderlyingCode。 +7. InterestRateDefault 只代表利率文本框中 + 号后的点差,不包含 FR007 基准利率,库内为小数原值,界面按百分比显示。 +8. 基准利率保护:FloatRateUnderlyingCode 必须为 FR007;为空或不是 FR007 时,说明规则16的 FR007 抵消前提不成立,抛异常触发风控报警。 +9. 所有普通利息端都要检查;若存在基准利率非法记录,异常信息同时列出基准利率非法记录,以及已满足 FR007 的记录偏离明细,避免只看到第一类问题。 +10. 若基准利率全部为 FR007,则逐条计算 ABS(InterestRateDefault * 100),取最大值与规则阈值比较;同时返回逐条明细,由结构化执行器按当前规则阈值拆分展示超限记录和未超限记录。 ``` 规则公式: ```text -ABS(利息端.InterestRateDefault * 100) > 5 +存在多头浮动端时,逐条检查普通利息端;若 FloatRateUnderlyingCode 为空或不是 FR007,则抛异常触发风控报警,异常信息同时列出基准利率非法记录和已满足 FR007 的记录偏离明细;若基准利率全部为 FR007,则取 ABS(InterestRateDefault * 100) 的最大值与阈值比较,并由结构化执行器按规则阈值拆分展示超限记录和未超限记录;不存在多头浮动端时返回 0,不触发 ``` 注释规则定义: @@ -3486,8 +3599,8 @@ ABS(利息端.InterestRateDefault * 100) > 5 //{ // Id = 1000016, // RuleName = "多头支付固定端利率偏离(本地)", -// RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取利息端收入固定利息方向记录的 InterestRateDefault。InterestRateDefault 只代表利率文本框中 + 号后的点差,不包含 FR007 基准利率。计算逻辑:按 ABS(InterestRateDefault) 计算点差绝对值,绝对值小于 5% 时触发审批。", -// RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.InterestDirection == 1).InterestRateDefault)*100m > 5m", +// RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 先取 IsInitial=true、Invalid=false、PosiDirection>0、PositionType=1 的多头浮动端记录;若不存在多头浮动端记录,说明当前 trade 不适用规则16,变量返回 0,不触发风控;若存在多头浮动端记录,再取同一 TradeId、同为初始有效、PosiDirection=0、PositionType=0、UnderlyingCode 为空、InterestMode=9 的普通利息端记录,不按 InterestDirection 区分收取或支付,排除 InterestMode=5/6 的保证金/预付金记录。InterestRateDefault 只代表利率文本框中 + 号后的点差,不包含 FR007 基准利率。基准利率保护:FloatRateUnderlyingCode 必须为 FR007;为空或不是 FR007 时,说明规则16的 FR007 抵消前提不成立,抛异常触发风控报警,异常信息同时列出基准利率非法记录和已满足 FR007 的记录偏离明细。计算逻辑:基准利率全部校验通过后,按 ABS(InterestRateDefault * 100) 计算点差百分比绝对值,取最大值与阈值比较;变量明细逐条返回偏离值,结构化执行器按规则阈值拆分展示超限记录和未超限记录。", +// RuleExpr = "DbContext.swap_position.Any(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection > 0 && p.PositionType == 1) && !DbContext.swap_position.Where(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 0 && p.PositionType == 0 && string.IsNullOrEmpty(p.UnderlyingCode) && p.InterestMode == 9).Any(p => string.IsNullOrEmpty(p.FloatRateUnderlyingCode) || p.FloatRateUnderlyingCode != \"FR007\") && DbContext.swap_position.Where(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 0 && p.PositionType == 0 && string.IsNullOrEmpty(p.UnderlyingCode) && p.InterestMode == 9).Max(p => Math.Abs(p.InterestRateDefault * 100m)) > 5m", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -3501,113 +3614,265 @@ ABS(利息端.InterestRateDefault * 100) > 5 变量形式: -变量名:固定端利率偏离绝对值 +变量名:多头支付固定端利率偏离值 DataType:Numeric -规则配置:固定端利率偏离绝对值 > 阈值(示例 5) +规则配置:多头支付固定端利率偏离值 > 阈值(示例 5) 变量取值表达式: ```csharp +var longFloatItems = DbContext.swap_position + .Where(p => p.SwapTradeId == TradeId + && p.IsInitial + && !p.Invalid + && p.PosiDirection > 0 + && p.PositionType == 1) + .Select(p => new { p.id }) + .ToList(); + +if (!longFloatItems.Any()) + return new RiskVariableValueDetail( + 0m, + $"交易ID {TradeId} 不存在多头浮动端记录,规则16不适用,不触发风控"); + var interestItems = DbContext.swap_position .Where(p => p.SwapTradeId == TradeId - && p.InterestDirection == 1) + && p.IsInitial + && !p.Invalid + && p.PosiDirection == 0 + && p.PositionType == 0 + && string.IsNullOrEmpty(p.UnderlyingCode) + && p.InterestMode == 9) .Select(p => new { p.id, + p.InterestMode, + p.InterestDirection, + p.FloatRateUnderlyingCode, p.InterestRateDefault, InterestRateDeviation = Math.Abs(p.InterestRateDefault * 100m) }) .ToList(); if (!interestItems.Any()) - throw new Exception("利息端收取方向记录不存在"); + throw new Exception("同次录入的利息端记录不存在"); -var maxDeviationItem = interestItems +var invalidFloatRateItems = interestItems + .Where(p => string.IsNullOrEmpty(p.FloatRateUnderlyingCode) || p.FloatRateUnderlyingCode != "FR007") + .ToList(); + +if (invalidFloatRateItems.Any()) +{ + var invalidFloatRateMessage = "基准利率不合法记录:" + string.Join(";", invalidFloatRateItems + .OrderBy(p => p.id) + .Select(p => $"记录ID为{p.id},基准利率为{(string.IsNullOrEmpty(p.FloatRateUnderlyingCode) ? "空" : p.FloatRateUnderlyingCode)}")); + + var validDeviationItems = interestItems + .Where(p => p.FloatRateUnderlyingCode == "FR007") + .OrderByDescending(p => p.InterestRateDeviation) + .ThenBy(p => p.id) + .ToList(); + + var validDeviationMessage = validDeviationItems.Any() + ? ";FR007记录偏离明细:" + string.Join(";", validDeviationItems.Select(p => $"记录ID为{p.id},点差为{p.InterestRateDefault * 100m:0.#########}%,偏离绝对值为{p.InterestRateDeviation:0.#########}%,收支方向为{(p.InterestDirection == 1 ? "收取" : p.InterestDirection == 2 ? "支付" : "未知:" + p.InterestDirection)}")) + : string.Empty; + + throw new Exception($"基准利率保护触发:规则16要求基准利率必须为FR007。{invalidFloatRateMessage}{validDeviationMessage}"); +} + +var orderedInterestItems = interestItems .OrderByDescending(p => p.InterestRateDeviation) .ThenBy(p => p.id) - .First(); + .ToList(); + +var maxDeviationItem = orderedInterestItems.First(); + +var interestDetails = orderedInterestItems + .Select(p => new RiskVariableValueDetailItem( + p.InterestRateDeviation, + $"记录ID为{p.id},基准利率为{(string.IsNullOrEmpty(p.FloatRateUnderlyingCode) ? "空" : p.FloatRateUnderlyingCode)},点差为{p.InterestRateDefault * 100m:0.#########}%,偏离绝对值为{p.InterestRateDeviation:0.#########}%,收支方向为{(p.InterestDirection == 1 ? "收取" : p.InterestDirection == 2 ? "支付" : "未知:" + p.InterestDirection)}")) + .ToList(); return new RiskVariableValueDetail( maxDeviationItem.InterestRateDeviation, - $"交易ID {TradeId},利息端记录ID为{maxDeviationItem.id},点差为{maxDeviationItem.InterestRateDefault * 100m:0.#########}%,最大偏离绝对值为{maxDeviationItem.InterestRateDeviation:0.#########}%"); + $"交易ID {TradeId},多头浮动端记录ID为{string.Join("、", longFloatItems.Select(p => p.id))},普通利息端共{orderedInterestItems.Count}条,最大偏离绝对值为{maxDeviationItem.InterestRateDeviation:0.#########}%", + interestDetails); ``` -汇总 SQL:与变量一样取全部符合条件记录中的最大偏离值;命中说明只展示产生最大偏离值的记录。 +汇总 SQL:先确认存在多头浮动端,再按同次录入普通利息端记录取最大偏离值用于规则比较;若存在基准利率为空或不是 FR007 的记录则实际执行时会抛异常触发风控报警,SQL 中单独列出此类记录数供排查。 ```sql SET @TradeId = 3001699; SET @Threshold = 5; SELECT - MAX(ABS(sp.InterestRateDefault * 100)) AS MaxInterestRateDeviation, CASE - WHEN MAX(ABS(sp.InterestRateDefault * 100)) > @Threshold THEN 1 + WHEN COUNT(DISTINCT fp.id) = 0 THEN NULL + ELSE MAX(ABS(ip.InterestRateDefault * 100)) + END AS MaxInterestRateDeviation, + CASE + WHEN COUNT(DISTINCT fp.id) = 0 THEN 0 + WHEN MAX(ABS(ip.InterestRateDefault * 100)) > @Threshold THEN 1 + ELSE 0 + END AS IsGreaterThanThreshold, + SUM(CASE WHEN ip.FloatRateUnderlyingCode IS NULL OR ip.FloatRateUnderlyingCode = '' OR ip.FloatRateUnderlyingCode <> 'FR007' THEN 1 ELSE 0 END) AS InvalidFloatRateCodeCount +FROM swap_position fp +LEFT JOIN swap_position ip + ON ip.SwapTradeId = fp.SwapTradeId + AND ip.IsInitial = fp.IsInitial + AND ip.Invalid = fp.Invalid + AND ip.PosiDirection = 0 + AND ip.PositionType = 0 + AND (ip.UnderlyingCode IS NULL OR ip.UnderlyingCode = '') + AND ip.InterestMode = 9 +WHERE fp.SwapTradeId = @TradeId + AND fp.IsInitial = 1 + AND fp.Invalid = 0 + AND fp.PosiDirection > 0 + AND fp.PositionType = 1; +``` + +明细 SQL:用于排查多头浮动端及其同次录入普通利息端记录;按偏离绝对值倒序展示所有纳入变量计算的普通利息端记录,基准利率为空或不是 FR007 的记录会标记 IsProtectionTrigger=1,实际执行时抛异常触发风控报警。 + +```sql +SET @TradeId = 3001699; +SET @Threshold = 5; + +SELECT + fp.id AS FloatPositionId, + ip.id AS InterestPositionId, + fp.SwapTradeId, + fp.IsInitial, + fp.Invalid, + fp.PosiDirection, + fp.PositionType, + ip.InterestMode, + ip.InterestDirection, + ip.FloatRateUnderlyingCode, + CASE + WHEN ip.FloatRateUnderlyingCode IS NULL OR ip.FloatRateUnderlyingCode = '' OR ip.FloatRateUnderlyingCode <> 'FR007' THEN 1 + ELSE 0 + END AS IsProtectionTrigger, + ip.InterestRateDefault, + ip.InterestRateDefault * 100 AS InterestRateDefault_100, + ABS(ip.InterestRateDefault * 100) AS DiffAbs, + CASE + WHEN ABS(ip.InterestRateDefault * 100) > @Threshold THEN 1 ELSE 0 END AS IsGreaterThanThreshold -FROM swap_position sp -WHERE sp.SwapTradeId = @TradeId - AND sp.InterestDirection = 1; +FROM swap_position fp +LEFT JOIN swap_position ip + ON ip.SwapTradeId = fp.SwapTradeId + AND ip.IsInitial = fp.IsInitial + AND ip.Invalid = fp.Invalid + AND ip.PosiDirection = 0 + AND ip.PositionType = 0 + AND (ip.UnderlyingCode IS NULL OR ip.UnderlyingCode = '') + AND ip.InterestMode = 9 +WHERE fp.SwapTradeId = @TradeId + AND fp.IsInitial = 1 + AND fp.Invalid = 0 + AND fp.PosiDirection > 0 + AND fp.PositionType = 1 +ORDER BY DiffAbs DESC, ip.id ASC; ``` -明细 SQL:用于排查全部利息端记录;变量命中说明只展示产生最大偏离值的记录。 +异常排查 SQL:用于排查变量执行失败“同次录入的利息端记录不存在”。重点看是否存在多头浮动端,以及是否存在同一 TradeId 下初始有效、PosiDirection=0、PositionType=0、UnderlyingCode 为空、InterestMode=9 的普通利息端记录;InterestMode=5/6 为保证金/预付金,不应计入规则16利息端。 ```sql SET @TradeId = 3001699; -SET @Threshold = 5; SELECT - sp.id AS SwapPositionId, + '当前交易初始有效持仓概览' AS CheckItem, + sp.id, sp.SwapTradeId, + sp.IsInitial, + sp.Invalid, + sp.PosiDirection, + sp.PositionType, + CASE sp.PositionType + WHEN 1 THEN '多头' + WHEN 2 THEN '空头' + ELSE CONCAT('未知:', sp.PositionType) + END AS PositionTypeName, + sp.InterestMode, + CASE sp.InterestMode + WHEN 5 THEN '初始预付金' + WHEN 6 THEN '追加预付金' + WHEN 9 THEN '标的期初全价' + ELSE CONCAT('未知:', sp.InterestMode) + END AS InterestModeName, sp.InterestDirection, + CASE sp.InterestDirection + WHEN 1 THEN '收取' + WHEN 2 THEN '支付' + ELSE CONCAT('未知:', sp.InterestDirection) + END AS InterestDirectionName, + sp.UnderlyingCode, + sp.FloatRateUnderlyingCode, sp.InterestRateDefault, sp.InterestRateDefault * 100 AS InterestRateDefault_100, - ABS(sp.InterestRateDefault * 100) AS DiffAbs, - CASE - WHEN ABS(sp.InterestRateDefault * 100) > @Threshold THEN 1 - ELSE 0 - END AS IsGreaterThanThreshold + ABS(sp.InterestRateDefault * 100) AS DiffAbs FROM swap_position sp WHERE sp.SwapTradeId = @TradeId - AND sp.InterestDirection = 1 -ORDER BY DiffAbs DESC, sp.id ASC; + AND sp.IsInitial = 1 + AND sp.Invalid = 0 +ORDER BY + sp.PosiDirection DESC, + sp.PositionType, + sp.InterestDirection, + sp.id; + +SELECT + SUM(CASE WHEN sp.PosiDirection > 0 AND sp.PositionType = 1 THEN 1 ELSE 0 END) AS LongFloatCount, + SUM(CASE WHEN sp.PosiDirection = 0 AND sp.PositionType = 0 AND (sp.UnderlyingCode IS NULL OR sp.UnderlyingCode = '') AND sp.InterestMode = 9 THEN 1 ELSE 0 END) AS InterestLegCount, + SUM(CASE WHEN sp.PosiDirection = 0 AND sp.PositionType = 0 AND (sp.UnderlyingCode IS NULL OR sp.UnderlyingCode = '') AND sp.InterestMode IN (5, 6) THEN 1 ELSE 0 END) AS MarginLegCount, + SUM(CASE WHEN sp.PosiDirection = 0 AND sp.PositionType = 0 AND (sp.UnderlyingCode IS NULL OR sp.UnderlyingCode = '') AND sp.InterestMode = 9 AND sp.InterestDirection = 1 THEN 1 ELSE 0 END) AS InterestLegDirection1Count, + SUM(CASE WHEN sp.PosiDirection = 0 AND sp.PositionType = 0 AND (sp.UnderlyingCode IS NULL OR sp.UnderlyingCode = '') AND sp.InterestMode = 9 AND sp.InterestDirection = 2 THEN 1 ELSE 0 END) AS InterestLegDirection2Count, + SUM(CASE WHEN sp.PosiDirection = 0 AND sp.PositionType = 0 AND (sp.UnderlyingCode IS NULL OR sp.UnderlyingCode = '') AND sp.InterestMode = 9 AND (sp.FloatRateUnderlyingCode IS NULL OR sp.FloatRateUnderlyingCode = '' OR sp.FloatRateUnderlyingCode <> 'FR007') THEN 1 ELSE 0 END) AS InvalidFloatRateCodeCount, + CASE + WHEN SUM(CASE WHEN sp.PosiDirection > 0 AND sp.PositionType = 1 THEN 1 ELSE 0 END) = 0 + THEN '不存在多头浮动端,规则16不适用,应返回0不触发' + WHEN SUM(CASE WHEN sp.PosiDirection = 0 AND sp.PositionType = 0 AND (sp.UnderlyingCode IS NULL OR sp.UnderlyingCode = '') AND sp.InterestMode = 9 THEN 1 ELSE 0 END) = 0 + THEN '存在多头浮动端,但不存在对应普通利息端记录;需检查同次录入利息端是否满足 PosiDirection=0、PositionType=0、UnderlyingCode 为空、InterestMode=9,不能把 InterestMode=5/6 的保证金/预付金当作利息端' + WHEN SUM(CASE WHEN sp.PosiDirection = 0 AND sp.PositionType = 0 AND (sp.UnderlyingCode IS NULL OR sp.UnderlyingCode = '') AND sp.InterestMode = 9 AND (sp.FloatRateUnderlyingCode IS NULL OR sp.FloatRateUnderlyingCode = '' OR sp.FloatRateUnderlyingCode <> 'FR007') THEN 1 ELSE 0 END) > 0 + THEN '存在普通利息端基准利率为空或不是FR007,规则16应触发基准利率保护异常,不应继续计算点差偏离' + ELSE '存在多头浮动端且存在对应普通利息端记录,基准利率均为FR007,可以计算规则16偏离值;规则16不按 InterestDirection 区分收取或支付,并排除保证金/预付金记录' + END AS DiagnosticResult +FROM swap_position sp +WHERE sp.SwapTradeId = @TradeId + AND sp.IsInitial = 1 + AND sp.Invalid = 0; ``` --- -## 28. 规则 18 查询结果排查 SQL +## 28. 规则 17 查询结果排查 SQL -规则 18:账户授权收支方向不匹配(本地)。用于核对账户授权的收支方向与交易浮动端多空方向是否匹配;不符合允许场景时命中。 - -校验内容: - -```text -不符合以下场景则不通过: -1. 利息端 = 收取,且浮动端 = 空头 -2. 利息端 = 支付,且浮动端 = 多头 -``` +规则 17:空头利息端利率与借贷加权费率偏离(本地)。用于核对空头浮动端同次录入的普通利息端利率与标的借贷加权费率的偏离绝对值是否大于阈值,利息端不按 InterestDirection 区分收取或支付。 取数流程: ```text 1. 根据 TradeId 查 swap_position。 -2. 限定 IsInitial=1、Invalid=0、PosiDirection>0,取浮动端初始持仓记录。 -3. 取 swap_position.InterestDirection 作为利息端收支方向,1=收取,2=支付。 -4. 取 swap_position.PositionType 作为浮动端多空方向,1=多头,2=空头。 -5. 若不是“利息端收取且浮动端空头”,也不是“利息端支付且浮动端多头”,则命中。 +2. 先限定 IsInitial=1、Invalid=0、PosiDirection>0、PositionType=2,取空头浮动端记录,得到标的 UnderlyingCode。 +3. 若不存在空头浮动端记录,说明当前 trade 不适用规则17,变量返回 0,不触发风控。 +4. 空头浮动端标的必须为“数字.IB”或“数字.BC”格式;合法时取点号前数字作为借贷费率查询标的,其他格式抛异常触发风控报警。 +5. 再取同一 TradeId、同为初始有效、PosiDirection=0、PositionType=0、UnderlyingCode 为空、InterestMode=9 的普通利息端记录,不按 InterestDirection 区分收取或支付,排除 InterestMode=5/6 的保证金/预付金记录。 +6. 从普通利息端记录取 InterestRateDefault 和 FloatRateUnderlyingCode。 +7. InterestRateDefault 只代表利率文本框中 + 号后的点差,库内为小数原值,界面按百分比显示。 +8. 基准利率保护:FloatRateUnderlyingCode 必须为 FR007;为空或不是 FR007 时抛异常触发风控报警。 +9. 所有空头浮动端和普通利息端都要检查;若存在空头浮动端标的非法或基准利率不是 FR007 的记录,异常信息同时列出非法记录,以及合法记录组合后的偏离明细,避免只看到第一类问题。 +10. 从 eod_commodity_future_price 取该基准利率代码在 TradeDate 之前最近一条的 ReferencePrice(小数口径,如 0.025 表示 2.5%)。 +11. 从 eod_bond_lending_rate 取空头浮动端标的点号前数字在 TradeDate 之前最近一条的 WeightedAvgRate(百分比值,如 2.5 表示 2.5%)。 +12. 利息端利率(%) = (基准利率 + 点差) * 100,借贷加权费率(%) = WeightedAvgRate。 +13. 若空头浮动端标的和基准利率均合法,则对所有空头浮动端和普通利息端组合逐条计算 ABS(利息端利率(%) - 借贷加权费率(%)),取最大值与规则阈值比较;同时返回逐条明细,由结构化执行器按当前规则阈值拆分展示超限记录和未超限记录。 ``` 规则公式: ```text -NOT ((InterestDirection = 1 AND PositionType = 2) OR (InterestDirection = 2 AND PositionType = 1)) -``` - -规则字段口径: - -```text -利息端收支方向:swap_position.InterestDirection,1=收取,2=支付 -浮动端多空方向:swap_position.PositionType,1=多头,2=空头 -浮动端记录:swap_position.IsInitial=1、Invalid=0、PosiDirection>0 +存在空头浮动端时,逐条检查空头浮动端和普通利息端;若空头浮动端标的不是“数字.IB”或“数字.BC”格式,或 FloatRateUnderlyingCode 为空/不是 FR007,则抛异常触发风控报警,异常信息同时列出非法记录和合法记录组合后的偏离明细;若空头浮动端标的和基准利率均合法,则用空头浮动端标的点号前数字查借贷加权费率,并取所有空头浮动端和普通利息端组合 ABS((基准利率 + InterestRateDefault) * 100 - 借贷加权费率) 的最大值与阈值比较;变量明细逐条返回偏离值,由结构化执行器按规则阈值拆分展示超限记录和未超限记录;不存在空头浮动端时返回 0,不触发 ``` 注释规则定义: @@ -3615,10 +3880,10 @@ NOT ((InterestDirection = 1 AND PositionType = 2) OR (InterestDirection = 2 AND ```csharp //rules.Add(new RiskRule //{ -// Id = 1000018, -// RuleName = "账户授权收支方向不匹配(本地)", -// RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、PosiDirection>0 的浮动端初始持仓记录;InterestDirection 表示利息端收支方向,1=收取、2=支付;PositionType 表示浮动端多空方向,1=多头、2=空头。计算逻辑:仅允许利息端=收取且浮动端=空头,或利息端=支付且浮动端=多头;其他组合触发审批。", -// RuleExpr = "!(((DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection > 0).InterestDirection == 1) && (DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection > 0).PositionType == 2)) || ((DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection > 0).InterestDirection == 2) && (DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection > 0).PositionType == 1)))", +// Id = 1000017, +// RuleName = "空头利息端利率与借贷加权费率偏离(本地)", +// RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 先取 IsInitial=true、Invalid=false、PosiDirection>0、PositionType=2 的空头浮动端记录;若不存在空头浮动端记录,说明当前 trade 不适用规则17,变量返回 0,不触发风控;若存在空头浮动端记录,再取同一 TradeId、同为初始有效、PosiDirection=0、PositionType=0、UnderlyingCode 为空、InterestMode=9 的普通利息端记录,不按 InterestDirection 区分收取或支付,排除 InterestMode=5/6 的保证金/预付金记录。利息端利率 = 基准利率 + 点差,基准利率取 eod_commodity_future_price 中 FloatRateUnderlyingCode 在 TradeDate 前最近一条的 ReferencePrice(小数口径);FloatRateUnderlyingCode 必须为 FR007,为空或不是 FR007 时抛异常触发风控报警,异常信息同时列出基准利率非法记录和基准利率为 FR007 的合法记录与空头浮动端组合后的偏离明细。点差为 InterestRateDefault(小数原值)。空头浮动端 UnderlyingCode 必须为“数字.IB”或“数字.BC”格式;合法时取点号前数字作为 eod_bond_lending_rate.UnderlyingSecurityId 查询 TradeDate 前最近一条 WeightedAvgRate(百分比值),其他格式抛异常触发风控报警。计算逻辑:对所有合法空头浮动端和普通利息端组合逐条按 ABS((基准利率 + 点差) * 100 - 借贷加权费率) 计算偏离绝对值,取最大值与阈值比较;变量明细逐条返回偏离值,结构化执行器按规则阈值拆分展示超限记录和未超限记录。", +// RuleExpr = "", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -3630,35 +3895,522 @@ NOT ((InterestDirection = 1 AND PositionType = 2) OR (InterestDirection = 2 AND //}); ``` +变量形式: + +变量名:利息端利率与借贷加权费率偏离度 +DataType:Numeric +规则配置:利息端利率与借贷加权费率偏离度 > 阈值(示例 5) + +变量取值表达式: + +```csharp +var shortFloatItems = DbContext.swap_position + .Where(p => p.SwapTradeId == TradeId + && p.IsInitial + && !p.Invalid + && p.PosiDirection > 0 + && p.PositionType == 2) + .Select(p => new { p.id, p.UnderlyingCode }) + .ToList(); + +if (!shortFloatItems.Any()) + return new RiskVariableValueDetail( + 0m, + $"交易ID {TradeId} 不存在空头浮动端记录,规则17不适用,不触发风控"); + +var invalidShortFloatItems = shortFloatItems + .Where(p => string.IsNullOrEmpty(p.UnderlyingCode) + || !(p.UnderlyingCode.EndsWith(".IB") || p.UnderlyingCode.EndsWith(".BC")) + || p.UnderlyingCode.LastIndexOf(".") <= 0 + || !p.UnderlyingCode.Substring(0, p.UnderlyingCode.LastIndexOf(".")).All(c => char.IsDigit(c))) + .ToList(); + +var validShortFloatItems = shortFloatItems + .Where(p => !string.IsNullOrEmpty(p.UnderlyingCode) + && (p.UnderlyingCode.EndsWith(".IB") || p.UnderlyingCode.EndsWith(".BC")) + && p.UnderlyingCode.LastIndexOf(".") > 0 + && p.UnderlyingCode.Substring(0, p.UnderlyingCode.LastIndexOf(".")).All(c => char.IsDigit(c))) + .Select(p => new + { + p.id, + p.UnderlyingCode, + LendingRateUnderlyingCode = p.UnderlyingCode.Substring(0, p.UnderlyingCode.LastIndexOf(".")) + }) + .ToList(); + +var interestItems = DbContext.swap_position + .Where(p => p.SwapTradeId == TradeId + && p.IsInitial + && !p.Invalid + && p.PosiDirection == 0 + && p.PositionType == 0 + && string.IsNullOrEmpty(p.UnderlyingCode) + && p.InterestMode == 9) + .Select(p => new { p.id, p.InterestMode, p.InterestDirection, p.InterestRateDefault, p.FloatRateUnderlyingCode }) + .ToList(); + +if (!interestItems.Any()) + throw new Exception("同次录入的普通利息端记录不存在"); + +var invalidFloatRateItems = interestItems + .Where(p => string.IsNullOrEmpty(p.FloatRateUnderlyingCode) || p.FloatRateUnderlyingCode != "FR007") + .ToList(); + +var validInterestItems = interestItems + .Where(p => p.FloatRateUnderlyingCode == "FR007") + .ToList(); + +if ((invalidShortFloatItems.Any() && !validShortFloatItems.Any()) || (invalidFloatRateItems.Any() && !validInterestItems.Any())) +{ + var invalidShortFloatMessage = invalidShortFloatItems.Any() + ? "空头浮动端标的不合法记录:" + string.Join(";", invalidShortFloatItems + .OrderBy(p => p.id) + .Select(p => $"记录ID为{p.id},标的为{(string.IsNullOrEmpty(p.UnderlyingCode) ? "空" : p.UnderlyingCode)},仅允许数字.IB或数字.BC格式")) + : string.Empty; + + var invalidFloatRateMessage = invalidFloatRateItems.Any() + ? (string.IsNullOrEmpty(invalidShortFloatMessage) ? string.Empty : ";") + "基准利率非法记录:" + string.Join(";", invalidFloatRateItems + .OrderBy(p => p.id) + .Select(p => $"记录ID为{p.id},基准利率为{(string.IsNullOrEmpty(p.FloatRateUnderlyingCode) ? "空" : p.FloatRateUnderlyingCode)},点差为{p.InterestRateDefault * 100m:0.#########}%,收支方向为{(p.InterestDirection == 1 ? "收取" : p.InterestDirection == 2 ? "支付" : "未知:" + p.InterestDirection)}")) + : string.Empty; + + throw new Exception($"规则17保护触发:空头浮动端标的仅允许数字.IB或数字.BC格式,普通利息端基准利率必须为FR007。{invalidShortFloatMessage}{invalidFloatRateMessage}"); +} + +var calculationInterestItems = invalidFloatRateItems.Any() ? validInterestItems : interestItems; + +var tradeDate = DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date; + +var orderedDeviationItems = validShortFloatItems + .OrderBy(p => p.id) + .SelectMany(shortFloatItem => + { + var lendingRate = DbContext.eod_bond_lending_rate + .Where(x => x.UnderlyingSecurityId == shortFloatItem.LendingRateUnderlyingCode && x.ValueDate <= tradeDate) + .OrderByDescending(x => x.ValueDate) + .FirstOrDefault(); + + if (lendingRate == null || !lendingRate.WeightedAvgRate.HasValue) + throw new Exception($"标的 {shortFloatItem.UnderlyingCode} 对应借贷费率查询标的 {shortFloatItem.LendingRateUnderlyingCode} 在交易日 {tradeDate:yyyy-MM-dd} 前未找到借贷加权费率"); + + var lendingRatePercent = lendingRate.WeightedAvgRate.Value; + + return calculationInterestItems + .OrderBy(p => p.id) + .Select(interestItem => + { + var floatRateCode = interestItem.FloatRateUnderlyingCode; + + var floatRatePrice = DbContext.eod_commodity_future_price + .Where(e => e.UnderlyingCode == floatRateCode && e.ValueDate <= tradeDate) + .OrderByDescending(e => e.ValueDate) + .FirstOrDefault(); + + var floatRate = floatRatePrice != null && floatRatePrice.ReferencePrice.HasValue ? Convert.ToDecimal(floatRatePrice.ReferencePrice.Value) : 0m; + var interestRatePercent = (floatRate + interestItem.InterestRateDefault) * 100m; + var deviation = Math.Abs(interestRatePercent - lendingRatePercent); + + return new + { + FloatPositionId = shortFloatItem.id, + shortFloatItem.UnderlyingCode, + shortFloatItem.LendingRateUnderlyingCode, + InterestPositionId = interestItem.id, + interestItem.InterestMode, + interestItem.InterestDirection, + interestItem.InterestRateDefault, + FloatRateCode = floatRateCode, + FloatRate = floatRate, + InterestRatePercent = interestRatePercent, + LendingRatePercent = lendingRatePercent, + Deviation = deviation + }; + }); + }) + .OrderByDescending(p => p.Deviation) + .ThenBy(p => p.FloatPositionId) + .ThenBy(p => p.InterestPositionId) + .ToList(); + +if (invalidShortFloatItems.Any() || invalidFloatRateItems.Any()) +{ + var invalidShortFloatMessage = invalidShortFloatItems.Any() + ? "空头浮动端标的不合法记录:" + string.Join(";", invalidShortFloatItems + .OrderBy(p => p.id) + .Select(p => $"记录ID为{p.id},标的为{(string.IsNullOrEmpty(p.UnderlyingCode) ? "空" : p.UnderlyingCode)},仅允许数字.IB或数字.BC格式")) + : string.Empty; + + var invalidFloatRateMessage = invalidFloatRateItems.Any() + ? (string.IsNullOrEmpty(invalidShortFloatMessage) ? string.Empty : ";") + "基准利率非法记录:" + string.Join(";", invalidFloatRateItems + .OrderBy(p => p.id) + .Select(p => $"记录ID为{p.id},基准利率为{(string.IsNullOrEmpty(p.FloatRateUnderlyingCode) ? "空" : p.FloatRateUnderlyingCode)},点差为{p.InterestRateDefault * 100m:0.#########}%,收支方向为{(p.InterestDirection == 1 ? "收取" : p.InterestDirection == 2 ? "支付" : "未知:" + p.InterestDirection)}")) + : string.Empty; + + var validDeviationMessage = orderedDeviationItems.Any() + ? ";合法记录偏离明细:" + string.Join(";", orderedDeviationItems.Select(p => $"空头浮动端记录ID为{p.FloatPositionId},标的{p.UnderlyingCode},借贷费率查询标的{p.LendingRateUnderlyingCode},普通利息端记录ID为{p.InterestPositionId},基准利率{p.FloatRateCode}为{p.FloatRate * 100m:0.#########}%,点差为{p.InterestRateDefault * 100m:0.#########}%,利息端利率为{p.InterestRatePercent:0.#########}%,借贷加权费率为{p.LendingRatePercent:0.#########}%,偏离绝对值为{p.Deviation:0.#########}%,收支方向为{(p.InterestDirection == 1 ? "收取" : p.InterestDirection == 2 ? "支付" : "未知:" + p.InterestDirection)}")) + : string.Empty; + + throw new Exception($"规则17保护触发:空头浮动端标的仅允许数字.IB或数字.BC格式,普通利息端基准利率必须为FR007。{invalidShortFloatMessage}{invalidFloatRateMessage}{validDeviationMessage}"); +} + +var maxDeviationItem = orderedDeviationItems.First(); + +var detailItems = orderedDeviationItems + .Select(p => new RiskVariableValueDetailItem( + p.Deviation, + $"空头浮动端记录ID为{p.FloatPositionId},标的{p.UnderlyingCode},借贷费率查询标的{p.LendingRateUnderlyingCode},普通利息端记录ID为{p.InterestPositionId},基准利率{p.FloatRateCode}为{p.FloatRate * 100m:0.#########}%,点差为{p.InterestRateDefault * 100m:0.#########}%,利息端利率为{p.InterestRatePercent:0.#########}%,借贷加权费率为{p.LendingRatePercent:0.#########}%,偏离绝对值为{p.Deviation:0.#########}%,收支方向为{(p.InterestDirection == 1 ? "收取" : p.InterestDirection == 2 ? "支付" : "未知:" + p.InterestDirection)}")) + .ToList(); + +return new RiskVariableValueDetail( + maxDeviationItem.Deviation, + $"交易ID {TradeId},空头浮动端共{shortFloatItems.Count}条,普通利息端共{interestItems.Count}条,最大偏离绝对值为{maxDeviationItem.Deviation:0.#########}%", + detailItems); +``` + +汇总 SQL:先确认存在空头浮动端,再按合法空头浮动端和同次录入普通利息端组合及对应行情数据计算最大偏离值;借贷费率查询标的取空头浮动端 UnderlyingCode 点号前数字;若存在空头浮动端标的非法或基准利率不是 FR007 的记录则实际执行时会抛异常触发风控报警,SQL 中单独列出此类记录数供排查。 + +```sql +SET @TradeId = 3001699; +SET @Threshold = 5; + +SELECT + CASE + WHEN COUNT(DISTINCT fp.id) = 0 THEN NULL + ELSE MAX(CASE WHEN fp.UnderlyingCode REGEXP '^[0-9]+[.](IB|BC)$' AND ip.FloatRateUnderlyingCode = 'FR007' THEN ABS((IFNULL(fr.ReferencePrice, 0) + ip.InterestRateDefault) * 100 - blr.WeightedAvgRate) END) + END AS MaxDeviation, + CASE + WHEN COUNT(DISTINCT fp.id) = 0 THEN 0 + WHEN MAX(CASE WHEN fp.UnderlyingCode REGEXP '^[0-9]+[.](IB|BC)$' AND ip.FloatRateUnderlyingCode = 'FR007' THEN ABS((IFNULL(fr.ReferencePrice, 0) + ip.InterestRateDefault) * 100 - blr.WeightedAvgRate) END) > @Threshold THEN 1 + ELSE 0 + END AS IsGreaterThanThreshold, + COUNT(DISTINCT CASE WHEN fp.id IS NOT NULL AND (fp.UnderlyingCode IS NULL OR fp.UnderlyingCode = '' OR NOT (fp.UnderlyingCode REGEXP '^[0-9]+[.](IB|BC)$')) THEN fp.id END) AS InvalidShortUnderlyingCodeCount, + COUNT(DISTINCT CASE WHEN ip.id IS NOT NULL AND IFNULL(ip.FloatRateUnderlyingCode, '') <> 'FR007' THEN ip.id END) AS InvalidFloatRateCodeCount +FROM swap_position fp +LEFT JOIN swap_position ip + ON ip.SwapTradeId = fp.SwapTradeId + AND ip.IsInitial = fp.IsInitial + AND ip.Invalid = fp.Invalid + AND ip.PosiDirection = 0 + AND ip.PositionType = 0 + AND (ip.UnderlyingCode IS NULL OR ip.UnderlyingCode = '') + AND ip.InterestMode = 9 +LEFT JOIN trade t + ON t.id = fp.SwapTradeId +LEFT JOIN eod_commodity_future_price fr + ON fr.FutureContractId = ip.FloatRateUnderlyingCode + AND fr.ValueDate = ( + SELECT MAX(e.ValueDate) + FROM eod_commodity_future_price e + WHERE e.FutureContractId = ip.FloatRateUnderlyingCode + AND e.ValueDate <= t.TradeDate + ) +LEFT JOIN eod_bond_lending_rate blr + ON blr.UnderlyingSecurityId = SUBSTRING_INDEX(fp.UnderlyingCode, '.', 1) + AND fp.UnderlyingCode REGEXP '^[0-9]+[.](IB|BC)$' + AND blr.ValueDate = ( + SELECT MAX(b.ValueDate) + FROM eod_bond_lending_rate b + WHERE b.UnderlyingSecurityId = SUBSTRING_INDEX(fp.UnderlyingCode, '.', 1) + AND fp.UnderlyingCode REGEXP '^[0-9]+[.](IB|BC)$' + AND b.ValueDate <= t.TradeDate + ) +WHERE fp.SwapTradeId = @TradeId + AND fp.IsInitial = 1 + AND fp.Invalid = 0 + AND fp.PosiDirection > 0 + AND fp.PositionType = 2; +``` + +明细 SQL:用于排查空头浮动端及其同次录入普通利息端记录、基准利率、借贷加权费率;展示空头浮动端原始标的、借贷费率查询标的和保护标记,基准利率不是 FR007 或空头浮动端标的非法的记录会标记保护字段,实际执行时抛异常触发风控报警。 + +```sql +SET @TradeId = 3001699; +SET @Threshold = 5; + +SELECT + fp.id AS FloatPositionId, + ip.id AS InterestPositionId, + fp.SwapTradeId, + fp.IsInitial, + fp.Invalid, + fp.PosiDirection, + fp.PositionType, + fp.UnderlyingCode, + CASE + WHEN fp.UnderlyingCode REGEXP '^[0-9]+[.](IB|BC)$' THEN SUBSTRING_INDEX(fp.UnderlyingCode, '.', 1) + ELSE NULL + END AS LendingRateUnderlyingCode, + CASE + WHEN fp.UnderlyingCode IS NULL OR fp.UnderlyingCode = '' OR NOT (fp.UnderlyingCode REGEXP '^[0-9]+[.](IB|BC)$') THEN 1 + ELSE 0 + END AS IsShortUnderlyingProtectionTrigger, + ip.InterestMode, + ip.InterestDirection, + CASE ip.InterestDirection + WHEN 1 THEN '收取' + WHEN 2 THEN '支付' + ELSE CONCAT('未知:', ip.InterestDirection) + END AS InterestDirectionName, + ip.FloatRateUnderlyingCode, + CASE + WHEN ip.id IS NOT NULL AND IFNULL(ip.FloatRateUnderlyingCode, '') <> 'FR007' THEN 1 + ELSE 0 + END AS IsProtectionTrigger, + ip.InterestRateDefault, + ip.InterestRateDefault * 100 AS InterestRateDefault_100, + fr.ReferencePrice AS FloatRateReferencePrice, + fr.ValueDate AS FloatRateValueDate, + (IFNULL(fr.ReferencePrice, 0) + ip.InterestRateDefault) * 100 AS InterestRatePercent, + blr.WeightedAvgRate AS LendingWeightedAvgRate, + blr.ValueDate AS LendingValueDate, + CASE WHEN fp.UnderlyingCode REGEXP '^[0-9]+[.](IB|BC)$' AND ip.FloatRateUnderlyingCode = 'FR007' THEN ABS((IFNULL(fr.ReferencePrice, 0) + ip.InterestRateDefault) * 100 - blr.WeightedAvgRate) END AS DiffAbs, + CASE + WHEN fp.UnderlyingCode REGEXP '^[0-9]+[.](IB|BC)$' AND ip.FloatRateUnderlyingCode = 'FR007' AND ABS((IFNULL(fr.ReferencePrice, 0) + ip.InterestRateDefault) * 100 - blr.WeightedAvgRate) > @Threshold THEN 1 + ELSE 0 + END AS IsGreaterThanThreshold +FROM swap_position fp +LEFT JOIN swap_position ip + ON ip.SwapTradeId = fp.SwapTradeId + AND ip.IsInitial = fp.IsInitial + AND ip.Invalid = fp.Invalid + AND ip.PosiDirection = 0 + AND ip.PositionType = 0 + AND (ip.UnderlyingCode IS NULL OR ip.UnderlyingCode = '') + AND ip.InterestMode = 9 +LEFT JOIN trade t + ON t.id = fp.SwapTradeId +LEFT JOIN eod_commodity_future_price fr + ON fr.FutureContractId = ip.FloatRateUnderlyingCode + AND fr.ValueDate = ( + SELECT MAX(e.ValueDate) + FROM eod_commodity_future_price e + WHERE e.FutureContractId = ip.FloatRateUnderlyingCode + AND e.ValueDate <= t.TradeDate + ) +LEFT JOIN eod_bond_lending_rate blr + ON blr.UnderlyingSecurityId = SUBSTRING_INDEX(fp.UnderlyingCode, '.', 1) + AND fp.UnderlyingCode REGEXP '^[0-9]+[.](IB|BC)$' + AND blr.ValueDate = ( + SELECT MAX(b.ValueDate) + FROM eod_bond_lending_rate b + WHERE b.UnderlyingSecurityId = SUBSTRING_INDEX(fp.UnderlyingCode, '.', 1) + AND fp.UnderlyingCode REGEXP '^[0-9]+[.](IB|BC)$' + AND b.ValueDate <= t.TradeDate + ) +WHERE fp.SwapTradeId = @TradeId + AND fp.IsInitial = 1 + AND fp.Invalid = 0 + AND fp.PosiDirection > 0 + AND fp.PositionType = 2 +ORDER BY DiffAbs DESC, fp.id ASC, ip.id ASC; +``` + +--- + +## 29. 规则 18 查询结果排查 SQL + +规则 18:账户授权收支方向不匹配(本地)。用于核对账户授权的收支方向与交易浮动端多空方向是否匹配;不符合允许场景时命中。 + +校验内容: + +```text +只允许利息端收取且浮动端空头或者利息端支付且浮动端多头。 +不符合以下场景则不通过: +1. 利息端 = 收取,且浮动端 = 空头 +2. 利息端 = 支付,且浮动端 = 多头 +``` + +取数流程: + +```text +1. 根据 TradeId 查 swap_position。 +2. 限定 IsInitial=1、Invalid=0、PosiDirection>0,取浮动端初始持仓记录,取 PositionType 作为浮动端多空方向,1=多头,2=空头。 +3. 参考规则16、17的同次录入口径,再取同一 TradeId、同为初始有效、PosiDirection=0、PositionType=0、UnderlyingCode 为空、InterestMode=9 的普通利息端记录,取 InterestDirection 作为利息端收支方向,1=收取,2=支付。 +4. 利息端可能有多条,需将所有浮动端与所有普通利息端逐条组合检查。 +5. 任一组合不是“利息端收取且浮动端空头”,也不是“利息端支付且浮动端多头”,则命中。 +``` + +规则公式: + +```text +NOT ((InterestDirection = 1 AND PositionType = 2) OR (InterestDirection = 2 AND PositionType = 1)) +``` + +规则字段口径: + +```text +利息端收支方向:普通利息端 swap_position.InterestDirection,1=收取,2=支付 +浮动端多空方向:浮动端 swap_position.PositionType,1=多头,2=空头 +浮动端记录:swap_position.IsInitial=1、Invalid=0、PosiDirection>0 +普通利息端记录:swap_position.IsInitial=1、Invalid=0、PosiDirection=0、PositionType=0、UnderlyingCode 为空、InterestMode=9 +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000018, +// RuleName = "账户授权收支方向不匹配(本地)", +// RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、PosiDirection>0 的浮动端初始持仓记录,PositionType 表示浮动端多空方向,1=多头、2=空头;参考规则16、17的同次录入口径,再取同一 TradeId、同为初始有效、PosiDirection=0、PositionType=0、UnderlyingCode 为空、InterestMode=9 的普通利息端记录,InterestDirection 表示利息端收支方向,1=收取、2=支付。计算逻辑:将所有浮动端与所有普通利息端逐条组合检查,仅允许利息端=收取且浮动端=空头,或利息端=支付且浮动端=多头;任一组合不符合则触发审批。", +// RuleExpr = "", +// Version = 1, +// Status = RiskRuleStatus.Active, +// OptId = 0, +// OptName = "system", +// OptDate = DateTime.Now, +// UpdateOptId = 0, +// UpdateOptName = "system", +// UpdateDate = DateTime.Now +//}); +``` + +变量形式: + +变量名:账户授权收支方向不匹配 +DataType:Boolean +ValueDomain:true/false +规则配置:账户授权收支方向不匹配 为是 + +变量取值表达式: + +```csharp +var floatItems = DbContext.swap_position + .Where(p => p.SwapTradeId == TradeId + && p.IsInitial + && !p.Invalid + && p.PosiDirection > 0) + .Select(p => new + { + p.id, + p.PosiDirection, + p.PositionType + }) + .ToList(); + +if (!floatItems.Any()) + throw new Exception("浮动端初始持仓记录不存在"); + +var interestItems = DbContext.swap_position + .Where(p => p.SwapTradeId == TradeId + && p.IsInitial + && !p.Invalid + && p.PosiDirection == 0 + && p.PositionType == 0 + && string.IsNullOrEmpty(p.UnderlyingCode) + && p.InterestMode == 9) + .Select(p => new + { + p.id, + p.InterestMode, + p.InterestDirection + }) + .ToList(); + +if (!interestItems.Any()) + throw new Exception("同次录入的普通利息端记录不存在"); + +var directionItems = floatItems + .SelectMany(floatItem => interestItems.Select(interestItem => new + { + FloatPositionId = floatItem.id, + floatItem.PosiDirection, + floatItem.PositionType, + PositionTypeName = floatItem.PositionType == 1 ? "多头" : floatItem.PositionType == 2 ? "空头" : "未知:" + floatItem.PositionType, + InterestPositionId = interestItem.id, + interestItem.InterestMode, + interestItem.InterestDirection, + InterestDirectionName = interestItem.InterestDirection == 1 ? "收取" : interestItem.InterestDirection == 2 ? "支付" : "未知:" + interestItem.InterestDirection, + IsMismatch = !((interestItem.InterestDirection == 1 && floatItem.PositionType == 2) || (interestItem.InterestDirection == 2 && floatItem.PositionType == 1)) + })) + .OrderByDescending(p => p.IsMismatch) + .ThenBy(p => p.FloatPositionId) + .ThenBy(p => p.InterestPositionId) + .ToList(); + +var mismatchItems = directionItems + .Where(p => p.IsMismatch) + .ToList(); + +var detailItems = directionItems + .Select(p => new RiskVariableValueDetailItem( + p.IsMismatch, + $"浮动端记录ID为{p.FloatPositionId},PosiDirection为{p.PosiDirection},浮动端为{p.PositionTypeName};普通利息端记录ID为{p.InterestPositionId},InterestMode为{p.InterestMode},利息端为{p.InterestDirectionName},{(p.IsMismatch ? "不符合允许场景" : "符合允许场景")}")) + .ToList(); + +if (mismatchItems.Any()) +{ + var matchedItems = directionItems + .Where(p => !p.IsMismatch) + .ToList(); + + var mismatchMessage = string.Join(";", mismatchItems + .Select(p => $"浮动端记录ID为{p.FloatPositionId},浮动端为{p.PositionTypeName},普通利息端记录ID为{p.InterestPositionId},利息端为{p.InterestDirectionName}")); + + var matchedMessage = matchedItems.Any() + ? string.Join(";", matchedItems.Select(p => $"浮动端记录ID为{p.FloatPositionId},浮动端为{p.PositionTypeName},普通利息端记录ID为{p.InterestPositionId},利息端为{p.InterestDirectionName}")) + : "无"; + + return new RiskVariableValueDetail( + true, + $"交易ID {TradeId} 存在账户授权收支方向不匹配组合。不匹配组合:{mismatchMessage}。匹配组合:{matchedMessage}。允许场景:利息端收取且浮动端空头,或利息端支付且浮动端多头", + detailItems); +} + +var allMatchedMessage = string.Join(";", directionItems + .Select(p => $"浮动端记录ID为{p.FloatPositionId},浮动端为{p.PositionTypeName},普通利息端记录ID为{p.InterestPositionId},利息端为{p.InterestDirectionName}")); + +return new RiskVariableValueDetail( + false, + $"交易ID {TradeId} 的账户授权收支方向均符合允许场景。匹配组合:{allMatchedMessage}。浮动端共{floatItems.Count}条,普通利息端共{interestItems.Count}条,已逐条组合检查", + detailItems); +``` + ```sql SET @TradeId = 3001699; SELECT - sp.id AS SwapPositionId, - sp.SwapTradeId, - sp.IsInitial, - sp.Invalid, - sp.PosiDirection, - sp.InterestDirection, - CASE sp.InterestDirection + fp.id AS FloatPositionId, + ip.id AS InterestPositionId, + fp.SwapTradeId, + fp.IsInitial, + fp.Invalid, + fp.PosiDirection AS FloatPosiDirection, + fp.PositionType AS FloatPositionType, + CASE fp.PositionType + WHEN 1 THEN '多头' + WHEN 2 THEN '空头' + ELSE '未知' + END AS FloatPositionTypeText, + ip.PosiDirection AS InterestPosiDirection, + ip.PositionType AS InterestPositionType, + ip.InterestMode, + ip.InterestDirection, + CASE ip.InterestDirection WHEN 1 THEN '收取' WHEN 2 THEN '支付' ELSE '未知' END AS InterestDirectionText, - sp.PositionType, - CASE sp.PositionType - WHEN 1 THEN '多头' - WHEN 2 THEN '空头' - ELSE '未知' - END AS PositionTypeText, CASE - WHEN (sp.InterestDirection = 1 AND sp.PositionType = 2) - OR (sp.InterestDirection = 2 AND sp.PositionType = 1) THEN 0 + WHEN (ip.InterestDirection = 1 AND fp.PositionType = 2) + OR (ip.InterestDirection = 2 AND fp.PositionType = 1) THEN 0 ELSE 1 END AS IsDirectionMismatch -FROM swap_position sp -WHERE sp.SwapTradeId = @TradeId - AND sp.IsInitial = 1 - AND sp.Invalid = 0 - AND sp.PosiDirection > 0; +FROM swap_position fp +INNER JOIN swap_position ip + ON ip.SwapTradeId = fp.SwapTradeId + AND ip.IsInitial = fp.IsInitial + AND ip.Invalid = fp.Invalid + AND ip.PosiDirection = 0 + AND ip.PositionType = 0 + AND (ip.UnderlyingCode IS NULL OR ip.UnderlyingCode = '') + AND ip.InterestMode = 9 +WHERE fp.SwapTradeId = @TradeId + AND fp.IsInitial = 1 + AND fp.Invalid = 0 + AND fp.PosiDirection > 0 +ORDER BY + IsDirectionMismatch DESC, + fp.id, + ip.id; ``` diff --git a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs index b81e4219..21b2a4c3 100644 --- a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs +++ b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs @@ -6892,7 +6892,8 @@ namespace YLErp.Modules.RiskModule return true; } /// - /// 保存试算结果 + /// 保存试算结果。 + /// 新试算对象按历史快照新增;传入已有ID时仅更新该次试算的说明、来源和操作信息,不承担审批状态流转职责。 /// /// 试算结果 public void SaveQuotaTrial(QuotaTrial obj) @@ -6922,6 +6923,40 @@ namespace YLErp.Modules.RiskModule DbContext.SaveChanges(); } + /// + /// 保存平仓风控试算说明,并保留首次试算时间作为二次确认超时起点。 + /// + /// 平仓风控试算记录ID。 + /// 用户填写的特批说明。 + /// 更新后的平仓风控试算记录。 + public QuotaTrial SaveCloseRiskTrialRemark(int trialDataId, string remark) + { + if (trialDataId <= 0) + { + throw new ServiceException("无效的平仓风控试算记录"); + } + if (string.IsNullOrWhiteSpace(remark)) + { + throw new ServiceException("必须填写说明内容,才可以提交平仓"); + } + + const int closeTrialSource = 2; + var trial = DbContext.quotaTrial.FirstOrDefault(item => item.id == trialDataId); + if (trial == null + || trial.TrialSource != closeTrialSource + || trial.TrialStatus != QuotaTrialStatusEnum.RiskWarning) + { + throw new ServiceException("未找到有效的平仓风控审批记录"); + } + + // 仅保存说明和操作人,不更新OptDate,避免重置首次试算的确认超时时间。 + trial.Remark = remark.Trim(); + trial.OptId = UserId; + trial.OptName = UserName; + DbContext.SaveChanges(); + return trial; + } + } } diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index f5334f51..1e29ffba 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -1979,6 +1979,33 @@ namespace YLErp.Modules.SwapModule SwapCalcTrace.Write(interestTrace); } + /// + /// 单标的平仓 + /// + /// + /// 新风控二次确认信息;首次提交传空。 + /// 本次平仓的新风控试算结果。 + /// + public YLErp.Modules.RiskEngine.RiskTrialResult SwapUnwind(UnwindData unwindData, YLErp.Modules.RiskEngine.RiskTrialConfirmation confirmation) + { + if (unwindData == null) + { + throw new ServiceException("平仓信息不能为空"); + } + + if (FindTrade(unwindData.SwapTradeId) == null) + { + throw new ServiceException("未找到交易信息"); + } + var riskTrialResult = CheckCloseRisk(unwindData, confirmation); + if (!riskTrialResult.Passed) + { + return riskTrialResult; + } + SwapUnwind(unwindData); + return riskTrialResult; + } + /// /// 单标的平仓 /// @@ -2381,6 +2408,29 @@ namespace YLErp.Modules.SwapModule SaveAllChanges(); } + /// + /// 互换/平仓提交审核 + /// + /// + /// + /// 新风控二次确认信息;首次提交传空。 + /// 平仓事件返回新风控试算结果,互换事件返回空。 + /// + public YLErp.Modules.RiskEngine.RiskTrialResult ApplySwapTrade(UnwindData unwindData, int eventType, YLErp.Modules.RiskEngine.RiskTrialConfirmation confirmation) + { + YLErp.Modules.RiskEngine.RiskTrialResult riskTrialResult = null; + if (eventType == (int)SwapEventTypeEnum.平仓) + { + riskTrialResult = CheckCloseRisk(unwindData, confirmation); + if (!riskTrialResult.Passed) + { + return riskTrialResult; + } + } + ApplySwapTrade(unwindData, eventType); + return riskTrialResult; + } + /// /// 互换/平仓提交审核 /// @@ -2436,6 +2486,33 @@ namespace YLErp.Modules.SwapModule } }); } + + /// + /// 执行互换交易平仓审核时点的新风控试算。 + /// + /// 包含本次平仓日期和交易ID的平仓请求。 + /// 新风控二次确认信息;首次提交传空。 + /// 包含阻断、审批、提示及试算记录ID的新风控结果。 + /// 平仓请求为空。 + private YLErp.Modules.RiskEngine.RiskTrialResult CheckCloseRisk(UnwindData unwindData, YLErp.Modules.RiskEngine.RiskTrialConfirmation confirmation) + { + if (unwindData == null) + { + throw new ServiceException("平仓信息不能为空"); + } + + const string triggerPoint = "CLOSE_REVIEW"; + const int closeTrialSource = 2; + var riskContext = new YLErp.Modules.RiskEngine.RiskContext + { + TradeId = unwindData.SwapTradeId, + TriggerPoint = triggerPoint, + UnwindDate = unwindData.UnwindDate, + PayDate = unwindData.PayDate + }; + return new YLErp.Modules.RiskEngine.RiskTrialService(this).CheckRisk(riskContext, closeTrialSource, confirmation); + } + private void ValidateIncomeValueDate(UnwindData unwindData, trade td) { var maxIncomeValueDate = GetMaxIncomeValueDate(td).Date; diff --git a/YLErpWeb/Controllers/SwapTrade2Controller.cs b/YLErpWeb/Controllers/SwapTrade2Controller.cs index 7dbb0d69..85960fc0 100644 --- a/YLErpWeb/Controllers/SwapTrade2Controller.cs +++ b/YLErpWeb/Controllers/SwapTrade2Controller.cs @@ -13,6 +13,8 @@ using YLErp.DBModels; using YLErp.DBModels.Consts; using YLErp.Enums; using YLErp.Model.Enum; +using YLErp.Modules.RiskEngine; +using YLErp.Modules.RiskModule; using YLErp.Modules.SalesModule; using YLErp.Modules.SwapModule; using YLErp.Modules.SwapModule.Dto; @@ -445,12 +447,14 @@ namespace YLErp.Web.Controllers /// ///单标的 平仓 /// - /// - /// - public JsonResult SwapUnwindJson(UnwindData unwindData) + /// 本次单标的平仓数据。 + /// 首次新风控试算记录ID;二次确认时传入。 + /// 首次试算返回的需忽略规则ID;二次确认时传入。 + /// 平仓结果或新风控确认信息。 + public JsonResult SwapUnwindJson(UnwindData unwindData, int? trialDataId = null, string ignoreRiskRuleIds = null) { - new SwapDealService(CurUser).SwapUnwind(unwindData); - return JsonSuccess("平仓成功"); + var result = new SwapDealService(CurUser).SwapUnwind(unwindData, CreateRiskTrialConfirmation(trialDataId, ignoreRiskRuleIds)); + return BuildCloseRiskResult(result, "平仓成功"); } /// /// 互换 @@ -465,13 +469,112 @@ namespace YLErp.Web.Controllers /// /// 互换/平仓提交申请 /// - /// - /// - /// - public JsonResult ApplyUnwind(UnwindData unwindData, int eventType) + /// 本次互换或平仓数据。 + /// 事件类型。 + /// 首次新风控试算记录ID;平仓二次确认时传入。 + /// 首次试算返回的需忽略规则ID;二次确认时传入。 + /// 提交结果或新风控确认信息。 + public JsonResult ApplyUnwind(UnwindData unwindData, int eventType, int? trialDataId = null, string ignoreRiskRuleIds = null) { - new SwapDealService(CurUser).ApplySwapTrade(unwindData, eventType); - return JsonSuccess("提交成功"); + var result = new SwapDealService(CurUser).ApplySwapTrade(unwindData, eventType, CreateRiskTrialConfirmation(trialDataId, ignoreRiskRuleIds)); + return BuildCloseRiskResult(result, "提交成功"); + } + + /// + /// 保存平仓风控特批说明,不改变首次试算时间。 + /// 平仓二次确认会重新执行风控并生成新的 quotaTrial 快照,本接口只补充首次试算记录的用户说明和审计日志。 + /// + /// 平仓风控试算记录ID。 + /// 用户填写的特批说明。 + /// 保存结果。 + public JsonResult SaveCloseRiskTrialRemark(int trialDataId, string remark) + { + try + { + var trial = new QuotaMonitorService(CurUser) + .SaveCloseRiskTrialRemark(trialDataId, remark); + new TradeRiskCheckLogService(CurUser).AddLog(trial); + return JsonSuccess(); + } + catch (Exception ex) + { + LogFactory.GetLogger("SaveCloseRiskTrialRemark").Error(ex); + return JsonError("保存平仓风控试算说明失败!"); + } + } + + /// + /// 根据首次试算记录ID创建平仓新风控二次确认参数。 + /// + /// 首次新风控试算记录ID。 + /// 首次试算返回的需忽略规则ID,多个ID使用逗号分隔。 + /// 首次提交返回空,二次确认返回包含有效期和需忽略规则ID的确认参数。 + private static RiskTrialConfirmation CreateRiskTrialConfirmation(int? trialDataId, string ignoreRiskRuleIds) + { + if (!trialDataId.HasValue) + { + return null; + } + + var expireSeconds = 300; + var expireSecondsConfig = AppManager.GetAppConfigValue("ProjectConfig", "Trade.RiskWarningConfirmExpireSeconds"); + if (!string.IsNullOrWhiteSpace(expireSecondsConfig) + && int.TryParse(expireSecondsConfig, out var configuredExpireSeconds) + && configuredExpireSeconds > 0) + { + expireSeconds = configuredExpireSeconds; + } + return new RiskTrialConfirmation + { + TrialDataId = trialDataId.Value, + ExpireSeconds = expireSeconds, + IgnoreRiskRuleIds = ParseIgnoreRiskRuleIds(ignoreRiskRuleIds) + }; + } + + private static List ParseIgnoreRiskRuleIds(string ignoreRiskRuleIds) + { + return (ignoreRiskRuleIds ?? string.Empty) + .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(ruleId => ruleId.Trim()) + .Where(ruleId => !string.IsNullOrWhiteSpace(ruleId)) + .Distinct() + .ToList(); + } + + /// + /// 将平仓新风控结果转换为控制器统一JSON响应。 + /// + /// 新风控试算结果;互换事件不执行平仓风控时为空。 + /// 业务执行成功后的提示。 + /// 阻断、二次确认或业务成功响应。 + private JsonResult BuildCloseRiskResult(RiskTrialResult result, string successMessage) + { + if (result == null || result.Passed) + { + return JsonSuccess(successMessage, result?.ShowTip == true ? new { result.TrialDataId, result.Message } : null); + } + if (result.Blocked) + { + // 风控阻断属于可预期的业务结果,返回成功协议供前端打开只读试算详情,不进入通用请求失败分支。 + return JsonSuccessData(new + { + proccessType = "QuotaTrialError", + result.TrialDataId, + message = string.IsNullOrWhiteSpace(result.Message) + ? "平仓风控校验未通过" + : result.Message + }); + } + return JsonSuccessData(new + { + proccessType = "AdditionalProcessing", + source = "RiskWarning", + type = "RiskWarningConfirm", + result.TrialDataId, + ignoreRiskRuleIds = result.ApprovalRuleIds, + message = result.ConfirmationExpired ? $"原风控确认已超时,请重新确认。{result.Message}" : result.Message + }); } /// /// 框架合约保存 diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js index 346174c4..77fe6c88 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js @@ -619,22 +619,75 @@ const vue = new Vue({ postUrl = "/swaptrade2/ApplyUnwind"; postData.eventType = 2;//互换3,平仓2 } - main.confirm(msg, - function () { - //重新计算百分比 - var thisObj2 = thisObj; - main.post(postUrl, postData).done(function (res) { - if (res.success) { - thisObj2.closetrade_cashWindow(); + // 提交平仓并处理新风控阻断、二次确认和成功提示。 + var submitClose = function (trialDataId, ignoreRiskRuleIds) { + var requestData = _.cloneDeep(postData); + if (trialDataId) { + requestData.trialDataId = trialDataId; + } + if (ignoreRiskRuleIds && ignoreRiskRuleIds.length > 0) { + requestData.ignoreRiskRuleIds = ignoreRiskRuleIds.join(','); + } + main.post(postUrl, requestData).done(function (res) { + var riskData = res.obj; + if (riskData && riskData.proccessType === "QuotaTrialError") { + // 禁止类规则只展示试算详情,不允许继续提交平仓。 + if (riskData.TrialDataId) { + layer.open({ + type: 2, + title: "风控试算详情", + shadeClose: false, + shade: 0.4, + area: ['800px', '500px'], + content: "/trade/showQuotaTrial?id=" + riskData.TrialDataId, + btn: ["关闭"] + }); } - else { - try { - thisObj2.closetrade_cashWindow(); - } catch (e) { + else if (res.msg) { + main.message(res.msg); + } + return; + } + if (res.success && riskData && riskData.proccessType === "AdditionalProcessing" && riskData.TrialDataId) { + // 审批类规则由用户填写说明并确认,二次提交回传试算记录ID和需忽略规则ID。 + var riskTrialLayerIndex = layer.open({ + type: 2, + title: "风控试算详情", + shadeClose: false, + shade: 0.4, + area: ['800px', '500px'], + content: "/trade/showQuotaTrial?id=" + riskData.TrialDataId, + btn: ["交易特批", "取消"], + yes: function (index) { + var trialPage = window["layui-layer-iframe" + index].page; + if (!trialPage.Data.Remark || trialPage.Data.Remark.length <= 0) { + main.message("必须填写说明内容,才可以提交平仓"); + return; + } + // 平仓使用专用接口且只提交记录ID和说明,避免客户端覆盖试算来源或刷新首次试算时间。 + main.post("/SwapTrade2/SaveCloseRiskTrialRemark", { + trialDataId: riskData.TrialDataId, + remark: trialPage.Data.Remark + }).done(function () { + layer.close(riskTrialLayerIndex); + submitClose(riskData.TrialDataId, riskData.ignoreRiskRuleIds || []); + }); } + }); + return; + } + if (res.success) { + // 提示类规则不阻断平仓,成功后展示本次风控提示。 + if (riskData && riskData.TrialDataId && riskData.Message) { + main.message(riskData.Message); } - }); + thisObj.closetrade_cashWindow(); + } }); + }; + main.confirm(msg, function () { + submitClose(); + }); }, getSumbitText: function () { return g_isShowReCheckClose ? "审核提交" : "保存";