diff --git a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs index e70e7648..6d48174c 100644 --- a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs +++ b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs @@ -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 { /// /// Roslyn 脚本全局变量容器 - /// CSharpScript 执行时通过此对象传递 DataMap /// public class ScriptGlobals { /// - /// 数据字典:key=表名/前缀,value=对应数据对象 + /// 当前交易ID /// - public Dictionary DataMap { get; set; } + public int TradeId { get; set; } + + /// + /// 当前触发时点 + /// + public string TriggerPoint { get; set; } + + /// + /// 数据库上下文,供规则公式直接查询数据库 + /// + public YLContext DbContext { get; set; } } /// @@ -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<RiskContext, bool>(内部通过 Roslyn 编译缓存) /// public static class RuleCompiler { - /// - /// 变量编码 → 类型全名的映射 - /// 用于 Roslyn 脚本中的类型强转,如 ((YLErp.DBModels.trade)DataMap["trade"]) - /// static IYcLogger _logger = LogFactory.GetLogger("RuleCompiler"); - private static readonly Dictionary VariableTypeMap = new Dictionary(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", - }; /// /// 校验并编译规则表达式。 @@ -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(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) diff --git a/YLErpDAL/Modules/RiskEngine/RiskContext.cs b/YLErpDAL/Modules/RiskEngine/RiskContext.cs index 7e9e550e..9686f980 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskContext.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskContext.cs @@ -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; } /// - /// 数据字典:key=表名/前缀,value=对应数据对象 + /// 数据库上下文,供规则公式直接查询数据库 /// - public Dictionary DataMap { get; set; } = new Dictionary(); + public YLContext DbContext { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs index f48bcc78..efd8b91d 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs @@ -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。 当前编译流程: @@ -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,这是规则判断函数,不是事件处理器 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 } /// - /// 刷新缓存:清空已编译委托与内存缓存后重新预加载。 + /// 刷新缓存:刷新规则与应用内存缓存,预编译成功时覆盖旧编译缓存。 /// 规则/应用配置更新后调用。 /// public void RefreshCache() { - _logger.Info("[风控引擎] RefreshCache 被调用 - 清空缓存并重新预加载"); + _logger.Info("[风控引擎] RefreshCache 被调用 - 刷新规则与应用缓存并重新预加载"); lock (_cacheLock) { _cachedRules = null; @@ -210,7 +206,7 @@ namespace YLErp.Modules.RiskEngine } /// - /// 刷新单条规则缓存:重新加载规则与应用列表,只编译指定规则。 + /// 刷新单条规则缓存:重新加载规则列表,只编译指定规则。 /// 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(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, diff --git a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs index 41b62b1b..a0817047 100644 --- a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs +++ b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs @@ -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(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}");