using Newtonsoft.Json; using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Runtime.CompilerServices; using YLErp.BLL; namespace YLErp.Modules.RiskEngine { /// /// 风控规则专用日历辅助类。 /// 用于按规则对应的市场日历确认“上一收盘日”,避免简单按行情表倒序取最近日期导致口径偏差。 /// public static class RiskCalendarHelper { private const string CalendarDateFormat = "yyyy,MM,dd"; /// /// 日历数据异常直接影响平仓日期校验和上一交易日取数,单独使用固定 logger 名称便于线上按模块检索。 /// private static readonly IYcLogger Logger = LogFactory.GetLogger("RiskCalendarHelper"); /// /// calendar.HolidayJson 历史主格式是 yyyy,MM,dd;这里额外兼容常见日期格式,避免历史数据被静默当作交易日。 /// 解析后统一转成 DateTime.Date,后续交易日判断不再依赖原始字符串格式。 /// private static readonly string[] SupportedCalendarDateFormats = { CalendarDateFormat, "yyyy/MM/dd", "yyyy-MM-dd", "yyyyMMdd", "yyyy-MM-ddTHH:mm:ss", "yyyy-MM-ddTHH:mm:ssK", "yyyy-MM-ddTHH:mm:ss.fffK" }; /// /// 按当前风控执行使用的数据库上下文保存日历缓存,同一次风控检查内复用,数据库上下文释放后不阻止缓存被回收。 /// private static readonly ConditionalWeakTable>> HolidayCaches = new ConditionalWeakTable>>(); /// /// 获取指定日期的上一银行间交易日。 /// /// 当前风控执行使用的数据库上下文。 /// 当前交易日或业务基准日。 /// 严格早于入参日期的上一银行间交易日。 /// 数据库上下文为空。 /// 缺少银行间日历、日历内容异常或在保护范围内找不到上一交易日。 public static DateTime GetPreviousInterbankTradingDay(YLContext dbContext, DateTime date) { return GetPreviousTradingDay(dbContext, date, "IB", "银行间"); } /// /// 判断指定日期是否为银行间交易日。 /// /// 当前风控执行使用的数据库上下文。 /// 待判断的平仓日期。 /// 日期不在银行间非交易日集合中时返回 true。 /// 数据库上下文为空。 /// 缺少银行间日历或日历内容异常。 public static bool IsInterbankTradingDay(YLContext dbContext, DateTime date) { if (dbContext == null) throw new ArgumentNullException(nameof(dbContext)); var holidayCache = HolidayCaches.GetOrCreateValue(dbContext); var holidays = GetHolidays(dbContext, date.Year, "IB", "银行间", holidayCache); return !holidays.Contains(date.Date); } public static DateTime GetPreviousExchangeTradingDay(YLContext dbContext, DateTime date) { return GetPreviousTradingDay(dbContext, date, "CHN", "交易所"); } private static DateTime GetPreviousTradingDay(YLContext dbContext, DateTime date, string country, string calendarName) { if (dbContext == null) throw new ArgumentNullException(nameof(dbContext)); // 以本次风控检查的数据库上下文为缓存边界,避免不同规则重复读取和解析相同市场日历。 var holidayCache = HolidayCaches.GetOrCreateValue(dbContext); var currentDate = date.Date.AddDays(-1); // 最多向前查 370 天,既覆盖跨年和长假场景,也避免日历配置异常时出现无限循环。 for (var i = 0; i < 370; i++) { var holidays = GetHolidays(dbContext, currentDate.Year, country, calendarName, holidayCache); // calendar.HolidayJson 存的是非交易日;不在非交易日集合内,即认为是对应市场的交易日。 if (!holidays.Contains(currentDate)) return currentDate; currentDate = currentDate.AddDays(-1); } throw new Exception($"未找到{date:yyyy-MM-dd}的上一{calendarName}交易日"); } /// /// 获取指定年份的非交易日集合。 /// /// 当前风控执行使用的数据库上下文。 /// 日历年份。 /// 单次风控检查内按市场和年份共享的非交易日缓存。 /// 按 Date 归一化后的非交易日集合。 private static HashSet GetHolidays(YLContext dbContext, int year, string country, string calendarName, Dictionary> holidayCache) { var normalizedCountry = NormalizeCountry(country); var cacheKey = $"{normalizedCountry}:{year}"; if (holidayCache.TryGetValue(cacheKey, out var holidays)) return holidays; // 先按年份和有效状态缩小范围,再在内存里做 Trim + ToUpperInvariant 匹配,兼容历史 Country 存在大小写或前后空格的情况。 // 这里没有在数据库查询里直接 Trim,是为了避免不同 EF/数据库提供方对字符串函数翻译不一致。 var validCalendars = dbContext.calendar .Where(c => c.Year == year && (c.ValidState == null || c.ValidState != ConsGlobal.InValid) && c.Country != null) .ToList(); var matchedCalendars = validCalendars.Where(c => NormalizeCountry(c.Country) == normalizedCountry).ToList(); var calendar = matchedCalendars.FirstOrDefault(); if (calendar == null) { var availableCountries = string.Join(",", validCalendars.Select(c => c.Country?.Trim()).Where(c => !string.IsNullOrWhiteSpace(c)).Distinct()); Logger.Error($"[风控日历] 未找到目标市场日历 - Year:{year}, CalendarName:{calendarName}, Country:{normalizedCountry}, AvailableCountries:{availableCountries}"); throw new Exception($"未找到{year}年{calendarName}日历,Country={normalizedCountry},当前可用日历:{availableCountries}"); } if (matchedCalendars.Count > 1) { // 多条匹配不改变原有“取第一条”的行为,只记录数据质量问题,避免线上突然因历史重复配置中断风控。 Logger.Info($"[风控日历] 同一年存在多个匹配市场日历,使用第一条 - Year:{year}, CalendarName:{calendarName}, Country:{normalizedCountry}, MatchedIds:{string.Join(",", matchedCalendars.Select(c => c.id))}"); } if (!string.Equals(calendar.Country, normalizedCountry, StringComparison.Ordinal)) { // Country 能通过归一化匹配说明历史数据存在大小写或空格差异,只在日历首次加载时记录一次,便于后续清洗数据。 Logger.Info($"[风控日历] 日历Country已归一化匹配 - Year:{year}, CalendarName:{calendarName}, RawCountry:{calendar.Country}, NormalizedCountry:{normalizedCountry}, CalendarId:{calendar.id}"); } if (string.IsNullOrWhiteSpace(calendar.HolidayJson)) { Logger.Error($"[风控日历] HolidayJson为空 - Year:{year}, CalendarName:{calendarName}, Country:{normalizedCountry}, CalendarId:{calendar.id}"); throw new Exception($"{year}年{calendarName}日历HolidayJson为空"); } List holidayList; try { holidayList = JsonConvert.DeserializeObject>(calendar.HolidayJson); } catch (Exception ex) { Logger.Error($"[风控日历] HolidayJson解析失败 - Year:{year}, CalendarName:{calendarName}, Country:{normalizedCountry}, CalendarId:{calendar.id}, Error:{ex.Message}"); throw new Exception($"{year}年{calendarName}日历HolidayJson解析失败", ex); } // 日历解析和日志统计只在缓存未命中时执行;同一次风控检查内重复判断交易日不会重复解析 HolidayJson。 holidays = NormalizeHolidays(holidayList, year, calendarName, normalizedCountry, calendar.id); holidayCache[cacheKey] = holidays; return holidays; } private static string NormalizeCountry(string country) { return (country ?? string.Empty).Trim().ToUpperInvariant(); } private static HashSet NormalizeHolidays(IEnumerable holidayList, int year, string calendarName, string normalizedCountry, int calendarId) { var holidays = new HashSet(); var formatCounts = new Dictionary(); var rawCount = 0; var blankCount = 0; var duplicateCount = 0; foreach (var holidayText in holidayList ?? Enumerable.Empty()) { rawCount++; if (string.IsNullOrWhiteSpace(holidayText)) { blankCount++; continue; } // 每条原始日期只在缓存加载阶段解析一次;正常数据只累计格式分布,避免大量节假日逐条写日志。 var holidayDate = ParseHolidayDate(holidayText, year, calendarName, normalizedCountry, calendarId, out var matchedFormat); if (!holidays.Add(holidayDate)) { duplicateCount++; } formatCounts[matchedFormat] = formatCounts.TryGetValue(matchedFormat, out var count) ? count + 1 : 1; } Logger.Info($"[风控日历] HolidayJson日期归一化完成 - Year:{year}, CalendarName:{calendarName}, Country:{normalizedCountry}, CalendarId:{calendarId}, RawCount:{rawCount}, HolidayCount:{holidays.Count}, BlankCount:{blankCount}, DuplicateCount:{duplicateCount}, Formats:{string.Join(",", formatCounts.Select(item => item.Key + ":" + item.Value))}"); return holidays; } private static DateTime ParseHolidayDate(string holidayText, int year, string calendarName, string normalizedCountry, int calendarId, out string matchedFormat) { var normalizedHolidayText = holidayText.Trim(); foreach (var format in SupportedCalendarDateFormats) { if (DateTime.TryParseExact(normalizedHolidayText, format, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var holidayDate)) { matchedFormat = format; return holidayDate.Date; } } // 支持带时区的历史数据;最终取日期部分用于非交易日集合匹配,避免字符串格式差异导致静默漏判。 if (DateTimeOffset.TryParse(normalizedHolidayText, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var holidayDateOffset)) { matchedFormat = "DateTimeOffset.TryParse"; return holidayDateOffset.Date; } Logger.Error($"[风控日历] HolidayJson日期格式无法识别 - Year:{year}, CalendarName:{calendarName}, Country:{normalizedCountry}, CalendarId:{calendarId}, RawValue:{holidayText}"); throw new Exception($"{year}年{calendarName}日历HolidayJson存在无法识别的日期格式:{holidayText}"); } } }