Files
zszq-trs/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs
T

1028 lines
54 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:进程内编译结果缓存,按规则 Id 缓存 Func<RiskContext, bool>。
当前编译流程:
1. 外部准备好规则对象,在 RuleExpr 中直接写最终可执行表达式
2. 调用 RuleCompiler.ValidateAndCompileRule(rule)
3. 内部使用 Roslyn 编译 RuleExpr,生成 Func<RiskContext, bool>
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.csRoslyn 编译入口,提供 ValidateAndCompileFormula / ValidateAndCompileRule
- RuleCompileResult.cs:编译结果模型
- RuleCompiledCache.cs:编译结果缓存
当前集成点:
- QuotaMonitorService.cs:构造 RiskContext,并传入当前 TradeId
【当前进度】(截至 2026-06-24
✅ 已完成:
1. RiskEngine 第一版执行链路已跑通:QuotaMonitorService -> RiskEngineService -> RuleCompiler
2. 风控上下文提供 DbContext 和 TradeId,支持规则脚本直接查询数据库
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. 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。
/// 项目启动时调用一次;规则/应用更新后调用 RefreshCache 刷新。
/// </summary>
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();
//非活跃的rule不编译
if (rule.Status != RiskRuleStatus.Active)
{
RuleCompiledCache.Remove(rule.Id.ToString());
_logger.Info($"[风控引擎] 规则非活跃,已移除预编译缓存 - RuleId: {rule.Id}, Status: {rule.Status}");
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("规则不存在或非活跃状态");
}
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();
_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();
// ------------------------------------------------------------
// 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;
}
/// <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 和 UnderlyingCodePosiNetNoFeePrice 对应债券类标的期初交割净价,库内为 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 和 UnderlyingCodeInitYtm 对应债券类标的期初成交收益率,库内为原值;通过 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 和 UnderlyingCodePosiGrossPrice 对应普通收益互换页面填写的期初标的价格,库内为 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;
}
}
}