1121 lines
60 KiB
C#
1121 lines
60 KiB
C#
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 表达式,
|
||
例如通过 DbContext 和 TradeId 查询数据库后做数值或日期比较。
|
||
- ConditionJson:结构化规则的主执行依据;有值时优先由结构化执行器执行。
|
||
- RiskContext:一次风控检查的数据上下文,包含 TradeId、TriggerPoint 和 DbContext。
|
||
- RuleCompiledCache:进程内编译结果缓存,仅用于自由文本 RuleExpr 规则,按规则 Id 缓存 Func<RiskContext, bool>。
|
||
|
||
当前编译流程:
|
||
1. 结构化规则以 ConditionJson 为执行依据,预热时只做 JSON 结构解析校验
|
||
2. 自由文本规则在 RuleExpr 中直接写最终可执行表达式
|
||
3. 调用 RuleCompiler.ValidateAndCompileRule(rule)
|
||
4. 内部使用 Roslyn 编译 RuleExpr,生成 Func<RiskContext, bool>
|
||
5. 编译成功后写入 rule.CompiledScript,并同步写入 RuleCompiledCache
|
||
|
||
当前执行流程(EvaluateRisk):
|
||
1. 从数据库加载规则列表和应用列表
|
||
2. 按应用状态、TriggerPoints 过滤有效应用
|
||
3. 按应用范围过滤交易是否命中(全局 / 账户 / 客户 / 标的类型 / 合约类型)
|
||
4. 根据应用配置中的 RuleIds 找到对应规则 Id 并关联规则
|
||
5. ConditionJson 有值时走结构化执行器;否则从 RuleCompiledCache 读取已编译委托,未命中时兜底编译一次
|
||
6. 执行规则判断,按 ControlStrategy 聚合为 Blocked / NeedApproval / Warnings
|
||
7. 返回 RiskResult
|
||
|
||
当前维度匹配规则:
|
||
- 全局:ScopeIsGlobal=true 时直接命中
|
||
- 同一维度内多选:并集(OR)
|
||
- 不同维度之间:交集(AND)
|
||
- 某维度留空:视为该维度不限制
|
||
|
||
【与早期方案的主要差异】
|
||
- 结构化规则已改为 ConditionJson 执行主导,自由文本规则继续保留 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,并传入当前 TradeId
|
||
|
||
【当前进度】(截至 2026-06-24)
|
||
✅ 已完成:
|
||
1. RiskEngine 第一版执行链路已跑通:QuotaMonitorService -> RiskEngineService -> RuleCompiler
|
||
2. 风控上下文提供 DbContext 和 TradeId,支持规则脚本直接查询数据库
|
||
3. 结构化规则已支持 ConditionJson 执行,自由文本规则保留 RuleExpr 直编译
|
||
4. Application 通用维度匹配已支持:全局 / 账户 / 客户 / 标的类型 / 合约类型
|
||
5. 维度组合逻辑已按文档确认:同维度 OR,不同维度 AND
|
||
6. RuleCompiler 已支持校验 + 编译 + 写缓存
|
||
7. RuleCompiledCache / RuleCompileResult 已落地到 Compile 目录
|
||
8. RiskEngineService 已改为结构化规则优先执行 ConditionJson,自由文本规则再读取 RuleCompiledCache
|
||
|
||
【待办事项 / TODO】
|
||
⬜ 1. 接入真实规则来源
|
||
- 由数据库或前端提交替换当前 LoadRules / LoadApplications 的规则来源
|
||
⬜ 2. 增加接口层
|
||
- 提供前端提交 RuleExpr 后的校验接口
|
||
- 提供规则保存 / 发布后预编译并写缓存的入口
|
||
⬜ 3. 启动预热与缓存刷新
|
||
- 服务启动时批量加载有效规则并预编译
|
||
- 支持规则更新后的缓存刷新 / 删除
|
||
⬜ 4. 完善执行期策略
|
||
- 当前保留“缓存未命中时兜底编译”逻辑
|
||
- 后续可收紧为“执行期只读缓存,未命中按发布失败处理”
|
||
⬜ 5. 补测试样例
|
||
- 数值比较、日期比较、维度过滤、非法公式、边界值
|
||
⬜ 6. 完善 RuleExpr 配套能力
|
||
- 提供前端可用的表达式编辑、校验与错误提示
|
||
- 约束可用变量、类型转换和脚本安全边界
|
||
|
||
【注意事项】
|
||
1. 当前 RuleExpr 必须是 Roslyn 最终可执行的 C# bool 表达式,不是业务语义短句
|
||
2. RuleExpr 可直接使用 DbContext 查询数据库,当前交易通过 TradeId 定位
|
||
3. 当前缓存的是 Func<RiskContext, bool>,这是规则判断函数,不是事件处理器
|
||
4. 业务可预期失败优先走结果返回,不要把高频校验失败都设计成异常
|
||
================================================================================
|
||
*/
|
||
|
||
namespace YLErp.Modules.RiskEngine
|
||
{
|
||
/// <summary>
|
||
/// 风控引擎服务(第一版 — 骨架版,先跑通)
|
||
/// </summary>
|
||
public class RiskEngineService : YLBaseService
|
||
{
|
||
IYcLogger _logger = LogFactory.GetLogger("RiskEngineService");
|
||
|
||
private static readonly Lazy<RiskEngineService> _instance =
|
||
new Lazy<RiskEngineService>(() => 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)
|
||
{
|
||
}
|
||
|
||
/// <summary>
|
||
/// 规则内存缓存(启动预热写入,EvaluateRisk 读取)
|
||
/// </summary>
|
||
private static volatile List<RiskRule> _cachedRules;
|
||
private static volatile List<RiskRuleApplication> _cachedApplications;
|
||
private static readonly object _cacheLock = new object();
|
||
|
||
/// <summary>
|
||
/// 预热:加载规则与应用到内存,并预编译自由文本规则到 RuleCompiledCache。
|
||
/// 结构化规则运行时直接执行 ConditionJson,预热时只做轻量结构校验,不再预编译 RuleExpr。
|
||
/// </summary>
|
||
public void Preload()
|
||
{
|
||
lock (_cacheLock)
|
||
{
|
||
_logger.Info("[风控引擎] Preload 开始 - 加载规则与应用并预编译");
|
||
|
||
var rules = LoadRulesFromDb();
|
||
var applications = LoadApplicationsFromDb();
|
||
// 仅预编译自由文本规则;结构化规则运行时由 ConditionJson 执行。
|
||
foreach (var rule in rules)
|
||
{
|
||
var ruleId = rule.Id.ToString();
|
||
//非活跃的rule不编译
|
||
if (rule.Status != RiskRuleStatus.Active)
|
||
{
|
||
RuleCompiledCache.Remove(rule.Id.ToString());
|
||
_logger.Info($"[风控引擎] 规则非活跃,已移除预编译缓存 - RuleId: {rule.Id}, Status: {rule.Status}");
|
||
continue;
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(rule.ConditionJson))
|
||
{
|
||
RuleCompiledCache.Remove(rule.Id.ToString());
|
||
try
|
||
{
|
||
RuleConditionExpressionBuilder.DeserializeConditions(rule.ConditionJson);
|
||
_logger.Info($"[风控引擎] 结构化规则预热校验成功,跳过 RuleExpr 预编译 - RuleId: {rule.Id}");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.Info($"[风控引擎] 结构化规则预热校验失败 - RuleId: {rule.Id}, Error: {ex.Message}");
|
||
}
|
||
continue;
|
||
}
|
||
|
||
var compileResult = RuleCompiler.ValidateAndCompileRule(rule);
|
||
if (compileResult.Success)
|
||
{
|
||
_logger.Info($"[风控引擎] 规则预编译成功 - RuleId: {rule.Id}");
|
||
}
|
||
else
|
||
{
|
||
_logger.Info($"[风控引擎] 规则预编译失败 - RuleId: {rule.Id}, Error: {compileResult.ErrorMessage}");
|
||
}
|
||
}
|
||
|
||
_cachedRules = rules;
|
||
_cachedApplications = applications;
|
||
|
||
_logger.Info($"[风控引擎] Preload 完成 - 规则数: {rules.Count}, 应用数: {applications.Count}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 刷新缓存:刷新规则与应用内存缓存,预编译成功时覆盖旧编译缓存。
|
||
/// 规则/应用配置更新后调用。
|
||
/// </summary>
|
||
public void RefreshCache()
|
||
{
|
||
_logger.Info("[风控引擎] RefreshCache 被调用 - 刷新规则与应用缓存并重新预加载");
|
||
lock (_cacheLock)
|
||
{
|
||
_cachedRules = null;
|
||
_cachedApplications = null;
|
||
RuleCompiledCache.Clear();
|
||
}
|
||
|
||
Preload();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 刷新单条规则缓存:重新加载规则列表,只编译指定规则。
|
||
/// </summary>
|
||
public RuleCompileResult RefreshOneRuleCache(long ruleId)
|
||
{
|
||
lock (_cacheLock)
|
||
{
|
||
_logger.Info($"[风控引擎] RefreshOneRuleCache 开始 - RuleId: {ruleId}");
|
||
// 从数据库加载最新规则列表
|
||
_cachedRules = LoadRulesFromDb();
|
||
|
||
var rule = _cachedRules.FirstOrDefault(r => r.Id == ruleId);
|
||
if (rule == null)
|
||
{
|
||
RuleCompiledCache.Remove(ruleId.ToString());
|
||
_logger.Info($"[风控引擎] RefreshOneRuleCache 未找到规则,已移除缓存 - RuleId: {ruleId}");
|
||
return RuleCompileResult.Fail("规则不存在或已删除");
|
||
}
|
||
|
||
if (rule.Status != RiskRuleStatus.Active)
|
||
{
|
||
RuleCompiledCache.Remove(ruleId.ToString());
|
||
_logger.Info($"[风控引擎] RefreshOneRuleCache 规则非活跃,已移除缓存 - RuleId: {ruleId}, Status: {rule.Status}");
|
||
return RuleCompileResult.Fail("规则不存在或非活跃状态");
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(rule.ConditionJson))
|
||
{
|
||
RuleCompiledCache.Remove(ruleId.ToString());
|
||
try
|
||
{
|
||
RuleConditionExpressionBuilder.DeserializeConditions(rule.ConditionJson);
|
||
_logger.Info($"[风控引擎] RefreshOneRuleCache 结构化规则校验成功,跳过 RuleExpr 编译 - RuleId: {ruleId}");
|
||
return RuleCompileResult.Ok(null);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.Info($"[风控引擎] RefreshOneRuleCache 结构化规则校验失败 - RuleId: {ruleId}, Error: {ex.Message}");
|
||
return RuleCompileResult.Fail(ex.Message);
|
||
}
|
||
}
|
||
|
||
var compileResult = RuleCompiler.ValidateAndCompileRule(rule);
|
||
_logger.Info($"[风控引擎] RefreshOneRuleCache 完成 - RuleId: {ruleId}, Success: {compileResult.Success}, Error: {compileResult.ErrorMessage}");
|
||
return compileResult;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 仅刷新规则应用列表,不清空规则编译缓存。
|
||
/// </summary>
|
||
public void RefreshApplication()
|
||
{
|
||
lock (_cacheLock)
|
||
{
|
||
_cachedApplications = LoadApplicationsFromDb();
|
||
_logger.Info($"[风控引擎] RefreshApplication 完成 - 应用数: {_cachedApplications.Count}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从内存缓存获取规则列表;缓存为空时兜底加载并填充缓存。
|
||
/// </summary>
|
||
private List<RiskRule> GetRules()
|
||
{
|
||
var rules = _cachedRules;
|
||
if (rules != null)
|
||
{
|
||
return rules;
|
||
}
|
||
|
||
lock (_cacheLock)
|
||
{
|
||
if (_cachedRules != null)
|
||
{
|
||
return _cachedRules;
|
||
}
|
||
|
||
var loaded = LoadRulesFromDb();
|
||
_cachedRules = loaded;
|
||
return loaded;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从内存缓存获取应用列表;缓存为空时兜底加载并填充缓存。
|
||
/// </summary>
|
||
private List<RiskRuleApplication> GetApplications()
|
||
{
|
||
var applications = _cachedApplications;
|
||
if (applications != null)
|
||
{
|
||
return applications;
|
||
}
|
||
|
||
lock (_cacheLock)
|
||
{
|
||
if (_cachedApplications != null)
|
||
{
|
||
return _cachedApplications;
|
||
}
|
||
|
||
var loaded = LoadApplicationsFromDb();
|
||
_cachedApplications = loaded;
|
||
return loaded;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 执行风控检查(最终设计版:预编译委托 + 规则筛选 + 策略判定)
|
||
/// </summary>
|
||
/// <param name="context">风控上下文</param>
|
||
/// <param name="triggerPoint">触发时点,如 BOOK_CONFIRM</param>
|
||
/// <returns>风控结果</returns>
|
||
public RiskResult EvaluateRisk(RiskContext context, string triggerPoint)
|
||
{
|
||
var result = new RiskResult();
|
||
|
||
try
|
||
{
|
||
using var ruleDbContext = DbContextFactory.GetYLDbContext();
|
||
ruleDbContext.ChangeTracker.QueryTrackingBehavior = Microsoft.EntityFrameworkCore.QueryTrackingBehavior.NoTracking;
|
||
|
||
context ??= new RiskContext();
|
||
context.TriggerPoint = triggerPoint;
|
||
context.DbContext = ruleDbContext;
|
||
|
||
_logger.Info($"[风控引擎] EvaluateRisk 开始 - TradeId: {context.TradeId}, TriggerPoint: {triggerPoint}");
|
||
|
||
// ============================================================
|
||
// Step 1: 从内存缓存读取规则定义和规则应用(启动时已预热)
|
||
// ============================================================
|
||
var rules = GetRules();
|
||
var applications = GetApplications();
|
||
var variables = new Dictionary<long, glms_risk_variable>();
|
||
_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.TradeId > 0
|
||
? context.DbContext.trade.AsNoTracking().FirstOrDefault(t => t.id == context.TradeId)
|
||
: 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();
|
||
|
||
bool triggered = false;
|
||
if (!string.IsNullOrWhiteSpace(rule.ConditionJson))
|
||
{
|
||
// 结构化规则优先使用 ConditionJson 执行,避免继续把条件整体拼成 Roslyn bool 公式。
|
||
try
|
||
{
|
||
var conditions = RuleConditionExpressionBuilder.DeserializeConditions(rule.ConditionJson);
|
||
var referencedVariableIds = RuleConditionExpressionBuilder.GetReferencedVariableIds(conditions);
|
||
var missingVariableIds = referencedVariableIds
|
||
.Where(id => !variables.ContainsKey(id))
|
||
.ToList();
|
||
if (missingVariableIds.Any())
|
||
{
|
||
// 按本次结构化规则实际引用的变量懒加载,避免每次风控检查全量读取变量池。
|
||
var loadedVariables = ruleDbContext.glms_risk_variable
|
||
.AsNoTracking()
|
||
.Where(v => missingVariableIds.Contains(v.id))
|
||
.ToDictionary(v => v.id);
|
||
foreach (var variable in loadedVariables)
|
||
variables[variable.Key] = variable.Value;
|
||
}
|
||
|
||
var ruleVariables = referencedVariableIds
|
||
.Where(variables.ContainsKey)
|
||
.ToDictionary(id => id, id => variables[id]);
|
||
|
||
var executeResult = StructuredRuleExecutor.Execute(conditions, ruleVariables, context);
|
||
if (!executeResult.Success)
|
||
{
|
||
var errorMessage = $"规则[{rule.RuleName}]执行异常:{executeResult.ErrorMessage}";
|
||
_logger.Error($"[风控引擎] 结构化规则执行异常,按阻断处理 - RuleId: {rule.Id}, Error: {executeResult.ErrorMessage}");
|
||
AddBlockError(result, ruleId, rule.RuleName, rule.RuleText, errorMessage);
|
||
continue;
|
||
}
|
||
|
||
triggered = executeResult.Triggered;
|
||
_logger.Info($"[风控引擎] 结构化规则执行 - RuleId: {rule.Id}, Triggered: {triggered}");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
var errorMessage = $"规则[{rule.RuleName}]执行异常:{ex.Message}";
|
||
_logger.Error($"[风控引擎] 结构化规则执行异常,按阻断处理 - RuleId: {rule.Id}, Error: {ex.Message}");
|
||
AddBlockError(result, ruleId, rule.RuleName, rule.RuleText, errorMessage);
|
||
continue;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// ------------------------------------------------------------
|
||
// 3.2 优先从编译缓存读取规则委托
|
||
// ------------------------------------------------------------
|
||
if (!RuleCompiledCache.TryGet(ruleId, out var compiledScript))
|
||
{
|
||
_logger.Info($"[风控引擎] 缓存中未命中已编译规则,执行兜底编译 - RuleId: {rule.Id}");
|
||
|
||
var compileResult = RuleCompiler.ValidateAndCompileRule(rule);
|
||
if (!compileResult.Success)
|
||
{
|
||
var errorMessage = $"规则[{rule.RuleName}]编译失败,已按阻断处理,请检查规则表达式配置:{compileResult.ErrorMessage}";
|
||
_logger.Info($"[风控引擎] 规则编译失败,按阻断处理 - RuleId: {rule.Id}, Error: {compileResult.ErrorMessage}");
|
||
AddBlockError(result, rule.Id.ToString(), rule.RuleName, rule.RuleText, errorMessage);
|
||
continue;
|
||
}
|
||
|
||
compiledScript = compileResult.CompiledScript;
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// 3.3 执行预编译委托
|
||
// ------------------------------------------------------------
|
||
try
|
||
{
|
||
triggered = compiledScript(context);
|
||
_logger.Info($"[风控引擎] 规则执行 - RuleId: {rule.Id}, Triggered: {triggered}");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
var errorMessage = $"规则[{rule.RuleName}]执行异常:{ex.Message}";
|
||
_logger.Error($"[风控引擎] 规则执行异常,按阻断处理 - RuleId: {rule.Id}, Error: {ex.Message}");
|
||
AddBlockError(result, ruleId, rule.RuleName, rule.RuleText, errorMessage);
|
||
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)
|
||
{
|
||
var errorMessage = $"风控引擎异常:{ex.Message}";
|
||
AddBlockError(result, "ENGINE_ERROR", "风控引擎执行异常", ex.Message, errorMessage);
|
||
_logger.Error($"[风控引擎] EvaluateRisk 异常 - TradeId: {context?.TradeId}, Error: {ex.Message}");
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
private static void AddBlockError(RiskResult result, string ruleId, string ruleName, string ruleText, string errorMessage)
|
||
{
|
||
result.Blocked = true;
|
||
result.Passed = false;
|
||
result.ErrorMessage = string.IsNullOrWhiteSpace(result.ErrorMessage)
|
||
? errorMessage
|
||
: $"{result.ErrorMessage};{errorMessage}";
|
||
result.TriggeredRules.Add(new TriggeredRuleInfo
|
||
{
|
||
RuleId = ruleId,
|
||
RuleName = ruleName,
|
||
ControlStrategy = RiskControlStrategy.Block,
|
||
RuleText = ruleText,
|
||
Message = errorMessage
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从数据库加载规则列表
|
||
/// </summary>
|
||
private List<RiskRule> LoadRulesFromDb()
|
||
{
|
||
using var dbContext = DbContextFactory.GetYLDbContext();
|
||
|
||
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 = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易;分子查询 trade 表同一标的存续/审批中交易 StockEqvNotional 汇总;分母查询 underlying_manager.ExJson 中债券 IssueSize(亿)。计算逻辑:同一标的总名义本金 ÷ 发行量 × 100%,发行量乘 100000000 还原为元,结果大于 30% 时触发审批。",
|
||
// RuleExpr = "Convert.ToDecimal(DbContext.trade.Where(t => t.ValidState != \"InValid\" && t.UnderlyingId == DbContext.trade.First(x => x.id == TradeId).UnderlyingId && t.ParentTradeId == 0 && (ConsTrade.NeedMarginTradeStatusList.Contains(t.TradeStatus) || t.TradeStatus == \"审批中\")).Sum(t => (double?)t.StockEqvNotional) ?? 0d) / (JsonConvert.DeserializeObject<YLErp.DBModels.UnderlyingBond>(DbContext.underlying_manager.Where(u => u.UnderlyingCode == DbContext.trade.First(x => x.id == TradeId).UnderlyingCode).Select(u => u.ExJson).FirstOrDefault()).IssueSize.Value * 100000000m) * 100m > 30m",
|
||
// Version = 1,
|
||
// Status = RiskRuleStatus.Active,
|
||
// OptId = 0,
|
||
// OptName = "system",
|
||
// OptDate = DateTime.Now,
|
||
// UpdateOptId = 0,
|
||
// UpdateOptName = "system",
|
||
// UpdateDate = DateTime.Now
|
||
//});
|
||
|
||
//rules.Add(new RiskRule
|
||
//{
|
||
// Id = 1000003,
|
||
// RuleName = "名义本金超阈值(本地)",
|
||
// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 StockEqvNotional,对应 trade 表名义本金字段。计算逻辑:StockEqvNotional 大于 100000000 时触发审批。",
|
||
// RuleExpr = "DbContext.trade.First(t => t.id == TradeId).StockEqvNotional > 100000000",
|
||
// Version = 1,
|
||
// Status = RiskRuleStatus.Active,
|
||
// OptId = 0,
|
||
// OptName = "system",
|
||
// OptDate = DateTime.Now,
|
||
// UpdateOptId = 0,
|
||
// UpdateOptName = "system",
|
||
// UpdateDate = DateTime.Now
|
||
//});
|
||
|
||
//rules.Add(new RiskRule
|
||
//{
|
||
// Id = 1000004,
|
||
// RuleName = "保证金支付比例超阈值(本地)",
|
||
// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 MarginRate,对应 trade 表保证金率字段。计算逻辑:本地测试按数值型比例直接比较,MarginRate 大于 0.5 视为超过 50%,触发审批。",
|
||
// RuleExpr = "DbContext.trade.First(t => t.id == TradeId).MarginRate > 0.5",
|
||
// Version = 1,
|
||
// Status = RiskRuleStatus.Active,
|
||
// OptId = 0,
|
||
// OptName = "system",
|
||
// OptDate = DateTime.Now,
|
||
// UpdateOptId = 0,
|
||
// UpdateOptName = "system",
|
||
// UpdateDate = DateTime.Now
|
||
//});
|
||
|
||
//rules.Add(new RiskRule
|
||
//{
|
||
// Id = 1000005,
|
||
// RuleName = "保证金利率偏离(本地)",
|
||
// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 MarginRate,作为保证金利率本地测试字段。计算逻辑:若 MarginRate 小于 0.02 或大于 0.05,则触发审批。",
|
||
// RuleExpr = "DbContext.trade.First(t => t.id == TradeId).MarginRate < 0.02 || DbContext.trade.First(t => t.id == TradeId).MarginRate > 0.05",
|
||
// Version = 1,
|
||
// Status = RiskRuleStatus.Active,
|
||
// OptId = 0,
|
||
// OptName = "system",
|
||
// OptDate = DateTime.Now,
|
||
// UpdateOptId = 0,
|
||
// UpdateOptName = "system",
|
||
// UpdateDate = DateTime.Now
|
||
//});
|
||
|
||
//rules.Add(new RiskRule
|
||
//{
|
||
// Id = 1000006,
|
||
// RuleName = "保证金收取比例低于最低标准(本地)",
|
||
// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 MarginRate 做本地测试比较。计算逻辑:先以 20% 作为本地测试最低标准,MarginRate 小于 0.2 时触发审批,后续接入正式配置后再替换阈值来源。",
|
||
// RuleExpr = "DbContext.trade.First(t => t.id == TradeId).MarginRate < 0.2",
|
||
// Version = 1,
|
||
// Status = RiskRuleStatus.Active,
|
||
// OptId = 0,
|
||
// OptName = "system",
|
||
// OptDate = DateTime.Now,
|
||
// UpdateOptId = 0,
|
||
// UpdateOptName = "system",
|
||
// UpdateDate = DateTime.Now
|
||
//});
|
||
|
||
//rules.Add(new RiskRule
|
||
//{
|
||
// Id = 1000007,
|
||
// RuleName = "起息日早于当前日期(本地)",
|
||
// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 StartDate,对应 trade 表开始日。计算逻辑:StartDate 有值且日期早于系统当天 DateTime.Today 时触发审批。",
|
||
// RuleExpr = "DbContext.trade.First(t => t.id == TradeId).StartDate.HasValue && DbContext.trade.First(t => t.id == TradeId).StartDate.Value.Date < DateTime.Today",
|
||
// Version = 1,
|
||
// Status = RiskRuleStatus.Active,
|
||
// OptId = 0,
|
||
// OptName = "system",
|
||
// OptDate = DateTime.Now,
|
||
// UpdateOptId = 0,
|
||
// UpdateOptName = "system",
|
||
// UpdateDate = DateTime.Now
|
||
//});
|
||
|
||
//rules.Add(new RiskRule
|
||
//{
|
||
// Id = 1000008,
|
||
// RuleName = "支付日为银行间交易日(本地)",
|
||
// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 SettlementDate,对应 trade 表结算日期;调用 QdpCalendarHelper.GetNonHolidayDefore 做交易日校验。计算逻辑:若 SettlementDate 有值,且向前修正到最近交易日后的结果不等于原日期,则说明原日期不是银行间交易日,触发审批。",
|
||
// RuleExpr = "DbContext.trade.First(t => t.id == TradeId).SettlementDate.HasValue && QdpCalendarHelper.GetNonHolidayDefore(DbContext.trade.First(t => t.id == TradeId).SettlementDate.Value.Date) != DbContext.trade.First(t => t.id == TradeId).SettlementDate.Value.Date",
|
||
// Version = 1,
|
||
// Status = RiskRuleStatus.Active,
|
||
// OptId = 0,
|
||
// OptName = "system",
|
||
// OptDate = DateTime.Now,
|
||
// UpdateOptId = 0,
|
||
// UpdateOptName = "system",
|
||
// UpdateDate = DateTime.Now
|
||
//});
|
||
|
||
//rules.Add(new RiskRule
|
||
//{
|
||
// Id = 1000009,
|
||
// RuleName = "到期日为银行间交易日(本地)",
|
||
// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 ExerciseDate,对应当前交易里更接近业务到期/行权日的字段;调用 QdpCalendarHelper.GetNonHolidayDefore 做交易日校验。计算逻辑:ExerciseDate 有值且向前修正到最近交易日后的结果不等于原日期时,视为不是银行间交易日,触发审批。",
|
||
// RuleExpr = "DbContext.trade.First(t => t.id == TradeId).ExerciseDate.HasValue && QdpCalendarHelper.GetNonHolidayDefore(DbContext.trade.First(t => t.id == TradeId).ExerciseDate.Value.Date) != DbContext.trade.First(t => t.id == TradeId).ExerciseDate.Value.Date",
|
||
// Version = 1,
|
||
// Status = RiskRuleStatus.Active,
|
||
// OptId = 0,
|
||
// OptName = "system",
|
||
// OptDate = DateTime.Now,
|
||
// UpdateOptId = 0,
|
||
// UpdateOptName = "system",
|
||
// UpdateDate = DateTime.Now
|
||
//});
|
||
|
||
//rules.Add(new RiskRule
|
||
//{
|
||
// Id = 1000010,
|
||
// RuleName = "平仓日为银行间交易日(本地)",
|
||
// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 UnWindDate,对应 trade 表平仓日;调用 QdpCalendarHelper.GetNonHolidayDefore 做交易日校验。计算逻辑:UnWindDate 有值且向前修正到最近交易日后的结果不等于原日期时,视为不是银行间交易日,触发审批。",
|
||
// RuleExpr = "DbContext.trade.First(t => t.id == TradeId).UnWindDate.HasValue && QdpCalendarHelper.GetNonHolidayDefore(DbContext.trade.First(t => t.id == TradeId).UnWindDate.Value.Date) != DbContext.trade.First(t => t.id == TradeId).UnWindDate.Value.Date",
|
||
// Version = 1,
|
||
// Status = RiskRuleStatus.Active,
|
||
// OptId = 0,
|
||
// OptName = "system",
|
||
// OptDate = DateTime.Now,
|
||
// UpdateOptId = 0,
|
||
// UpdateOptName = "system",
|
||
// UpdateDate = DateTime.Now
|
||
//});
|
||
|
||
//rules.Add(new RiskRule
|
||
//{
|
||
// Id = 1000011,
|
||
// RuleName = "合约期限超阈值(本地)",
|
||
// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 StartDate 和 ExerciseDate。计算逻辑:当 StartDate 和 ExerciseDate 都有值时,用 ExerciseDate.Date 减 StartDate.Date 的总天数,若大于 365 天则触发审批。",
|
||
// RuleExpr = "DbContext.trade.First(t => t.id == TradeId).StartDate.HasValue && DbContext.trade.First(t => t.id == TradeId).ExerciseDate.HasValue && (DbContext.trade.First(t => t.id == TradeId).ExerciseDate.Value.Date - DbContext.trade.First(t => t.id == TradeId).StartDate.Value.Date).TotalDays > 365d",
|
||
// Version = 1,
|
||
// Status = RiskRuleStatus.Active,
|
||
// OptId = 0,
|
||
// OptName = "system",
|
||
// OptDate = DateTime.Now,
|
||
// UpdateOptId = 0,
|
||
// UpdateOptName = "system",
|
||
// UpdateDate = DateTime.Now
|
||
//});
|
||
|
||
//rules.Add(new RiskRule
|
||
//{
|
||
// Id = 1000012,
|
||
// RuleName = "债券类净价偏离(本地)",
|
||
// RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、PosiDirection=2 且有标的代码的浮动支付端 PosiNetNoFeePrice 和 UnderlyingCode,PosiNetNoFeePrice 对应债券类标的期初交割净价,库内为 1 左右原值;通过 DbContext.china_bond_valuation 按该浮动支付端标的和交易日前日期优先取 credibility=1 的上一收盘日 net_price,库内为 100 左右报价。计算逻辑:按 ABS(PosiNetNoFeePrice×100-net_price) 计算绝对价差,价差大于 5 元时触发审批。",
|
||
// RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).PosiNetNoFeePrice.Value * 100m - DbContext.china_bond_valuation.Where(v => v.bond_id == DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).UnderlyingCode && v.valuation_date < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderBy(v => v.credibility).ThenByDescending(v => v.valuation_date).First().net_price.Value) > 5m",
|
||
// Version = 1,
|
||
// Status = RiskRuleStatus.Active,
|
||
// OptId = 0,
|
||
// OptName = "system",
|
||
// OptDate = DateTime.Now,
|
||
// UpdateOptId = 0,
|
||
// UpdateOptName = "system",
|
||
// UpdateDate = DateTime.Now
|
||
//});
|
||
|
||
//rules.Add(new RiskRule
|
||
//{
|
||
// Id = 1000013,
|
||
// RuleName = "债券类收益率偏离(本地)",
|
||
// RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、PosiDirection=2 且有标的代码的浮动支付端 InitYtm 和 UnderlyingCode,InitYtm 对应债券类标的期初成交收益率,库内为原值;通过 DbContext.china_bond_valuation 按该浮动支付端标的和交易日前日期优先取 credibility=1 的上一收盘日 yield,库内为 1.5 到 2.2 左右百分数。计算逻辑:按 ABS(InitYtm×100-yield) 计算收益率绝对差,差值大于 1 时触发审批。",
|
||
// RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).InitYtm.Value * 100m - DbContext.china_bond_valuation.Where(v => v.bond_id == DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).UnderlyingCode && v.valuation_date < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderBy(v => v.credibility).ThenByDescending(v => v.valuation_date).First().yield.Value) > 1m",
|
||
// Version = 1,
|
||
// Status = RiskRuleStatus.Active,
|
||
// OptId = 0,
|
||
// OptName = "system",
|
||
// OptDate = DateTime.Now,
|
||
// UpdateOptId = 0,
|
||
// UpdateOptName = "system",
|
||
// UpdateDate = DateTime.Now
|
||
//});
|
||
|
||
//rules.Add(new RiskRule
|
||
//{
|
||
// Id = 1000014,
|
||
// RuleName = "非债券类价格偏离(本地)",
|
||
// RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、PosiDirection=2 且有标的代码的浮动支付端 PosiGrossPrice 和 UnderlyingCode,PosiGrossPrice 对应普通收益互换页面填写的期初标的价格,库内为 1 左右原值;通过 DbContext.eod_commodity_future_price 按该浮动支付端标的和交易日前日期取上一日收盘价 ClosePrice。注意:eod_commodity_future_price 模型属性 UnderlyingCode 实际映射数据库列 FutureContractId,数据库排查时应使用 FutureContractId 与 swap_position.UnderlyingCode 关联。计算逻辑:按 ABS(PosiGrossPrice×100-ClosePrice) 计算绝对价差,价差大于 5 时触发审批。",
|
||
// RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).PosiGrossPrice * 100m - Convert.ToDecimal(DbContext.eod_commodity_future_price.Where(e => e.UnderlyingCode == DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).UnderlyingCode && e.ValueDate < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderByDescending(e => e.ValueDate).First().ClosePrice)) > 5m",
|
||
// Version = 1,
|
||
// Status = RiskRuleStatus.Active,
|
||
// OptId = 0,
|
||
// OptName = "system",
|
||
// OptDate = DateTime.Now,
|
||
// UpdateOptId = 0,
|
||
// UpdateOptName = "system",
|
||
// UpdateDate = DateTime.Now
|
||
//});
|
||
|
||
//rules.Add(new RiskRule
|
||
//{
|
||
// Id = 1000015,
|
||
// RuleName = "单一交易对手累计标的数量超阈值(本地)",
|
||
// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易对手 ClientId,再查询同一交易对手有效交易对应的实时存续持仓 swap_position.UnderlyingCode 去重数量。实时存续持仓口径:IsInitial=false、PosiQuantity>0、Invalid=false、PosiDirection>0 且 UnderlyingCode 非空。计算逻辑:同一交易对手累计标的数量超过 10 个时触发审批。",
|
||
// RuleExpr= DbContext.swap_position.Where(p => !string.IsNullOrEmpty(p.UnderlyingCode) && !p.IsInitial && p.PosiQuantity > 0 && !p.Invalid && p.PosiDirection > 0 && DbContext.trade.Any(t => t.id == p.SwapTradeId && t.ValidState != "InValid" && t.ClientId == DbContext.trade.First(x => x.id == TradeId).ClientId)).Select(p => p.UnderlyingCode).Distinct().Count() > 10
|
||
// Version = 1,
|
||
// Status = RiskRuleStatus.Active,
|
||
// OptId = 0,
|
||
// OptName = "system",
|
||
// OptDate = DateTime.Now,
|
||
// UpdateOptId = 0,
|
||
// UpdateOptName = "system",
|
||
// UpdateDate = DateTime.Now
|
||
//});
|
||
|
||
//rules.Add(new RiskRule
|
||
//{
|
||
// Id = 1000016,
|
||
// RuleName = "多头支付固定端利率偏离(本地)",
|
||
// RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取利息端收入固定利息方向记录的 InterestRateDefault。InterestRateDefault 只代表利率文本框中 + 号后的点差,不包含 FR007 基准利率,库内为小数原值,界面按百分比显示。计算逻辑:按 ABS(InterestRateDefault×100) 计算点差百分比绝对值,绝对值小于 5 时触发审批。",
|
||
// RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.InterestDirection == 1).InterestRateDefault * 100m) < 5m",
|
||
// Version = 1,
|
||
// Status = RiskRuleStatus.Active,
|
||
// OptId = 0,
|
||
// OptName = "system",
|
||
// OptDate = DateTime.Now,
|
||
// UpdateOptId = 0,
|
||
// UpdateOptName = "system",
|
||
// UpdateDate = DateTime.Now
|
||
//});
|
||
|
||
//rules.Add(new RiskRule
|
||
//{
|
||
// Id = 1000017,
|
||
// RuleName = "空头利率减点借贷加权偏离(本地)",
|
||
// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 FixedRate,并结合 BuySell 判断空头方向。计算逻辑:当 BuySell 表示空头且 FixedRate 有值时,先以 2% 作为本地测试基准,若 ABS(FixedRate-0.02)/0.02×100% 大于 5%,则触发审批。",
|
||
// RuleExpr = "DbContext.trade.First(t => t.id == TradeId).BuySell == \"Sell\" && DbContext.trade.First(t => t.id == TradeId).FixedRate.HasValue && Math.Abs((DbContext.trade.First(t => t.id == TradeId).FixedRate.Value - 0.02d) / 0.02d) * 100d > 5d",
|
||
// Version = 1,
|
||
// Status = RiskRuleStatus.Active,
|
||
// OptId = 0,
|
||
// OptName = "system",
|
||
// OptDate = DateTime.Now,
|
||
// UpdateOptId = 0,
|
||
// UpdateOptName = "system",
|
||
// UpdateDate = DateTime.Now
|
||
//});
|
||
|
||
//rules.Add(new RiskRule
|
||
//{
|
||
// Id = 1000018,
|
||
// RuleName = "账户授权收支方向不匹配(本地)",
|
||
// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 OpponentRole 与 BuySell 做本地测试占位判断。计算逻辑:当 OpponentRole 和 BuySell 都有值,且 OpponentRole 为 Pay 且 BuySell 为 Buy 时视为方向不匹配,触发禁止。",
|
||
// RuleExpr = "!string.IsNullOrWhiteSpace(DbContext.trade.First(t => t.id == TradeId).OpponentRole) && !string.IsNullOrWhiteSpace(DbContext.trade.First(t => t.id == TradeId).BuySell) && DbContext.trade.First(t => t.id == TradeId).OpponentRole == \"Pay\" && DbContext.trade.First(t => t.id == TradeId).BuySell == \"Buy\"",
|
||
// Version = 1,
|
||
// Status = RiskRuleStatus.Active,
|
||
// OptId = 0,
|
||
// OptName = "system",
|
||
// OptDate = DateTime.Now,
|
||
// UpdateOptId = 0,
|
||
// UpdateOptName = "system",
|
||
// UpdateDate = DateTime.Now
|
||
//});
|
||
|
||
//rules.Add(new RiskRule
|
||
//{
|
||
// Id = 1000019,
|
||
// RuleName = "执行价偏离超阈值(本地)",
|
||
// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 Strike 和 SpotPrice,分别对应行权价与现价。计算逻辑:当 Strike 和 SpotPrice 都有值且 SpotPrice 不为 0 时,按 ABS(Strike/SpotPrice-1)×100% 计算执行价相对现价的偏离率,大于 5% 时触发审批。",
|
||
// RuleExpr = "DbContext.trade.First(t => t.id == TradeId).Strike.HasValue && DbContext.trade.First(t => t.id == TradeId).SpotPrice.HasValue && DbContext.trade.First(t => t.id == TradeId).SpotPrice.Value != 0 && Math.Abs((DbContext.trade.First(t => t.id == TradeId).Strike.Value / DbContext.trade.First(t => t.id == TradeId).SpotPrice.Value) - 1d) * 100d > 5d",
|
||
// Version = 1,
|
||
// Status = RiskRuleStatus.Active,
|
||
// OptId = 0,
|
||
// OptName = "system",
|
||
// OptDate = DateTime.Now,
|
||
// UpdateOptId = 0,
|
||
// UpdateOptName = "system",
|
||
// UpdateDate = DateTime.Now
|
||
//});
|
||
|
||
//rules.Add(new RiskRule
|
||
//{
|
||
// Id = 1000021,
|
||
// RuleName = "接近/触发敲入敲出价(本地)",
|
||
// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 Strike 和 SpotPrice,近似模拟触发价与现价。计算逻辑:当 Strike 和 SpotPrice 都有值且 Strike 不为 0 时,按 ABS(SpotPrice/Strike-1)×100% 计算两者距离,距离小于等于 2% 时视为接近触发价,给出提示。",
|
||
// RuleExpr = "DbContext.trade.First(t => t.id == TradeId).Strike.HasValue && DbContext.trade.First(t => t.id == TradeId).SpotPrice.HasValue && DbContext.trade.First(t => t.id == TradeId).Strike.Value != 0 && Math.Abs((DbContext.trade.First(t => t.id == TradeId).SpotPrice.Value / DbContext.trade.First(t => t.id == TradeId).Strike.Value) - 1d) * 100d <= 2d",
|
||
// Version = 1,
|
||
// Status = RiskRuleStatus.Active,
|
||
// OptId = 0,
|
||
// OptName = "system",
|
||
// OptDate = DateTime.Now,
|
||
// UpdateOptId = 0,
|
||
// UpdateOptName = "system",
|
||
// UpdateDate = DateTime.Now
|
||
//});
|
||
|
||
//return rules;
|
||
//#endregion
|
||
}
|
||
|
||
|
||
private List<RiskRuleApplication> LoadApplicationsFromDb()
|
||
{
|
||
using var dbContext = DbContextFactory.GetYLDbContext();
|
||
|
||
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",
|
||
//}
|
||
|
||
/// <summary>
|
||
/// 判断应用配置是否命中当前交易。
|
||
/// 匹配规则遵循设计文档:
|
||
/// 1. 全局命中时直接返回 true;
|
||
/// 2. 同一维度内多选按并集处理;
|
||
/// 3. 不同维度之间按交集处理;
|
||
/// 4. 某维度留空表示该维度不限制。
|
||
/// </summary>
|
||
private bool IsApplicationMatched(RiskRuleApplication application, YLErp.DBModels.trade trade)
|
||
{
|
||
if (application == null)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (application.ScopeIsGlobal == 1)
|
||
{
|
||
return true;
|
||
}
|
||
|
||
var hasAnyScope =
|
||
!IsScopeEmpty(application.ScopeAssetBookIds) ||
|
||
!IsScopeEmpty(application.ScopeClientIds) ||
|
||
!IsScopeEmpty(application.ScopeUnderlyingTypes) ||
|
||
!IsScopeEmpty(application.ScopeTradeTypes);
|
||
if (!hasAnyScope)
|
||
{
|
||
_logger.Error($"[风控引擎] 非全局应用未配置任何适用范围,拒绝匹配 - ApplicationId: {application.Id}");
|
||
return false;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 解析应用配置中的规则ID列表。
|
||
/// 多个规则ID使用逗号分隔,返回去空格后的 long 集合。
|
||
/// </summary>
|
||
private List<long> ParseRuleIds(string ruleIds)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(ruleIds))
|
||
{
|
||
return new List<long>();
|
||
}
|
||
|
||
var ids = new List<long>();
|
||
foreach (var part in ruleIds.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||
{
|
||
if (long.TryParse(part.Trim(), out long id))
|
||
{
|
||
ids.Add(id);
|
||
}
|
||
}
|
||
|
||
return ids;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 判断某个范围字段是否为空。
|
||
/// 为空表示该维度不限制。
|
||
/// </summary>
|
||
private bool IsScopeEmpty(string scopeValue)
|
||
{
|
||
return string.IsNullOrWhiteSpace(scopeValue);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 判断单个值是否命中逗号分隔的范围配置。
|
||
/// 同一维度内多选按并集处理,只要命中任一值即返回 true。
|
||
/// </summary>
|
||
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));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 读取交易的资产簿账户ID。
|
||
/// </summary>
|
||
private object GetTradeAssetBookId(YLErp.DBModels.trade trade)
|
||
{
|
||
return trade.AssetId;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 读取交易的标的类型。
|
||
/// </summary>
|
||
private object GetTradeUnderlyingType(YLErp.DBModels.trade trade)
|
||
{
|
||
return trade.UnderlyingAssetClass;
|
||
}
|
||
}
|
||
}
|