226 lines
12 KiB
C#
226 lines
12 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// 风控规则专用日历辅助类。
|
|
/// 用于按规则对应的市场日历确认“上一收盘日”,避免简单按行情表倒序取最近日期导致口径偏差。
|
|
/// </summary>
|
|
public static class RiskCalendarHelper
|
|
{
|
|
private const string CalendarDateFormat = "yyyy,MM,dd";
|
|
|
|
/// <summary>
|
|
/// 日历数据异常直接影响平仓日期校验和上一交易日取数,单独使用固定 logger 名称便于线上按模块检索。
|
|
/// </summary>
|
|
private static readonly IYcLogger Logger = LogFactory.GetLogger("RiskCalendarHelper");
|
|
|
|
/// <summary>
|
|
/// calendar.HolidayJson 历史主格式是 yyyy,MM,dd;这里额外兼容常见日期格式,避免历史数据被静默当作交易日。
|
|
/// 解析后统一转成 DateTime.Date,后续交易日判断不再依赖原始字符串格式。
|
|
/// </summary>
|
|
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"
|
|
};
|
|
|
|
/// <summary>
|
|
/// 按当前风控执行使用的数据库上下文保存日历缓存,同一次风控检查内复用,数据库上下文释放后不阻止缓存被回收。
|
|
/// </summary>
|
|
private static readonly ConditionalWeakTable<YLContext, Dictionary<string, HashSet<DateTime>>> HolidayCaches =
|
|
new ConditionalWeakTable<YLContext, Dictionary<string, HashSet<DateTime>>>();
|
|
|
|
/// <summary>
|
|
/// 获取指定日期的上一银行间交易日。
|
|
/// </summary>
|
|
/// <param name="dbContext">当前风控执行使用的数据库上下文。</param>
|
|
/// <param name="date">当前交易日或业务基准日。</param>
|
|
/// <returns>严格早于入参日期的上一银行间交易日。</returns>
|
|
/// <exception cref="ArgumentNullException">数据库上下文为空。</exception>
|
|
/// <exception cref="Exception">缺少银行间日历、日历内容异常或在保护范围内找不到上一交易日。</exception>
|
|
public static DateTime GetPreviousInterbankTradingDay(YLContext dbContext, DateTime date)
|
|
{
|
|
return GetPreviousTradingDay(dbContext, date, "IB", "银行间");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 判断指定日期是否为银行间交易日。
|
|
/// </summary>
|
|
/// <param name="dbContext">当前风控执行使用的数据库上下文。</param>
|
|
/// <param name="date">待判断的平仓日期。</param>
|
|
/// <returns>日期不在银行间非交易日集合中时返回 true。</returns>
|
|
/// <exception cref="ArgumentNullException">数据库上下文为空。</exception>
|
|
/// <exception cref="Exception">缺少银行间日历或日历内容异常。</exception>
|
|
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}交易日");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 获取指定年份的非交易日集合。
|
|
/// </summary>
|
|
/// <param name="dbContext">当前风控执行使用的数据库上下文。</param>
|
|
/// <param name="year">日历年份。</param>
|
|
/// <param name="holidayCache">单次风控检查内按市场和年份共享的非交易日缓存。</param>
|
|
/// <returns>按 Date 归一化后的非交易日集合。</returns>
|
|
private static HashSet<DateTime> GetHolidays(YLContext dbContext, int year, string country, string calendarName, Dictionary<string, HashSet<DateTime>> 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<string> holidayList;
|
|
try
|
|
{
|
|
holidayList = JsonConvert.DeserializeObject<List<string>>(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<DateTime> NormalizeHolidays(IEnumerable<string> holidayList, int year, string calendarName, string normalizedCountry, int calendarId)
|
|
{
|
|
var holidays = new HashSet<DateTime>();
|
|
var formatCounts = new Dictionary<string, int>();
|
|
var rawCount = 0;
|
|
var blankCount = 0;
|
|
var duplicateCount = 0;
|
|
foreach (var holidayText in holidayList ?? Enumerable.Empty<string>())
|
|
{
|
|
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}");
|
|
}
|
|
}
|
|
}
|