feat:风控引擎内存上下文改为查询数据库,修改注释中的相应规则,

This commit is contained in:
ruisu
2026-07-13 15:47:37 +08:00
parent b5f6b7f61a
commit 82688fa930
4 changed files with 101 additions and 107 deletions
@@ -1,23 +1,32 @@
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using YLErp.BLL;
using YLErp.QdpModule;
namespace YLErp.Modules.RiskEngine
{
/// <summary>
/// Roslyn 脚本全局变量容器
/// CSharpScript 执行时通过此对象传递 DataMap
/// </summary>
public class ScriptGlobals
{
/// <summary>
/// 数据字典:key=表名/前缀,value=对应数据对象
/// 当前交易ID
/// </summary>
public Dictionary<string, object> DataMap { get; set; }
public int TradeId { get; set; }
/// <summary>
/// 当前触发时点
/// </summary>
public string TriggerPoint { get; set; }
/// <summary>
/// 数据库上下文,供规则公式直接查询数据库
/// </summary>
public YLContext DbContext { get; set; }
}
/// <summary>
@@ -32,29 +41,12 @@ namespace YLErp.Modules.RiskEngine
/// - 可调试:生成的脚本代码可直接阅读和理解
///
/// 示例:
/// RuleExpr: Convert.ToDecimal(((YLErp.DBModels.trade)DataMap["trade"]).StockEqvNotional) > 100000000m
/// RuleExpr: Convert.ToDecimal(DbContext.trade.First(t => t.id == TradeId).StockEqvNotional) > 100000000m
/// 编译结果:Func&lt;RiskContext, bool&gt;(内部通过 Roslyn 编译缓存)
/// </summary>
public static class RuleCompiler
{
/// <summary>
/// 变量编码 → 类型全名的映射
/// 用于 Roslyn 脚本中的类型强转,如 ((YLErp.DBModels.trade)DataMap["trade"])
/// </summary>
static IYcLogger _logger = LogFactory.GetLogger("RuleCompiler");
private static readonly Dictionary<string, string> VariableTypeMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["trade"] = "YLErp.DBModels.trade",
["swap_position"] = "YLErp.DBModels.swap_position",
["client"] = "YLErp.DBModels.client",
["credit"] = "YLErp.DBModels.credit",
["client_marginrate"] = "YLErp.DBModels.client_marginrate",
["market"] = "YLErp.DBModels.market",
["underlying_manager"] = "YLErp.DBModels.underlying_manager",
["sys"] = "YLErp.DBModels.sys",
["eod_swap_position"] = "YLErp.DBModels.eod_swap_position",
["realtime_trade_risk"] = "YLErp.DBModels.realtime_trade_risk",
};
/// <summary>
/// 校验并编译规则表达式。
@@ -114,7 +106,6 @@ namespace YLErp.Modules.RiskEngine
{
_logger.Error($"规则校验失败 - RuleId: {rule.Id}, Error: {result.ErrorMessage}");
rule.CompiledScript = null;
RuleCompiledCache.Remove(rule.Id.ToString());
return RuleCompileResult.Fail($"规则[{rule.Id}]编译失败:{result.ErrorMessage}");
}
@@ -135,9 +126,14 @@ namespace YLErp.Modules.RiskEngine
var options = ScriptOptions.Default
.WithReferences(
typeof(RiskContext).Assembly,
typeof(YLErp.DBModels.trade).Assembly
typeof(YLContext).Assembly,
typeof(YLErp.DBModels.trade).Assembly,
typeof(QdpCalendarHelper).Assembly,
typeof(JsonConvert).Assembly,
typeof(Microsoft.EntityFrameworkCore.DbContext).Assembly,
typeof(Queryable).Assembly
)
.WithImports("System");
.WithImports("System", "System.Linq", "Newtonsoft.Json", "YLErp.DBModels", "YLErp.QdpModule");
// 创建脚本(尚未执行,仅编译)
var script = CSharpScript.Create<bool>(scriptCode, options, globalsType: typeof(ScriptGlobals));
@@ -162,7 +158,12 @@ namespace YLErp.Modules.RiskEngine
{
try
{
var globals = new ScriptGlobals { DataMap = ctx.DataMap };
var globals = new ScriptGlobals
{
TradeId = ctx.TradeId,
TriggerPoint = ctx.TriggerPoint,
DbContext = ctx.DbContext
};
return runner(globals).GetAwaiter().GetResult();
}
catch (Exception ex)
+3 -3
View File
@@ -1,4 +1,4 @@
using System.Collections.Generic;
using YLErp.BLL;
namespace YLErp.Modules.RiskEngine
{
@@ -18,8 +18,8 @@ namespace YLErp.Modules.RiskEngine
public string TriggerPoint { get; set; }
/// <summary>
/// 数据字典:key=表名/前缀,value=对应数据对象
/// 数据库上下文,供规则公式直接查询数据库
/// </summary>
public Dictionary<string, object> DataMap { get; set; } = new Dictionary<string, object>();
public YLContext DbContext { get; set; }
}
}
@@ -22,9 +22,9 @@ using YLErp.Model;
- RiskRuleApplication(规则应用):与规则分离,承载启用状态、控制策略、触发时点、
适用范围(全局 / 账户 / 客户 / 标的类型 / 合约类型)。
- RuleExpr:当前唯一主编译入口。要求内容是 Roslyn 可直接执行的 C# bool 表达式,
例如直接访问 DataMap["trade"] 后做数值或日期比较。
例如通过 DbContext 和 TradeId 查询数据库后做数值或日期比较。
- ConditionJson:当前已在规则定义中保留,但不参与主执行链路。
- RiskContext:一次风控检查的数据上下文,包含 TradeId、TriggerPoint 和 DataMap
- RiskContext:一次风控检查的数据上下文,包含 TradeId、TriggerPoint 和 DbContext
- RuleCompiledCache:进程内编译结果缓存,按规则 Id 缓存 Func<RiskContext, bool>。
当前编译流程:
@@ -69,12 +69,12 @@ using YLErp.Model;
- RuleCompiledCache.cs:编译结果缓存
当前集成点:
- QuotaMonitorService.cs:构造 RiskContext,并将 trade 对象放入 DataMap["trade"]
- QuotaMonitorService.cs:构造 RiskContext,并传入当前 TradeId
【当前进度】(截至 2026-06-24
✅ 已完成:
1. RiskEngine 第一版执行链路已跑通:QuotaMonitorService -> RiskEngineService -> RuleCompiler
2. 风控上下文通过 DataMap 传入 trade 对象,支持规则脚本直接访问交易字段
2. 风控上下文提供 DbContext 和 TradeId,支持规则脚本直接查询数据库
3. RuleExpr 直编译方案已接入,支持数值比较和日期比较
4. Application 通用维度匹配已支持:全局 / 账户 / 客户 / 标的类型 / 合约类型
5. 维度组合逻辑已按文档确认:同维度 OR,不同维度 AND
@@ -102,7 +102,7 @@ using YLErp.Model;
【注意事项】
1. 当前 RuleExpr 必须是 Roslyn 最终可执行的 C# bool 表达式,不是业务语义短句
2. DataMap 中的值由宿主业务代码准备,编译器与执行器本身不负责查库补数
2. RuleExpr 可直接使用 DbContext 查询数据库,当前交易通过 TradeId 定位
3. 当前缓存的是 Func<RiskContext, bool>,这是规则判断函数,不是事件处理器
4. 业务可预期失败优先走结果返回,不要把高频校验失败都设计成异常
================================================================================
@@ -164,16 +164,12 @@ namespace YLErp.Modules.RiskEngine
//非活跃的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}");
RuleCompiledCache.Remove(rule.Id.ToString());
_logger.Info($"[风控引擎] 规则非活跃,已移除预编译缓存 - RuleId: {rule.Id}, Status: {rule.Status}");
continue;
}
var compileResult = RuleCompiler.ValidateAndCompileRule(rule);
if (compileResult.Success)
{
@@ -193,12 +189,12 @@ namespace YLErp.Modules.RiskEngine
}
/// <summary>
/// 刷新缓存:清空已编译委托与内存缓存后重新预加载
/// 刷新缓存:刷新规则与应用内存缓存,预编译成功时覆盖旧编译缓存
/// 规则/应用配置更新后调用。
/// </summary>
public void RefreshCache()
{
_logger.Info("[风控引擎] RefreshCache 被调用 - 清空缓存并重新预加载");
_logger.Info("[风控引擎] RefreshCache 被调用 - 刷新规则与应用缓存并重新预加载");
lock (_cacheLock)
{
_cachedRules = null;
@@ -210,7 +206,7 @@ namespace YLErp.Modules.RiskEngine
}
/// <summary>
/// 刷新单条规则缓存:重新加载规则与应用列表,只编译指定规则。
/// 刷新单条规则缓存:重新加载规则列表,只编译指定规则。
/// </summary>
public RuleCompileResult RefreshOneRuleCache(long ruleId)
{
@@ -314,6 +310,10 @@ namespace YLErp.Modules.RiskEngine
try
{
_logger.Info($"[风控引擎] EvaluateRisk 开始 - TradeId: {context?.TradeId}, TriggerPoint: {triggerPoint}");
if (context != null && context.DbContext == null)
{
context.DbContext = DbContext;
}
// ============================================================
// Step 1: 从内存缓存读取规则定义和规则应用(启动时已预热)
@@ -343,8 +343,8 @@ namespace YLErp.Modules.RiskEngine
// 2. 同一维度内多选按并集处理;
// 3. 不同维度之间按交集处理;
// 4. 某维度留空表示该维度不限制。
var trade = context?.DataMap != null && context.DataMap.ContainsKey("trade")
? context.DataMap["trade"] as YLErp.DBModels.trade
var trade = context != null && context.TradeId > 0
? DbContext.trade.FirstOrDefault(t => t.id == context.TradeId)
: null;
var matchedApplications = triggerMatchedApps
@@ -544,8 +544,24 @@ namespace YLErp.Modules.RiskEngine
//{
// 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",
// 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,
@@ -560,8 +576,8 @@ namespace YLErp.Modules.RiskEngine
//{
// 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",
// 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,
@@ -576,8 +592,8 @@ namespace YLErp.Modules.RiskEngine
//{
// 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))",
// 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,
@@ -592,8 +608,8 @@ namespace YLErp.Modules.RiskEngine
//{
// 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",
// 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,
@@ -608,8 +624,8 @@ namespace YLErp.Modules.RiskEngine
//{
// 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",
// 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,
@@ -624,8 +640,8 @@ namespace YLErp.Modules.RiskEngine
//{
// 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",
// 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,
@@ -640,8 +656,8 @@ namespace YLErp.Modules.RiskEngine
//{
// 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",
// 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,
@@ -656,8 +672,8 @@ namespace YLErp.Modules.RiskEngine
//{
// 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",
// 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,
@@ -672,8 +688,8 @@ namespace YLErp.Modules.RiskEngine
//{
// 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",
// 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,
@@ -688,8 +704,8 @@ namespace YLErp.Modules.RiskEngine
//{
// 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",
// RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 PosiNetNoFeePrice,对应债券类标的期初交割净价,库内为 1 左右原值;通过 DbContext.china_bond_valuation 按当前交易标的和交易日前日期取上一收盘日 net_price,库内为 100 左右报价。计算逻辑:按 ABS(PosiNetNoFeePrice×100-net_price) 计算绝对价差,价差大于 5 时触发审批。",
// RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId).PosiNetNoFeePrice.Value * 100m - DbContext.china_bond_valuation.Where(v => v.bond_id == DbContext.trade.First(t => t.id == TradeId).UnderlyingCode && v.valuation_date < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderByDescending(v => v.credibility).ThenByDescending(v => v.valuation_date).First().net_price.Value) > 5m",
// Version = 1,
// Status = RiskRuleStatus.Active,
// OptId = 0,
@@ -704,8 +720,8 @@ namespace YLErp.Modules.RiskEngine
//{
// 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",
// RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 InitYtm,对应债券类标的期初成交收益率,库内为原值;通过 DbContext.china_bond_valuation 按当前交易标的和交易日前日期取上一收盘日 yield,库内为 1.5 到 2.2 左右百分数。计算逻辑:按 ABS(InitYtm×100-yield) 计算收益率绝对差,差值大于 1 时触发审批。",
// RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId).InitYtm.Value * 100m - DbContext.china_bond_valuation.Where(v => v.bond_id == DbContext.trade.First(t => t.id == TradeId).UnderlyingCode && v.valuation_date < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderByDescending(v => v.credibility).ThenByDescending(v => v.valuation_date).First().yield.Value) > 1m",
// Version = 1,
// Status = RiskRuleStatus.Active,
// OptId = 0,
@@ -720,8 +736,8 @@ namespace YLErp.Modules.RiskEngine
//{
// 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",
// RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 PosiGrossPrice,对应普通收益互换页面填写的期初标的交割全价%,库内为 1 左右原值;通过 DbContext.eod_stock_price / eod_commodity_future_price 按当前交易标的和交易日前日期取上一日收盘价 ClosePrice。计算逻辑:按 ABS(PosiGrossPrice×100-ClosePrice) 计算绝对价差,价差大于 5 时触发审批。",
// RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId).PosiGrossPrice * 100m - Convert.ToDecimal((DbContext.eod_stock_price.Where(e => e.UnderlyingCode == DbContext.trade.First(t => t.id == TradeId).UnderlyingCode && e.ValueDate < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderByDescending(e => e.ValueDate).Select(e => (double?)e.ClosePrice).FirstOrDefault() ?? DbContext.eod_commodity_future_price.Where(e => e.UnderlyingCode == DbContext.trade.First(t => t.id == TradeId).UnderlyingCode && e.ValueDate < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderByDescending(e => e.ValueDate).Select(e => (double?)e.ClosePrice).FirstOrDefault()).Value)) > 5m",
// Version = 1,
// Status = RiskRuleStatus.Active,
// OptId = 0,
@@ -736,8 +752,8 @@ namespace YLErp.Modules.RiskEngine
//{
// 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",
// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易对手 ClientId,再查询同一交易对手存续/审批中交易的去重标的数量。计算逻辑:同一交易对手累计标的数量超过 10 个时触发审批。",
// RuleExpr = "DbContext.trade.Where(t => t.ValidState != \"InValid\" && t.ClientId == DbContext.trade.First(x => x.id == TradeId).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,
@@ -752,8 +768,8 @@ namespace YLErp.Modules.RiskEngine
//{
// 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",
// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 FixedRate,并结合 BuySell 判断多头方向。计算逻辑:当 BuySell 表示多头且 FixedRate 有值时,先以 2.5% 作为本地测试基准,若 ABS(FixedRate-0.025)/0.025×100% 大于 5%,则触发审批。",
// RuleExpr = "DbContext.trade.First(t => t.id == TradeId).BuySell == \"Buy\" && DbContext.trade.First(t => t.id == TradeId).FixedRate.HasValue && Math.Abs((DbContext.trade.First(t => t.id == TradeId).FixedRate.Value - 0.025d) / 0.025d) * 100d > 5d",
// Version = 1,
// Status = RiskRuleStatus.Active,
// OptId = 0,
@@ -768,8 +784,8 @@ namespace YLErp.Modules.RiskEngine
//{
// 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",
// 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,
@@ -784,8 +800,8 @@ namespace YLErp.Modules.RiskEngine
//{
// 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\"",
// 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,
@@ -800,8 +816,8 @@ namespace YLErp.Modules.RiskEngine
//{
// 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",
// 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,
@@ -816,8 +832,8 @@ namespace YLErp.Modules.RiskEngine
//{
// 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",
// 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,
@@ -5183,29 +5183,6 @@ namespace YLErp.Modules.RiskModule
TradeId = tradeId,
TriggerPoint = "BOOK_CONFIRM"
};
riskContext.DataMap["trade"] = tradeObj;
var sameUnderlyingTotalNotional = DbContext.trade
.Where(t => t.ValidState != "InValid"
&& t.UnderlyingId == tradeObj.UnderlyingId
&& t.ParentTradeId == 0
&& (ConsTrade.NeedMarginTradeStatusList.Contains(t.TradeStatus) || t.TradeStatus == "审批中"))
.Sum(t => (double?)t.StockEqvNotional) ?? 0d;
riskContext.DataMap["same_underlying_total_notional"] = sameUnderlyingTotalNotional;
var underlying = DataCacheProvider.GetUnderlyingDataSource().GetData(tradeObj.UnderlyingCode);
if (underlying != null)
{
if (underlying.IsBond() && !string.IsNullOrEmpty(underlying.ExJson))
{
var bond = JsonHelper.Deserialize<UnderlyingBond>(underlying.ExJson);
if (bond != null)
{
underlying.IssueSize = bond.IssueSize;
underlying.UnderlyingFullName = bond.UnderlyingFullName;
underlying.UnderlyingIssuer = bond.UnderlyingIssuer;
}
}
riskContext.DataMap["underlying_manager"] = underlying;
}
var riskResult = riskEngine.EvaluateRisk(riskContext, "BOOK_CONFIRM");
_logger.Info($"[风控引擎] 簿记交易确认 - TradeId: {tradeId}, Passed: {riskResult.Passed}, Blocked: {riskResult.Blocked}, NeedApproval: {riskResult.NeedApproval}, ShowTip: {riskResult.ShowTip}");