Coverage for services / market_calendar_service.py: 92%

139 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-04 15:08 +0000

1import asyncio 

2import logging 

3from datetime import datetime, timedelta 

4from typing import Optional 

5 

6from common.types import ResCommonResponse 

7from core.performance_profiler import PerformanceProfiler 

8 

9class MarketCalendarService: 

10 """ 

11 주식 시장의 개장일, 휴장일, 과거 최신 영업일 및 다음 개장 시간을 통합 관리하는 달력(Calendar) 매니저입니다. 

12 """ 

13 def __init__(self, market_clock, logger=None, performance_profiler: Optional[PerformanceProfiler] = None): 

14 self._market_clock = market_clock 

15 self._logger = logger or logging.getLogger(__name__) 

16 self._broker = None 

17 self._pm = performance_profiler if performance_profiler else PerformanceProfiler(enabled=False) 

18 

19 # [과거/현재] get_latest_trading_date 캐시 변수 (기존 로직 유지) 

20 self._cached_date = None 

21 self._last_check_date = None 

22 

23 # [미래/달력] check_holiday API 기반 휴장일 캐시 변수 (신규 추가) 

24 self._business_days_cache = {} 

25 self._synced_months: set = set() # 동기화 완료된 월 집합 (단일 변수 → set으로 변경: 월 경계 재호출 방지) 

26 

27 def set_broker(self, broker): 

28 self._broker = broker 

29 

30 # ============================================================================== 

31 # 1. 과거/현재 기준 최신 영업일 조회 (기존 구현 완벽 유지 -> 기존 TC 통과 보장) 

32 # ============================================================================== 

33 async def get_latest_trading_date(self) -> Optional[str]: 

34 """오늘을 포함하여 가장 최근에 장이 열렸던 영업일(YYYYMMDD)을 반환합니다.""" 

35 current_date = self._market_clock.get_current_kst_time().strftime("%Y%m%d") 

36 

37 # 캐시가 있고, 오늘 이미 확인했다면 캐시 반환 

38 if self._cached_date and self._last_check_date == current_date: 

39 return self._cached_date 

40 

41 if not self._broker: 

42 self._logger.warning("MarketCalendarService: Broker is not set.") 

43 return None 

44 

45 t_start = self._pm.start_timer() 

46 try: 

47 latest_date = await self._fetch_from_api() 

48 if latest_date: 

49 self._cached_date = latest_date 

50 self._last_check_date = current_date 

51 self._pm.log_timer("MarketCalendarService.get_latest_trading_date", t_start) 

52 return latest_date 

53 except Exception as e: 

54 self._logger.error(f"최근 영업일 조회 실패: {e}") 

55 self._pm.log_timer("MarketCalendarService.get_latest_trading_date [예외]", t_start) 

56 return None 

57 

58 async def _fetch_from_api(self) -> Optional[str]: 

59 """삼성전자(005930) 일봉 조회를 통해 가장 최근 영업일을 API에서 가져옵니다.""" 

60 if not self._broker: 60 ↛ 61line 60 didn't jump to line 61 because the condition on line 60 was never true

61 self._logger.warning("MarketCalendarService: Broker가 설정되지 않았습니다.") 

62 return None 

63 

64 now = self._market_clock.get_current_kst_time() 

65 end_dt = now.strftime("%Y%m%d") 

66 # 7일 전부터 오늘까지 조회 (명절 연휴 등을 감안) 

67 start_dt = (now - timedelta(days=7)).strftime("%Y%m%d") 

68 

69 try: 

70 # 1. 내부 클래스에 직접 접근하지 않고, 상위 래퍼(BrokerAPIWrapper)의 메서드를 활용 

71 # 2. 파라미터를 키워드 인자(kwargs)로 명시하여 안전하게 전달 

72 resp = await self._broker.inquire_daily_itemchartprice( 

73 stock_code="005930", 

74 start_date=start_dt, 

75 end_date=end_dt, 

76 fid_period_div_code="D", 

77 _skip_cache=True # 캐시 우회: cache_wrapper가 get_latest_trading_date를 호출하므로 무한 재귀 방지 

78 ) 

79 

80 # API 호출이 실패한 경우 원인 로깅 

81 if resp.rt_cd != "0": 

82 self._logger.error(f"일봉 조회 API 실패: {resp.msg1} (코드: {resp.rt_cd})") 

83 return None 

84 

85 if resp.data: 

86 first_item = resp.data[0] 

87 # 3. 응답 데이터가 딕셔너리인지, 객체(Dataclass)인지 판별하여 안전하게 추출 

88 if isinstance(first_item, dict): 88 ↛ 92line 88 didn't jump to line 92 because the condition on line 88 was always true

89 return first_item.get("stck_bsop_date") 

90 else: 

91 # 객체 형태인 경우 getattr 사용 

92 return getattr(first_item, "stck_bsop_date", None) 

93 

94 return None 

95 

96 except Exception as e: 

97 self._logger.error(f"최근 영업일 일봉 조회 중 예외 발생: {e}", exc_info=True) 

98 return None 

99 

100 # ============================================================================== 

101 # 2. 휴장일 판별 및 미래 개장일 계산 (chk-holiday API 활용 신규 로직) 

102 # ============================================================================== 

103 async def _sync_calendar_if_needed(self, target_date: Optional[datetime] = None): 

104 """특정 날짜가 속한 '월'의 달력 데이터가 캐시에 없다면 API를 호출해 동기화합니다.""" 

105 if target_date is None: 

106 target_date = self._market_clock.get_current_kst_time() 

107 

108 target_month = target_date.strftime("%Y%m") 

109 

110 # 이미 해당 월 데이터를 가져왔고, 해당 날짜가 캐시에 있다면 스킵 

111 if target_month in self._synced_months and target_date.strftime("%Y%m%d") in self._business_days_cache: 

112 return 

113 

114 if not self._broker: 114 ↛ 115line 114 didn't jump to line 115 because the condition on line 114 was never true

115 self._logger.error("Broker가 설정되지 않아 휴장일 API를 호출할 수 없습니다.") 

116 return 

117 

118 t_start = self._pm.start_timer() 

119 # 한투 '국내휴장일조회' API 호출 

120 target_date_str = target_date.strftime("%Y%m%d") 

121 holiday_data: ResCommonResponse = await self._broker.check_holiday(target_date_str) 

122 

123 if holiday_data and holiday_data.rt_cd == "0" and holiday_data.data and "output" in holiday_data.data: 

124 for day_info in holiday_data.data["output"]: 

125 date_str = day_info["bass_dt"] 

126 # 영업일이면서 거래일이어야 개장일 

127 is_open = (day_info["bzdy_yn"] == "Y" and day_info["tr_day_yn"] == "Y") 

128 self._business_days_cache[date_str] = is_open 

129 self._synced_months.add(target_month) 

130 else: 

131 self._logger.warning(f"휴장일 API 동기화 실패 ({target_month}): {holiday_data.msg1 if holiday_data else 'No response'}") 

132 

133 self._pm.log_timer(f"MarketCalendarService._sync_calendar_if_needed({target_date_str})", t_start) 

134 

135 async def is_business_day(self, date_str: str = None) -> bool: 

136 """특정 날짜(YYYYMMDD)가 공휴일/휴장일이 아닌 영업일인지 확인합니다.""" 

137 if not date_str: 137 ↛ 138line 137 didn't jump to line 138 because the condition on line 137 was never true

138 date_str = self._market_clock.get_current_kst_time().strftime("%Y%m%d") 

139 

140 target_date = datetime.strptime(date_str, "%Y%m%d") 

141 

142 # [최적화 1] 주말(토, 일)은 무조건 휴장일이므로 캐시/API 확인 스킵 

143 if target_date.weekday() >= 5: 143 ↛ 144line 143 didn't jump to line 144 because the condition on line 143 was never true

144 return False 

145 

146 await self._sync_calendar_if_needed(target_date) 

147 

148 return self._business_days_cache.get(date_str, False) 

149 

150 async def is_market_open_now(self) -> bool: 

151 """현재 시점이 휴일이 아니며, 장 운영 시간(09:00~15:40) 이내인지 확인합니다.""" 

152 # 장 운영 시간이 아니면 달력(API/캐시)을 확인할 필요도 없이 바로 False 반환 (성능 최적화) 

153 if not self._market_clock.is_market_operating_hours(): 

154 return False 

155 return await self.is_business_day() 

156 

157 async def get_next_open_day(self, current_date_str: str = None) -> str: 

158 """기준일의 '다음 영업일(YYYYMMDD)'을 반환합니다 (연휴 완벽 스킵).""" 

159 if not current_date_str: 

160 current_date_str = self._market_clock.get_current_kst_time().strftime("%Y%m%d") 

161 

162 check_dt = datetime.strptime(current_date_str, "%Y%m%d") + timedelta(days=1) 

163 

164 # 최대 15일 탐색 (긴 명절 연휴 커버) 

165 for _ in range(15): 

166 # [최적화 2] 주말이면 달력 동기화 검사를 스킵하고 다음 날로 이동 

167 if check_dt.weekday() >= 5: 

168 check_dt += timedelta(days=1) 

169 continue 

170 

171 await self._sync_calendar_if_needed(check_dt) 

172 check_str = check_dt.strftime("%Y%m%d") 

173 

174 if self._business_days_cache.get(check_str) is True: 

175 return check_str 

176 

177 check_dt += timedelta(days=1) 

178 

179 return current_date_str 

180 

181 async def get_next_open_time(self) -> datetime: 

182 """다음 장이 열리는 정확한 '시간(datetime)'을 반환합니다.""" 

183 now = self._market_clock.get_current_kst_time() 

184 today_str = now.strftime("%Y%m%d") 

185 

186 # 오늘이 영업일인데 아직 장 시작 전(09:00 이전)이라면 오늘이 개장일임 

187 if await self.is_business_day(today_str) and now < self._market_clock.get_market_open_time(): 

188 next_open_str = today_str 

189 else: 

190 # 이미 장이 끝났거나 장 중이라면, 혹은 휴일이라면 다음 영업일을 찾음 

191 next_open_str = await self.get_next_open_day(today_str) 

192 

193 open_time_str = self._market_clock.market_open_time_str 

194 open_hour, open_minute = map(int, open_time_str.split(":")) 

195 next_open_date = datetime.strptime(next_open_str, "%Y%m%d") 

196 

197 return self._market_clock.market_timezone.localize( 

198 datetime(next_open_date.year, next_open_date.month, next_open_date.day, open_hour, open_minute) 

199 ) 

200 

201 async def wait_until_next_open(self): 

202 """다음 개장 시간까지 스케줄러를 비동기적으로 대기시킵니다.""" 

203 now = self._market_clock.get_current_kst_time() 

204 next_open = await self.get_next_open_time() 

205 

206 seconds_left = max(0.0, (next_open - now).total_seconds()) 

207 if seconds_left > 0: 207 ↛ 208line 207 didn't jump to line 208 because the condition on line 207 was never true

208 self._logger.info(f"다음 개장시간({next_open.strftime('%Y-%m-%d %H:%M:%S')})까지 {seconds_left:.1f}초 대기합니다. 💤") 

209 await asyncio.sleep(seconds_left) 

210 

211 async def get_latest_market_close_time(self) -> Optional[datetime]: 

212 """ 

213 가장 최근에 장이 마감된 정확한 '시간(datetime)'을 반환합니다. 

214 (예: 월요일 오전 10시라면 -> 지난주 금요일 15:40 반환) 

215 """ 

216 now = self._market_clock.get_current_kst_time() 

217 today_str = now.strftime("%Y%m%d") 

218 

219 # 1. 오늘이 영업일이고, 현재 시간이 이미 오늘 장 마감(15:40) 이후라면? -> 오늘 15:40 

220 if await self.is_business_day(today_str) and now >= self._market_clock.get_market_close_time(): 

221 latest_close_str = today_str 

222 else: 

223 # 2. 휴장일이거나, 아직 오늘 장이 안 끝났다면(장전/장중) -> 과거로 거슬러 올라감 

224 check_dt = now - timedelta(days=1) 

225 

226 # 최대 15일 전까지 거슬러 올라가며 가장 최근 영업일을 찾음 

227 for _ in range(15): 

228 check_str = check_dt.strftime("%Y%m%d") 

229 if await self.is_business_day(check_str): 

230 latest_close_str = check_str 

231 break 

232 check_dt -= timedelta(days=1) 

233 else: 

234 self._logger.error("최근 15일 내에 영업일이 없습니다. (시스템 오류 의심)") 

235 return None 

236 

237 # 찾아낸 영업일 문자열(latest_close_str)과 MarketClock의 마감 시간(15:40)을 결합 

238 close_time_str = self._market_clock.market_close_time_str 

239 close_hour, close_minute = map(int, close_time_str.split(":")) 

240 close_date = datetime.strptime(latest_close_str, "%Y%m%d") 

241 

242 return self._market_clock.market_timezone.localize( 

243 datetime(close_date.year, close_date.month, close_date.day, close_hour, close_minute) 

244 )