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

994 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 表达式,
例如直接访问 DataMap["trade"] 后做数值或日期比较。
- ConditionJson:当前已在规则定义中保留,但不参与主执行链路。
- RiskContext:一次风控检查的数据上下文,包含 TradeId、TriggerPoint 和 DataMap。
- 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,并将 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<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)
{
_logger.Info($"[风控引擎] 规则非活跃,跳过预编译 - RuleId: {rule.Id}, Status: {rule.Status}");
continue;
}
//已存在编译缓存的规则直接跳过,避免重复编译
if (RuleCompiledCache.TryGet(ruleId, out _))
{
_logger.Info($"[风控引擎] 规则已存在编译缓存,跳过预编译 - RuleId: {rule.Id}");
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}");
}
}
/// <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
{
_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;
}
/// <summary>
/// 从数据库加载规则列表
/// </summary>
private List<RiskRule> 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<RiskRuleApplication> 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",
//}
/// <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;
}
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。
/// 当前先按 BookId 取值;如果后续真实字段不是 BookId,再统一调整这里即可。
/// </summary>
private object GetTradeAssetBookId(YLErp.DBModels.trade trade)
{
var property = trade.GetType().GetProperty("BookId");
return property?.GetValue(trade);
}
/// <summary>
/// 读取交易的标的类型。
/// 当前先按 UnderlyingInstrumentType 取值;如果后续真实字段名不同,再统一调整这里即可。
/// </summary>
private object GetTradeUnderlyingType(YLErp.DBModels.trade trade)
{
var property = trade.GetType().GetProperty("UnderlyingInstrumentType");
return property?.GetValue(trade);
}
}
}