diff --git a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs index 084b1e3f..d827a6db 100644 --- a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs +++ b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs @@ -359,6 +359,8 @@ namespace YLErp.Modules.RiskEngine var options = ScriptOptions.Default .WithReferences( typeof(RiskContext).Assembly, + typeof(RiskCalendarHelper).Assembly, + typeof(RiskMarketDeviationHelper).Assembly, typeof(YLContext).Assembly, typeof(YLErp.DBModels.trade).Assembly, typeof(QdpCalendarHelper).Assembly, diff --git a/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs b/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs new file mode 100644 index 00000000..c2b4758c --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs @@ -0,0 +1,87 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using YLErp.BLL; + +namespace YLErp.Modules.RiskEngine +{ + /// + /// 风控规则专用日历辅助类。 + /// 当前主要用于债券类规则按银行间日历确认“上一收盘日”,避免简单按估值表倒序取最近日期导致口径偏差。 + /// + public static class RiskCalendarHelper + { + /// + /// 获取指定日期的上一银行间交易日。 + /// + /// 当前风控执行使用的数据库上下文。 + /// 当前交易日或业务基准日。 + /// 严格早于入参日期的上一银行间交易日。 + /// 数据库上下文为空。 + /// 缺少银行间日历、日历内容异常或在保护范围内找不到上一交易日。 + public static DateTime GetPreviousInterbankTradingDay(YLContext dbContext, DateTime date) + { + if (dbContext == null) + throw new ArgumentNullException(nameof(dbContext)); + + var holidayCache = new Dictionary>(); + var currentDate = date.Date.AddDays(-1); + + // 最多向前查 370 天,既覆盖跨年和长假场景,也避免日历配置异常时出现无限循环。 + for (var i = 0; i < 370; i++) + { + var holidays = GetInterbankHolidays(dbContext, currentDate.Year, holidayCache); + var currentDateText = currentDate.ToString("yyyy,MM,dd", CultureInfo.InvariantCulture); + + // calendar.HolidayJson 存的是非交易日;不在非交易日集合内,即认为是银行间交易日。 + if (!holidays.Contains(currentDateText)) + return currentDate; + + currentDate = currentDate.AddDays(-1); + } + + throw new Exception($"未找到{date:yyyy-MM-dd}的上一银行间交易日"); + } + + /// + /// 获取指定年份的银行间非交易日集合。 + /// + /// 当前风控执行使用的数据库上下文。 + /// 日历年份。 + /// 单次查询过程内的年份级缓存,跨年查找时避免重复读取同一年日历。 + /// 格式为 yyyy,MM,dd 的非交易日集合。 + private static HashSet GetInterbankHolidays(YLContext dbContext, int year, Dictionary> holidayCache) + { + if (holidayCache.TryGetValue(year, out var holidays)) + return holidays; + + // 同一年可能存在多种市场日历;规则 12 明确使用 Country=IB 的银行间日历。 + var calendar = dbContext.calendar + .Where(c => c.Year == year && (c.ValidState == null || c.ValidState != ConsGlobal.InValid)) + .ToList() + .FirstOrDefault(c => string.Equals(c.Country, "IB", StringComparison.OrdinalIgnoreCase)); + + if (calendar == null) + throw new Exception($"未找到{year}年银行间日历"); + + if (string.IsNullOrWhiteSpace(calendar.HolidayJson)) + throw new Exception($"{year}年银行间日历HolidayJson为空"); + + List holidayList; + try + { + holidayList = JsonConvert.DeserializeObject>(calendar.HolidayJson); + } + catch (Exception ex) + { + throw new Exception($"{year}年银行间日历HolidayJson解析失败", ex); + } + + holidays = new HashSet(holidayList ?? new List()); + holidayCache[year] = holidays; + return holidays; + } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/Helper/RiskMarketDeviationHelper.cs b/YLErpDAL/Modules/RiskEngine/Helper/RiskMarketDeviationHelper.cs new file mode 100644 index 00000000..0af2c96f --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Helper/RiskMarketDeviationHelper.cs @@ -0,0 +1,340 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using YLErp.BLL; + +namespace YLErp.Modules.RiskEngine +{ + /// + /// 风控行情偏离类变量辅助方法,统一封装债券中债估值偏离和非债券行情价格偏离的取数、计算和命中明细生成逻辑。 + /// + public static class RiskMarketDeviationHelper + { + /// + /// 计算当前交易所有浮动支付端的债券类净价偏离值,返回最大偏离值用于规则比较。 + /// + /// 数据库上下文。 + /// 当前交易ID。 + /// 包含最大净价偏离值和逐笔偏离明细的变量返回值。 + public static RiskVariableValueDetail GetBondNetPriceDeviation(YLContext dbContext, int tradeId) + { + return GetBondValuationDeviation( + dbContext, + tradeId, + "债券类净价偏离", + "期初交割净价", + "中债估值净价", + p => p.PosiNetNoFeePrice, + v => v.net_price); + } + + /// + /// 计算当前交易所有浮动支付端的债券类收益率偏离值,返回最大偏离值用于规则比较。 + /// + /// 数据库上下文。 + /// 当前交易ID。 + /// 包含最大收益率偏离值和逐笔偏离明细的变量返回值。 + public static RiskVariableValueDetail GetBondYieldDeviation(YLContext dbContext, int tradeId) + { + return GetBondValuationDeviation( + dbContext, + tradeId, + "债券类收益率偏离", + "期初成交收益率", + "中债估值收益率", + p => p.InitYtm, + v => v.yield); + } + + /// + /// 计算当前交易所有浮动支付端的非债券类价格偏离值,返回最大偏离值用于规则比较。 + /// + /// 数据库上下文。 + /// 当前交易ID。 + /// 包含最大价格偏离值和逐笔偏离明细的变量返回值。 + public static RiskVariableValueDetail GetNonBondPriceDeviation(YLContext dbContext, int tradeId) + { + if (dbContext == null) + throw new ArgumentNullException(nameof(dbContext)); + + var tradeDate = GetTradeDate(dbContext, tradeId); + var floatingPositions = GetFloatingPaymentPositions(dbContext, tradeId) + .Select(p => new + { + p.id, + p.UnderlyingCode, + p.PosiGrossPrice + }) + .ToList(); + + if (!floatingPositions.Any()) + throw new Exception("浮动支付端记录不存在"); + + var underlyingCodes = floatingPositions + .Select(p => p.UnderlyingCode) + .Distinct() + .ToList(); + + // 非债券类取交易日前最近一条行情,不使用银行间日历,也不要求行情日等于上一银行间交易日。 + var eodRows = dbContext.eod_commodity_future_price + .Where(e => underlyingCodes.Contains(e.UnderlyingCode) + && e.ValueDate < tradeDate) + .Select(e => new + { + e.id, + e.UnderlyingCode, + e.ValueDate, + e.ClosePrice + }) + .ToList(); + + // 先按标的批量查出行情,再在内存中分组取最近日,避免每条浮动支付端单独访问数据库。 + var eodByUnderlyingCode = eodRows + .GroupBy(e => e.UnderlyingCode) + .ToDictionary( + g => g.Key, + g => g.OrderByDescending(e => e.ValueDate).ThenBy(e => e.id).First()); + + var valuationItems = floatingPositions + .Select(p => new + { + Position = p, + Eod = eodByUnderlyingCode.ContainsKey(p.UnderlyingCode) ? eodByUnderlyingCode[p.UnderlyingCode] : null + }) + .ToList(); + + var missingEodItems = valuationItems + .Where(x => x.Eod == null) + .Select(x => $"记录ID {x.Position.id},标的{x.Position.UnderlyingCode}") + .ToList(); + + if (missingEodItems.Any()) + throw new Exception($"未找到交易日前行情收盘价:" + string.Join(";", missingEodItems)); + + var diffItems = valuationItems + .Select(x => new + { + PositionId = x.Position.id, + UnderlyingCode = x.Position.UnderlyingCode, + PositionPrice = x.Position.PosiGrossPrice * 100m, + MarketDate = x.Eod.ValueDate, + MarketPrice = Convert.ToDecimal(x.Eod.ClosePrice), + DiffAbs = Math.Abs(x.Position.PosiGrossPrice * 100m - Convert.ToDecimal(x.Eod.ClosePrice)) + }) + .ToList(); + + return BuildDeviationDetail( + diffItems.Select(x => new DeviationItem + { + PositionId = x.PositionId, + UnderlyingCode = x.UnderlyingCode, + PositionValue = x.PositionPrice, + MarketDate = x.MarketDate, + MarketValue = x.MarketPrice, + DiffAbs = x.DiffAbs + }).ToList(), + "非债券类价格偏离", + "期初标的价格", + "上一行情收盘价"); + } + + /// + /// 债券类中债估值偏离的公共计算入口,净价偏离和收益率偏离仅通过字段选择器区分取值字段。 + /// + /// 数据库上下文。 + /// 当前交易ID。 + /// 偏离规则名称,用于生成命中明细。 + /// 交易侧取值名称,用于生成命中明细。 + /// 市场估值取值名称,用于生成命中明细。 + /// 交易侧字段选择器。 + /// 中债估值字段选择器。 + /// 包含最大偏离值和逐笔偏离明细的变量返回值。 + private static RiskVariableValueDetail GetBondValuationDeviation( + YLContext dbContext, + int tradeId, + string deviationName, + string positionValueName, + string marketValueName, + Func positionValueSelector, + Func valuationValueSelector) + { + if (dbContext == null) + throw new ArgumentNullException(nameof(dbContext)); + + var tradeDate = GetTradeDate(dbContext, tradeId); + var previousTradingDay = RiskCalendarHelper.GetPreviousInterbankTradingDay(dbContext, tradeDate); + var nextTradingDate = previousTradingDay.AddDays(1); + var floatingPositions = GetFloatingPaymentPositions(dbContext, tradeId).ToList(); + + if (!floatingPositions.Any()) + throw new Exception("浮动支付端记录不存在"); + + var positionItems = floatingPositions + .Select(p => new + { + p.id, + p.UnderlyingCode, + PositionValue = positionValueSelector(p) + }) + .ToList(); + + var missingPositionValueIds = positionItems + .Where(p => !p.PositionValue.HasValue) + .Select(p => p.id.ToString()) + .ToList(); + + if (missingPositionValueIds.Any()) + throw new Exception($"浮动支付端{positionValueName}为空,记录ID:" + string.Join("、", missingPositionValueIds)); + + var underlyingCodes = positionItems + .Select(p => p.UnderlyingCode) + .Distinct() + .ToList(); + + // 债券类必须严格匹配上一银行间交易日当天的中债估值,不能简单取交易日前最近估值日。 + var valuationRows = dbContext.china_bond_valuation + .Where(v => underlyingCodes.Contains(v.bond_id) + && v.valuation_date >= previousTradingDay + && v.valuation_date < nextTradingDate) + .ToList() + .Select(v => new + { + v.id, + v.bond_id, + v.valuation_date, + v.credibility, + ValuationValue = valuationValueSelector(v) + }) + .Where(v => v.ValuationValue.HasValue) + .ToList(); + + // 同一标的同一估值日可能有多条来源,按可信度优先,ID兜底稳定排序。 + var valuationByBondId = valuationRows + .GroupBy(v => v.bond_id) + .ToDictionary( + g => g.Key, + g => g.OrderBy(v => v.credibility).ThenBy(v => v.id).First()); + + var valuationItems = positionItems + .Select(p => new + { + Position = p, + Valuation = valuationByBondId.ContainsKey(p.UnderlyingCode) ? valuationByBondId[p.UnderlyingCode] : null + }) + .ToList(); + + var missingValuationItems = valuationItems + .Where(x => x.Valuation == null) + .Select(x => $"记录ID {x.Position.id},标的{x.Position.UnderlyingCode}") + .ToList(); + + if (missingValuationItems.Any()) + throw new Exception($"未找到上一银行间交易日{previousTradingDay:yyyy-MM-dd}的{marketValueName}:" + string.Join(";", missingValuationItems)); + + var diffItems = valuationItems + .Select(x => new DeviationItem + { + PositionId = x.Position.id, + UnderlyingCode = x.Position.UnderlyingCode, + PositionValue = x.Position.PositionValue.Value * 100m, + MarketDate = x.Valuation.valuation_date, + MarketValue = x.Valuation.ValuationValue.Value, + DiffAbs = Math.Abs(x.Position.PositionValue.Value * 100m - x.Valuation.ValuationValue.Value) + }) + .ToList(); + + return BuildDeviationDetail(diffItems, deviationName, positionValueName, marketValueName); + } + + /// + /// 获取当前交易的交易日,所有行情偏离规则都以交易日作为市场数据取数基准。 + /// + /// 数据库上下文。 + /// 当前交易ID。 + /// 交易日日期部分。 + private static DateTime GetTradeDate(YLContext dbContext, int tradeId) + { + var tradeDate = dbContext.trade + .Where(t => t.id == tradeId) + .Select(t => t.TradeDate) + .FirstOrDefault(); + + if (!tradeDate.HasValue) + throw new Exception("交易日为空"); + + return tradeDate.Value.Date; + } + + /// + /// 获取当前交易下全部浮动支付端记录,行情偏离类规则需要遍历同一TradeId下所有浮动支付端。 + /// + /// 数据库上下文。 + /// 当前交易ID。 + /// 浮动支付端记录查询对象。 + private static IQueryable GetFloatingPaymentPositions(YLContext dbContext, int tradeId) + { + return dbContext.swap_position + .Where(p => p.SwapTradeId == tradeId + && p.IsInitial + && !p.Invalid + && p.PosiDirection == 2 + && !string.IsNullOrEmpty(p.UnderlyingCode)); + } + + /// + /// 统一生成行情偏离类变量返回值,变量值取最大偏离值,命中说明保留逐笔偏离明细。 + /// + /// 逐笔偏离结果。 + /// 偏离规则名称。 + /// 交易侧取值名称。 + /// 市场侧取值名称。 + /// 包含最大偏离值和逐笔偏离明细的变量返回值。 + private static RiskVariableValueDetail BuildDeviationDetail( + List diffItems, + string deviationName, + string positionValueName, + string marketValueName) + { + var maxDiffItem = diffItems + .OrderByDescending(x => x.DiffAbs) + .ThenBy(x => x.PositionId) + .First(); + + var deviatedItems = diffItems + .Where(x => x.DiffAbs > 0m) + .OrderByDescending(x => x.DiffAbs) + .ThenBy(x => x.PositionId) + .Select(x => $"记录ID {x.PositionId},标的{x.UnderlyingCode}:{positionValueName}{FormatDecimal(x.PositionValue)},{x.MarketDate:yyyy-MM-dd}{marketValueName}{FormatDecimal(x.MarketValue)},偏离{FormatDecimal(x.DiffAbs)}") + .ToList(); + + string diffMessage = deviatedItems.Any() + ? $"存在{deviationName}的浮动支付端记录:" + string.Join(";", deviatedItems) + : $"未发现{deviationName}记录"; + + return new RiskVariableValueDetail(maxDiffItem.DiffAbs, diffMessage); + } + + /// + /// 格式化风控命中说明中的数值,避免展示过长小数。 + /// + /// 待格式化数值。 + /// 最多9位小数的展示文本。 + private static string FormatDecimal(decimal value) + { + return value.ToString("0.#########"); + } + + /// + /// 行情偏离计算的中间结果模型,用于把债券和非债券两类计算结果统一交给明细构建逻辑。 + /// + private class DeviationItem + { + public long PositionId { get; set; } + public string UnderlyingCode { get; set; } + public decimal PositionValue { get; set; } + public DateTime MarketDate { get; set; } + public decimal MarketValue { get; set; } + public decimal DiffAbs { get; set; } + } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs b/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs index 6f6be1cb..7ba84352 100644 --- a/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs +++ b/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs @@ -576,6 +576,12 @@ namespace YLErp.Modules.RiskEngine return dateValue.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); if (value is bool boolValue) return boolValue ? "是" : "否"; + if (value is decimal decimalValue) + return decimalValue.ToString("0.#########", CultureInfo.InvariantCulture); + if (value is double doubleValue) + return doubleValue.ToString("0.#########", CultureInfo.InvariantCulture); + if (value is float floatValue) + return floatValue.ToString("0.#########", CultureInfo.InvariantCulture); return Convert.ToString(value, CultureInfo.InvariantCulture); } diff --git a/YLErpDAL/Modules/RiskEngine/测试用例.md b/YLErpDAL/Modules/RiskEngine/测试用例.md index ebf769b8..f3397446 100644 --- a/YLErpDAL/Modules/RiskEngine/测试用例.md +++ b/YLErpDAL/Modules/RiskEngine/测试用例.md @@ -1546,6 +1546,28 @@ WHERE t.id = @TradeId; 开仓名义本金 > 100000000 ``` +变量 Roslyn 示例:变量名为“开仓名义本金”,DataType 为 Numeric;规则前端配置“开仓名义本金 > 阈值”。`OriginalStockEqvNotional` 在实体模型中为 `double?`,脚本中转为 `decimal` 后参与数值比较。 + +```csharp +double? openingNotionalRaw = DbContext.trade + .Where(t => t.id == TradeId) + .Select(t => t.OriginalStockEqvNotional) + .FirstOrDefault(); + +if (!openingNotionalRaw.HasValue) +{ + return new RiskVariableValueDetail( + 0m, + "开仓名义本金为空,本规则不命中"); +} + +decimal openingNotional = (decimal)openingNotionalRaw.Value; + +return new RiskVariableValueDetail( + openingNotional, + $"开仓名义本金为{openingNotional}"); +``` + 规则字段口径: ```text @@ -1613,14 +1635,22 @@ WHERE t.id = @TradeId; 变量 Roslyn 示例:变量名为“保证金支付比例”,DataType 为 Numeric;规则前端仍配置“保证金支付比例 > 0.5”。字段类型需为 decimal / decimal?,否则需要字段层调整或显式类型转换。 ```csharp -decimal marginPaymentAmount = DbContext.swap_position +var marginPaymentItems = DbContext.swap_position .Where(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && (p.InterestMode == 5 || p.InterestMode == 6) && p.InterestDirection == 2) - .Select(p => p.InterestPrincipalFix) - .Sum(); + .Select(p => new + { + p.id, + p.InterestPrincipalFix, + p.InterestRateDefault, + p.HappenDate + }) + .ToList(); + +decimal marginPaymentAmount = marginPaymentItems.Sum(p => p.InterestPrincipalFix); double? openingNotionalRaw = DbContext.trade .Where(t => t.id == TradeId) @@ -1637,9 +1667,19 @@ if (openingNotional == 0m) decimal marginPaymentRatio = marginPaymentAmount / openingNotional; +var marginPaymentDetails = marginPaymentItems + .OrderBy(p => p.HappenDate) + .ThenBy(p => p.id) + .Select(p => $"保证金记录ID为{p.id},支付金额为{p.InterestPrincipalFix},返息率为{p.InterestRateDefault},发生日期为{p.HappenDate}") + .ToList(); + +string marginPaymentMessage = marginPaymentDetails.Any() + ? string.Join(";", marginPaymentDetails) + : "未查询到保证金支付记录"; + return new RiskVariableValueDetail( marginPaymentRatio, - $"保证金支付金额为{marginPaymentAmount},开仓名义本金为{openingNotional}"); + $"保证金支付总金额为{marginPaymentAmount},开仓名义本金为{openingNotional},{marginPaymentMessage}"); ``` 规则字段口径: @@ -1747,28 +1787,44 @@ ORDER BY ABS((预付金返息率 - 1) * 100) > 阈值 ``` -变量 Roslyn 示例:变量名为“保证金利率偏离”,DataType 为 Numeric;规则前端仍配置“保证金利率偏离 > 阈值”。`InterestRateDefault` 实体字段类型为 decimal,可直接参与 decimal 计算。 +变量 Roslyn 示例:变量名为“保证金利率偏离”,DataType 为 Numeric;规则前端仍配置“保证金利率偏离 > 阈值”。脚本会查询当前 TradeId 下所有符合口径的保证金记录,变量值返回最大偏离值用于判断,命中说明列出所有存在偏离的记录。`InterestRateDefault` 实体字段类型为 decimal,可直接参与 decimal 计算。 ```csharp -var marginPosition = DbContext.swap_position +var marginRateItems = DbContext.swap_position .Where(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && (p.InterestMode == 5 || p.InterestMode == 6)) - .OrderBy(p => p.HappenDate) - .ThenBy(p => p.id) - .FirstOrDefault(); + .Select(p => new + { + p.id, + p.InterestRateDefault, + InterestRateDeviation = Math.Abs((p.InterestRateDefault - 1m) * 100m) + }) + .ToList(); -if (marginPosition == null) +if (!marginRateItems.Any()) throw new Exception("预付金记录不存在"); -decimal interestRateRawValue = marginPosition.InterestRateDefault; -decimal interestRatePercentValue = interestRateRawValue * 100m; -decimal interestRateDeviation = Math.Abs((interestRateRawValue - 1m) * 100m); +var maxDeviationItem = marginRateItems + .OrderByDescending(p => p.InterestRateDeviation) + .ThenBy(p => p.id) + .First(); + +var deviatedItems = marginRateItems + .Where(p => p.InterestRateDeviation > 0m) + .OrderByDescending(p => p.InterestRateDeviation) + .ThenBy(p => p.id) + .Select(p => $"记录ID {p.id}:返息率{(p.InterestRateDefault * 100m).ToString("0.#########")}%,偏离{p.InterestRateDeviation.ToString("0.#########")}%") + .ToList(); + +string deviationMessage = deviatedItems.Any() + ? "存在偏离的预付金记录:" + string.Join(";", deviatedItems) + : "未发现保证金利率偏离记录"; return new RiskVariableValueDetail( - interestRateDeviation, - $"预付金返息率原值为{interestRateRawValue},页面百分比口径为{interestRatePercentValue},保证金利率偏离为{interestRateDeviation}"); + maxDeviationItem.InterestRateDeviation, + deviationMessage); ``` 规则字段口径: @@ -1878,14 +1934,22 @@ ORDER BY 变量 Roslyn 示例:变量名为“保证金收取比例”,DataType 为 Numeric;规则前端仍配置“保证金收取比例 < 0.2”。字段类型需为 decimal / decimal?,否则需要字段层调整或显式类型转换。 ```csharp -decimal marginReceiveAmount = DbContext.swap_position +var marginReceiveItems = DbContext.swap_position .Where(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && (p.InterestMode == 5 || p.InterestMode == 6) && p.InterestDirection == 1) - .Select(p => p.InterestPrincipalFix) - .Sum(); + .Select(p => new + { + p.id, + p.InterestPrincipalFix, + p.InterestRateDefault, + p.HappenDate + }) + .ToList(); + +decimal marginReceiveAmount = marginReceiveItems.Sum(p => p.InterestPrincipalFix); double? openingNotionalRaw = DbContext.trade .Where(t => t.id == TradeId) @@ -1902,9 +1966,19 @@ if (openingNotional == 0m) decimal marginReceiveRatio = marginReceiveAmount / openingNotional; +var marginReceiveDetails = marginReceiveItems + .OrderBy(p => p.HappenDate) + .ThenBy(p => p.id) + .Select(p => $"保证金记录ID为{p.id},收取金额为{p.InterestPrincipalFix},返息率为{p.InterestRateDefault},发生日期为{p.HappenDate}") + .ToList(); + +string marginReceiveMessage = marginReceiveDetails.Any() + ? string.Join(";", marginReceiveDetails) + : "未查询到保证金收取记录"; + return new RiskVariableValueDetail( marginReceiveRatio, - $"保证金收取金额为{marginReceiveAmount},开仓名义本金为{openingNotional}"); + $"保证金收取总金额为{marginReceiveAmount},开仓名义本金为{openingNotional},{marginReceiveMessage}"); ``` 规则字段口径: @@ -2011,6 +2085,36 @@ ORDER BY 起息日 < 当前日期 ``` +变量 Roslyn 示例:拆成两个 Date 类型变量,规则前端配置“起息日 < 今日”,右侧阈值类型选择变量“今日”。 + +变量 1:变量名为“起息日”,DataType 为 Date。为保持原公式 `StartDate.HasValue && StartDate.Value.Date < DateTime.Today` 的语义,起息日为空时返回当前日期,使本规则不命中,避免空值被当作执行异常。 + +```csharp +DateTime? startDate = DbContext.trade + .Where(t => t.id == TradeId) + .Select(t => t.StartDate) + .FirstOrDefault(); + +if (!startDate.HasValue) +{ + return new RiskVariableValueDetail( + DateTime.Today, + "起息日为空,本规则不命中"); +} + +return new RiskVariableValueDetail( + startDate.Value.Date, + $"起息日为{startDate.Value.Date:yyyy-MM-dd}"); +``` + +变量 2:变量名为“今日”,DataType 为 Date。 + +```csharp +return new RiskVariableValueDetail( + DateTime.Today, + $"当前日期为{DateTime.Today:yyyy-MM-dd}"); +``` + 规则字段口径: ```text @@ -2340,6 +2444,68 @@ WHERE t.id = @TradeId; (到期日.Date - 起始日.Date).Days + 是否算头 - 是否不算尾 > 阈值 ``` +变量 Roslyn 示例:变量名为“合约期限天数”,DataType 为 Numeric;规则前端配置“合约期限天数 > 阈值”。变量内部统一读取起始日、到期日和计息方式,并返回实际合约期限天数。 + +```csharp +var tradeInfo = DbContext.trade + .Where(t => t.id == TradeId) + .Select(t => new + { + t.StartDate, + t.ExerciseDate + }) + .FirstOrDefault(); + +if (tradeInfo == null) + throw new Exception("交易不存在"); + +if (!tradeInfo.StartDate.HasValue || !tradeInfo.ExerciseDate.HasValue) +{ + return new RiskVariableValueDetail( + 0m, + $"起始日或到期日为空,起始日为{tradeInfo.StartDate?.ToString("yyyy-MM-dd") ?? "空"},到期日为{tradeInfo.ExerciseDate?.ToString("yyyy-MM-dd") ?? "空"},本规则不命中"); +} + +var tradeExtend = DbContext.trade_extend + .Where(e => e.TradeId == TradeId) + .FirstOrDefault(); + +string interestCalcModeRaw = tradeExtend?.ExtendObj?.InterestCalcMode; +string interestCalcMode = string.IsNullOrWhiteSpace(interestCalcModeRaw) ? "11" : interestCalcModeRaw; + +if (interestCalcMode.Length != 2 + || (interestCalcMode[0] != '0' && interestCalcMode[0] != '1') + || (interestCalcMode[1] != '0' && interestCalcMode[1] != '1')) +{ + throw new Exception($"计息方式不合法:{interestCalcMode}"); +} + +DateTime startDate = tradeInfo.StartDate.Value.Date; +DateTime exerciseDate = tradeInfo.ExerciseDate.Value.Date; +int baseNaturalDays = (exerciseDate - startDate).Days; +int calcFirstDays = interestCalcMode.StartsWith("1") ? 1 : 0; +int notCalcLastDays = interestCalcMode.EndsWith("1") ? 0 : -1; +int contractNaturalDays = baseNaturalDays + calcFirstDays + notCalcLastDays; +string interestCalcModeText = interestCalcMode == "00" + ? "不计头不计尾" + : interestCalcMode == "01" + ? "不计头计尾" + : interestCalcMode == "10" + ? "计头不计尾" + : "计头计尾"; +string interestCalcModeDescription = interestCalcMode == "00" + ? "不计入起始日,不计入到期日" + : interestCalcMode == "01" + ? "不计入起始日,计入到期日" + : interestCalcMode == "10" + ? "计入起始日,不计入到期日" + : "计入起始日,计入到期日"; + +return new RiskVariableValueDetail( + contractNaturalDays, + $"起始日为{startDate:yyyy-MM-dd},到期日为{exerciseDate:yyyy-MM-dd},计息方式为{interestCalcModeText},{interestCalcModeDescription}"); +``` + 规则字段口径: ```text @@ -2423,13 +2589,14 @@ WHERE t.id = @TradeId; 取数流程: ```text -1. 根据 TradeId 查 swap_position。 -2. 限定 IsInitial=1、Invalid=0、PosiDirection=2、UnderlyingCode 非空,取浮动支付端。 -3. 从浮动支付端取 PosiNetNoFeePrice 和 UnderlyingCode。 -4. 用 swap_position.UnderlyingCode 关联 china_bond_valuation.bond_id。 -5. 限定 valuation_date < trade.TradeDate,取交易日前估值。 -6. 按 credibility ASC、valuation_date DESC 排序,优先 credibility=1,再取最近估值日。 -7. 计算 ABS(PosiNetNoFeePrice * 100 - net_price),大于 5 则命中。 +1. 根据 TradeId 查 trade.TradeDate。 +2. 通过银行间日历 Country=IB 计算交易日的上一银行间交易日,查不到日历或上一交易日时报异常。 +3. 根据 TradeId 查 swap_position。 +4. 限定 IsInitial=1、Invalid=0、PosiDirection=2、UnderlyingCode 非空,取所有浮动支付端。 +5. 从浮动支付端取 PosiNetNoFeePrice 和 UnderlyingCode。 +6. 用 swap_position.UnderlyingCode 关联 china_bond_valuation.bond_id。 +7. 限定 valuation_date 为上一银行间交易日当天,优先取 credibility=1。 +8. 计算 ABS(PosiNetNoFeePrice * 100 - net_price),大于 5 则命中。 ``` 规则公式: @@ -2438,6 +2605,13 @@ WHERE t.id = @TradeId; ABS(浮动支付端.PosiNetNoFeePrice * 100 - 上一收盘日中债估值.net_price) > 5 ``` +变量 Roslyn 示例:变量名为“债券类净价偏离值”,DataType 为 Numeric;规则前端配置“债券类净价偏离值 > 阈值”。具体取数、上一银行间交易日确认、估值匹配、债券/非债券差异处理统一放在 `RiskMarketDeviationHelper` 中,变量公式只保留公共方法调用。 + +```csharp +return YLErp.Modules.RiskEngine.RiskMarketDeviationHelper.GetBondNetPriceDeviation(DbContext, TradeId); +``` + + 注释规则定义: ```csharp @@ -2461,9 +2635,42 @@ ABS(浮动支付端.PosiNetNoFeePrice * 100 - 上一收盘日中债估值.net_pr ```sql SET @TradeId = 3001699; +WITH RECURSIVE candidate_dates AS ( + SELECT DATE(t.TradeDate) - INTERVAL 1 DAY AS CandidateDate + FROM trade t + WHERE t.id = @TradeId + + UNION ALL + + SELECT CandidateDate - INTERVAL 1 DAY + FROM candidate_dates + WHERE CandidateDate > DATE_SUB((SELECT DATE(TradeDate) FROM trade WHERE id = @TradeId), INTERVAL 370 DAY) +), +previous_trading_day AS ( + SELECT cd.CandidateDate + FROM candidate_dates cd + INNER JOIN calendar c + ON c.Year = YEAR(cd.CandidateDate) + AND UPPER(c.Country) = 'IB' + AND (c.ValidState IS NULL OR c.ValidState <> 'InValid') + WHERE NOT JSON_CONTAINS(c.HolidayJson, JSON_QUOTE(DATE_FORMAT(cd.CandidateDate, '%Y,%m,%d'))) + ORDER BY cd.CandidateDate DESC + LIMIT 1 +), +bond_valuation_ranked AS ( + SELECT + bv.*, + ROW_NUMBER() OVER (PARTITION BY bv.bond_id ORDER BY bv.credibility ASC, bv.id ASC) AS RowNo + FROM china_bond_valuation bv + INNER JOIN previous_trading_day ptd + ON bv.valuation_date >= ptd.CandidateDate + AND bv.valuation_date < DATE_ADD(ptd.CandidateDate, INTERVAL 1 DAY) + WHERE bv.net_price IS NOT NULL +) SELECT t.id AS TradeId, t.TradeDate, + ptd.CandidateDate AS PreviousInterbankTradingDay, sp.id AS SwapPositionId, sp.SwapTradeId, @@ -2487,6 +2694,7 @@ SELECT ELSE 0 END AS IsGreaterThan5 FROM trade t +CROSS JOIN previous_trading_day ptd INNER JOIN swap_position sp ON sp.SwapTradeId = t.id AND sp.IsInitial = 1 @@ -2494,14 +2702,13 @@ INNER JOIN swap_position sp AND sp.PosiDirection = 2 AND sp.UnderlyingCode IS NOT NULL AND sp.UnderlyingCode <> '' -LEFT JOIN china_bond_valuation bv +LEFT JOIN bond_valuation_ranked bv ON bv.bond_id = sp.UnderlyingCode - AND bv.valuation_date < DATE(t.TradeDate) + AND bv.RowNo = 1 WHERE t.id = @TradeId ORDER BY - bv.credibility ASC, - bv.valuation_date DESC -LIMIT 1; + ABS(sp.PosiNetNoFeePrice * 100 - bv.net_price) DESC, + sp.id ASC; ``` --- @@ -2726,6 +2933,48 @@ COUNT(DISTINCT swap_position.UnderlyingCode) > 10 //}); ``` +变量形式: + +变量名:单一交易对手累计标的数量 +DataType:Numeric +规则配置:单一交易对手累计标的数量 > 阈值(示例 10) + +变量取值表达式: + +```csharp +int? currentClientId = DbContext.trade + .Where(t => t.id == TradeId) + .Select(t => (int?)t.ClientId) + .FirstOrDefault(); + +if (!currentClientId.HasValue) + throw new Exception("当前交易不存在或交易对手为空"); + +// 查询同一交易对手下所有有效交易的实时存续持仓,对标的去重计数 +var underlyingCodes = DbContext.swap_position + .Where(p => !p.IsInitial + && p.PosiQuantity > 0 + && !p.Invalid + && p.PosiDirection > 0 + && !string.IsNullOrEmpty(p.UnderlyingCode) + && DbContext.trade.Any(t => t.id == p.SwapTradeId + && t.ValidState != "InValid" + && t.ClientId == currentClientId.Value)) + .Select(p => p.UnderlyingCode) + .Distinct() + .ToList(); + +int distinctCount = underlyingCodes.Count; + +string underlyingList = underlyingCodes.Any() + ? string.Join("、", underlyingCodes.OrderBy(c => c)) + : "无"; + +return new RiskVariableValueDetail( + distinctCount, + $"交易对手ID为{currentClientId},存续标的共{distinctCount}个:{underlyingList}"); +``` + 汇总 SQL: ```sql diff --git a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs index 725fcd7b..e090ee27 100644 --- a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs +++ b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs @@ -5017,7 +5017,7 @@ namespace YLErp.Modules.RiskModule } riskWarning += "风险预警: " + quotaTrial.RiskWarningDetails; } - log.risk_warning = riskWarning; + log.risk_warning = TruncateRiskCheckLogText(riskWarning); log.limit_warning = quotaTrial.QuotaCheckDetails; log.remark = RiskCheckTriggerRemark; if (isOldRiskErrorSpecialApproval) @@ -5411,6 +5411,17 @@ namespace YLErp.Modules.RiskModule return detail + "\n"; } + private const int RiskCheckLogTextMaxLength = 500; + + private static string TruncateRiskCheckLogText(string text) + { + if (string.IsNullOrEmpty(text) || text.Length <= RiskCheckLogTextMaxLength) + return text; + + const string suffix = "……(日志内容过长已截断)"; + return text.Substring(0, RiskCheckLogTextMaxLength - suffix.Length) + suffix; + } + /// /// 校验标的白名单 /// diff --git a/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs b/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs index 687754dd..f045e2f5 100644 --- a/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs +++ b/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs @@ -22,6 +22,16 @@ namespace YLErp.Modules.RiskModule { } + private const int RiskCheckLogTextMaxLength = 500; + + private static string TruncateRiskCheckLogText(string text) + { + if (string.IsNullOrEmpty(text) || text.Length <= RiskCheckLogTextMaxLength) + return text; + + const string suffix = "……(日志内容过长已截断)"; + return text.Substring(0, RiskCheckLogTextMaxLength - suffix.Length) + suffix; + } public SearchListResult Search(SearchTradeRiskCheckLogRequest req) { @@ -153,7 +163,7 @@ namespace YLErp.Modules.RiskModule } riskWarning += "风控预警: " + quotaTrial.RiskWarningDetails; } - log.risk_warning = riskWarning; + log.risk_warning = TruncateRiskCheckLogText(riskWarning); log.limit_warning = quotaTrial.QuotaCheckDetails; log.remark = quotaTrial.Remark; log.create_user = UserId; @@ -202,9 +212,10 @@ namespace YLErp.Modules.RiskModule log.client_name = quotaTrial.ClientName; log.trader = td?.TraderName; log.trade_number = quotaTrial.TradeNumber; - log.risk_warning = string.IsNullOrWhiteSpace(quotaTrial.RiskWarningDetails) + var riskWarning = string.IsNullOrWhiteSpace(quotaTrial.RiskWarningDetails) ? $"[风控预警处理]{decision}" : quotaTrial.RiskWarningDetails + Environment.NewLine + $"[风控预警处理]{decision}"; + log.risk_warning = TruncateRiskCheckLogText(riskWarning); log.limit_warning = quotaTrial.QuotaCheckDetails; log.remark = string.IsNullOrWhiteSpace(quotaTrial.Remark) ? $"风控预警处理结果:{decision}" diff --git a/YLErpWeb/Controllers/tradeController.cs b/YLErpWeb/Controllers/tradeController.cs index 39714b40..827bc8a6 100644 --- a/YLErpWeb/Controllers/tradeController.cs +++ b/YLErpWeb/Controllers/tradeController.cs @@ -2493,8 +2493,8 @@ namespace YLErp.Web.Controllers } var result = new TradeConfirmService(CurUser).tradeConfirm(tradeidArr, ignoreMoneyCheck, isSkipApproval, ignoreRiskWarning, ignoreRiskRuleIdArr); - //如果客户缺少资金而操作者有交易特批权限 - if (!ignoreMoneyCheck && !ignoreRiskWarning && result.LackOfMoney) + //如果需要前端确认信息,触发新老风控 + if (result.LackOfMoney) { if (result.type == TradeOpenRetCode.RiskWarning.ToString()) {