feat:增加内存刷新接口,优化rule日志记录,不再使用EXception。非ACTIVE的rule不再编译。

This commit is contained in:
ruisu
2026-07-02 16:46:28 +08:00
parent cc18bdafda
commit 917eacc7b7
6 changed files with 455 additions and 211 deletions
@@ -41,7 +41,7 @@ namespace YLErp.Modules.RiskEngine
/// 变量编码 → 类型全名的映射
/// 用于 Roslyn 脚本中的类型强转,如 ((YLErp.DBModels.trade)DataMap["trade"])
/// </summary>
static IYcLogger _logger =LogFactory.GetLogger("RuleCompiler");
static IYcLogger _logger = LogFactory.GetLogger("RuleCompiler");
private static readonly Dictionary<string, string> VariableTypeMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["trade"] = "YLErp.DBModels.trade",
@@ -50,43 +50,37 @@ namespace YLErp.Modules.RiskEngine
["credit"] = "YLErp.DBModels.credit",
["client_marginrate"] = "YLErp.DBModels.client_marginrate",
["market"] = "YLErp.DBModels.market",
["calc"] = "YLErp.DBModels.calc",
["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>
/// 将规则编译为可执行委托,并写入 rule.CompiledScript
/// 调用时机:规则创建时 / 规则加载时 / 缓存刷新时
/// </summary>
public static void Compile(this RiskRule rule)
{
var result = ValidateAndCompileRule(rule);
if (!result.Success)
{
throw new InvalidOperationException(result.ErrorMessage);
}
}
/// <summary>
/// 校验并编译规则表达式。
/// 当前 RuleExpr 要求是 Roslyn 可直接执行的 bool 表达式。
/// </summary>
public static RuleCompileResult ValidateAndCompileFormula(string formulaExp)
public static RuleCompileResult ValidateAndCompileFormula(int ruleId, string formulaExp)
{
if (string.IsNullOrWhiteSpace(formulaExp))
{
_logger.Error($"规则表达式为空,无法编译 - RuleId: {ruleId}");
return RuleCompileResult.Fail("规则 RuleExpr 不能为空");
}
try
{
var compiled = CompileScript(formulaExp);
var compiled = CompileScript(ruleId, formulaExp, out string compileErrorMessage);
if (compiled == null)
{
return RuleCompileResult.Fail(compileErrorMessage ?? "脚本编译失败");
}
return RuleCompileResult.Ok(compiled);
}
catch (Exception ex)
{
_logger.Error($"规则表达式校验异常 - RuleId: {ruleId}, Error: {ex.Message}\n脚本代码:{formulaExp}");
return RuleCompileResult.Fail(ex.Message);
}
}
@@ -98,17 +92,27 @@ namespace YLErp.Modules.RiskEngine
{
if (rule == null)
{
_logger.Error("规则对象为空,无法编译 - RuleId: 0");
return RuleCompileResult.Fail("规则不能为空");
}
if (rule.Id <= 0)
{
_logger.Error($"规则 Id 非法,无法编译 - RuleId: {rule.Id}");
return RuleCompileResult.Fail("规则 Id 不能为空");
}
if (rule.Status != RiskRuleStatus.Active)
{
_logger.Error($"规则状态非启用,跳过编译 - RuleId: {rule.Id}, Status: {rule.Status}");
return RuleCompileResult.Fail("规则 状态为未启动");
}
var result = ValidateAndCompileFormula(rule.RuleExpr);
var result = ValidateAndCompileFormula(rule.Id, rule.RuleExpr);
if (!result.Success)
{
_logger.Error($"规则校验失败 - RuleId: {rule.Id}, Error: {result.ErrorMessage}");
rule.CompiledScript = null;
RuleCompiledCache.Remove(rule.Id.ToString());
return RuleCompileResult.Fail($"规则[{rule.Id}]编译失败:{result.ErrorMessage}");
}
@@ -117,111 +121,18 @@ namespace YLErp.Modules.RiskEngine
return result;
}
/// <summary>
/// 解析 ConditionJson,提取条件列表。
/// 要求 expression 明确描述实际取值路径。
/// 当前主执行链不再使用该方法,仅作为早期方案保留。
/// </summary>
// private static List<(string variableCode, string expression, string op, object value, string variableType)> ParseConditionJson(string conditionJson)
// {
// var result = new List<(string, string, string, object, string)>();
// var jObj = JObject.Parse(formulaJson);
// var conditions = jObj["conditions"] as JArray;
// if (conditions == null || !conditions.Any())
// {
// throw new ArgumentException("ConditionJson 中缺少 conditions");
// }
// foreach (var cond in conditions)
// {
// string variableCode = cond["variableCode"]?.Value<string>();
// string expression = cond["expression"]?.Value<string>();
// string op = cond["operator"]?.Value<string>();
// object value = cond["value"]?.Value<object>();
// string variableType = cond["variableType"]?.Value<string>() ?? "numeric";
// if (string.IsNullOrWhiteSpace(expression) || string.IsNullOrWhiteSpace(op))
// {
// throw new ArgumentException("条件中缺少 expression 或 operator");
// }
// result.Add((variableCode, expression, op, value, variableType));
// }
// return result;
// }
/// <summary>
/// 根据条件列表生成 Roslyn C# 脚本代码。
/// 多条件自动用 &&AND)连接。
/// 左值表达式统一基于 expression 构建。
/// </summary>
private static string BuildScriptCode(List<(string variableCode, string expression, string op, object value, string variableType)> conditions)
{
var exprParts = new List<string>();
foreach (var (_, expression, op, value, variableType) in conditions)
{
// 先根据 expression 构建左值访问表达式,再拼接比较符和右值字面量
string leftExpr = BuildMemberAccessExpression(expression, variableType);
// 格式化阈值
string valueLiteral = FormatValueLiteral(value, variableType);
// 格式化操作符
string csharpOp = FormatOperator(op);
// 生成单条件表达式
string expr = $"{leftExpr} {csharpOp} {valueLiteral}";
exprParts.Add(expr);
}
// 多条件用 && 连接
return string.Join(" && ", exprParts);
}
/// <summary>
/// 将前端传入的 expression 转成 Roslyn 可执行的成员访问表达式。
/// 例如 trade.StockEqvNotional 会转成 ((YLErp.DBModels.trade)DataMap["trade"]).StockEqvNotional。
/// 数值类型会自动包一层 Convert.ToDecimal,便于与 decimal 阈值比较。
/// </summary>
private static string BuildMemberAccessExpression(string expression, string variableType)
{
var parts = expression.Split('.');
if (parts.Length < 2)
{
throw new ArgumentException($"expression 格式不正确:{expression}");
}
string prefix = parts[0].Trim().ToLower();
if (!VariableTypeMap.TryGetValue(prefix, out string typeFullName))
{
throw new ArgumentException($"未知前缀:{prefix}");
}
// 除前缀外,其余部分都视为成员访问路径,便于后续扩展多级属性访问
string memberAccess = string.Join(".", parts.Skip(1).Select(p => p.Trim()));
string objectExpr = $"(({typeFullName})DataMap[\"{prefix}\"]).{memberAccess}";
if (variableType == "numeric" || variableType == "number")
{
return $"Convert.ToDecimal({objectExpr})";
}
return objectExpr;
}
/// <summary>
/// 用 Roslyn 编译 C# 脚本代码为可执行委托
/// </summary>
private static Func<RiskContext, bool> CompileScript(string scriptCode)
private static Func<RiskContext, bool> CompileScript(int ruleId, string scriptCode, out string errorMessage)
{
errorMessage = null;
// 配置编译选项:引用必要的程序集
var options = ScriptOptions.Default
.WithReferences(
typeof(RiskContext).Assembly, // YLErpDAL
typeof(YLErp.DBModels.trade).Assembly // Model 所在程序集
typeof(RiskContext).Assembly,
typeof(YLErp.DBModels.trade).Assembly
)
.WithImports("System");
@@ -235,9 +146,9 @@ namespace YLErp.Modules.RiskEngine
var errors = diagnostics.Where(d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error).ToList();
if (errors.Any())
{
string errorMsg = string.Join("; ", errors.Select(e => e.GetMessage()));
_logger.Error($"规则编译失败{errorMsg}\n脚本代码:{scriptCode}");
throw new InvalidOperationException($"脚本编译失败:{errorMsg}");
errorMessage = string.Join("; ", errors.Select(e => e.GetMessage()));
_logger.Error($"规则编译失败 - RuleId: {ruleId}, Error: {errorMessage}\n脚本代码:{scriptCode}");
return null;
}
// 生成可调用委托
@@ -246,71 +157,44 @@ namespace YLErp.Modules.RiskEngine
// 包装为同步的 Func<RiskContext, bool>
return ctx =>
{
var globals = new ScriptGlobals { DataMap = ctx.DataMap };
try
{
var globals = new ScriptGlobals { DataMap = ctx.DataMap };
return runner(globals).GetAwaiter().GetResult();
}
catch (Exception ex)
{
// 脚本执行异常(如空引用、类型转换失败)视为规则不触发
_logger.Error($"规则执行异常{ex.Message}\n脚本代码:{scriptCode}");
_logger.Error($"规则执行异常 - RuleId: {ruleId}, Error: {ex.Message}\n脚本代码:{scriptCode}");
return false;
}
};
}
}
/// <summary>
/// 格式化阈值为 C# 字面量
/// </summary>
private static string FormatValueLiteral(object value, string variableType)
public class BuildMemberAccessResult
{
public bool Success { get; set; }
public string Expression { get; set; }
public string ErrorMessage { get; set; }
public static BuildMemberAccessResult Ok(string expression)
{
if (value == null) return "null";
string strValue = value.ToString();
// 数值类型统一加 m 后缀(decimal)
if (variableType == "numeric" || variableType == "number")
return new BuildMemberAccessResult
{
if (decimal.TryParse(strValue, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal d))
{
return d.ToString(CultureInfo.InvariantCulture) + "m";
}
}
// 布尔
if (variableType == "boolean" || variableType == "bool")
{
if (bool.TryParse(strValue, out bool b))
{
return b ? "true" : "false";
}
}
// 日期
if (variableType == "date" && DateTime.TryParse(strValue, out DateTime dt))
{
return $"DateTime.Parse(\"{dt:yyyy-MM-dd}\")";
}
// 字符串(兜底)
return $"\"{strValue.Replace("\\", "\\\\").Replace("\"", "\\\"")}\"";
Success = true,
Expression = expression
};
}
/// <summary>
/// 将操作符统一为 C# 操作符
/// </summary>
private static string FormatOperator(string op)
public static BuildMemberAccessResult Fail(string errorMessage)
{
return op?.Trim() switch
return new BuildMemberAccessResult
{
"=" or "==" => "==",
"!=" or "≠" => "!=",
">" => ">",
"<" => "<",
">=" => ">=",
"<=" => "<=",
_ => throw new ArgumentException($"不支持的操作符:{op}")
Success = false,
ErrorMessage = errorMessage
};
}
}
+349 -38
View File
@@ -157,20 +157,20 @@ namespace YLErp.Modules.RiskEngine
var rules = LoadRulesFromDb();
var applications = LoadApplicationsFromDb();
List<int> res = new List<int>();
foreach(var i in applications)
{
if(i.Status!= RiskRuleStatus.Active)
{
res.Add(i.Id);
}
}
// 预编译所有规则到 RuleCompiledCache
foreach (var rule in rules)
{
var ruleId = rule.Id.ToString();
//已存在编译缓存的规则直接跳过,避免重复编译
if (RuleCompiledCache.TryGet(ruleId, out _))
{
_logger.Info($"[风控引擎] 规则已存在编译缓存,跳过预编译 - RuleId: {rule.Id}");
continue;
}
//非活跃的rule不编译
if (rule.Status != RiskRuleStatus.Active)
{
_logger.Info($"[风控引擎] 规则非活跃,跳过预编译 - RuleId: {rule.Id}, Status: {rule.Status}");
continue;
}
@@ -321,16 +321,23 @@ namespace YLErp.Modules.RiskEngine
}
var applicationRules = rules
.Where(r => applicationRuleIds.Contains(r.Id) && r.Id == 3)
.Where(r => applicationRuleIds.Contains(r.Id) && r.Status == RiskRuleStatus.Active)
.ToList();
var missingRuleIds = applicationRuleIds
.Where(ruleId => applicationRules.All(r => r.Id != ruleId))
.ToList();
// 区分"规则不存在"与"规则非活跃"两种情况,分别记录日志
var ruleDict = rules.Where(r => applicationRuleIds.Contains(r.Id))
.ToDictionary(r => r.Id);
foreach (var missingRuleId in missingRuleIds)
foreach (var ruleId in applicationRuleIds)
{
_logger.Info($"[风控引擎] 未找到对应规则定义 - RuleId: {missingRuleId}, ApplicationRuleIds: {application.RuleIds}");
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)
@@ -379,7 +386,6 @@ namespace YLErp.Modules.RiskEngine
switch (application.ControlStrategy)
{
case RiskControlStrategy.Block:
break;
result.Blocked = true;
result.Passed = false;
result.TriggeredRules.Add(new TriggeredRuleInfo
@@ -465,36 +471,324 @@ namespace YLErp.Modules.RiskEngine
/// </summary>
private List<RiskRule> LoadRulesFromDb()
{
return DbContext.glms_risk_rule
var rules = DbContext.glms_risk_rule
.AsNoTracking()
.Where(r => r.Status != RiskRuleStatus.Deleted)
.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,
IsDeleted = r.Status == RiskRuleStatus.Deleted,
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();
}).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
}
/// <summary>
/// 从数据库加载应用配置
/// </summary>
private List<RiskRuleApplication> LoadApplicationsFromDb()
{
return DbContext.glms_risk_rule_application
var applications = DbContext.glms_risk_rule_application
.AsNoTracking()
.Where(a => a.Status != RiskRuleStatus.Deleted)
.OrderByDescending(a => a.UpdateDate ?? a.OptDate)
.Select(a => new RiskRuleApplication
{
@@ -517,16 +811,33 @@ namespace YLErp.Modules.RiskEngine
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>
/// <summary>
/// 判断应用配置是否命中当前交易。
/// 匹配规则遵循设计文档:
/// 1. 全局命中时直接返回 true;
/// 2. 同一维度内多选按并集处理;
/// 3. 不同维度之间按交集处理;
/// 4. 某维度留空表示该维度不限制。
/// </summary>
private bool IsApplicationMatched(RiskRuleApplication application, YLErp.DBModels.trade trade)
{
if (application == null)
@@ -559,17 +870,17 @@ namespace YLErp.Modules.RiskEngine
/// 解析应用配置中的规则ID列表。
/// 多个规则ID使用逗号分隔,返回去空格后的 long 集合。
/// </summary>
private List<long> ParseRuleIds(string ruleIds)
private List<int> ParseRuleIds(string ruleIds)
{
if (string.IsNullOrWhiteSpace(ruleIds))
{
return new List<long>();
return new List<int>();
}
var ids = new List<long>();
var ids = new List<int>();
foreach (var part in ruleIds.Split(',', StringSplitOptions.RemoveEmptyEntries))
{
if (long.TryParse(part.Trim(), out long id))
if (int.TryParse(part.Trim(), out int id))
{
ids.Add(id);
}
@@ -634,5 +945,5 @@ namespace YLErp.Modules.RiskEngine
var property = trade.GetType().GetProperty("UnderlyingInstrumentType");
return property?.GetValue(trade);
}
}
}
}
+2 -1
View File
@@ -13,6 +13,7 @@ namespace YLErp.Modules.RiskEngine
public int Id { get; set; }
public string RuleName { get; set; }
public string RuleText { get; set; }
/// <summary>
/// 【核心】条件JSON(结构化模式的条件列表存储),如
@@ -29,7 +30,7 @@ namespace YLErp.Modules.RiskEngine
// === 生命周期 ===
public int Version { get; set; }
public bool IsDeleted { get; set; }
public RiskRuleStatus Status { get; set; }
// === 审计字段 ===
public int OptId { get; set; }
@@ -4380,9 +4380,11 @@ namespace YLErp.Modules.RiskModule
if (!string.IsNullOrWhiteSpace(quotaObj.QuotaWarningDetails))
detailParts.Add($"限额预警:{quotaObj.QuotaWarningDetails}");
res.ErrorMsg = string.Join("\n", detailParts);
//确认本次为需审批后,二次特批。因为只靠点击“交易特批”的ignoreRiskWarning,不能保证本次校验通过。
var isRiskApprovalWarning = quotaObj.TrialStatus == QuotaTrialStatusEnum.Warning
&& !string.IsNullOrWhiteSpace(quotaObj.RiskWarningDetails);
//需清除ErrorMsg,不然外部调用会认为失败
//触发需审批,交易特批逻辑
if (ignoreRiskWarning && isRiskApprovalWarning)
{
res.ErrorMsg = string.Empty;
@@ -4755,14 +4757,14 @@ namespace YLErp.Modules.RiskModule
_logger.Info($"[限额试算] 开始执行 - tradeId: {tradeId}, trialSource: {trialSource}");
var result = new QuotaTrial();
trade tradeObj = DbContext.trade.AsNoTracking().Where(O => O.id == tradeId).FirstOrDefault();
// 检查交易对象是否存在
if (tradeObj == null)
{
_logger.Info($"[限额试算] 找不到交易记录 - tradeId: {tradeId}");
throw new ArgumentNullException(nameof(tradeObj), $"找不到ID为 {tradeId} 的交易记录");
}
_logger.Info($"[限额试算] 交易信息 - tradeId: {tradeObj.id}, TradeNumber: {tradeObj.TradeNumber}, TradeType: {tradeObj.TradeType}, ClientId: {tradeObj.ClientId}");
//// 检查关键字段是否为null
@@ -4968,8 +4970,30 @@ namespace YLErp.Modules.RiskModule
TradeId = tradeId,
TriggerPoint = "BOOK_CONFIRM"
};
// 构造 DataMap(第一版只塞 trade 对象,后续按需扩展)
tradeObj.StockEqvNotional = 10000000000;
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}");
@@ -5016,7 +5040,7 @@ namespace YLErp.Modules.RiskModule
}
// ===== 新风控引擎接入结束 =====
_logger.Info($"[限额试算] 试算完成 - tradeId: {tradeId}, TrialStatus: {result.TrialStatus}, FundCheck: {fundStatus}, QuotaCheck: {quotaStatus}");
//_logger.Info($"[限额试算] 试算完成 - tradeId: {tradeId}, TrialStatus: {result.TrialStatus}, FundCheck: {fundStatus}, QuotaCheck: {quotaStatus}");
SaveQuotaTrial(result);
return result;
}
@@ -568,6 +568,30 @@ namespace YLErp.Web.Controllers
#endregion
#region Cache Management
[HttpGet("riskCache/refresh")]
[MyAuthorize("风险控制-异常交易监控")]
public JsonResult RefreshRiskEngineCache()
{
try
{
RiskEngineService.GetInstance().RefreshCache();
return Json(new { success = true, message = "风控缓存刷新成功" });
}
catch (ServiceException ex)
{
return Json(new { success = false, message = ex.Message });
}
catch (Exception ex)
{
_logger.Error(ex, "刷新风控缓存");
return Json(new { success = false, message = "系统异常,请联系管理员" });
}
}
#endregion
#region Trade Types
[HttpGet("trade-types")]
+1 -1
View File
@@ -2480,7 +2480,7 @@ namespace YLErp.Web.Controllers
{
return JsonSuccessData(new { proccessType = "AdditionalProcessing", type = tradeBLL.LackOfMoney, TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type });
}
//限额试算不通过(老风控检查失败),展示详情但不允许审批
//限额试算不通过(老风控,新风控检查失败),展示详情但不允许审批
if (result.type == TradeOpenRetCode.QuotaTrialError.ToString())
{
return JsonSuccessData(new { proccessType = "QuotaTrialError", TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type });