using Domain.Entities.Paper.ValueObject; namespace Application.Helpers; /// /// 모의투자 체결 규칙(KST) 계산 — 접수 시각에 따라 FillRule/TargetDate/CancelableUntil 을 확정한다 (d4 §③). /// • 영업일 t < 09:00 → 당일 시가 (CancelableUntil = 당일 09:00) /// • 영업일 09:00 ≤ t < 15:30 → 당일 종가 (CancelableUntil = 당일 15:30) /// • t ≥ 15:30 또는 휴장일 → 다음 영업일 시가 (CancelableUntil = 익영업일 09:00) /// 휴장일은 주말 + 호출자가 주입하는 MarketHoliday 집합으로 판정한다. /// public static class PaperTradingClock { private static readonly TimeOnly MarketOpen = new(9, 0); private static readonly TimeOnly MarketClose = new(15, 30); private static readonly TimeZoneInfo Kst = ResolveKst(); public static DateTime NowKst() => TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, Kst); public static DateTime ToUtc(DateTime kst) { var unspecified = DateTime.SpecifyKind(kst, DateTimeKind.Unspecified); return TimeZoneInfo.ConvertTimeToUtc(unspecified, Kst); } public static bool IsHoliday(DateOnly date, IReadOnlySet holidays) { if (date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday) { return true; } return holidays.Contains(date); } /// date 이후(자기 자신 제외)의 다음 영업일. public static DateOnly NextBusinessDay(DateOnly date, IReadOnlySet holidays) { var next = date.AddDays(1); while (IsHoliday(next, holidays)) { next = next.AddDays(1); } return next; } /// date 로부터 N 영업일 뒤(자기 자신 제외). count <= 0 이면 date 그대로. (예측 채점 DueDate 산정 — d2 §③) public static DateOnly AddBusinessDays(DateOnly date, int count, IReadOnlySet holidays) { var result = date; for (var i = 0; i < count; i++) { result = NextBusinessDay(result, holidays); } return result; } /// date 가 영업일이면 그대로, 아니면 다음 영업일. public static DateOnly EnsureBusinessDay(DateOnly date, IReadOnlySet holidays) { var d = date; while (IsHoliday(d, holidays)) { d = d.AddDays(1); } return d; } /// /// 접수 시각(KST)으로 체결 계획을 산정한다. 반환은 (FillRule, TargetDate, CancelableUntilKst). /// CancelableUntilKst 는 KST 벽시계 기준 — 호출자가 ToUtc 로 변환해 저장한다. /// public static (PaperFillRule FillRule, DateOnly TargetDate, DateTime CancelableUntilKst) Plan( DateTime nowKst, IReadOnlySet holidays) { var today = DateOnly.FromDateTime(nowKst); var timeOfDay = TimeOnly.FromDateTime(nowKst); if (!IsHoliday(today, holidays)) { if (timeOfDay < MarketOpen) { // 당일 시가 var cancelable = today.ToDateTime(MarketOpen); return (PaperFillRule.Open, today, cancelable); } if (timeOfDay < MarketClose) { // 당일 종가 var cancelable = today.ToDateTime(MarketClose); return (PaperFillRule.Close, today, cancelable); } } // 15:30 이후 또는 휴장일 → 다음 영업일 시가 var nextDay = NextBusinessDay(today, holidays); var cancelableUntil = nextDay.ToDateTime(MarketOpen); return (PaperFillRule.Open, nextDay, cancelableUntil); } private static TimeZoneInfo ResolveKst() { if (TimeZoneInfo.TryFindSystemTimeZoneById("Asia/Seoul", out var tz)) { return tz; } if (TimeZoneInfo.TryFindSystemTimeZoneById("Korea Standard Time", out tz)) { return tz; } // KST 는 DST 없음 — 고정 +9 fallback return TimeZoneInfo.CreateCustomTimeZone("KST", TimeSpan.FromHours(9), "KST", "KST"); } }