Coverage for core / cache / cache_wrapper.py: 85%
131 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-04 15:08 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-04 15:08 +0000
1# core/cache_wrapper.py
2import asyncio
3from typing import TypeVar, Callable, Optional, Any
5from common.types import ResCommonResponse, ErrorCode
6from core.cache.cache_store import CacheStore
7from core.cache.cache_config import load_cache_config
8from datetime import datetime
10T = TypeVar("T")
13class ClientWithCache:
14 def __init__(
15 self,
16 client,
17 logger,
18 market_clock,
19 mode_fn: Callable[[], str],
20 cache_store: Optional[CacheStore] = None,
21 config: Optional[dict] = None,
22 market_calendar_service: Optional[Any] = None # [추가] MarketCalendarService 주입
23 ):
24 self._client = client
25 self._logger = logger
26 self._market_clock = market_clock
27 self._mcs = market_calendar_service # [추가]
28 self._mode_fn = mode_fn # 동적으로 모드 가져오기
30 # ✅ 설정에서 읽기
31 if config is None:
32 config = load_cache_config()
34 cache_cfg = config.get("cache", {})
36 self._file_enabled = bool(cache_cfg.get("file_cache_enabled", True))
37 self._memory_enabled = bool(cache_cfg.get("memory_cache_enabled", True))
38 self._caching_enabled = self._file_enabled or self._memory_enabled
40 self._cache = cache_store if cache_store else CacheStore(config)
41 self._cache.set_logger(self._logger)
42 self.cached_methods = set(config["cache"]["enabled_methods"])
44 # market_close datetime 파싱 결과를 거래일 문자열 단위로 캐시 (hot-path strptime 절감)
45 self._market_close_cache: dict = {}
46 # in-flight 요청 중복 방지 (Thundering Herd 방어)
47 self._in_flight: dict = {}
49 def __getattr__(self, name: str):
50 # ✅ 무한 루프 방지
51 if name.startswith("_"): # ✅ 내부 속성은 직접 접근
52 return object.__getattribute__(self, name)
54 orig_attr = getattr(self._client, name)
56 if not callable(orig_attr) or name not in self.cached_methods:
57 self._logger.debug(f"Bypass - {name} 캐시 건너뜀")
58 return orig_attr
60 def _build_cache_key(mode: str, func_name: str, args: tuple, kwargs: dict) -> str:
61 arg_str = "_".join(map(str, args)) if args else ""
62 kwarg_str = "_".join(f"{k}={v}" for k, v in sorted(kwargs.items())) if kwargs else ""
63 parts = [p for p in [mode, func_name, arg_str, kwarg_str] if p]
64 return "_".join(parts)
66 async def wrapped(*args, **kwargs):
67 # _skip_cache 플래그가 있으면 캐시를 완전히 우회 (무한 재귀 방지용)
68 skip_cache = kwargs.pop("_skip_cache", False)
69 if skip_cache:
70 self._logger.debug(f"🔓 _skip_cache=True → 캐시 우회: {name}")
71 return await orig_attr(*args, **kwargs)
73 mode = self._mode_fn() or "unknown"
74 key = _build_cache_key(mode, name, args, kwargs)
76 # 캐시 전체 비활성화면 즉시 API 호출
77 if not self._caching_enabled:
78 self._logger.debug(f"🧺 Caching disabled → direct API call: {key}")
79 return await orig_attr(*args, **kwargs)
81 # ✅ 1. 메모리 or 파일 캐시 조회
82 # [수정] is_market_open_now는 async 메서드이므로 await 필요
83 is_open = False
84 if self._mcs:
85 is_open = await self._mcs.is_market_open_now()
87 if is_open:
88 self._logger.debug(f"⏳ 시장 개장 중 → 캐시 우회: {key}")
89 else:
90 raw = self._cache.get_raw(key)
91 wrapper, cache_type = raw if raw is not None else (None, None)
93 if wrapper:
94 cache_time = self._parse_timestamp(wrapper.get("timestamp"))
96 is_valid = False
97 # [수정] next_open_time도 async 메서드
98 next_open_time = await self._mcs.get_next_open_time() if self._mcs else None
100 if cache_time and next_open_time and cache_time < next_open_time:
101 # [수정] MarketCalendarService가 있으면 실제 거래일 기준으로 검증
102 if self._mcs: 102 ↛ 130line 102 didn't jump to line 130 because the condition on line 102 was always true
103 latest_trading_date_str = await self._mcs.get_latest_trading_date()
104 if latest_trading_date_str:
105 cache_date_str = cache_time.strftime("%Y%m%d")
106 if cache_date_str > latest_trading_date_str: 106 ↛ 108line 106 didn't jump to line 108 because the condition on line 106 was never true
107 # 캐시가 최근 거래일 이후에 저장됨 → 유효
108 is_valid = True
109 elif cache_date_str == latest_trading_date_str:
110 # 캐시 날짜 == 최근 거래일: 장 마감(15:40) 이후에 저장된 경우만 유효
111 # 장 마감 전에 저장된 캐시는 전일 데이터이므로 무효
112 # 최근 거래일 날짜 기준 장 마감 시간과 비교 (다음날 접근 시에도 정확히 비교)
113 if latest_trading_date_str not in self._market_close_cache:
114 latest_trading_date_dt = datetime.strptime(latest_trading_date_str, "%Y%m%d")
115 self._market_close_cache[latest_trading_date_str] = \
116 self._market_clock.get_market_close_time(target_dt=latest_trading_date_dt)
117 market_close = self._market_close_cache[latest_trading_date_str]
118 if cache_time >= market_close:
119 is_valid = True
120 else:
121 self._logger.debug(
122 f"📉 캐시 만료 (거래일 {latest_trading_date_str} 장 마감 전 저장: {cache_time})"
123 )
124 else:
125 self._logger.debug(f"📉 캐시 만료 (최근 거래일 {latest_trading_date_str} > 캐시 데이터 {cache_date_str})")
126 else:
127 # mcs이 거래일을 확인할 수 없는 경우 캐시를 유효한 것으로 간주
128 is_valid = True
130 if is_valid:
131 if cache_type == "memory": 131 ↛ 134line 131 didn't jump to line 134 because the condition on line 131 was always true
132 if self._cache.memory_cache and self._cache.memory_cache.has(key): 132 ↛ 137line 132 didn't jump to line 137 because the condition on line 132 was always true
133 self._logger.debug(f"🧠 Memory Cache HIT (유효): {key}")
134 elif cache_type == "file":
135 if self._cache.file_cache and self._cache.file_cache.exists(key):
136 self._logger.debug(f"📂 File Cache HIT (유효): {key}")
137 cached_result = wrapper.get("data")
138 try:
139 if cached_result is not None: 139 ↛ 143line 139 didn't jump to line 143 because the condition on line 139 was always true
140 cached_result._cache_hit = True
141 except AttributeError:
142 pass # dict 등 속성 설정 불가 타입
143 return cached_result
144 else:
145 if self._cache.file_cache and self._cache.file_cache.exists(key):
146 self._logger.debug(f"📂 File Cache 무시 (만료됨): {key} / 저장 시각: {cache_time}")
147 self._cache.file_cache.delete(key)
148 if self._cache.memory_cache:
149 self._cache.memory_cache.delete(key)
151 # ✅ 2. In-flight 중복 방지 (같은 키에 동시 요청 시 첫 번째 결과 공유)
152 if key in self._in_flight: 152 ↛ 153line 152 didn't jump to line 153 because the condition on line 152 was never true
153 self._logger.debug(f"⏳ In-flight HIT (중복 요청 방지): {key}")
154 return await asyncio.shield(self._in_flight[key])
156 loop = asyncio.get_event_loop()
157 future = loop.create_future()
158 self._in_flight[key] = future
160 # ✅ 3. API 호출
161 self._logger.debug(f"🌐 실시간 API 호출: {key}")
162 try:
163 result = await orig_attr(*args, **kwargs)
164 if not future.done(): 164 ↛ 171line 164 didn't jump to line 171 because the condition on line 164 was always true
165 future.set_result(result)
166 except Exception as exc:
167 if not future.done():
168 future.set_exception(exc)
169 raise
170 finally:
171 self._in_flight.pop(key, None)
173 if isinstance(result, ResCommonResponse) and result.rt_cd == ErrorCode.SUCCESS.value:
174 # ✅ 4. 캐싱 데이터 저장
175 self._cache.set(key, {
176 "data": result,
177 "timestamp": datetime.now().isoformat()
178 }, save_to_file=True)
179 else:
180 self._logger.debug(f"응답 실패로 🧠📂 Cache Update 무시 : {key}")
182 return result
184 return wrapped
186 def __dir__(self):
187 # 포함해야 할 속성 목록:
188 # 1. self._client의 속성
189 # 2. self 객체의 __dict__ 속성
190 # 3. 클래스 자체의 속성
191 return list(set(
192 dir(self._client) +
193 list(self.__dict__.keys()) +
194 dir(type(self))
195 ))
197 def _parse_timestamp(self, timestamp_str: str) -> Optional[datetime]:
198 if not timestamp_str:
199 return None
200 try:
201 dt = datetime.fromisoformat(timestamp_str)
202 if dt.tzinfo is None: 202 ↛ 204line 202 didn't jump to line 204 because the condition on line 202 was always true
203 return self._market_clock.market_timezone.localize(dt)
204 return dt
205 except Exception as e:
206 if self._logger: 206 ↛ 208line 206 didn't jump to line 208 because the condition on line 206 was always true
207 self._logger.warning(f"[CacheWrapper] 잘못된 timestamp 포맷: {timestamp_str} ({e})")
208 return None
211def cache_wrap_client(
212 api_client: T,
213 logger,
214 market_clock,
215 mode_getter: Callable[[], str],
216 config: Optional[dict] = None,
217 cache_store: Optional[CacheStore] = None,
218 market_calendar_service: Optional[Any] = None
219) -> T:
220 return ClientWithCache(
221 client=api_client,
222 logger=logger,
223 market_clock=market_clock,
224 mode_fn=mode_getter,
225 cache_store=cache_store,
226 config=config,
227 market_calendar_service=market_calendar_service
228 )