using Newtonsoft.Json; using Qdp.Foundation.Utilities; using System.Reflection; using YLErp.BLL; using YLErp.Model; /* ================================================================================ 风控引擎服务 — RiskEngineService 技术方案与当前实现说明 ================================================================================ 【项目背景】 当前 TRS 系统已在 QuotaMonitorService 中接入第一版风控引擎,用于在交易关键 时点执行可配置规则判断。当前目标不是一次性做成最终版,而是在最小可运行链路 跑通后,逐步演进为可由前端配置、后端预编译、执行期直接命中的正式版本。 【当前实现口径】(以本注释和实际代码为准) 核心概念: - RiskRule(风控规则):规则定义本体,当前重点字段包括 Id、RuleName、 RuleText、RuleExpr、Version、CompiledScript。 - RiskRuleApplication(规则应用):与规则分离,承载启用状态、控制策略、触发时点、 适用范围(全局 / 账户 / 客户 / 标的类型 / 合约类型)。 - RuleExpr:当前唯一主编译入口。要求内容是 Roslyn 可直接执行的 C# bool 表达式, 例如直接访问 DataMap["trade"] 后做数值或日期比较。 - ConditionJson:当前已在规则定义中保留,但不参与主执行链路。 - RiskContext:一次风控检查的数据上下文,包含 TradeId、TriggerPoint 和 DataMap。 - RuleCompiledCache:进程内编译结果缓存,按规则 Id 缓存 Func。 当前编译流程: 1. 外部准备好规则对象,在 RuleExpr 中直接写最终可执行表达式 2. 调用 RuleCompiler.ValidateAndCompileRule(rule) 3. 内部使用 Roslyn 编译 RuleExpr,生成 Func 4. 编译成功后写入 rule.CompiledScript,并同步写入 RuleCompiledCache 5. 执行阶段优先从 RuleCompiledCache 取委托执行 当前执行流程(EvaluateRisk): 1. 从数据库加载规则列表和应用列表 2. 按应用状态、TriggerPoints 过滤有效应用 3. 按应用范围过滤交易是否命中(全局 / 账户 / 客户 / 标的类型 / 合约类型) 4. 根据应用配置中的 RuleIds 找到对应规则 Id 并关联规则 5. 优先从 RuleCompiledCache 读取已编译委托,未命中时兜底编译一次 6. 执行规则委托,按 ControlStrategy 聚合为 Blocked / NeedApproval / Warnings 7. 返回 RiskResult 当前维度匹配规则: - 全局:ScopeIsGlobal=true 时直接命中 - 同一维度内多选:并集(OR) - 不同维度之间:交集(AND) - 某维度留空:视为该维度不限制 【与早期方案的主要差异】 - 当前不是 Content 解析或表达式树主导,而是 RuleExpr 直编译主导 - RiskRule 与 RiskRuleApplication 当前仍是分离模型,没有合并 - 编译器文件已放入 RiskEngine/Compile 目录下 - 当前已引入 RuleCompileResult、RuleCompiledCache,用于校验结果与进程内缓存 - Rule2 这类“左值与右值都来自对象字段”的规则,当前通过 RuleExpr 直接表达 【当前代码结构】 风控引擎层:YLErpDAL/Modules/RiskEngine/ - RiskEngineService.cs:执行入口,负责规则筛选、应用过滤、委托执行、结果聚合 - RiskRule.cs:规则定义模型,含 CompiledScript 运行时字段 - RiskRuleApplication.cs:规则应用模型,承载策略、触发点和适用范围 - RiskContext.cs / RiskResult.cs:执行上下文与结果模型 编译相关:YLErpDAL/Modules/RiskEngine/Compile/ - RuleCompiler.cs:Roslyn 编译入口,提供 ValidateAndCompileFormula / ValidateAndCompileRule - RuleCompileResult.cs:编译结果模型 - RuleCompiledCache.cs:编译结果缓存 当前集成点: - QuotaMonitorService.cs:构造 RiskContext,并将 trade 对象放入 DataMap["trade"] 【当前进度】(截至 2026-06-24) ✅ 已完成: 1. RiskEngine 第一版执行链路已跑通:QuotaMonitorService -> RiskEngineService -> RuleCompiler 2. 风控上下文通过 DataMap 传入 trade 对象,支持规则脚本直接访问交易字段 3. RuleExpr 直编译方案已接入,支持数值比较和日期比较 4. Application 通用维度匹配已支持:全局 / 账户 / 客户 / 标的类型 / 合约类型 5. 维度组合逻辑已按文档确认:同维度 OR,不同维度 AND 6. RuleCompiler 已支持校验 + 编译 + 写缓存 7. RuleCompiledCache / RuleCompileResult 已落地到 Compile 目录 8. RiskEngineService 已改为执行时优先从 RuleCompiledCache 读取委托 【待办事项 / TODO】 ⬜ 1. 接入真实规则来源 - 由数据库或前端提交替换当前 LoadRules / LoadApplications 的规则来源 ⬜ 2. 增加接口层 - 提供前端提交 RuleExpr 后的校验接口 - 提供规则保存 / 发布后预编译并写缓存的入口 ⬜ 3. 启动预热与缓存刷新 - 服务启动时批量加载有效规则并预编译 - 支持规则更新后的缓存刷新 / 删除 ⬜ 4. 完善执行期策略 - 当前保留“缓存未命中时兜底编译”逻辑 - 后续可收紧为“执行期只读缓存,未命中按发布失败处理” ⬜ 5. 补测试样例 - 数值比较、日期比较、维度过滤、非法公式、边界值 ⬜ 6. 完善 RuleExpr 配套能力 - 提供前端可用的表达式编辑、校验与错误提示 - 约束可用变量、类型转换和脚本安全边界 【注意事项】 1. 当前 RuleExpr 必须是 Roslyn 最终可执行的 C# bool 表达式,不是业务语义短句 2. DataMap 中的值由宿主业务代码准备,编译器与执行器本身不负责查库补数 3. 当前缓存的是 Func,这是规则判断函数,不是事件处理器 4. 业务可预期失败优先走结果返回,不要把高频校验失败都设计成异常 ================================================================================ */ namespace YLErp.Modules.RiskEngine { /// /// 风控引擎服务(第一版 — 骨架版,先跑通) /// public class RiskEngineService : YLBaseService { IYcLogger _logger = LogFactory.GetLogger("RiskEngineService"); private static readonly Lazy _instance = new Lazy(() => new RiskEngineService()); public static RiskEngineService GetInstance() => _instance.Value; private RiskEngineService() : base(OptUserInfo.SystemUser) { } public RiskEngineService(OptUserInfo userInfo) : base(userInfo) { } public RiskEngineService(YLBaseService baseService) : base(baseService) { } public RiskEngineService(OptUserInfo optUser, YLContext dbContext) : base(optUser, dbContext) { } /// /// 规则内存缓存(启动预热写入,EvaluateRisk 读取) /// private static volatile List _cachedRules; private static volatile List _cachedApplications; private static readonly object _cacheLock = new object(); /// /// 预热:加载规则与应用到内存,并预编译所有规则到 RuleCompiledCache。 /// 项目启动时调用一次;规则/应用更新后调用 RefreshCache 刷新。 /// public void Preload() { lock (_cacheLock) { _logger.Info("[风控引擎] Preload 开始 - 加载规则与应用并预编译"); var rules = LoadRulesFromDb(); var applications = LoadApplicationsFromDb(); // 预编译所有规则到 RuleCompiledCache foreach (var rule in rules) { var ruleId = rule.Id.ToString(); //已存在编译缓存的规则直接跳过,避免重复编译 if (RuleCompiledCache.TryGet(ruleId, out _)) { _logger.Info($"[风控引擎] 规则已存在编译缓存,跳过预编译 - RuleId: {rule.Id}"); continue; } //非活跃的rule不编译 if (rule.Status != RiskRuleStatus.Active) { _logger.Info($"[风控引擎] 规则非活跃,跳过预编译 - RuleId: {rule.Id}, Status: {rule.Status}"); continue; } var compileResult = RuleCompiler.ValidateAndCompileRule(rule); if (!compileResult.Success) { _logger.Info($"[风控引擎] 规则预编译失败 - RuleId: {rule.Id}, Error: {compileResult.ErrorMessage}"); } } _cachedRules = rules; _cachedApplications = applications; _logger.Info($"[风控引擎] Preload 完成 - 规则数: {rules.Count}, 应用数: {applications.Count}"); } } /// /// 刷新缓存:清空已编译委托与内存缓存后重新预加载。 /// 规则/应用配置更新后调用。 /// public void RefreshCache() { _logger.Info("[风控引擎] RefreshCache 被调用 - 清空缓存并重新预加载"); lock (_cacheLock) { _cachedRules = null; _cachedApplications = null; RuleCompiledCache.Clear(); } Preload(); } /// /// 从内存缓存获取规则列表;缓存为空时兜底加载并填充缓存。 /// private List GetRules() { var rules = _cachedRules; if (rules != null) { return rules; } lock (_cacheLock) { if (_cachedRules != null) { return _cachedRules; } var loaded = LoadRulesFromDb(); _cachedRules = loaded; return loaded; } } /// /// 从内存缓存获取应用列表;缓存为空时兜底加载并填充缓存。 /// private List GetApplications() { var applications = _cachedApplications; if (applications != null) { return applications; } lock (_cacheLock) { if (_cachedApplications != null) { return _cachedApplications; } var loaded = LoadApplicationsFromDb(); _cachedApplications = loaded; return loaded; } } /// /// 执行风控检查(最终设计版:预编译委托 + 规则筛选 + 策略判定) /// /// 风控上下文 /// 触发时点,如 BOOK_CONFIRM /// 风控结果 public RiskResult EvaluateRisk(RiskContext context, string triggerPoint) { var result = new RiskResult(); try { _logger.Info($"[风控引擎] EvaluateRisk 开始 - TradeId: {context?.TradeId}, TriggerPoint: {triggerPoint}"); // ============================================================ // Step 1: 从内存缓存读取规则定义和规则应用(启动时已预热) // ============================================================ var rules = GetRules(); var applications = GetApplications(); _logger.Info($"[风控引擎] 加载规则数: {rules.Count}, 应用数: {applications.Count}"); // ============================================================ // Step 2: 先按 Application 过滤 Active 状态和 TriggerPoint 匹配的应用 // ============================================================ var activeApps = applications .Where(a => a.Status == RiskRuleStatus.Active) .ToList(); var apps = activeApps .Where(a => !string.IsNullOrEmpty(a.TriggerPoints)) .ToList(); var triggerMatchedApps = apps .Where(a => a.TriggerPoints.Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(s => s.Trim()) .Contains(triggerPoint)) .ToList(); // 再按应用范围过滤。 // 统一走通用维度匹配: // 1. 全局命中时直接通过; // 2. 同一维度内多选按并集处理; // 3. 不同维度之间按交集处理; // 4. 某维度留空表示该维度不限制。 var trade = context?.DataMap != null && context.DataMap.ContainsKey("trade") ? context.DataMap["trade"] as YLErp.DBModels.trade : null; var matchedApplications = triggerMatchedApps .Where(a => IsApplicationMatched(a, trade)) .ToList(); _logger.Info($"[风控引擎] 匹配 TriggerPoint 的应用数: {matchedApplications.Count}"); // ============================================================ // Step 3: 遍历匹配的应用,通过 RuleIds 关联规则并执行预编译委托 // ============================================================ foreach (var application in matchedApplications) { // ------------------------------------------------------------ // 3.1 通过 RuleIds 关联规则定义 // ------------------------------------------------------------ var applicationRuleIds = ParseRuleIds(application.RuleIds); if (!applicationRuleIds.Any()) { _logger.Info($"[风控引擎] 应用未配置有效规则 - RuleIds: {application.RuleIds}"); continue; } var applicationRules = rules .Where(r => applicationRuleIds.Contains(r.Id) && r.Status == RiskRuleStatus.Active) .ToList(); // 区分"规则不存在"与"规则非活跃"两种情况,分别记录日志 var ruleDict = rules.Where(r => applicationRuleIds.Contains(r.Id)) .ToDictionary(r => r.Id); foreach (var ruleId in applicationRuleIds) { if (!ruleDict.TryGetValue(ruleId, out var ruleDef)) { _logger.Info($"[风控引擎] 未找到对应规则定义 - RuleId: {ruleId}, ApplicationRuleIds: {application.RuleIds}"); } else if (ruleDef.Status != RiskRuleStatus.Active) { _logger.Info($"[风控引擎] 规则非活跃,跳过执行 - RuleId: {ruleId}, Status: {ruleDef.Status}, ApplicationRuleIds: {application.RuleIds}"); } } foreach (var rule in applicationRules) { var ruleId = rule.Id.ToString(); // ------------------------------------------------------------ // 3.2 优先从编译缓存读取规则委托 // ------------------------------------------------------------ if (!RuleCompiledCache.TryGet(ruleId, out var compiledScript)) { _logger.Info($"[风控引擎] 缓存中未命中已编译规则,执行兜底编译 - RuleId: {rule.Id}"); var compileResult = RuleCompiler.ValidateAndCompileRule(rule); if (!compileResult.Success) { _logger.Info($"[风控引擎] 规则编译失败 - RuleId: {rule.Id}, Error: {compileResult.ErrorMessage}"); continue; } compiledScript = compileResult.CompiledScript; } // ------------------------------------------------------------ // 3.3 执行预编译委托 // ------------------------------------------------------------ bool triggered = false; try { triggered = compiledScript(context); _logger.Info($"[风控引擎] 规则执行 - RuleId: {rule.Id}, Triggered: {triggered}"); } catch (Exception ex) { _logger.Info($"[风控引擎] 规则执行异常 - RuleId: {rule.Id}, Error: {ex.Message}"); continue; } // ------------------------------------------------------------ // 3.4 命中后按 Application 的控制策略聚合结果 // ------------------------------------------------------------ if (triggered) { _logger.Info($"[风控引擎] 规则触发 - RuleId: {rule.Id}, Strategy: {application.ControlStrategy}"); switch (application.ControlStrategy) { case RiskControlStrategy.Block: result.Blocked = true; result.Passed = false; result.TriggeredRules.Add(new TriggeredRuleInfo { RuleId = rule.Id.ToString(), RuleName = rule.RuleName, ControlStrategy = RiskControlStrategy.Block, RuleText = rule.RuleText, Message = $"规则[{rule.RuleName}]触发:禁止" }); break; case RiskControlStrategy.Approval: result.NeedApproval = true; result.Passed = false; result.TriggeredRules.Add(new TriggeredRuleInfo { RuleId = ruleId, RuleName = rule.RuleName, ControlStrategy = RiskControlStrategy.Approval, RuleText = rule.RuleText, Message = $"规则[{rule.RuleName}]触发:需审批" }); break; case RiskControlStrategy.ShowTip: result.ShowTip = true; result.TriggeredRules.Add(new TriggeredRuleInfo { RuleId = ruleId, RuleName = rule.RuleName, ControlStrategy = RiskControlStrategy.ShowTip, RuleText = rule.RuleText, Message = $"规则[{rule.RuleName}]触发:提示" }); break; default: _logger.Info($"[风控引擎] 未知策略类型 - ControlStrategy: {application.ControlStrategy}"); break; } } else { _logger.Info($"[风控引擎] 规则未触发 - RuleId: {rule.Id}, TriggerPoint: {triggerPoint}"); } } } // ============================================================ // Step 4: 聚合最终结果 // ============================================================ if (!result.Blocked && !result.NeedApproval) { result.Passed = true; } else { result.Passed = false; } _logger.Info($"[风控引擎] EvaluateRisk 完成 - TradeId: {context?.TradeId}, Passed: {result.Passed}, Blocked: {result.Blocked}, NeedApproval: {result.NeedApproval}, TriggeredRules: {result.TriggeredRules.Count}"); } catch (Exception ex) { result.NeedApproval = true; result.Passed = false; result.TriggeredRules.Add(new TriggeredRuleInfo { RuleId = "ENGINE_ERROR", RuleName = "风控引擎执行异常", RuleText = ex.Message, Message = $"风控引擎异常:{ex.Message}" }); _logger.Error($"[风控引擎] EvaluateRisk 异常 - TradeId: {context?.TradeId}, Error: {ex.Message}"); } return result; } /// /// 从数据库加载规则列表 /// private List LoadRulesFromDb() { var rules = DbContext.glms_risk_rule .AsNoTracking() .OrderByDescending(r => r.UpdateDate ?? r.OptDate) .Select(r => new RiskRule { Status = r.Status, Id = r.id, RuleName = r.RuleName, RuleText = r.RuleText, ConditionJson = r.ConditionJson, RuleExpr = r.RuleExpr, Version = r.Version, OptId = r.OptId ?? 0, OptName = r.OptName, OptDate = r.OptDate ?? DateTime.MinValue, UpdateOptId = r.UpdateOptId ?? 0, UpdateOptName = r.UpdateOptName, UpdateDate = r.UpdateDate ?? r.OptDate ?? DateTime.MinValue }).ToList() ; return rules; //#region 测试本地规则 //rules.Add(new RiskRule //{ // Id = 1000001, // RuleName = "挂钩标的集中度校验(本地)", // RuleText = "取值字段:分子取 QuotaMonitorService 已注入 DataMap[same_underlying_total_notional],该值按存续交易口径汇总同一标的 StockEqvNotional;分母取 DataMap[underlying_manager].IssueSize,债券标的场景下该值由 underlying_manager.ExJson 反序列化回填,含义按当前业务测试口径使用发行量(亿)。为什么这么取:当前上下文已经稳定注入了这两个值,且与集中度规则最接近正式口径。计算逻辑:同一标的总名义本金 ÷ 发行量 × 100%,发行量需先乘 100000000 还原为元,结果大于 30% 时触发审批。", // RuleExpr = "DataMap.ContainsKey(\"same_underlying_total_notional\") && DataMap[\"same_underlying_total_notional\"] != null && DataMap.ContainsKey(\"underlying_manager\") && DataMap[\"underlying_manager\"] != null && ((YLErp.DBModels.underlying_manager)DataMap[\"underlying_manager\"]).IssueSize.HasValue && ((YLErp.DBModels.underlying_manager)DataMap[\"underlying_manager\"]).IssueSize.Value > 0 && Convert.ToDecimal(DataMap[\"same_underlying_total_notional\"]) / (((YLErp.DBModels.underlying_manager)DataMap[\"underlying_manager\"]).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 = 1000004, // RuleName = "保证金支付比例超阈值(本地)", // RuleText = "取值字段:直接取 DataMap[trade].MarginRate,对应 trade 表保证金率字段。为什么这么取:该字段已经在当前上下文稳定注入,且 seed 规则 4 的判断核心也是保证金比例。计算逻辑:本地测试按数值型比例直接比较,MarginRate 大于 0.5 视为超过 50%,触发审批。", // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).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 = "取值字段:正式口径应取收益互换扩展数据中的保证金利率字段,但当前 RiskContext 未注入 trade_swap,因此本地测试先取 DataMap[trade].MarginRate 近似代替。为什么这么取:当前上下文只有 trade 可直接取值,先保证规则链路可验证。计算逻辑:若近似保证金利率不在 2% 到 5% 区间内,即小于 0.02 或大于 0.05,则触发审批。", // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((((YLErp.DBModels.trade)DataMap[\"trade\"]).MarginRate < 0.02) || (((YLErp.DBModels.trade)DataMap[\"trade\"]).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 = "取值字段:正式口径应取配置项 MinMarginRate 或客户/品种最低保证金率,当前上下文未注入配置对象,因此本地测试仍取 DataMap[trade].MarginRate 做比较。为什么这么取:trade.MarginRate 是当前唯一稳定可得且能反映保证金比例的字段。计算逻辑:先以 20% 作为本地测试最低标准,MarginRate 小于 0.2 时触发审批,后续接入正式配置后再替换阈值来源。", // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).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 = "取值字段:取 DataMap[trade].StartDate,对应 trade 表开始日。为什么这么取:seed 规则 7 直接使用 StartDate 与当前日期比较,当前上下文也已注入 trade。计算逻辑:StartDate 有值且日期早于系统当天 DateTime.Today 时触发审批。", // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).StartDate.HasValue && ((YLErp.DBModels.trade)DataMap[\"trade\"]).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 = "取值字段:取 DataMap[trade].SettlementDate,对应 trade 表结算日期。为什么这么取:当前代码中支付相关日期可直接从 trade 取得,且项目已有 QdpCalendarHelper.GetNonHolidayDefore 可用于交易日校验。计算逻辑:若 SettlementDate 有值,且向前修正到最近交易日后的结果不等于原日期,则说明原日期不是银行间交易日,触发审批。", // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).SettlementDate.HasValue && QdpCalendarHelper.GetNonHolidayDefore(((YLErp.DBModels.trade)DataMap[\"trade\"]).SettlementDate.Value.Date) != ((YLErp.DBModels.trade)DataMap[\"trade\"]).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 = "取值字段:取 DataMap[trade].ExerciseDate,对应当前交易里更接近业务到期/行权日的字段。为什么这么取:TradeBase 中 MaturityDate 注释已提示容易与 ExerciseDate 混淆,当前测试按交易实际到期处理字段 ExerciseDate 落地,避免先取错口径。计算逻辑:ExerciseDate 有值且向前修正到最近交易日后的结果不等于原日期时,视为不是银行间交易日,触发审批。", // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).ExerciseDate.HasValue && QdpCalendarHelper.GetNonHolidayDefore(((YLErp.DBModels.trade)DataMap[\"trade\"]).ExerciseDate.Value.Date) != ((YLErp.DBModels.trade)DataMap[\"trade\"]).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 = "取值字段:取 DataMap[trade].UnWindDate,对应 trade 表平仓日。为什么这么取:当前上下文已注入该字段,且 seed 规则中的平仓日判断在本地最接近该口径。计算逻辑:UnWindDate 有值且向前修正到最近交易日后的结果不等于原日期时,视为不是银行间交易日,触发审批。", // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).UnWindDate.HasValue && QdpCalendarHelper.GetNonHolidayDefore(((YLErp.DBModels.trade)DataMap[\"trade\"]).UnWindDate.Value.Date) != ((YLErp.DBModels.trade)DataMap[\"trade\"]).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 = "取值字段:取 DataMap[trade].StartDate 和 DataMap[trade].ExerciseDate。为什么这么取:seed 规则 11 本质是计算合约剩余天数,当前 trade 中最稳定可得且最接近交易起止区间的就是开始日和到期/行权日。计算逻辑:当 StartDate 和 ExerciseDate 都有值时,用 ExerciseDate.Date 减 StartDate.Date 的总天数,若大于 365 天则触发审批。", // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).StartDate.HasValue && ((YLErp.DBModels.trade)DataMap[\"trade\"]).ExerciseDate.HasValue && (((YLErp.DBModels.trade)DataMap[\"trade\"]).ExerciseDate.Value.Date - ((YLErp.DBModels.trade)DataMap[\"trade\"]).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 = "取值字段:当前上下文未注入债券估值净价 market.CBValuationPrice,因此本地测试先取 DataMap[trade].SpotPrice 作为可直接获得的价格字段。为什么这么取:债券类正式估值未接入前,需要用交易上已有价格先验证偏离类规则链路。计算逻辑:若 SpotPrice 有值且不为 0,则按 ABS(SpotPrice-100)/100×100% 计算相对面值 100 的偏离率,偏离率大于 5% 时触发审批。", // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).SpotPrice.HasValue && Math.Abs(((YLErp.DBModels.trade)DataMap[\"trade\"]).SpotPrice.Value - 100d) / 100d * 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 = 1000013, // RuleName = "债券类收益率偏离(本地)", // RuleText = "取值字段:正式口径应比较 DataMap[trade].InitYtm 与市场估值收益率 market.CBValuationYtm,但当前未注入 market,因此本地测试直接取 trade.InitYtm。为什么这么取:InitYtm 是 trade 上已有且与收益率偏离最接近的字段。计算逻辑:先以 2.5% 作为本地测试基准收益率,若 InitYtm 有值且 ABS(InitYtm-0.025)/0.025×100% 大于 5%,则触发审批。", // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).InitYtm.HasValue && Math.Abs((((YLErp.DBModels.trade)DataMap[\"trade\"]).InitYtm.Value - 0.025d) / 0.025d) * 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 = 1000014, // RuleName = "非债券类价格偏离(本地)", // RuleText = "取值字段:正式口径应比较 trade.TradePrice 与市场参考价 market.ReferencePrice,当前未注入 market,因此本地测试直接取 trade.TradePrice 与 trade.SpotPrice 互相比对。为什么这么取:这两个字段都来自 trade,且能够表达成交价相对现价的偏离。计算逻辑:当 TradePrice 和 SpotPrice 都有值且 SpotPrice 不为 0 时,按 ABS(TradePrice/SpotPrice-1)×100% 计算偏离率,大于 5% 时触发审批。", // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).TradePrice.HasValue && ((YLErp.DBModels.trade)DataMap[\"trade\"]).SpotPrice.HasValue && ((YLErp.DBModels.trade)DataMap[\"trade\"]).SpotPrice.Value != 0 && Math.Abs((((YLErp.DBModels.trade)DataMap[\"trade\"]).TradePrice.Value / ((YLErp.DBModels.trade)DataMap[\"trade\"]).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 = 1000015, // RuleName = "单一交易对手累计标的数量超阈值(本地)", // RuleText = "取值字段:取 DataMap[trade].ClientId 作为交易对手标识,并在表达式里直接查询 trade 表的 UnderlyingId。为什么这么取:当前上下文尚未预先注入该聚合值,但 DbContext 在脚本环境可用,且项目已有存续口径可以复用。计算逻辑:按 ValidState 不等于 InValid、ParentTradeId 等于 0、TradeStatus 属于 NeedMarginTradeStatusList 或审批中 的存续口径,统计同一 ClientId 下去重后的 UnderlyingId 数量,超过 10 个时触发审批。", // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && DbContext.trade.Where(t => t.ValidState != \"InValid\" && t.ClientId == ((YLErp.DBModels.trade)DataMap[\"trade\"]).ClientId && t.ParentTradeId == 0 && (ConsTrade.NeedMarginTradeStatusList.Contains(t.TradeStatus) || t.TradeStatus == \"审批中\")).Select(t => t.UnderlyingId).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 = "取值字段:正式口径应同时取 trade_swap 固定端方向、固定利率以及市场基准利率,当前未注入 trade_swap 和 market,因此本地测试先取 DataMap[trade].FixedRate,并结合 BuySell 判断多头方向。为什么这么取:FixedRate 和 BuySell 都已在 trade 上可取,能先验证方向类利率规则链路。计算逻辑:当 BuySell 表示多头且 FixedRate 有值时,先以 2.5% 作为本地测试基准,若 ABS(FixedRate-0.025)/0.025×100% 大于 5%,则触发审批。", // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).BuySell == \"Buy\" && ((YLErp.DBModels.trade)DataMap[\"trade\"]).FixedRate.HasValue && Math.Abs((((YLErp.DBModels.trade)DataMap[\"trade\"]).FixedRate.Value - 0.025d) / 0.025d) * 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 = 1000017, // RuleName = "空头利率减点借贷加权偏离(本地)", // RuleText = "取值字段:正式口径应取 trade_swap 空头端利率减点、借贷成本和加权基准,当前未注入这些对象,因此本地测试仍取 DataMap[trade].FixedRate,并结合 BuySell 判断空头方向。为什么这么取:当前 trade 上只有 FixedRate 可稳定表达利率数值,先用于验证空头分支规则。计算逻辑:当 BuySell 表示空头且 FixedRate 有值时,先以 2% 作为本地测试基准,若 ABS(FixedRate-0.02)/0.02×100% 大于 5%,则触发审批。", // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).BuySell == \"Sell\" && ((YLErp.DBModels.trade)DataMap[\"trade\"]).FixedRate.HasValue && Math.Abs((((YLErp.DBModels.trade)DataMap[\"trade\"]).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 = "取值字段:正式口径应取账户授权配置中的收支方向和当前交易实际收支方向,当前上下文未注入账户授权对象,因此本地测试先用 trade.OpponentRole 与 trade.BuySell 做占位判断。为什么这么取:当前只有 trade 上的方向类字段可直接取得,先用于验证禁止类规则是否能命中。计算逻辑:当 OpponentRole 和 BuySell 都有值,且出现本地定义的不允许组合时触发禁止;当前测试口径先将 OpponentRole 为 Pay 且 BuySell 为 Buy 视为方向不匹配。", // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && !string.IsNullOrWhiteSpace(((YLErp.DBModels.trade)DataMap[\"trade\"]).OpponentRole) && !string.IsNullOrWhiteSpace(((YLErp.DBModels.trade)DataMap[\"trade\"]).BuySell) && ((YLErp.DBModels.trade)DataMap[\"trade\"]).OpponentRole == \"Pay\" && ((YLErp.DBModels.trade)DataMap[\"trade\"]).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 = "取值字段:取 DataMap[trade].Strike 和 DataMap[trade].SpotPrice,分别对应行权价与现价。为什么这么取:当前项目已有使用 Strike 与 SpotPrice 做偏离判断的场景,且这两个字段都已在 trade 上可直接获取。计算逻辑:当 Strike 和 SpotPrice 都有值且 SpotPrice 不为 0 时,按 ABS(Strike/SpotPrice-1)×100% 计算执行价相对现价的偏离率,大于 5% 时触发审批。", // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).Strike.HasValue && ((YLErp.DBModels.trade)DataMap[\"trade\"]).SpotPrice.HasValue && ((YLErp.DBModels.trade)DataMap[\"trade\"]).SpotPrice.Value != 0 && Math.Abs((((YLErp.DBModels.trade)DataMap[\"trade\"]).Strike.Value / ((YLErp.DBModels.trade)DataMap[\"trade\"]).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 = "取值字段:正式口径应取产品条款中的敲入价/敲出价以及现价,当前上下文未注入条款对象,因此本地测试先取 DataMap[trade].Strike 和 DataMap[trade].SpotPrice 近似模拟触发价与现价。为什么这么取:这两个字段当前即可直接取得,适合先验证提示类规则链路。计算逻辑:当 Strike 和 SpotPrice 都有值且 Strike 不为 0 时,按 ABS(SpotPrice/Strike-1)×100% 计算两者距离,距离小于等于 2% 时视为接近触发价,给出提示。", // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).Strike.HasValue && ((YLErp.DBModels.trade)DataMap[\"trade\"]).SpotPrice.HasValue && ((YLErp.DBModels.trade)DataMap[\"trade\"]).Strike.Value != 0 && Math.Abs((((YLErp.DBModels.trade)DataMap[\"trade\"]).SpotPrice.Value / ((YLErp.DBModels.trade)DataMap[\"trade\"]).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 } private List LoadApplicationsFromDb() { var applications = DbContext.glms_risk_rule_application .AsNoTracking() .OrderByDescending(a => a.UpdateDate ?? a.OptDate) .Select(a => new RiskRuleApplication { Id = a.id, RuleIds = a.RuleIds, Status = a.Status, ControlStrategy = a.ControlStrategy, TriggerPoints = a.TriggerPoints, ScopeIsGlobal = a.ScopeIsGlobal, ScopeAssetBookIds = a.ScopeAssetBookIds, ScopeClientIds = a.ScopeClientIds, ScopeUnderlyingTypes = a.ScopeUnderlyingTypes, ScopeTradeTypes = a.ScopeTradeTypes, Version = a.Version, OptId = a.OptId ?? 0, OptName = a.OptName, OptDate = a.OptDate ?? DateTime.MinValue, UpdateOptId = a.UpdateOptId ?? 0, UpdateOptName = a.UpdateOptName, UpdateDate = a.UpdateDate ?? a.OptDate ?? DateTime.MinValue }) .ToList(); return applications; } // applications.Add(new RiskRuleApplication // { // Id = 1000001, // RuleIds = "1000001", // Status = RiskRuleStatus.Active, // ControlStrategy = RiskControlStrategy.Approval, // TriggerPoints = "BOOK_CONFIRM", // ScopeIsGlobal = true, // ScopeAssetBookIds = string.Empty, // ScopeClientIds = string.Empty, // ScopeUnderlyingTypes = string.Empty, // ScopeTradeTypes = string.Empty, // Version = 1, // OptId = 0, // OptName = "system", //} /// /// 判断应用配置是否命中当前交易。 /// 匹配规则遵循设计文档: /// 1. 全局命中时直接返回 true; /// 2. 同一维度内多选按并集处理; /// 3. 不同维度之间按交集处理; /// 4. 某维度留空表示该维度不限制。 /// private bool IsApplicationMatched(RiskRuleApplication application, YLErp.DBModels.trade trade) { if (application == null) { return false; } if (application.ScopeIsGlobal == 1) { return true; } if (trade == null) { return false; } var accountMatched = IsScopeEmpty(application.ScopeAssetBookIds) || IsValueMatched(application.ScopeAssetBookIds, GetTradeAssetBookId(trade)); var clientMatched = IsScopeEmpty(application.ScopeClientIds) || IsValueMatched(application.ScopeClientIds, trade.ClientId); var underlyingTypeMatched = IsScopeEmpty(application.ScopeUnderlyingTypes) || IsValueMatched(application.ScopeUnderlyingTypes, GetTradeUnderlyingType(trade)); var tradeTypeMatched = IsScopeEmpty(application.ScopeTradeTypes) || IsValueMatched(application.ScopeTradeTypes, trade.TradeType); return accountMatched && clientMatched && underlyingTypeMatched && tradeTypeMatched; } /// /// 解析应用配置中的规则ID列表。 /// 多个规则ID使用逗号分隔,返回去空格后的 long 集合。 /// private List ParseRuleIds(string ruleIds) { if (string.IsNullOrWhiteSpace(ruleIds)) { return new List(); } var ids = new List(); foreach (var part in ruleIds.Split(',', StringSplitOptions.RemoveEmptyEntries)) { if (int.TryParse(part.Trim(), out int id)) { ids.Add(id); } } return ids; } /// /// 判断某个范围字段是否为空。 /// 为空表示该维度不限制。 /// private bool IsScopeEmpty(string scopeValue) { return string.IsNullOrWhiteSpace(scopeValue); } /// /// 判断单个值是否命中逗号分隔的范围配置。 /// 同一维度内多选按并集处理,只要命中任一值即返回 true。 /// private bool IsValueMatched(string scopeValue, object currentValue) { if (string.IsNullOrWhiteSpace(scopeValue)) { return true; } if (currentValue == null) { return false; } var currentText = currentValue.ToString()?.Trim(); if (string.IsNullOrWhiteSpace(currentText)) { return false; } return scopeValue .Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(s => s.Trim()) .Any(s => string.Equals(s, currentText, StringComparison.OrdinalIgnoreCase)); } /// /// 读取交易的资产簿账户ID。 /// 当前先按 BookId 取值;如果后续真实字段不是 BookId,再统一调整这里即可。 /// private object GetTradeAssetBookId(YLErp.DBModels.trade trade) { var property = trade.GetType().GetProperty("BookId"); return property?.GetValue(trade); } /// /// 读取交易的标的类型。 /// 当前先按 UnderlyingInstrumentType 取值;如果后续真实字段名不同,再统一调整这里即可。 /// private object GetTradeUnderlyingType(YLErp.DBModels.trade trade) { var property = trade.GetType().GetProperty("UnderlyingInstrumentType"); return property?.GetValue(trade); } } }