diff --git a/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs b/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs
index 07dee68c..8ea8d558 100644
--- a/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs
+++ b/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs
@@ -14,11 +14,33 @@ 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>>();
+ private static readonly ConditionalWeakTable>> HolidayCaches =
+ new ConditionalWeakTable>>();
///
/// 获取指定日期的上一银行间交易日。
@@ -48,8 +70,7 @@ namespace YLErp.Modules.RiskEngine
var holidayCache = HolidayCaches.GetOrCreateValue(dbContext);
var holidays = GetHolidays(dbContext, date.Year, "IB", "银行间", holidayCache);
- var dateText = date.Date.ToString("yyyy,MM,dd", CultureInfo.InvariantCulture);
- return !holidays.Contains(dateText);
+ return !holidays.Contains(date.Date);
}
public static DateTime GetPreviousExchangeTradingDay(YLContext dbContext, DateTime date)
@@ -70,10 +91,9 @@ namespace YLErp.Modules.RiskEngine
for (var i = 0; i < 370; i++)
{
var holidays = GetHolidays(dbContext, currentDate.Year, country, calendarName, holidayCache);
- var currentDateText = currentDate.ToString("yyyy,MM,dd", CultureInfo.InvariantCulture);
// calendar.HolidayJson 存的是非交易日;不在非交易日集合内,即认为是对应市场的交易日。
- if (!holidays.Contains(currentDateText))
+ if (!holidays.Contains(currentDate))
return currentDate;
currentDate = currentDate.AddDays(-1);
@@ -83,32 +103,51 @@ namespace YLErp.Modules.RiskEngine
}
///
- /// 获取指定年份的银行间非交易日集合。
+ /// 获取指定年份的非交易日集合。
///
/// 当前风控执行使用的数据库上下文。
/// 日历年份。
/// 单次风控检查内按市场和年份共享的非交易日缓存。
- /// 格式为 yyyy,MM,dd 的非交易日集合。
- private static HashSet GetHolidays(YLContext dbContext, int year, string country, string calendarName, Dictionary> holidayCache)
+ /// 按 Date 归一化后的非交易日集合。
+ private static HashSet GetHolidays(YLContext dbContext, int year, string country, string calendarName, Dictionary> holidayCache)
{
- // 缓存需要同时区分市场和年份,避免银行间与交易所同一年日历相互串用。
- var cacheKey = $"{country.ToUpperInvariant()}:{year}";
+ var normalizedCountry = NormalizeCountry(country);
+ var cacheKey = $"{normalizedCountry}:{year}";
if (holidayCache.TryGetValue(cacheKey, out var holidays))
return holidays;
- // 在数据库端同时按年份和市场代码筛选,避免加载同一年份的其他市场日历。
- var normalizedCountry = country.ToUpperInvariant();
- var calendar = dbContext.calendar.FirstOrDefault(c =>
- c.Year == year
- && (c.ValidState == null || c.ValidState != ConsGlobal.InValid)
- && c.Country != null
- && c.Country.ToUpper() == normalizedCountry);
+ // 先按年份和有效状态缩小范围,再在内存里做 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)
- throw new Exception($"未找到{year}年{calendarName}日历");
+ {
+ 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
@@ -117,12 +156,70 @@ namespace YLErp.Modules.RiskEngine
}
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);
}
- holidays = new HashSet(holidayList ?? new List());
+ // 日历解析和日志统计只在缓存未命中时执行;同一次风控检查内重复判断交易日不会重复解析 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}");
+ }
}
}