diff --git a/api/circuit_breaker.py b/api/circuit_breaker.py index 0418754a..9cb1f972 100644 --- a/api/circuit_breaker.py +++ b/api/circuit_breaker.py @@ -80,8 +80,25 @@ def on_success(self): self._half_open_probe_in_flight = False self._half_open_probe_started_at = 0.0 + def release_probe(self): + """HALF_OPEN probe가 확정적 응답(400/401/403/429 등)을 받았을 때 호출. + + 서버가 응답했으니 장애는 아니지만 요청이 성공한 것도 아니다. 상태는 + HALF_OPEN으로 두고(닫지 않는다) probe 점유만 풀어, 다음 호출이 + recovery_timeout(60초)을 기다리지 않고 곧바로 다시 probe하게 한다. + 풀지 않으면 그동안 손절 SELL을 포함한 모든 KIS 요청이 이유 없이 막힌다. + """ + with self._lock: + if self.state == CircuitState.HALF_OPEN and self._half_open_probe_in_flight: + self._half_open_probe_in_flight = False + self._half_open_probe_started_at = 0.0 + logger.info( + "Circuit Breaker HALF_OPEN probe 해제 — 서버가 확정 응답, 상태는 HALF_OPEN 유지" + ) + def on_failure(self): """요청 실패(50x, 타임아웃 등) 시 호출""" + alert_failure_count = None with self._lock: self.last_failure_time = time.monotonic() self._half_open_probe_in_flight = False @@ -91,24 +108,29 @@ def on_failure(self): # HALF_OPEN 상태에서 또 실패하면 다시 OPEN으로 회귀 self.state = CircuitState.OPEN logger.warning("Circuit Breaker 복구 실패: 다시 OPEN 상태로 전환") - + elif self.state == CircuitState.CLOSED: self.failure_count += 1 if self.failure_count >= self.failure_threshold: self.state = CircuitState.OPEN logger.error("🚨 Circuit Breaker 발동! 상태 전환: CLOSED -> OPEN ({}회 연속 실패)", self.failure_count) - - # 여기서 디스코드나 긴급 알림 연결 가능 (외부에서 구독) - self._trigger_alert() + alert_failure_count = self.failure_count + + # 알림은 Discord·SMTP 네트워크 I/O(수 초 이상)라 락을 놓은 뒤 보낸다. 락 안에서 + # 보내면 그동안 can_request/on_success를 부르는 모든 스레드(주문 제출·손절 + # SELL 포함)가 알림 전송이 끝날 때까지 멈춘다. 발동 판정은 위 락 안에서 끝났다. + if alert_failure_count is not None: + self._trigger_alert(alert_failure_count) - def _trigger_alert(self): + def _trigger_alert(self, failure_count: int | None = None): """서킷 브레이커 오픈 시 알림 — 치명적 이벤트이므로 모든 채널 동시 발송.""" + count = self.failure_count if failure_count is None else failure_count try: from core.notifier import Notifier notifier = Notifier() notifier.send_message( f"🚨 **서킷 브레이커 발동 (API 차단)**\n" - f"연속 {self.failure_count}회 API 요청 실패로 인해 모든 통신을 {self.recovery_timeout}초간 차단합니다.\n" + f"연속 {count}회 API 요청 실패로 인해 모든 통신을 {self.recovery_timeout}초간 차단합니다.\n" f"서버 다운 또는 장애가 의심됩니다.", critical=True, ) diff --git a/api/kis_api.py b/api/kis_api.py index 3140c64b..00116b4d 100644 --- a/api/kis_api.py +++ b/api/kis_api.py @@ -62,6 +62,13 @@ def _shared_rate_state( "minute_window": deque(), "token_lock": threading.Lock(), "minute_lock": threading.Lock(), + # 사용량 카운터도 호출 예산과 같은 단위로 프로세스 전체가 공유한다. + # 인스턴스별로 세면 새로 만든 인스턴스의 통계는 늘 0건·429 0회라 + # 429 폭주·연결 오류가 로그에서 보이지 않는다. minute_lock으로 보호. + "created_at": time.monotonic(), + "total_requests": 0, + "total_429s": 0, + "total_conn_errors": 0, } _RATE_STATE_REGISTRY[key] = state else: @@ -78,6 +85,57 @@ def _shared_rate_state( return state +# 접근 토큰도 호출 예산처럼 (base_url, app_key) 단위로 프로세스 전체가 공유한다. +# 인스턴스마다 토큰을 따로 발급하면 live 한 사이클(동기화·잔고 요약·매수마다 새 +# KISApi)에서 몇 초 사이에 발급 요청이 여러 번 나가는데, KIS는 발급을 1분당 1회로 +# 제한한다(EGW00133). 두 번째 발급부터 거절돼 잔고 확인이 실패하고, 그 결과 live +# 매수가 전부 보류되며 인증 실패 알림이 폭주한다. 모의(VTS)와 실전은 base_url이 +# 달라 절대 같은 토큰을 쓰지 않는다. +TOKEN_ERROR_COOLDOWN_SECONDS = 60.0 +_TOKEN_REGISTRY_LOCK = threading.Lock() +_TOKEN_REGISTRY: dict[tuple[str, str], dict[str, Any]] = {} + + +def _new_token_state() -> dict[str, Any]: + return { + # 발급 요청(네트워크)을 이 락 안에서 수행해 동시 발급을 한 번으로 모은다. + "lock": threading.RLock(), + "access_token": None, + "expires_at": None, # 로컬 시각 기준, 실제 만료보다 1시간 당겨 둔 값 + "issued_at": None, + "issue_count": 0, + # 발급 실패 후 재발급 억제 종료 시각(time.monotonic 기준). 발급에 성공하면 + # 즉시 0으로 돌아간다 — 다른 인스턴스의 성공 뒤까지 쿨다운이 남지 않는다. + "error_until": 0.0, + "last_error": "", + "last_error_at": None, + } + + +def _shared_token_state(base_url: str, app_key: str) -> dict[str, Any]: + """같은 KIS app/domain 인스턴스들이 공유하는 토큰 상태.""" + key = (str(base_url), str(app_key)) + with _TOKEN_REGISTRY_LOCK: + state = _TOKEN_REGISTRY.get(key) + if state is None: + state = _new_token_state() + _TOKEN_REGISTRY[key] = state + return state + + +def reset_shared_token_cache() -> None: + """프로세스 공유 토큰 캐시를 비운다(테스트 격리·운영 점검용).""" + with _TOKEN_REGISTRY_LOCK: + _TOKEN_REGISTRY.clear() + + +# KIS 게이트웨이는 일부 업무 오류를 HTTP 500 + JSON 본문(rt_cd='1', msg_cd)으로 돌려준다 +# (KIS 개발자 커뮤니티 보고 기준 — VTS 리허설에서 실제 코드 확인 필요). +# 이것들은 서버 장애가 아니므로 서킷 브레이커 실패로 세지 않는다. +_KIS_RATE_LIMIT_CODES = frozenset({"EGW00201"}) # 초당 거래건수 초과 +_KIS_TOKEN_ERROR_CODES = frozenset({"EGW00121", "EGW00123"}) # 유효하지 않은/만료된 토큰 + + class KISTokenExpiredError(Exception): """KIS API 401 응답(토큰 만료) 시 사용. CircuitBreaker 실패로 누적하지 않음.""" @@ -126,9 +184,14 @@ def __init__(self, account_no: str = None): self.base_url, ) - # 인증 토큰 + # 인증 토큰: (base_url, app_key)별 프로세스 공유 캐시를 쓴다. 인스턴스 + # 필드(_access_token 등)는 공유 토큰의 미러다 — 요청 때마다 다시 맞춘다. + # 발급 실패 쿨다운(_token_error_until)도 공유 상태에 있으므로 여기서 + # 초기화하지 않는다(새 인스턴스가 진행 중인 쿨다운을 지우면 안 된다). + self._token_state = _shared_token_state(self.base_url, self.app_key) self._access_token = None self._token_expires_at = None + self._adopt_shared_token() # 계좌번호 파싱 (XXXXXXXX-XX) parts = self.account_no.split("-") @@ -149,20 +212,12 @@ def __init__(self, account_no: str = None): self._tokens = self._rate_state["tokens"] self._last_refill = self._rate_state["last_refill"] self._token_lock = self._rate_state["token_lock"] - self._auth_lock = threading.Lock() # 분당 슬라이딩 윈도우: 최근 60초 내 요청 타임스탬프 self._minute_window = self._rate_state["minute_window"] self._minute_lock = self._rate_state["minute_lock"] - # 모니터링 카운터 (사용량 추적) - self._total_requests = 0 - self._total_429s = 0 - self._total_conn_errors = 0 - self._session_start = time.monotonic() - - # 토큰 에러 쿨다운: 발급 실패 시 60초간 재시도 억제 - self._token_error_until: float = 0.0 + # 모니터링 카운터(총 요청·429·연결 오류)는 _rate_state에서 프로세스 공유로 센다. masked_account = "미설정" if self.account_no: @@ -230,76 +285,190 @@ def _mask_key(key: str, head: int = 4, tail: int = 4) -> str: # 인증 # ============================================================= - def authenticate(self) -> bool: + def _get_token_state(self) -> dict[str, Any]: + """이 인스턴스가 쓰는 토큰 상태(정상 인스턴스는 프로세스 공유 항목). + + ``object.__new__``로 만든 테스트 더블처럼 ``__init__``을 거치지 않은 + 인스턴스는 공유 레지스트리를 오염시키지 않도록 인스턴스 전용 상태를 쓴다. """ - OAuth 토큰 발급 (동시 갱신 방지를 위해 Lock 사용) - Returns: - 성공 여부 + state = self.__dict__.get("_token_state") + if state is None: + state = _new_token_state() + self.__dict__["_token_state"] = state + return state + + @property + def _token_error_until(self) -> float: + """토큰 발급 실패 쿨다운 종료 시각(time.monotonic 기준, 공유 상태).""" + return float(self._get_token_state().get("error_until") or 0.0) + + @_token_error_until.setter + def _token_error_until(self, value: float) -> None: + state = self._get_token_state() + with state["lock"]: + state["error_until"] = float(value or 0.0) + + def _adopt_shared_token(self) -> bool: + """공유 캐시에 유효한 토큰이 있으면 인스턴스 미러에 반영하고 True.""" + state = self._get_token_state() + with state["lock"]: + token = state.get("access_token") + expires_at = state.get("expires_at") + if token and expires_at is not None and datetime.now() < expires_at: + self._access_token = token + self._token_expires_at = expires_at + return True + return False + + def token_status(self) -> Dict[str, Any]: + """토큰을 새로 발급하지 않고 공유 캐시 상태만 보고한다(헬스체크용). + + 토큰 값 자체는 돌려주지 않는다 — 로그·알림으로 새지 않게. """ - with self._auth_lock: - return self._authenticate_impl() + state = self._get_token_state() + with state["lock"]: + token = state.get("access_token") + expires_at = state.get("expires_at") + issued_at = state.get("issued_at") + error_until = float(state.get("error_until") or 0.0) + last_error = state.get("last_error") or "" + last_error_at = state.get("last_error_at") + return { + "has_token": bool(token), + "valid": bool(token) and expires_at is not None and datetime.now() < expires_at, + "expires_at": expires_at, + "issued_at": issued_at, + "cooldown_remaining": max(0.0, error_until - time.monotonic()), + "last_error": last_error, + "last_error_at": last_error_at, + } + + def authenticate(self) -> bool: + """유효한 접근 토큰을 확보한다(성공 여부 반환). + + 같은 (base_url, app_key)의 인스턴스들은 토큰 하나를 공유한다. 유효한 공유 + 토큰이 있으면 발급 요청 없이 그대로 쓰고, 없을 때만 발급한다. 발급이 + 실패하면 60초 동안 어느 인스턴스도 다시 발급을 시도하지 않는다. + """ + return self._acquire_token() + + def _acquire_token(self, rejected_token: Optional[str] = None) -> bool: + """공유 토큰을 확보한다. rejected_token은 서버가 거절(401 등)한 토큰. - def _authenticate_impl(self) -> bool: - """토큰 발급 실제 로직 (Lock 내부에서만 호출).""" + 거절된 토큰이 아직 공유 캐시에 있으면 폐기하고 새로 발급한다. 다른 + 인스턴스가 이미 새 토큰으로 바꿔 놓았으면 발급 없이 그 토큰을 쓴다 — + 401을 받은 인스턴스마다 재발급하면 1분 1회 한도에 바로 걸린다. + """ if not self._is_configured(): logger.warning("KIS API 키가 설정되지 않았습니다. settings.yaml을 확인해주세요.") return False + state = self._get_token_state() + with state["lock"]: + token = state.get("access_token") + expires_at = state.get("expires_at") + if rejected_token is not None and token == rejected_token: + state["access_token"] = None + state["expires_at"] = None + token = None + if token and expires_at is not None and datetime.now() < expires_at: + self._access_token = token + self._token_expires_at = expires_at + return True + + remaining = float(state.get("error_until") or 0.0) - time.monotonic() + if remaining > 0: + logger.warning( + "KIS 토큰 발급 억제 중 ({:.0f}초 남음) — 직전 발급 실패: {}", + remaining, state.get("last_error") or "원인 미상", + ) + return False + + ok, failure = self._issue_token(state) + if ok: + return True + state["error_until"] = time.monotonic() + TOKEN_ERROR_COOLDOWN_SECONDS + state["last_error"] = failure + state["last_error_at"] = datetime.now() + self._access_token = None + self._token_expires_at = None + + # 알림은 Discord·SMTP 네트워크 I/O라 락을 놓은 뒤 보낸다 — 다른 인스턴스가 + # 알림 전송 시간만큼 토큰 확인에서 막히지 않게. 쿨다운 동안의 재시도는 + # 위에서 발급 없이 끝나므로 알림도 발급 실패 1건당 1번만 나간다. + self._notify_auth_failure( + f"{failure} ({TOKEN_ERROR_COOLDOWN_SECONDS:.0f}초간 재발급 억제)" + ) + return False + + def _issue_token(self, state: dict[str, Any]) -> tuple[bool, str]: + """토큰 발급 POST. state 락 안에서만 호출하며 성공 시 공유 상태를 갱신한다. + + Returns: + (성공 여부, 실패 사유) + """ url = f"{self.base_url}/oauth2/tokenP" body = { "grant_type": "client_credentials", "appkey": self.app_key, "appsecret": self.app_secret, } + domain = "모의투자" if self.use_mock else "실전" try: response = requests.post(url, json=body, timeout=10) - domain = "모의투자" if self.use_mock else "실전" if not response.ok: try: err_body = response.json() - err_msg = err_body.get("msg", err_body.get("error_description", str(err_body)))[:200] + err_msg = str( + err_body.get("msg", err_body.get("error_description", str(err_body))) + )[:200] except Exception: - err_msg = response.text[:200] if response.text else "" + err_msg = str(response.text or "")[:200] logger.error( "KIS API 토큰 발급 실패 [{}] {} (app_key: {})", response.status_code, err_msg, self._mask_key(self.app_key), ) - self._notify_auth_failure( - f"HTTP {response.status_code} / {err_msg or '토큰 발급 실패'}" - ) - return False + return False, f"HTTP {response.status_code} / {err_msg or '토큰 발급 실패'}" response.raise_for_status() data = response.json() - - self._access_token = data.get("access_token") + access_token = data.get("access_token") + if not access_token: + logger.error("KIS API 토큰 발급 실패: 응답에 access_token 없음 (도메인: {})", domain) + return False, "토큰 발급 응답에 access_token 없음" # 토큰 유효시간 (기본 24시간에서 1시간 여유) expires_in = int(data.get("expires_in", 86400)) - self._token_expires_at = datetime.now() + timedelta(seconds=expires_in - 3600) - - logger.info( - "KIS API 토큰 발급 성공 (도메인: {}, 만료: {})", - domain, self._token_expires_at, - ) - return True + expires_at = datetime.now() + timedelta(seconds=expires_in - 3600) except requests.RequestException as e: logger.error( "KIS API 토큰 발급 네트워크 오류: {} (url: {})", e, url.split("?")[0], ) - self._notify_auth_failure(str(e)) - return False + return False, str(e) except Exception as e: logger.error("KIS API 토큰 발급 실패: {}", e) - self._notify_auth_failure(str(e)) - return False + return False, str(e) + + state["access_token"] = access_token + state["expires_at"] = expires_at + state["issued_at"] = datetime.now() + state["issue_count"] = int(state.get("issue_count") or 0) + 1 + state["error_until"] = 0.0 + state["last_error"] = "" + state["last_error_at"] = None + self._access_token = access_token + self._token_expires_at = expires_at + logger.info( + "KIS API 토큰 발급 성공 (도메인: {}, 만료: {})", + domain, expires_at, + ) + return True, "" - def _ensure_token(self): - """토큰이 유효한지 확인하고, 만료 임박 시 갱신""" - if self._access_token is None or ( - self._token_expires_at and datetime.now() >= self._token_expires_at - ): - self.authenticate() + def _ensure_token(self) -> bool: + """유효한 공유 토큰을 미러에 반영하고, 없거나 만료됐으면 발급을 시도한다.""" + if self._adopt_shared_token(): + return True + return self._acquire_token() def _get_headers(self, tr_id: str) -> dict: """API 요청 헤더 생성""" @@ -345,7 +514,23 @@ def _wait_for_token(self): # 분당 윈도우에 현재 요청 기록 with state["minute_lock"]: state["minute_window"].append(time.monotonic()) - self._total_requests += 1 + state["total_requests"] = int(state.get("total_requests") or 0) + 1 + + def _bump_usage_counter(self, name: str) -> int: + """사용량 카운터(total_429s 등)를 1 올리고 새 값을 반환한다. + + 정상 인스턴스는 프로세스 공유 상태를 올린다. ``__init__``을 거치지 않은 + 테스트 더블(_rate_state 없음)은 인스턴스 속성으로 센다. + """ + state = self.__dict__.get("_rate_state") + if state is None: + value = int(self.__dict__.get(f"_{name}", 0) or 0) + 1 + self.__dict__[f"_{name}"] = value + return value + with state["minute_lock"]: + value = int(state.get(name) or 0) + 1 + state[name] = value + return value def _wait_for_minute_window(self): """분당 한도 초과 시 가장 오래된 요청이 윈도우를 벗어날 때까지 대기.""" @@ -369,7 +554,7 @@ def _wait_for_minute_window(self): time.sleep(wait) def get_rate_limit_stats(self) -> dict: - """현재 Rate Limiter 사용량 통계 반환.""" + """현재 Rate Limiter 사용량 통계 반환 (같은 app/domain 프로세스 전체 합계).""" state = self._rate_state with state["minute_lock"]: now = time.monotonic() @@ -378,22 +563,54 @@ def get_rate_limit_stats(self) -> dict: while window and window[0] < cutoff: window.popleft() recent_minute = len(window) + total_requests = int(state.get("total_requests") or 0) + total_429s = int(state.get("total_429s") or 0) + total_conn_errors = int(state.get("total_conn_errors") or 0) + started_at = float(state.get("created_at") or now) - elapsed_sec = max(1, time.monotonic() - self._session_start) + elapsed_sec = max(1, time.monotonic() - started_at) return { - "total_requests": self._total_requests, - "total_429s": self._total_429s, - "total_conn_errors": self._total_conn_errors, + "total_requests": total_requests, + "total_429s": total_429s, + "total_conn_errors": total_conn_errors, "requests_last_60s": recent_minute, "max_per_sec": state["max_calls_per_sec"], "max_per_min": state["max_calls_per_min"], - "avg_per_sec": round(self._total_requests / elapsed_sec, 2), + "avg_per_sec": round(total_requests / elapsed_sec, 2), "minute_utilization_pct": round( recent_minute / int(state["max_calls_per_min"]) * 100, 1 ), "token_cooldown_active": time.monotonic() < self._token_error_until, } + @staticmethod + def _gateway_error_code(response) -> tuple[str, str]: + """5xx 응답 본문에서 KIS 업무 오류 코드를 읽는다. + + Returns: + (msg_cd, 본문 요약). 본문이 JSON이 아니면 msg_cd는 빈 문자열이고 + 요약에 읽지 못한 이유가 담긴다(진짜 서버 장애의 HTML 오류 페이지 등). + """ + try: + payload = response.json() + except (ValueError, AttributeError, TypeError) as exc: + return "", f"본문 JSON 아님({type(exc).__name__})" + if not isinstance(payload, dict): + return "", "본문 형식 비정상" + code = str(payload.get("msg_cd") or payload.get("error_code") or "").strip() + message = str(payload.get("msg1") or payload.get("error_description") or "").strip() + return code, f"{code or '코드 없음'} {message[:80]}".strip() + + @staticmethod + def _retry_after_seconds(response, default: int) -> int: + """Retry-After 헤더(초) — 없거나 HTTP-date 형식이면 default. 1~60초로 제한.""" + try: + retry_after = int(response.headers.get("Retry-After", default)) + except (TypeError, ValueError, AttributeError): + # Retry-After가 HTTP-date 형식이면 정수 변환이 실패한다 — 기본값 사용. + retry_after = default + return max(1, min(retry_after, 60)) + @staticmethod def _backoff_with_jitter(attempt: int, base: float = 1.0, cap: float = 30.0) -> float: """지수 백오프 + 랜덤 지터. 동시 요청의 thundering-herd 방지.""" @@ -448,6 +665,12 @@ def _request( return {} headers = self._get_headers(tr_id) + if time.monotonic() < self._token_error_until: + # 토큰을 확보하지 못했다(발급 실패 → 공유 쿨다운). 'Bearer None'으로 + # 요청을 보내 401만 되돌려 받는 대신 여기서 멈춘다. + logger.warning("KIS 접근 토큰 없음 — 요청 스킵: {}", path) + return {} + used_token = getattr(self, "_access_token", None) self._wait_for_token() try: @@ -457,7 +680,10 @@ def _request( response = requests.post(url, headers=headers, json=body, timeout=10) if response.status_code == 429: - self._total_429s += 1 + total_429s = self._bump_usage_counter("total_429s") + # 429는 서버가 살아 있다는 확정 응답이다 — HALF_OPEN probe였다면 + # 점유만 풀어 다음 요청이 60초를 기다리지 않게 한다(상태 유지). + breaker.release_probe() if not idempotent: # 주문 POST는 브로커가 요청을 처리했는지 클라이언트가 # 단정할 수 없는 응답을 받으면 절대 재전송하지 않는다. @@ -468,20 +694,39 @@ def _request( path, ) raise KISOrderResponseUnknown(f"{path}: HTTP 429") - try: - retry_after = int(response.headers.get("Retry-After", 5)) - except (TypeError, ValueError): - # Retry-After가 HTTP-date 형식이면 정수 변환이 실패한다 — 기본값 사용. - retry_after = 5 - retry_after = max(1, min(retry_after, 60)) + retry_after = self._retry_after_seconds(response, 5) logger.warning( "[429 Too Many Requests] {}초 대기 후 재시도 ({}/{}) - 경로: {} (누적 429: {}회)", - retry_after, attempt, max_retries, path, self._total_429s, + retry_after, attempt, max_retries, path, total_429s, ) time.sleep(retry_after) continue if response.status_code in (500, 502, 503, 504): + body_detail = "" + if idempotent: + # 조회(멱등) 요청만 본문을 본다. KIS 게이트웨이는 초당 한도 + # 초과(EGW00201)·토큰 무효(EGW00121/00123)도 5xx로 돌려준다 — + # 이를 서버 장애로 세면 조회 두 건의 재시도만으로 서킷이 열려 + # 60초간 손절 SELL까지 막히고, 무효 토큰은 로컬 만료 시각까지 + # 갱신되지 않는다. 주문(비멱등) POST는 아래의 '응답 불명' 처리를 + # 그대로 따른다 — 거절이 확정인지 단정할 수 없으면 재전송 금지. + msg_cd, body_detail = self._gateway_error_code(response) + if msg_cd in _KIS_RATE_LIMIT_CODES: + total_429s = self._bump_usage_counter("total_429s") + breaker.release_probe() + retry_after = self._retry_after_seconds(response, 1) + logger.warning( + "[{} {}] KIS 초당 거래건수 초과 — {}초 대기 후 재시도 ({}/{}) - 경로: {} (누적 429: {}회)", + response.status_code, msg_cd, retry_after, attempt, + max_retries, path, total_429s, + ) + time.sleep(retry_after) + continue + if msg_cd in _KIS_TOKEN_ERROR_CODES: + raise KISTokenExpiredError( + f"KIS {msg_cd} (HTTP {response.status_code}) — 토큰 무효/만료" + ) breaker.on_failure() if not idempotent: # 5xx는 브로커가 주문을 접수한 뒤 응답 생성에 실패한 @@ -497,8 +742,9 @@ def _request( ) wait = self._backoff_with_jitter(attempt) logger.warning( - "[{}] 서버 오류, {:.1f}초 후 재시도 ({}/{}) - 경로: {}", - response.status_code, wait, attempt, max_retries, path, + "[{}] 서버 오류({}), {:.1f}초 후 재시도 ({}/{}) - 경로: {}", + response.status_code, body_detail or "본문 미확인", wait, + attempt, max_retries, path, ) time.sleep(wait) continue @@ -508,26 +754,26 @@ def _request( if response.status_code in (400, 403): logger.error("[{}] 복구 불가 오류 즉시 중단 - 경로: {}", response.status_code, path) + breaker.release_probe() return {} response.raise_for_status() breaker.on_success() return response.json() - except KISTokenExpiredError: - logger.error("[401] 토큰 만료. 갱신 후 재시도 ({}/{})", attempt, max_retries) - if not self.authenticate(): - self._token_error_until = time.monotonic() + 60.0 - self._notify_auth_failure( - "401 응답 후 토큰 자동 갱신 실패 (60초 쿨다운 진입). API 키·네트워크를 확인하세요." - ) + except KISTokenExpiredError as token_exc: + breaker.release_probe() + logger.error("[토큰 거절] {} — 갱신 후 재시도 ({}/{})", token_exc, attempt, max_retries) + # 거절된 토큰만 폐기하고 한 번만 재발급한다. 발급 실패 시 공유 쿨다운과 + # 알림은 _acquire_token이 처리한다(발급 실패 1건당 알림 1번). + if not self._acquire_token(rejected_token=used_token): return {} if attempt < max_retries: continue return {} except (requests.exceptions.ConnectionError, ssl.SSLError, ConnectionResetError, EOFError) as e: - self._total_conn_errors += 1 + total_conn_errors = self._bump_usage_counter("total_conn_errors") breaker.on_failure() if not idempotent: # 주문 제출처럼 비멱등 요청은 응답 유실 시 재전송하면 이중 체결 위험. @@ -541,7 +787,7 @@ def _request( wait = self._backoff_with_jitter(attempt, base=2.0) logger.warning( "연결/SSL 오류, {:.1f}초 후 재시도 ({}/{}) - 경로: {} - {} (누적: {}회)", - wait, attempt, max_retries, path, type(e).__name__, self._total_conn_errors, + wait, attempt, max_retries, path, type(e).__name__, total_conn_errors, ) time.sleep(wait) diff --git a/api/websocket_handler.py b/api/websocket_handler.py index 578fc012..3b35d80e 100644 --- a/api/websocket_handler.py +++ b/api/websocket_handler.py @@ -1,6 +1,8 @@ """ 실시간 웹소켓 데이터 핸들러 - KIS API 웹소켓을 통한 실시간 체결/호가 스트리밍 +- 주의: 현재 어떤 런타임(main.py 일일 CLI·스케줄러·대시보드)도 이 핸들러를 띄우지 + 않는다(테스트에서만 생성). 아래 갭 보충·BlackSwan 재점검은 연결했을 때의 동작이다. - asyncio 기반 비동기 처리 - connect() 시 KISApi.get_approval_key()로 웹소켓 전용 승인키 발급 후 구독 메시지에 사용. KIS 공식 문서(웹소켓 인증) 변경 시 해당 로직 재검증 필요. @@ -630,26 +632,55 @@ async def _handle_message(self, message: str): raw_data = parts[3] # 실제 데이터 if tr_id == "H0STCNT0": - # 체결 데이터 - price_data = self._parse_price_data(raw_data) - - # 실시간 데이터 정합성 검증 + # 체결 데이터 — 한 프레임에 여러 건이 올 수 있다(data_count) from core.data_validator import DataValidator - if price_data and DataValidator.validate_realtime_data(price_data): - if not self._first_data_logged: - self._first_data_logged = True - logger.info( - "웹소켓 첫 실시간 데이터 수신 (tr_id: {}, symbol: {}, price: {})", - tr_id, price_data.get("symbol"), price_data.get("price"), - ) - self._update_price_cache_from_ws(price_data) - self._emit_price_update(price_data) - elif price_data: - logger.warning("웹소켓 손상 데이터 드롭: {}", price_data) + + for record in self._split_records(raw_data, data_count): + price_data = self._parse_price_data(record) + + # 실시간 데이터 정합성 검증 + if price_data and DataValidator.validate_realtime_data(price_data): + if not self._first_data_logged: + self._first_data_logged = True + logger.info( + "웹소켓 첫 실시간 데이터 수신 (tr_id: {}, symbol: {}, price: {})", + tr_id, price_data.get("symbol"), price_data.get("price"), + ) + self._update_price_cache_from_ws(price_data) + self._emit_price_update(price_data) + elif price_data: + logger.warning("웹소켓 손상 데이터 드롭: {}", price_data) except Exception as e: logger.error("메시지 처리 오류: {}", e) + @staticmethod + def _split_records(raw: str, data_count: str) -> List[str]: + """'^'로 이어 붙은 N건(data_count)의 체결 레코드를 레코드별 문자열로 나눈다. + + KIS는 체결이 몰리면 한 프레임에 여러 건(예: '002')을 필드를 이어 붙여 보낸다. + 예전 파서는 첫 레코드만 읽고 나머지를 버렸다. 필드 수가 건수로 나누어떨어지지 + 않으면 형식이 바뀐 것이므로 첫 레코드만 쓰고 경고를 남긴다. + """ + try: + count = int(str(data_count).strip()) + except (TypeError, ValueError): + logger.warning("[WebSocket] 데이터 건수 해석 실패({!r}) — 첫 레코드만 처리", data_count) + return [raw] + if count <= 1: + return [raw] + fields = raw.split("^") + if fields and fields[-1] == "": + fields = fields[:-1] + if len(fields) % count != 0: + logger.warning( + "[WebSocket] 다건 프레임 필드 수 {}가 건수 {}로 나누어떨어지지 않음 — 첫 레코드만 처리", + len(fields), count, + ) + return [raw] + width = len(fields) // count + return ["^".join(fields[i * width:(i + 1) * width]) for i in range(count)] + @staticmethod def _parse_price_data(raw: str) -> Optional[Dict[str, Any]]: """ diff --git a/backtest/backtester.py b/backtest/backtester.py index 3cb9e991..9dc6be4d 100644 --- a/backtest/backtester.py +++ b/backtest/backtester.py @@ -18,10 +18,28 @@ from core.market_regime import resolve_market_regime_config from core.risk_manager import RiskManager +# 보유 종목의 데이터가 백테스트 도중 끝나면(상장폐지·합병·종목 캐시 조기 종료) 마지막 관측 +# 종가로 강제 청산했다는 기록용 액션. 포트폴리오 엔진이 쓰며, 지표·리포트에서 빠지지 않게 +# 전량 청산 목록에 함께 둔다. +DATA_END_EXIT_ACTION = "DATA_END" _FULL_EXIT_SELL_ACTIONS = frozenset( - ("SELL", "STOP_LOSS", "TAKE_PROFIT", "TRAILING_STOP", "MAX_HOLD", "GAP_DOWN", "BLACKSWAN") + ( + "SELL", + "STOP_LOSS", + "TAKE_PROFIT", + "TRAILING_STOP", + "MAX_HOLD", + "GAP_DOWN", + "BLACKSWAN", + DATA_END_EXIT_ACTION, + ) ) _PARTIAL_EXIT_ACTION = "TAKE_PROFIT_PARTIAL" +# 실현 손익이 생기는 모든 매도 액션(전량 청산 + 부분 익절). 승률·손익비·거래 수 같은 지표와 +# 리포트 거래표가 모두 이 한 목록만 쓴다. 목록이 여러 벌이면 한쪽에서 빠진 청산 사유의 +# 거래가 통계에서 조용히 사라진다(MAX_HOLD가 지표에서, GAP_DOWN·BLACKSWAN이 리포트에서 +# 빠져 있던 사례). 청산 사유를 새로 만들면 위 목록에만 추가한다. +PNL_EXIT_ACTIONS = _FULL_EXIT_SELL_ACTIONS | frozenset((_PARTIAL_EXIT_ACTION,)) EXECUTION_MODEL_NEXT_OPEN = "next_open" EXECUTION_MODEL_LEGACY_SAME_CLOSE = "legacy_same_close" @@ -29,6 +47,13 @@ (EXECUTION_MODEL_NEXT_OPEN, EXECUTION_MODEL_LEGACY_SAME_CLOSE) ) +# 백테스트 샤프·소르티노에 쓰는 연 무위험수익률. 운영 성과 렌즈(core/performance_lens.py)는 +# rf=0이라 같은 '샤프'라도 약 0.03/연변동성만큼 정의가 다르다 — 운영 샤프와 비교할 때 감안한다. +# 값을 바꾸면 min_sharpe·OOS 샤프 게이트·후보 순위처럼 이 기준에 맞춰 둔 문턱이 함께 +# 움직이므로 반드시 재기준화와 같이 바꾼다. 리포트는 샤프 옆에 BACKTEST_RISK_FREE_LABEL을 표기한다. +BACKTEST_RISK_FREE_ANNUAL = 0.03 +BACKTEST_RISK_FREE_LABEL = f"금리 {BACKTEST_RISK_FREE_ANNUAL * 100:g}%" + def _validate_execution_model(execution_model: str) -> str: """전략 신호 체결 모델을 검증한다.""" @@ -40,6 +65,13 @@ def _validate_execution_model(execution_model: str) -> str: return execution_model +def _trade_start_position(index: pd.Index, trade_start_date) -> int: + """trade_start_date 이상인 첫 행의 위치. None이면 0 (워밍업 없음).""" + if trade_start_date is None: + return 0 + return int(index.searchsorted(pd.Timestamp(trade_start_date), side="left")) + + def _count_roundtrips(trades: list) -> int: """완전 청산 1회당 왕복 1회 (부분 익절 후 잔량 청산까지 한 사이클로 묶음).""" pos = 0 @@ -121,6 +153,7 @@ def run( notify_overtrading: bool = False, symbol: str = None, execution_model: str = EXECUTION_MODEL_NEXT_OPEN, + trade_start_date=None, ) -> dict: """ 백테스팅 실행 @@ -137,6 +170,10 @@ def run( execution_model: 전략 신호 체결 모델. 기본값 ``next_open``은 T일 종가로 확정된 BUY/SELL 신호를 다음 거래일 시가에 체결한다. 과거 결과 재현이 필요할 때만 ``legacy_same_close``를 사용한다. + trade_start_date: 데이터 워밍업과 별도로 실제 거래·평가를 시작할 날짜 + (PortfolioBacktester.run과 같은 규약). 이전 행은 지표 계산에만 쓰고, 거래·자본 + 곡선·성과 지표는 이 날짜부터 잰다. 직전 행의 신호는 next_open에서 첫날 시가에 + 체결될 수 있다. None이면 첫 행부터 거래한다. Returns: 백테스팅 결과 딕셔너리 @@ -147,14 +184,27 @@ def run( ).get("initial_capital", 10000000) execution_model = _validate_execution_model(execution_model) + start_pos = _trade_start_position(df.index, trade_start_date) + if trade_start_date is not None and start_pos >= len(df): + raise ValueError( + f"trade_start_date={trade_start_date} 이후 거래할 데이터가 없습니다 " + f"(마지막 행 {df.index[-1] if len(df) else 'N/A'})." + ) self._param_overrides = param_overrides strategy = self._get_strategy(strategy_name) if strict_lookahead: # Look-Ahead Bias 방어: 시점 T에서는 T 이전(및 T) 데이터만 사용 logger.info("strict_lookahead=True: 시점별 슬라이싱 분석 실행 중...") + # 워밍업 행 중 신호가 쓰이지 않는 행(거래 시작 전날보다 앞)은 분석을 건너뛴다. + # 쓰이는 행의 신호는 여전히 그 시점까지의 데이터(df[:i+1])로만 계산하므로 + # 지표는 워밍업 구간 전체로 예열된 상태다. + first_needed = max(0, start_pos - 1) rows = [] for i in range(len(df)): + if i < first_needed: + rows.append({"signal": "HOLD", "close": df.iloc[i]["close"]}) + continue chunk = strategy.analyze(df.iloc[: i + 1].copy()) if not chunk.empty and "signal" in chunk.columns: rows.append(chunk.iloc[-1].to_dict()) @@ -163,6 +213,13 @@ def run( df_analyzed = pd.DataFrame(rows, index=df.index) if "close" not in df_analyzed.columns: df_analyzed["close"] = df["close"].values + if first_needed > 0: + # 분석을 건너뛴 워밍업 행은 원본 값으로 채운다 (거래량 이동평균·전일 종가 계산용). + for col in df.columns: + if col in df_analyzed.columns and col != "signal": + df_analyzed.iloc[:first_needed, df_analyzed.columns.get_loc(col)] = ( + df[col].iloc[:first_needed].to_numpy() + ) else: df_analyzed = strategy.analyze(df.copy()) @@ -204,6 +261,7 @@ def run( regime_series=regime_series, symbol=symbol, execution_model=execution_model, + trade_start_date=trade_start_date, ) result["look_ahead_bias_verified"] = ( "STRICT" if strict_lookahead else "DISABLED_WITH_WARNING" @@ -213,8 +271,9 @@ def run( metrics = self._calculate_metrics(result, initial_capital) logger.info( - "백테스팅 완료 | 수익률: {:.2f}% | 샤프: {:.2f} | MDD: {:.2f}% | 승률: {:.1f}%", + "백테스팅 완료 | 수익률: {:.2f}% | 샤프({}): {:.2f} | MDD: {:.2f}% | 승률: {:.1f}%", metrics["total_return"], + BACKTEST_RISK_FREE_LABEL, metrics["sharpe_ratio"], metrics["max_drawdown"], metrics["win_rate"], @@ -231,7 +290,9 @@ def run( "trades": result["trades"], "equity_curve": result["equity_curve"], "strategy": strategy_name, - "period": f"{df.index[0]} ~ {df.index[-1]}", + # 평가(거래) 구간. 워밍업 행은 기간에 넣지 않는다. + "period": f"{df.index[start_pos]} ~ {df.index[-1]}", + "warmup_rows": start_pos, "initial_capital": initial_capital, "execution_model": execution_model, "look_ahead_bias_verified": result.get("look_ahead_bias_verified", "PASS"), @@ -371,17 +432,20 @@ def _simulate( regime_series: pd.Series = None, symbol: str = None, execution_model: str = EXECUTION_MODEL_NEXT_OPEN, + trade_start_date=None, ) -> dict: """ 거래 시뮬레이션 실행. 방어: 날짜 순으로 순회하며 미래 행을 참조하지 않는다. 기본 ``next_open``은 T일 종가로 확정된 전략 신호를 T+1일 시가에 체결한다. 설정에 따라 ATR 손절, 1% 룰 포지션 사이징, 부분 익절을 반영한다. + trade_start_date 이전 행(워밍업)은 건너뛰고, 거래·자본 곡선은 그 날짜부터 기록한다. """ execution_model = _validate_execution_model(execution_model) assert df.index.is_monotonic_increasing or len(df) <= 1, ( "시뮬레이션은 시간 순서대로만 순회해야 하며, 미래 데이터를 참조하지 않습니다." ) + start_pos = _trade_start_position(df.index, trade_start_date) cash = initial_capital position = 0 avg_price = 0 @@ -798,6 +862,9 @@ def _execute_strategy_signal( sold_today = True for i, (date, row) in enumerate(df.iterrows()): + if i < start_pos: + # 워밍업 구간: 지표 예열에만 쓰고 거래·자본 곡선은 기록하지 않는다. + continue close = row["close"] raw_open = row.get("open") valid_open = ( @@ -847,6 +914,11 @@ def _execute_strategy_signal( if regime_enabled and date in regime_series.index: regime_at_t = regime_series.loc[date] + # 갭다운 청산은 전일 종가를 넘겨 보유한 포지션에만 적용한다. 아래 next_open + # 시가 주문으로 방금 산 주식을 같은 시가에 갭다운으로 되팔면 비용만 내는 가짜 + # 왕복이 생긴다(실전 갭다운 청산도 장 시작 시점 보유 종목만 대상). + carried_position = position > 0 + # next_open은 당일 시가 전략 주문이 먼저, 이후 갭/블랙스완/종가 위험 # 청산이 발생하는 실제 시간 순서를 따른다. 시가 결측 주문은 종가로 # 대체하지 않고 명시적으로 스킵한다. @@ -873,7 +945,7 @@ def _execute_strategy_signal( bs_daily_returns = bs_daily_returns[-bs_consecutive_days:] if position > 0 and previous_close is not None and previous_close > 0: - if gap_enabled and open_price is not None: + if gap_enabled and open_price is not None and carried_position: gap_pct = (open_price - previous_close) / previous_close if gap_pct <= gap_down_threshold: if _execute_full_exit( @@ -909,9 +981,19 @@ def _execute_strategy_signal( stop_loss_price = _stop_loss_price(row_atr) take_profit_price = avg_price * (1 + tp_rate) holding_days = (date - buy_date).days if buy_date is not None else 0 + # 실전(order_executor)은 최소 보유 기간 동안 손실 방어 청산(STOP_LOSS· + # TRAILING_STOP·GAP_DOWN·BLACKSWAN)만 허용하고 익절·부분 익절·보유기간 만료 + # 매도는 거부한다. 백테스트도 같은 규칙을 따라야 1~4일차 익절로 승률이 부풀지 + # 않는다. legacy_same_close는 과거 결과 재현 경로라 기존 규칙을 그대로 둔다. + in_min_hold = ( + execution_model == EXECUTION_MODEL_NEXT_OPEN + and min_holding_days > 0 + and buy_date is not None + and holding_days < min_holding_days + ) # 최대 보유 기간 초과 시 강제 청산 - if max_holding_days > 0 and holding_days >= max_holding_days: + if max_holding_days > 0 and holding_days >= max_holding_days and not in_min_hold: costs = self.risk_manager.calculate_transaction_costs( close, position, "SELL", avg_daily_volume=row_volume, avg_price=avg_price, symbol=symbol, @@ -965,7 +1047,12 @@ def _execute_strategy_signal( sold_today = True # 부분 익절 (1차 목표 도달) - elif partial_exit and not partial_exit_done and close >= avg_price * (1 + partial_target): + elif ( + partial_exit + and not partial_exit_done + and not in_min_hold + and close >= avg_price * (1 + partial_target) + ): sell_qty = max(1, int(position * partial_ratio)) costs = self.risk_manager.calculate_transaction_costs( close, sell_qty, "SELL", avg_daily_volume=row_volume, @@ -995,7 +1082,7 @@ def _execute_strategy_signal( sold_today = True # 전량 익절 - elif close >= take_profit_price: + elif not in_min_hold and close >= take_profit_price: costs = self.risk_manager.calculate_transaction_costs( close, position, "SELL", avg_daily_volume=row_volume, avg_price=avg_price, symbol=symbol, @@ -1116,11 +1203,11 @@ def _calculate_metrics(self, result: dict, initial_capital: float) -> dict: ) - 1.0 daily_returns = equity["daily_return"].dropna() - # 샤프 지수 (연율화, 무위험수익률 3%) + # 샤프 지수 (연율화, 무위험수익률 BACKTEST_RISK_FREE_ANNUAL) if len(daily_returns) > 0 and daily_returns.std() > 0: annual_return = daily_returns.mean() * 252 annual_std = daily_returns.std() * np.sqrt(252) - sharpe = (annual_return - 0.03) / annual_std + sharpe = (annual_return - BACKTEST_RISK_FREE_ANNUAL) / annual_std else: sharpe = 0 @@ -1128,24 +1215,12 @@ def _calculate_metrics(self, result: dict, initial_capital: float) -> dict: downside_returns = daily_returns[daily_returns < 0] if len(downside_returns) > 0 and downside_returns.std() > 0: downside_std = downside_returns.std() * np.sqrt(252) - sortino = (daily_returns.mean() * 252 - 0.03) / downside_std + sortino = (daily_returns.mean() * 252 - BACKTEST_RISK_FREE_ANNUAL) / downside_std else: sortino = sharpe - # 매매 기준 성과 - sell_trades = [ - t - for t in trades - if t["action"] in ( - "SELL", - "STOP_LOSS", - "TAKE_PROFIT", - "TAKE_PROFIT_PARTIAL", - "TRAILING_STOP", - "GAP_DOWN", - "BLACKSWAN", - ) - ] + # 매매 기준 성과 (보유기간 만료 MAX_HOLD 포함 — 실현 손익이 있는 매도 전부) + sell_trades = [t for t in trades if t["action"] in PNL_EXIT_ACTIONS] # 꼬리 리스크: VaR 95%, CVaR 95% (일일 기준) if len(daily_returns) >= 20: @@ -1204,10 +1279,9 @@ def _calculate_metrics(self, result: dict, initial_capital: float) -> dict: gross_loss = abs(sum(t["pnl"] for t in losing)) profit_factor = gross_profit / gross_loss if gross_loss > 0 else 0 - # 칼마 비율 + # 연간 수익률(산술: 총수익률 ÷ 연수). 복리 기준은 아래 CAGR이며 칼마도 CAGR로 계산한다. years = len(equity) / 252 if len(equity) > 0 else 1 annual_return_pct = total_return / years - calmar = abs(annual_return_pct / max_drawdown) if max_drawdown != 0 else 0 # 과매매 분석: 총 수수료·세금·슬리피지(종가 대비 체결 불리분), 평균 보유 기간(일) total_commission = sum(t.get("commission", 0) for t in trades) @@ -1224,13 +1298,14 @@ def _calculate_metrics(self, result: dict, initial_capital: float) -> dict: for t in trades: if t["action"] == "BUY": position_open_date = t["date"] - elif t["action"] in ("SELL", "STOP_LOSS", "TAKE_PROFIT", "TAKE_PROFIT_PARTIAL", "TRAILING_STOP", "GAP_DOWN", "BLACKSWAN") and position_open_date is not None: + elif t["action"] in PNL_EXIT_ACTIONS and position_open_date is not None: try: delta = t["date"] - position_open_date holding_days_list.append(delta.days if hasattr(delta, "days") else 0) except (TypeError, ValueError): pass - if t["action"] in ("SELL", "STOP_LOSS", "TAKE_PROFIT", "TRAILING_STOP", "GAP_DOWN", "BLACKSWAN"): + # 부분 익절은 포지션이 남아 있으므로 전량 청산일 때만 보유 시작일을 지운다. + if t["action"] in _FULL_EXIT_SELL_ACTIONS: position_open_date = None avg_holding_days = round(np.mean(holding_days_list), 1) if holding_days_list else 0.0 @@ -1258,6 +1333,10 @@ def _calculate_metrics(self, result: dict, initial_capital: float) -> dict: if initial_capital > 0 and final_value > 0 and years_bt > 0: cagr = ((final_value / initial_capital) ** (1 / years_bt) - 1) * 100 + # 칼마 비율 = CAGR / |MDD|. 부호를 유지해야 꾸준히 잃는 전략이 양(+)의 칼마로 + # '우수'해 보이지 않는다(예전 abs(산술 연수익 / MDD)는 손실 전략도 양수였다). + calmar = (cagr / abs(max_drawdown)) if max_drawdown < 0 else 0.0 + # 2) Turnover: 연간 총 거래대금 / 평균 자산 (100% = 전 자산 1회 회전) total_buy_amount = sum(t["price"] * t["quantity"] for t in trades if t["action"] == "BUY") avg_equity = equity["value"].mean() if not equity.empty else initial_capital @@ -1393,10 +1472,11 @@ def print_report(self, result: dict): print(f" 초기 자본 : {m['initial_capital']:>14,.0f}원") print(f" 최종 자본 : {m['final_value']:>14,.0f}원") print(f" 총 수익률 : {m['total_return']:>13.2f}%") - print(f" 연간 수익률 : {m['annual_return']:>13.2f}%") + print(f" 연간 수익률 : {m['annual_return']:>13.2f}% (산술)") + print(f" CAGR : {m.get('cagr', 0):>13.2f}% (복리, 칼마 기준)") print("-" * 60) - print(f" 샤프 지수 : {m['sharpe_ratio']:>13.2f}") - print(f" 소르티노 비율 : {m.get('sortino_ratio', 0):>13.2f}") + print(f" 샤프 지수 : {m['sharpe_ratio']:>13.2f} ({BACKTEST_RISK_FREE_LABEL})") + print(f" 소르티노 비율 : {m.get('sortino_ratio', 0):>13.2f} ({BACKTEST_RISK_FREE_LABEL})") print(f" 최대 낙폭 : {m['max_drawdown']:>13.2f}%") print(f" MDD 회복 기간 : {m.get('mdd_recovery_days', 0):>13d}일") print(f" 칼마 비율 : {m['calmar_ratio']:>13.2f}") diff --git a/backtest/param_optimizer.py b/backtest/param_optimizer.py index e4aac9fc..fac617d8 100644 --- a/backtest/param_optimizer.py +++ b/backtest/param_optimizer.py @@ -36,7 +36,8 @@ "trend_following": { "adx_threshold": [15, 20, 25, 30], "trend_ma_period": [60, 120, 200], - "atr_stop_multiplier": [1.5, 2.0, 2.5, 3.0], + # atr_stop_multiplier는 뺐다: 손절은 risk_params.stop_loss.atr_multiplier를 쓰고 + # 전략 설정은 어디서도 읽지 않아, 조합만 4배로 늘리고 결과는 같았다. }, } @@ -144,8 +145,12 @@ def _run_single( config: Config, strict_lookahead: bool, initial_capital: float, + trade_start_date=None, ) -> Optional[dict]: - """한 번의 백테스트 실행. 실패 시 None.""" + """한 번의 백테스트 실행. 실패 시 None. + + trade_start_date가 있으면 그 이전 행은 지표 워밍업으로만 쓰고 거래·지표는 그 날짜부터 잰다. + """ try: backtester = Backtester(config=config) result = backtester.run( @@ -154,6 +159,7 @@ def _run_single( initial_capital=initial_capital, strict_lookahead=strict_lookahead, param_overrides={strategy_name: param_overrides}, + trade_start_date=trade_start_date, ) return result.get("metrics") if result else None except Exception as e: @@ -284,9 +290,11 @@ def grid_search( oos_metrics = None if df_oos is not None and len(df_oos) >= 30: + # OOS는 학습 구간을 지표 워밍업으로 함께 넣고 거래·지표는 OOS 구간만 잰다. oos_metrics = _run_single( - df_oos, strategy_name, best_params, config, + df, strategy_name, best_params, config, strict_lookahead, initial_capital, + trade_start_date=df_oos.index[0], ) logger.info( "OOS 구간 성과 (오버피팅 확인용): {} ~ {} | {}={}", @@ -471,8 +479,10 @@ def grid_search_scoring_weights( logger.warning("OOS 구간이 30거래일 미만입니다. 채택 검증을 수행할 수 없습니다.") return None + # OOS는 학습 구간을 지표 워밍업으로 함께 넣고 거래·지표는 OOS 구간만 잰다. oos_metrics = _run_single( - df_oos, "scoring", best["params"], config, strict_lookahead, initial_capital, + df, "scoring", best["params"], config, strict_lookahead, initial_capital, + trade_start_date=df_oos.index[0], ) oos_sharpe = None if oos_metrics: @@ -610,7 +620,6 @@ def bayesian_optimize( "trend_following": { "adx_threshold": (15, 30), "trend_ma_period": (60, 200), - "atr_stop_multiplier": (1.5, 3.0), }, } bounds = param_bounds or default_bounds.get(strategy_name, {}) @@ -667,9 +676,11 @@ def objective(x): oos_metrics = None if df_oos is not None and len(df_oos) >= 30: + # OOS는 학습 구간을 지표 워밍업으로 함께 넣고 거래·지표는 OOS 구간만 잰다. oos_metrics = _run_single( - df_oos, strategy_name, best_params, config, + df, strategy_name, best_params, config, strict_lookahead, initial_capital, + trade_start_date=df_oos.index[0], ) return { diff --git a/backtest/portfolio_backtester.py b/backtest/portfolio_backtester.py index 02cbe008..7d791faa 100644 --- a/backtest/portfolio_backtester.py +++ b/backtest/portfolio_backtester.py @@ -1,9 +1,17 @@ """ 멀티종목 포트폴리오 백테스터 - 여러 종목에 대해 동시에 매매 신호를 평가하고 포트폴리오 수준에서 자금을 관리 -- 분산 투자 제한, 업종 비중, 최대 포지션 수 등 실전 리스크 관리 반영 +- 종목당 비중·총 투자 비중·최소 현금 비중·최대 포지션 수 한도를 반영 - 단일 종목 백테스트와 달리 "포트폴리오 MDD", "종목 간 상관관계 영향" 등을 측정 - gap/어닝/BlackSwan 이벤트 guard로 paper/live와 백테스트 리스크 전제 차이를 축소 + +실전(order_executor)·단일 종목 Backtester보다 느슨한 부분 — 아직 반영하지 않는다: +- 업종 비중(max_sector_ratio)·상관관계 기반 축소 한도 +- 전역 최소 보유 기간(position_limits.min_holding_days). 전략 설정의 min_hold_days만 쓴다. +- 종목별 월간 매수 횟수 상한(position_limits.max_monthly_roundtrips) +- 냉각기(min_hold_days)는 전략 SELL과 TRAILING_STOP을 막는데, 실전은 TRAILING_STOP을 손실 방어 + 청산으로 보고 막지 않는 대신 익절·보유기간 만료 매도를 막는다. +따라서 이 엔진의 스윕·승격 지표는 실전보다 회전이 잦고 업종 집중이 제한 없는 조건의 결과다. """ import pandas as pd @@ -11,6 +19,11 @@ from loguru import logger from datetime import timedelta +from backtest.backtester import ( + BACKTEST_RISK_FREE_ANNUAL, + BACKTEST_RISK_FREE_LABEL, + DATA_END_EXIT_ACTION, +) from backtest.cost_impact import ( cost_impact_metric_fields, render_cost_impact_text, @@ -202,9 +215,9 @@ def run( metrics = self._calculate_portfolio_metrics(result, initial_capital) logger.info( - "포트폴리오 백테스트 완료 | 종목={}개 | 수익률: {:.2f}% | 샤프: {:.2f} | MDD: {:.2f}%", + "포트폴리오 백테스트 완료 | 종목={}개 | 수익률: {:.2f}% | 샤프({}): {:.2f} | MDD: {:.2f}%", len(valid_symbols), metrics["total_return"], - metrics["sharpe_ratio"], metrics["max_drawdown"], + BACKTEST_RISK_FREE_LABEL, metrics["sharpe_ratio"], metrics["max_drawdown"], ) return { @@ -238,6 +251,7 @@ def run( "blackswan_triggers": result.get("blackswan_triggers", 0), "blackswan_buy_blocks": result.get("blackswan_buy_blocks", 0), "blackswan_recovery_buys": result.get("blackswan_recovery_buys", 0), + "data_end_exits": result.get("data_end_exits", 0), } def _strategy_config_for_run(self, strategy_name: str, param_overrides: dict | None = None): @@ -464,6 +478,19 @@ def _trailing_stop_price(hwm, row_atr): blackswan_triggers = 0 blackswan_buy_blocks = 0 blackswan_recovery_buys = 0 + data_end_exits = 0 + + # 당일 행이 없는 보유 종목(거래정지·데이터 결측)은 마지막 관측 종가로 평가한다. + # 평균단가로 되돌리면 그동안의 평가손실이 하루아침에 사라지는 가짜 수익이 생긴다. + # legacy_same_close는 과거 결과 재현 경로라 기존 평가(평균단가)를 그대로 둔다. + use_last_close_marks = execution_model == EXECUTION_MODEL_NEXT_OPEN + last_close: dict[str, float] = {} + # 종목별 마지막 데이터 일자. 이 날짜가 지나도 들고 있으면 데이터 종료로 보고 청산한다. + data_end_date = { + sym: sig_df.index.max() + for sym, sig_df in signals.items() + if sig_df is not None and len(sig_df.index) > 0 + } # ── 진단 계측 (ablation diagnostics) ── exit_reason_counts = {} @@ -607,161 +634,142 @@ def _record_blocked_buy(sym: str, row: pd.Series, reason: str) -> None: "reason": reason, }) - for date_idx, date in enumerate(all_dates): - total_pos_value = sum( - self._get_close(signals, s, date, positions[s]["avg_price"]) * positions[s]["qty"] - for s in positions - ) - total_equity = cash + total_pos_value + def _holding_days(pos: dict) -> int: + return (date - pos["buy_date"]).days if pos.get("buy_date") and hasattr(date, "date") else 0 - portfolio_blackswan_reason = "" - blackswan_activated_today = False + def _fallback_mark(sym: str) -> float: + """당일 행이 없을 때의 평가가격 (next_open: 마지막 관측 종가, legacy: 평균단가).""" + if use_last_close_marks and sym in last_close: + return last_close[sym] + return positions[sym]["avg_price"] + + def _close_mark(sym: str) -> float: + """종가 기준 평가가격. 당일 행이 없으면 _fallback_mark.""" + return self._get_close(signals, sym, date, _fallback_mark(sym)) + + def _open_mark(sym: str) -> float: + """시가 시점에 알 수 있는 평가가격: 당일 시가 → 전일 종가 → _fallback_mark 순.""" + sig_df = signals.get(sym) + pos_idx = _index_position(sig_df, date) + if pos_idx is not None: + raw_open = sig_df["open"].iloc[pos_idx] if "open" in sig_df.columns else None + if raw_open is not None and pd.notna(raw_open) and float(raw_open) > 0: + return float(raw_open) + prev_close = _previous_close(sym, date) + if prev_close is not None: + return prev_close + return _fallback_mark(sym) + + def _record_daily_return(sym: str, close: float, prev_close: float | None) -> float | None: + if prev_close is None or prev_close <= 0: + return None + stock_daily_return = (close - prev_close) / prev_close + if bs_enabled: + symbol_returns = bs_symbol_returns.setdefault(sym, []) + symbol_returns.append(stock_daily_return) + if len(symbol_returns) > bs_consecutive_days: + bs_symbol_returns[sym] = symbol_returns[-bs_consecutive_days:] + return stock_daily_return + + def _portfolio_blackswan_reason(total_equity: float) -> str: if bs_enabled and positions and prev_portfolio_value is not None and prev_portfolio_value > 0: portfolio_return = (total_equity - prev_portfolio_value) / prev_portfolio_value if portfolio_return <= bs_portfolio_threshold: - portfolio_blackswan_reason = f"portfolio_drop {portfolio_return * 100:.2f}%" - - to_sell = [] - for sym in list(positions.keys()): - sig_df = signals.get(sym) - if sig_df is None or date not in sig_df.index: - continue - row = sig_df.loc[date] - close = float(row.get("close", positions[sym]["avg_price"])) - open_price = _row_price(row, "open", close) - pos = positions[sym] - pos["high_water_mark"] = max(pos["high_water_mark"], close) - - sell_reason = None - sell_price_ref = close - sell_signal_date = None - avg_daily_volume = _avg_daily_volume(sig_df, date, row) - row_atr = _get_atr(sig_df, date) - hd = (date - pos["buy_date"]).days if pos.get("buy_date") and hasattr(date, "date") else 0 - in_cooling = min_hold_days > 0 and hd < min_hold_days - prev_close = _previous_close(sym, date) - stock_daily_return = None - if prev_close is not None and prev_close > 0: - stock_daily_return = (close - prev_close) / prev_close - if bs_enabled: - symbol_returns = bs_symbol_returns.setdefault(sym, []) - symbol_returns.append(stock_daily_return) - if len(symbol_returns) > bs_consecutive_days: - bs_symbol_returns[sym] = symbol_returns[-bs_consecutive_days:] - - if gap_enabled and prev_close is not None and prev_close > 0: - gap_pct = (open_price - prev_close) / prev_close - if gap_pct <= gap_down_threshold: - sell_reason = "GAP_DOWN" - sell_price_ref = open_price - gap_down_exits += 1 - - if not sell_reason and bs_enabled: - bs_reason = portfolio_blackswan_reason - if not bs_reason and stock_daily_return is not None and stock_daily_return <= bs_single_threshold: - bs_reason = f"single_stock_drop {stock_daily_return * 100:.2f}%" - symbol_returns = bs_symbol_returns.get(sym, []) - if ( - not bs_reason - and len(symbol_returns) >= bs_consecutive_days - and all(ret <= bs_consecutive_threshold for ret in symbol_returns[-bs_consecutive_days:]) - ): - bs_reason = "consecutive_drop" - if bs_reason: - sell_reason = "BLACKSWAN" - sell_price_ref = close - if not blackswan_activated_today: - blackswan_triggers += 1 - _activate_backtest_blackswan(date_idx) - blackswan_activated_today = True - - if not sell_reason and max_holding_days > 0 and hd >= max_holding_days: - sell_reason = "MAX_HOLD" - if not sell_reason and close <= _stop_loss_price(pos["avg_price"], row_atr): - sell_reason = "STOP_LOSS" - if not sell_reason and close >= pos["avg_price"] * (1 + tp_rate): - sell_reason = "TAKE_PROFIT" - if not disable_trailing_stop: - ts_price = _trailing_stop_price(pos["high_water_mark"], row_atr) - if not sell_reason and ts_price is not None and close <= ts_price: - if in_cooling: - sell_reason = None # 냉각기: TRAILING_STOP 억제 - else: - sell_reason = "TRAILING_STOP" - strategy_row, strategy_signal_date = _strategy_signal_context(sym, date) + return f"portfolio_drop {portfolio_return * 100:.2f}%" + return "" + + def _close_rule_exit_reason( + sym: str, + pos: dict, + close: float, + row_atr, + hd: int, + in_cooling: bool, + stock_daily_return: float | None, + portfolio_blackswan_reason: str, + date_idx: int, + ) -> str | None: + """종가로 판정하는 청산 사유: 블랙스완 → 보유기간 만료 → 손절 → 익절 → 트레일링.""" + nonlocal blackswan_triggers, blackswan_activated_today + sell_reason = None + if bs_enabled: + bs_reason = portfolio_blackswan_reason + if not bs_reason and stock_daily_return is not None and stock_daily_return <= bs_single_threshold: + bs_reason = f"single_stock_drop {stock_daily_return * 100:.2f}%" + symbol_returns = bs_symbol_returns.get(sym, []) if ( - not sell_reason - and strategy_row is not None - and strategy_row.get("signal") == "SELL" + not bs_reason + and len(symbol_returns) >= bs_consecutive_days + and all(ret <= bs_consecutive_threshold for ret in symbol_returns[-bs_consecutive_days:]) ): - if in_cooling: - sell_reason = None # 냉각기: 전략 SELL 억제 - else: - sell_reason = "SELL" - sell_signal_date = strategy_signal_date - sell_price_ref = ( - open_price - if execution_model == EXECUTION_MODEL_NEXT_OPEN - else close - ) - avg_daily_volume = _avg_daily_volume( - sig_df, strategy_signal_date, strategy_row - ) - - if sell_reason: - to_sell.append( - ( - sym, - sell_price_ref, - sell_reason, - avg_daily_volume, - sell_signal_date, - ) - ) - exit_reason_counts[sell_reason] = exit_reason_counts.get(sell_reason, 0) + 1 - - executed_sell_count += len(to_sell) - for sym, close, reason, avg_daily_volume, signal_date in to_sell: - pos = positions.pop(sym) - costs = self.risk_manager.calculate_transaction_costs( - close, - pos["qty"], - "SELL", - avg_daily_volume=avg_daily_volume, - avg_price=pos["avg_price"], - symbol=sym, - ) - sell_price = costs["execution_price"] - tax_amt = costs["tax"] + costs.get("capital_gains_tax", 0) - pnl = (sell_price - pos["avg_price"]) * pos["qty"] - costs["commission"] - tax_amt - cash += sell_price * pos["qty"] - costs["commission"] - tax_amt - per_symbol_pnl[sym] = per_symbol_pnl.get(sym, 0) + pnl - holding_days = (date - pos["buy_date"]).days if pos.get("buy_date") and hasattr(date, "date") else 0 - trade = { - "date": date, "symbol": sym, "action": reason, - "price": sell_price, "quantity": pos["qty"], - "pnl": pnl, "pnl_rate": ((sell_price / pos["avg_price"]) - 1) * 100, - "commission": costs["commission"], - "tax": float(tax_amt), - "slippage_cost": costs.get("slippage", 0), - "slippage_multiplier": costs.get("slippage_multiplier", 1.0), - "participation_rate": costs.get("participation_rate", 0), - "entry_score": pos.get("entry_score", 0), - "score_macd": pos.get("score_macd", 0), - "score_bollinger": pos.get("score_bollinger", 0), - "score_volume": pos.get("score_volume", 0), - "holding_days": holding_days, - } - if signal_date is not None: - trade["signal_date"] = signal_date - trades.append(trade) - - # 시장국면 판별 (TICKET-05): T-1일 지수 기준, look-ahead bias 없음 - regime_at_t = "bullish" - if regime_enabled and date in regime_series.index: - regime_at_t = regime_series.loc[date] - - # ── 신호 집계: 전략이 생성한 원본 BUY/SELL 수 ── + bs_reason = "consecutive_drop" + if bs_reason: + sell_reason = "BLACKSWAN" + if not blackswan_activated_today: + blackswan_triggers += 1 + _activate_backtest_blackswan(date_idx) + blackswan_activated_today = True + + if not sell_reason and max_holding_days > 0 and hd >= max_holding_days: + sell_reason = "MAX_HOLD" + if not sell_reason and close <= _stop_loss_price(pos["avg_price"], row_atr): + sell_reason = "STOP_LOSS" + if not sell_reason and close >= pos["avg_price"] * (1 + tp_rate): + sell_reason = "TAKE_PROFIT" + if not sell_reason and not disable_trailing_stop: + ts_price = _trailing_stop_price(pos["high_water_mark"], row_atr) + # 냉각기(in_cooling)에는 TRAILING_STOP을 억제한다. + if ts_price is not None and close <= ts_price and not in_cooling: + sell_reason = "TRAILING_STOP" + return sell_reason + + def _execute_exit( + sym: str, + price_ref: float, + reason: str, + avg_daily_volume: float | None, + signal_date, + ) -> None: + """보유 포지션 하나를 청산하고 현금·종목 손익·계측을 갱신한다.""" + nonlocal cash, executed_sell_count + pos = positions.pop(sym) + costs = self.risk_manager.calculate_transaction_costs( + price_ref, + pos["qty"], + "SELL", + avg_daily_volume=avg_daily_volume, + avg_price=pos["avg_price"], + symbol=sym, + ) + sell_price = costs["execution_price"] + tax_amt = costs["tax"] + costs.get("capital_gains_tax", 0) + pnl = (sell_price - pos["avg_price"]) * pos["qty"] - costs["commission"] - tax_amt + cash += sell_price * pos["qty"] - costs["commission"] - tax_amt + per_symbol_pnl[sym] = per_symbol_pnl.get(sym, 0) + pnl + trade = { + "date": date, "symbol": sym, "action": reason, + "price": sell_price, "quantity": pos["qty"], + "pnl": pnl, "pnl_rate": ((sell_price / pos["avg_price"]) - 1) * 100, + "commission": costs["commission"], + "tax": float(tax_amt), + "slippage_cost": costs.get("slippage", 0), + "slippage_multiplier": costs.get("slippage_multiplier", 1.0), + "participation_rate": costs.get("participation_rate", 0), + "entry_score": pos.get("entry_score", 0), + "score_macd": pos.get("score_macd", 0), + "score_bollinger": pos.get("score_bollinger", 0), + "score_volume": pos.get("score_volume", 0), + "holding_days": _holding_days(pos), + } + if signal_date is not None: + trade["signal_date"] = signal_date + trades.append(trade) + executed_sell_count += 1 + exit_reason_counts[reason] = exit_reason_counts.get(reason, 0) + 1 + + def _count_raw_signals() -> None: + """전략이 오늘 종가로 낸 원본 BUY/SELL 신호 수 (체결 여부와 무관).""" + nonlocal signal_buy_count, signal_sell_count for sym in symbols: sig_df = signals.get(sym) if sig_df is None or date not in sig_df.index: @@ -774,6 +782,9 @@ def _record_blocked_buy(sym: str, row: pd.Series, reason: str) -> None: elif sig_val == "SELL": signal_sell_count += 1 + def _collect_buy_candidates(date_idx: int, regime_at_t: str, sold_today) -> list: + """오늘 체결할 예약 BUY 후보 (점수 내림차순). 진입 guard에 걸린 신호는 사유별로 센다.""" + nonlocal gap_up_buy_blocks, earnings_buy_blocks, blackswan_buy_blocks, regime_buy_blocks buy_candidates = [] if regime_at_t != "bearish": for sym in symbols: @@ -788,6 +799,10 @@ def _record_blocked_buy(sym: str, row: pd.Series, reason: str) -> None: execution_row = sig_df.iloc[current_pos] strategy_row, signal_date = _strategy_signal_context(sym, date) if strategy_row is not None and strategy_row.get("signal") == "BUY": + if sym in sold_today: + # 같은 날 이미 청산한 종목은 다시 사지 않는다 (시가 청산 직후 재매수 방지). + skipped_reasons["sold_today"] = skipped_reasons.get("sold_today", 0) + 1 + continue close = float(execution_row.get("close", 0)) prev_close = _previous_close(sym, date) open_price = _row_price(execution_row, "open", close) @@ -835,7 +850,7 @@ def _record_blocked_buy(sym: str, row: pd.Series, reason: str) -> None: # bearish: 오늘 체결될 예약 BUY 신호를 모두 차단한다. blocked_pending = [] for sym in symbols: - if sym in positions: + if sym in positions or sym in sold_today: continue strategy_row, signal_date = _strategy_signal_context(sym, date) if strategy_row is not None and strategy_row.get("signal") == "BUY": @@ -858,23 +873,21 @@ def _record_blocked_buy(sym: str, row: pd.Series, reason: str) -> None: }) buy_candidates.sort(key=lambda x: -x[2]) + return buy_candidates + def _execute_buys(date_idx: int, buy_candidates: list, regime_at_t: str, mark) -> None: + """후보 순서대로 매수한다. mark(sym)는 보유 종목 평가가격 (주문 시점에 알 수 있는 값).""" + nonlocal cash, executed_buy_count, regime_caution_buys, blackswan_recovery_buys for sym, execution_price_ref, score, sig_row, signal_date in buy_candidates: if len(positions) >= max_positions: skipped_reasons["max_positions"] = skipped_reasons.get("max_positions", 0) + 1 continue - total_equity_now = cash + sum( - self._get_close(signals, s, date, positions[s]["avg_price"]) * positions[s]["qty"] - for s in positions - ) + total_equity_now = cash + sum(mark(s) * positions[s]["qty"] for s in positions) if total_equity_now <= 0: skipped_reasons["no_equity"] = skipped_reasons.get("no_equity", 0) + 1 continue - invested_now = sum( - self._get_close(signals, s, date, positions[s]["avg_price"]) * positions[s]["qty"] - for s in positions - ) + invested_now = sum(mark(s) * positions[s]["qty"] for s in positions) if total_equity_now > 0 and invested_now / total_equity_now >= max_investment_ratio: skipped_reasons["max_investment_ratio"] = skipped_reasons.get("max_investment_ratio", 0) + 1 continue @@ -983,10 +996,177 @@ def _record_blocked_buy(sym: str, row: pd.Series, reason: str) -> None: **entry_scores, }) - portfolio_value = cash + sum( - self._get_close(signals, s, date, positions[s]["avg_price"]) * positions[s]["qty"] - for s in positions - ) + blackswan_activated_today = False + for date_idx, date in enumerate(all_dates): + blackswan_activated_today = False + + # 시장국면 판별 (TICKET-05): T-1일 지수 기준, look-ahead bias 없음 + regime_at_t = "bullish" + if regime_enabled and date in regime_series.index: + regime_at_t = regime_series.loc[date] + + if execution_model == EXECUTION_MODEL_LEGACY_SAME_CLOSE: + # 과거 결과 재현 경로 — 체결 순서를 바꾸지 않는다. 모든 청산 판정과 전략 주문을 + # 당일 종가 한 시점에서 처리한다. + total_equity = cash + sum(_close_mark(s) * positions[s]["qty"] for s in positions) + portfolio_blackswan_reason = _portfolio_blackswan_reason(total_equity) + + to_sell = [] + for sym in list(positions.keys()): + sig_df = signals.get(sym) + if sig_df is None or date not in sig_df.index: + continue + row = sig_df.loc[date] + close = float(row.get("close", positions[sym]["avg_price"])) + open_price = _row_price(row, "open", close) + pos = positions[sym] + pos["high_water_mark"] = max(pos["high_water_mark"], close) + + sell_reason = None + sell_price_ref = close + sell_signal_date = None + avg_daily_volume = _avg_daily_volume(sig_df, date, row) + row_atr = _get_atr(sig_df, date) + hd = _holding_days(pos) + in_cooling = min_hold_days > 0 and hd < min_hold_days + prev_close = _previous_close(sym, date) + stock_daily_return = _record_daily_return(sym, close, prev_close) + + if gap_enabled and prev_close is not None and prev_close > 0: + gap_pct = (open_price - prev_close) / prev_close + if gap_pct <= gap_down_threshold: + sell_reason = "GAP_DOWN" + sell_price_ref = open_price + gap_down_exits += 1 + + if not sell_reason: + sell_reason = _close_rule_exit_reason( + sym, pos, close, row_atr, hd, in_cooling, + stock_daily_return, portfolio_blackswan_reason, date_idx, + ) + + strategy_row, strategy_signal_date = _strategy_signal_context(sym, date) + if ( + not sell_reason + and strategy_row is not None + and strategy_row.get("signal") == "SELL" + and not in_cooling # 냉각기: 전략 SELL 억제 + ): + sell_reason = "SELL" + sell_signal_date = strategy_signal_date + avg_daily_volume = _avg_daily_volume( + sig_df, strategy_signal_date, strategy_row + ) + + if sell_reason: + to_sell.append( + (sym, sell_price_ref, sell_reason, avg_daily_volume, sell_signal_date) + ) + + for sym, price_ref, reason, avg_daily_volume, signal_date in to_sell: + _execute_exit(sym, price_ref, reason, avg_daily_volume, signal_date) + + _count_raw_signals() + buy_candidates = _collect_buy_candidates(date_idx, regime_at_t, frozenset()) + _execute_buys(date_idx, buy_candidates, regime_at_t, _close_mark) + else: + # next_open — 실제 시간 순서: (1) 시가 청산 → (2) 시가 매수 → (3) 종가 청산. + # 종가 사건(손절·익절 등)이 그보다 앞선 시가 주문을 밀어내거나, 종가에 판 종목을 + # 같은 날 시가에 다시 사는 시간 역행이 생기지 않게 한다. + sold_today: set[str] = set() + + # (0) 데이터가 끝난 보유 종목(이후 행이 하나도 없음)은 마지막 관측 종가로 청산한다. + # 중간에 행만 빠진 거래정지는 청산하지 않고 마지막 종가로 평가만 한다. + for sym in list(positions.keys()): + last_date = data_end_date.get(sym) + if last_date is None or date <= last_date: + continue + sig_df = signals[sym] + sell_reason = DATA_END_EXIT_ACTION + data_end_exits += 1 + _execute_exit( + sym, _fallback_mark(sym), sell_reason, + _avg_daily_volume(sig_df, last_date, sig_df.iloc[-1]), None, + ) + sold_today.add(sym) + + # (1) 시가: 전일부터 보유한 종목만. 갭다운 청산이 먼저, 그다음 예약된 전략 SELL. + for sym in list(positions.keys()): + sig_df = signals.get(sym) + if sig_df is None or date not in sig_df.index: + continue + row = sig_df.loc[date] + pos = positions[sym] + close = float(row.get("close", pos["avg_price"])) + open_price = _row_price(row, "open", close) + prev_close = _previous_close(sym, date) + + if gap_enabled and prev_close is not None and prev_close > 0: + gap_pct = (open_price - prev_close) / prev_close + if gap_pct <= gap_down_threshold: + sell_reason = "GAP_DOWN" + gap_down_exits += 1 + _execute_exit( + sym, open_price, sell_reason, + _avg_daily_volume(sig_df, date, row), None, + ) + sold_today.add(sym) + continue + + hd = _holding_days(pos) + in_cooling = min_hold_days > 0 and hd < min_hold_days + strategy_row, strategy_signal_date = _strategy_signal_context(sym, date) + if ( + strategy_row is not None + and strategy_row.get("signal") == "SELL" + and not in_cooling # 냉각기: 전략 SELL 억제 + ): + sell_reason = "SELL" + _execute_exit( + sym, open_price, sell_reason, + _avg_daily_volume(sig_df, strategy_signal_date, strategy_row), + strategy_signal_date, + ) + sold_today.add(sym) + + # (2) 시가 매수: 수량·비중 한도는 시가 시점에 알 수 있는 평가가격으로 계산한다. + buy_candidates = _collect_buy_candidates(date_idx, regime_at_t, sold_today) + _execute_buys(date_idx, buy_candidates, regime_at_t, _open_mark) + + # (3) 종가: 시가를 넘긴 포지션(오늘 산 종목 포함)에 종가 기준 청산 규칙을 적용한다. + # 포트폴리오 급락 판정도 시가 거래까지 반영한 종가 기준 자산으로 한다. + total_equity = cash + sum(_close_mark(s) * positions[s]["qty"] for s in positions) + portfolio_blackswan_reason = _portfolio_blackswan_reason(total_equity) + to_sell = [] + for sym in list(positions.keys()): + sig_df = signals.get(sym) + if sig_df is None or date not in sig_df.index: + continue + row = sig_df.loc[date] + pos = positions[sym] + close = float(row.get("close", pos["avg_price"])) + pos["high_water_mark"] = max(pos["high_water_mark"], close) + hd = _holding_days(pos) + in_cooling = min_hold_days > 0 and hd < min_hold_days + prev_close = _previous_close(sym, date) + stock_daily_return = _record_daily_return(sym, close, prev_close) + sell_reason = _close_rule_exit_reason( + sym, pos, close, _get_atr(sig_df, date), hd, in_cooling, + stock_daily_return, portfolio_blackswan_reason, date_idx, + ) + if sell_reason: + to_sell.append( + (sym, close, sell_reason, _avg_daily_volume(sig_df, date, row), None) + ) + for sym, price_ref, reason, avg_daily_volume, signal_date in to_sell: + _execute_exit(sym, price_ref, reason, avg_daily_volume, signal_date) + + # 원본 신호 집계는 신호가 확정되는 장 마감 기준 보유 상태로 센다. + _count_raw_signals() + + for sym in positions: + last_close[sym] = _close_mark(sym) + portfolio_value = cash + sum(last_close[s] * positions[s]["qty"] for s in positions) equity_curve.append({ "date": date, "value": portfolio_value, @@ -1030,6 +1210,7 @@ def _record_blocked_buy(sym: str, row: pd.Series, reason: str) -> None: "blackswan_triggers": blackswan_triggers, "blackswan_buy_blocks": blackswan_buy_blocks, "blackswan_recovery_buys": blackswan_recovery_buys, + "data_end_exits": data_end_exits, } @staticmethod @@ -1066,13 +1247,15 @@ def _calculate_portfolio_metrics(self, result: dict, initial_capital: float) -> if len(daily_returns) > 0 and daily_returns.std() > 0: annual_return = daily_returns.mean() * 252 annual_std = daily_returns.std() * np.sqrt(252) - sharpe = (annual_return - 0.03) / annual_std + sharpe = (annual_return - BACKTEST_RISK_FREE_ANNUAL) / annual_std else: sharpe = 0 downside = daily_returns[daily_returns < 0] if len(downside) > 0 and downside.std() > 0: - sortino = (daily_returns.mean() * 252 - 0.03) / (downside.std() * np.sqrt(252)) + sortino = (daily_returns.mean() * 252 - BACKTEST_RISK_FREE_ANNUAL) / ( + downside.std() * np.sqrt(252) + ) else: sortino = sharpe @@ -1091,9 +1274,14 @@ def _calculate_portfolio_metrics(self, result: dict, initial_capital: float) -> gross_loss = abs(sum(t["pnl"] for t in losing)) profit_factor = gross_profit / gross_loss if gross_loss > 0 else 0 + # 연간 수익률(산술)은 기존 의미 그대로 두고, 칼마는 단일 종목 엔진과 같이 + # 부호 있는 CAGR / |MDD|로 계산한다(MDD가 0이면 0.0). years = len(equity) / 252 if len(equity) > 0 else 1 annual_return_pct = total_return / years - calmar = abs(annual_return_pct / max_drawdown) if max_drawdown != 0 else 0 + cagr = 0.0 + if initial_capital > 0 and final_value > 0 and years > 0: + cagr = ((final_value / initial_capital) ** (1 / years) - 1) * 100 + calmar = (cagr / abs(max_drawdown)) if max_drawdown < 0 else 0.0 if len(daily_returns) >= 20: var_95 = float(np.percentile(daily_returns, 5)) @@ -1115,6 +1303,7 @@ def _calculate_portfolio_metrics(self, result: dict, initial_capital: float) -> ), "total_return": round(total_return, 2), "annual_return": round(annual_return_pct, 2), + "cagr": round(cagr, 2), "sharpe_ratio": round(sharpe, 2), "sortino_ratio": round(sortino, 2), "max_drawdown": round(max_drawdown, 2), @@ -1142,6 +1331,7 @@ def _calculate_portfolio_metrics(self, result: dict, initial_capital: float) -> "blackswan_triggers": result.get("blackswan_triggers", 0), "blackswan_buy_blocks": result.get("blackswan_buy_blocks", 0), "blackswan_recovery_buys": result.get("blackswan_recovery_buys", 0), + "data_end_exits": result.get("data_end_exits", 0), } cost_impact = summarize_cost_impact(metrics, trades) metrics.update(cost_impact_metric_fields(cost_impact)) @@ -1161,10 +1351,11 @@ def print_report(self, result: dict): print(f" 초기 자본 : {m['initial_capital']:>14,.0f}원") print(f" 최종 자본 : {m['final_value']:>14,.0f}원") print(f" 총 수익률 : {m['total_return']:>13.2f}%") - print(f" 연간 수익률 : {m['annual_return']:>13.2f}%") + print(f" 연간 수익률 : {m['annual_return']:>13.2f}% (산술)") + print(f" CAGR : {m.get('cagr', 0):>13.2f}% (복리, 칼마 기준)") print("-" * 60) - print(f" 샤프 지수 : {m['sharpe_ratio']:>13.2f}") - print(f" 소르티노 비율 : {m.get('sortino_ratio', 0):>13.2f}") + print(f" 샤프 지수 : {m['sharpe_ratio']:>13.2f} ({BACKTEST_RISK_FREE_LABEL})") + print(f" 소르티노 비율 : {m.get('sortino_ratio', 0):>13.2f} ({BACKTEST_RISK_FREE_LABEL})") print(f" 최대 낙폭 : {m['max_drawdown']:>13.2f}%") print(f" 칼마 비율 : {m.get('calmar_ratio', 0):>13.2f}") print(f" VaR 95%(일) : {m.get('var_95_daily', 0):>13.3f}%") @@ -1207,6 +1398,7 @@ def print_report(self, result: dict): print(f" blackswan_triggers : {result.get('blackswan_triggers', 0):>5d}건") print(f" blackswan_buy_blocks : {result.get('blackswan_buy_blocks', 0):>5d}건") print(f" blackswan_recovery_buys: {result.get('blackswan_recovery_buys', 0):>5d}건") + print(f" data_end_exits : {result.get('data_end_exits', 0):>5d}건 (데이터 종료 강제 청산)") if result.get("blocked_buy_examples"): print(f" [진단: blocked BUY 예시 (최대 10건)]") for ex in result["blocked_buy_examples"]: diff --git a/backtest/report_generator.py b/backtest/report_generator.py index 798922a1..09814da7 100644 --- a/backtest/report_generator.py +++ b/backtest/report_generator.py @@ -17,16 +17,27 @@ import pandas as pd from loguru import logger +from backtest.backtester import ( + BACKTEST_RISK_FREE_ANNUAL, + BACKTEST_RISK_FREE_LABEL, + PNL_EXIT_ACTIONS, +) from backtest.cost_impact import render_cost_impact_text, summarize_cost_impact def _default_backtest_slippage_pct() -> float: - """risk_params.slippage 비율을 퍼센트로 (기본 0.05%).""" + """백테스트 고정 슬리피지 비율(transaction_costs.slippage)을 퍼센트로 (기본 0.05%). + + RiskManager와 같은 키를 읽는다(예전엔 없는 최상위 risk_params.slippage를 읽어 항상 기본값). + 실제 백테스트 체결가에는 이 비율 위에 호가 틱 하한(slippage_ticks)과 거래량 기반 동적 + 배수가 더해지므로, 이 값은 백테스트 가정 슬리피지의 '하한'이다. + """ try: from config.config_loader import Config rp = Config.get().risk_params or {} - return float(rp.get("slippage", 0.0005)) * 100.0 + costs = rp.get("transaction_costs") or {} + return float(costs.get("slippage", 0.0005)) * 100.0 except Exception: return 0.05 @@ -68,7 +79,7 @@ def _format_live_slippage_text_table(summary: Optional[Dict[str, Any]]) -> List[ return lines bt = summary["backtest_assumed_pct"] lines.extend([ - f" 백테스트 가정 슬리피지 : {bt:.3f}% (risk_params.slippage)", + f" 백테스트 가정 슬리피지 : {bt:.3f}% (transaction_costs.slippage 고정 비율 — 호가 틱·동적 배수 적용 전 하한)", f" 실전 집계 건수 : {summary['n']}건", "-" * 52, f" 평균 (실전) : {summary['mean_pct']:+.4f}%", @@ -91,7 +102,7 @@ def _format_live_slippage_html_card(summary: Optional[Dict[str, Any]]) -> str: bt = summary["backtest_assumed_pct"] body = f""" - + @@ -103,20 +114,15 @@ def _format_live_slippage_html_card(summary: Optional[Dict[str, Any]]) -> str:

📉 실전 vs 백테스트 슬리피지 비교

실전 mode=live 거래 중 DB에 기록된 슬리피지 % (체결가 vs 주문 시점 예상가). + 백테스트 값은 transaction_costs.slippage 고정 비율로, 호가 틱(slippage_ticks)·거래량 동적 배수가 + 적용되기 전 하한이다 — 저가·소형주의 실제 백테스트 가정은 이보다 크다.

{body} """ -_TRADES_WITH_PNL = frozenset( - ( - "SELL", - "STOP_LOSS", - "TAKE_PROFIT", - "TAKE_PROFIT_PARTIAL", - "TRAILING_STOP", - "MAX_HOLD", - ) -) +# 리포트의 거래표·누적 실현손익 차트는 엔진 지표와 같은 청산 목록(PNL_EXIT_ACTIONS)을 쓴다. +# 별도 목록을 두면 GAP_DOWN·BLACKSWAN처럼 가장 큰 하루 손실이 표에서 빠져 리포트가 +# 자본 곡선보다 좋아 보인다. def _overtrading_charts_png_base64(trades: list, equity: pd.DataFrame) -> str: @@ -155,7 +161,7 @@ def _overtrading_charts_png_base64(trades: list, equity: pd.DataFrame) -> str: idx = pd.date_range(d0, d1, freq="D") tdf["dn"] = tdf["d"].dt.normalize() comm_day = tdf.groupby("dn")["commission"].sum() - tdf_p = tdf.loc[tdf["action"].isin(_TRADES_WITH_PNL)] + tdf_p = tdf.loc[tdf["action"].isin(PNL_EXIT_ACTIONS)] pnl_day = tdf_p.groupby(tdf_p["dn"])["pnl"].sum() if not tdf_p.empty else pd.Series(dtype=float) cum_c = comm_day.reindex(idx, fill_value=0).cumsum() cum_p = pnl_day.reindex(idx, fill_value=0).cumsum() @@ -210,30 +216,60 @@ def _overtrading_charts_png_base64(trades: list, equity: pd.DataFrame) -> str: } -def _strategy_monthly_returns(equity: pd.DataFrame) -> pd.Series: - """자본 곡선에서 월말 기준 월간 수익률 (당월 첫일 자산 대비 말일).""" +def _chain_month_end_returns(month_end: pd.Series, first_base: float) -> pd.Series: + """월말 값 시리즈 → 월간 수익률(당월 말 / 전월 말 - 1). 첫 달은 first_base 기준.""" + base = month_end.shift(1) + base.iloc[0] = first_base + return month_end / base - 1.0 + + +def _strategy_monthly_returns( + equity: pd.DataFrame, initial_capital: Optional[float] = None +) -> pd.Series: + """자본 곡선의 월간 수익률 = 당월 말 자산 / 전월 말 자산 - 1. + + 첫 달은 초기자본(없으면 첫 관측 자산)을 기준으로 삼는다. 예전처럼 당월 첫 거래일 자산을 + 기준으로 쓰면 월 첫 거래일의 등락(예: 월초 갭 하락)이 어느 달에도 잡히지 않는다. + """ if equity.empty or "date" not in equity.columns or "value" not in equity.columns: return pd.Series(dtype=float) eq = equity.copy() eq["date"] = pd.to_datetime(eq["date"]) eq = eq.sort_values("date") - eq["period"] = eq["date"].dt.to_period("M") - g = eq.groupby("period", sort=True)["value"].agg(["first", "last"]) - ret = g["last"] / g["first"] - 1.0 - return ret + values = eq.set_index("date")["value"].astype(float) + month_end = values.groupby(values.index.to_period("M"), sort=True).last() + if initial_capital is not None and float(initial_capital) > 0: + first_base = float(initial_capital) + else: + first_base = float(values.iloc[0]) + return _chain_month_end_returns(month_end, first_base) + +def _kospi_monthly_returns_from_ohlc(ks11: pd.DataFrame, start=None) -> pd.Series: + """KS11 일봉(DatetimeIndex + close) → 월간 수익률 = 당월 말 종가 / 전월 말 종가 - 1. -def _kospi_monthly_returns_from_ohlc(ks11: pd.DataFrame) -> pd.Series: - """KS11 일봉( DatetimeIndex + close ) → 월간 수익률.""" + start가 주어지면 그 이후만 집계하고, 첫 달의 기준은 start 직전 거래일 종가로 삼아 전략 쪽 + (첫 달 기준 = 초기자본)과 같은 구간을 잰다. 직전 종가가 없으면 첫 관측 종가를 기준으로 한다. + 국면 분류가 이 값으로 정해지므로 전략 쪽과 같은 공식을 써야 월 소속이 한쪽으로 틀어지지 않는다. + """ if ks11 is None or ks11.empty or "close" not in ks11.columns: return pd.Series(dtype=float) d = ks11.copy() if not isinstance(d.index, pd.DatetimeIndex): d.index = pd.to_datetime(d.index) - d = d.sort_index() - d["period"] = d.index.to_period("M") - g = d.groupby("period", sort=True)["close"].agg(["first", "last"]) - return g["last"] / g["first"] - 1.0 + close = d.sort_index()["close"].astype(float).dropna() + prior_close = None + if start is not None: + start_ts = pd.Timestamp(start) + before = close.loc[close.index < start_ts] + if not before.empty: + prior_close = float(before.iloc[-1]) + close = close.loc[close.index >= start_ts] + if close.empty: + return pd.Series(dtype=float) + month_end = close.groupby(close.index.to_period("M"), sort=True).last() + first_base = prior_close if prior_close is not None else float(close.iloc[0]) + return _chain_month_end_returns(month_end, first_base) def _classify_regime(kospi_monthly_ret: float) -> str: @@ -244,7 +280,9 @@ def _classify_regime(kospi_monthly_ret: float) -> str: return REGIME_SIDEWAYS -def _monthly_sharpe(monthly_rets: List[float], risk_free_annual: float = 0.03) -> float: +def _monthly_sharpe( + monthly_rets: List[float], risk_free_annual: float = BACKTEST_RISK_FREE_ANNUAL +) -> float: arr = np.array(monthly_rets, dtype=float) if len(arr) < 2: return 0.0 @@ -256,20 +294,18 @@ def _monthly_sharpe(monthly_rets: List[float], risk_free_annual: float = 0.03) - return float(np.mean(xs) / std * np.sqrt(12)) -def _mdd_from_month_end_equity(equity: pd.DataFrame, periods: List) -> float: - """지정된 월(Period)들에 대해 월말 자산만 이어 MDD(%).""" - if equity.empty or not periods: - return 0.0 - eq = equity.copy() - eq["date"] = pd.to_datetime(eq["date"]) - eq["period"] = eq["date"].dt.to_period("M") - me = eq.groupby("period", sort=True)["value"].last() - sub = me[me.index.isin(periods)].sort_index() - if len(sub) < 2: +def _mdd_from_monthly_returns(monthly_rets: List[float]) -> float: + """국면 MDD(%): 해당 국면 달들의 월수익률만 복리로 이은 합성 지수의 최대 낙폭. + + 서로 떨어진 달들의 월말 자산을 그대로 이으면 사이에 낀 다른 국면 달의 손익이 섞인다 + (예: 1월·3월이 모두 보합이어도 2월 -30%가 1월 말→3월 말 낙폭으로 잡힘). + """ + arr = np.asarray(monthly_rets, dtype=float) + if arr.size == 0: return 0.0 - peak = sub.cummax() - dd = (sub - peak) / peak - return float(dd.min() * 100) + index = np.concatenate(([1.0], np.cumprod(1.0 + arr))) + peak = np.maximum.accumulate(index) + return float(((index - peak) / peak).min() * 100) def compute_market_regime_breakdown( @@ -279,6 +315,9 @@ def compute_market_regime_breakdown( """ 코스피(KS11) 월 수익률로 국면을 나누고, 전략의 월별 성과를 집계한다. + 월 수익률은 양쪽 모두 '당월 말 / 전월 말 - 1'(첫 달은 초기자본 / 시작 직전 종가 기준)이고, + 국면 MDD는 그 국면 달들의 월수익률만 복리로 이은 합성 지수에서 잰다. + Returns: { "bull": {n_months, avg_strat_pct, avg_kospi_pct, excess_pct, sharpe, mdd_pct, periods}, ... } 데이터 부족 시 None. @@ -292,13 +331,14 @@ def compute_market_regime_breakdown( eq_start = pd.to_datetime(equity["date"]).min() eq_end = pd.to_datetime(equity["date"]).max() - start_s = eq_start.strftime("%Y-%m-%d") + # 첫 달 코스피 수익률의 기준(시작 직전 거래일 종가)을 얻으려고 연휴를 넘길 만큼 앞에서부터 받는다. + fetch_start_s = (eq_start - pd.Timedelta(days=14)).strftime("%Y-%m-%d") end_s = eq_end.strftime("%Y-%m-%d") try: from core.data_collector import DataCollector - ks11 = DataCollector().fetch_korean_stock("KS11", start_date=start_s, end_date=end_s) + ks11 = DataCollector().fetch_korean_stock("KS11", start_date=fetch_start_s, end_date=end_s) except Exception as e: logger.warning("시장 국면 분석: KS11 수집 실패 — {}", e) return None @@ -307,8 +347,11 @@ def compute_market_regime_breakdown( logger.warning("시장 국면 분석: KS11 데이터 없음") return None - kospi_m = _kospi_monthly_returns_from_ohlc(ks11) - strat_m = _strategy_monthly_returns(equity) + initial_capital = result.get("initial_capital") or (result.get("metrics") or {}).get( + "initial_capital" + ) + kospi_m = _kospi_monthly_returns_from_ohlc(ks11, start=eq_start) + strat_m = _strategy_monthly_returns(equity, initial_capital=initial_capital) common = strat_m.index.intersection(kospi_m.index) if len(common) == 0: logger.warning("시장 국면 분석: 전략·코스피 공통 월 없음") @@ -351,7 +394,7 @@ def compute_market_regime_breakdown( "avg_kospi_pct": round(avg_k, 2), "excess_pct": round(avg_s - avg_k, 2), "sharpe": round(_monthly_sharpe(b["strat"]), 2), - "mdd_pct": round(_mdd_from_month_end_equity(equity, b["periods"]), 2), + "mdd_pct": round(_mdd_from_monthly_returns(b["strat"]), 2), "periods": b["periods"], } @@ -380,7 +423,10 @@ def _format_regime_text_table(breakdown: Dict[str, Dict[str, Any]]) -> List[str] f"{r['avg_kospi_pct']:>+12.1f}% | {r['excess_pct']:>+7.1f}%" ) lines.append("-" * 72) - lines.append("[ 국면별 위험지표 — 월간 수익 기준 샤프, 월말 자산 기준 MDD ]") + lines.append( + f"[ 국면별 위험지표 — 월간 수익 기준 샤프({BACKTEST_RISK_FREE_LABEL}), " + "국면 월수익률 복리 기준 MDD ]" + ) for key in (REGIME_BULL, REGIME_BEAR, REGIME_SIDEWAYS): r = breakdown[key] if r["n_months"] == 0: @@ -408,11 +454,11 @@ def _format_regime_html_table(breakdown: Dict[str, Dict[str, Any]]) -> str:

📉 시장 국면별 성과 (KS11 월 수익률 기준)

- 상승장: 월 > +2% · 하락장: 월 < -2% · 횡보장: 그 외. 샤프는 월간 수익 연율화, MDD는 해당 월들의 월말 자산만으로 산출. + 상승장: 월 > +2% · 하락장: 월 < -2% · 횡보장: 그 외. 월수익률은 당월 말 / 전월 말 기준. 샤프는 월간 수익 연율화({BACKTEST_RISK_FREE_LABEL}), MDD는 해당 국면 달들의 월수익률만 복리로 이은 합성 지수 기준.

백테스트 가정 슬리피지{bt:.3f}%
백테스트 가정 슬리피지 (고정 비율 하한){bt:.3f}%
실전 집계 건수{summary["n"]}건
평균 (실전){summary["mean_pct"]:+.4f}%
중앙값 (실전){summary["median_pct"]:+.4f}%
- + {rows}
국면기간(개월)전략 평균 월수익률코스피 월수익률초과 수익샤프MDD국면기간(개월)전략 평균 월수익률코스피 월수익률초과 수익샤프({BACKTEST_RISK_FREE_LABEL})MDD
@@ -452,7 +498,7 @@ def generate_text_report(self, result: dict) -> str: trades = result.get("trades", []) # 매도·청산 거래만 추출 (수익 계산용) - sell_trades = [t for t in trades if t["action"] in _TRADES_WITH_PNL] + sell_trades = [t for t in trades if t["action"] in PNL_EXIT_ACTIONS] lines = [ "=" * 60, @@ -472,7 +518,7 @@ def generate_text_report(self, result: dict) -> str: f" 연간 수익률 : {m['annual_return']:>13.2f}%", "", "[ 위험 지표 ]", - f" 샤프 지수 : {m['sharpe_ratio']:>13.2f}", + f" 샤프 지수 : {m['sharpe_ratio']:>13.2f} ({BACKTEST_RISK_FREE_LABEL})", f" 최대 낙폭 : {m['max_drawdown']:>13.2f}%", f" 칼마 비율 : {m['calmar_ratio']:>13.2f}", "", @@ -700,7 +746,7 @@ def generate_html_report(self, result: dict, filename: str = None) -> str:
{m['total_return']:.2f}%
-
샤프 지수
+
샤프 지수 ({BACKTEST_RISK_FREE_LABEL})
{m['sharpe_ratio']:.2f}
@@ -814,7 +860,7 @@ def _generate_equity_chart_svg(self, equity: pd.DataFrame) -> str: @staticmethod def _generate_trades_table(trades: list) -> str: """거래 내역 HTML 테이블 생성""" - sell_trades = [t for t in trades if t["action"] in _TRADES_WITH_PNL] + sell_trades = [t for t in trades if t["action"] in PNL_EXIT_ACTIONS] if not sell_trades: return "

거래 내역 없음

" diff --git a/backtest/strategy_validator.py b/backtest/strategy_validator.py index b189b64c..73d141b2 100644 --- a/backtest/strategy_validator.py +++ b/backtest/strategy_validator.py @@ -17,7 +17,11 @@ import pandas as pd from loguru import logger -from backtest.backtester import Backtester +from backtest.backtester import ( + BACKTEST_RISK_FREE_ANNUAL, + BACKTEST_RISK_FREE_LABEL, + Backtester, +) from config.config_loader import Config from core.data_collector import DataCollector from core.notifier import Notifier @@ -103,7 +107,7 @@ def _portfolio_metrics_from_equity(equity: pd.Series, initial_capital: float) -> if len(daily_returns) > 0 and daily_returns.std() > 0: annual_return = daily_returns.mean() * 252 annual_std = daily_returns.std() * np.sqrt(252) - sharpe = (annual_return - 0.03) / annual_std + sharpe = (annual_return - BACKTEST_RISK_FREE_ANNUAL) / annual_std else: sharpe = 0.0 peak = equity.cummax() @@ -127,7 +131,13 @@ def _build_equal_weight_panel( start_date: str, end_date: str, ) -> pd.DataFrame: - """동일 비중 벤치용: (date index, columns=symbol) close 패널. 공통 일자만 join='inner'.""" + """동일 비중 벤치용: (date index, columns=symbol) close 패널. + + 모든 종목의 일자를 합친다(outer). 예전 inner 조인은 한 종목만 늦게 상장하거나 일찍 끝나도 + 패널 전체 기간이 그 종목 기준으로 잘려, 벤치마크가 전략과 다른(짧은) 기간을 쟀다. + 상장 전·데이터 종료 후는 NaN으로 두고, 첫 관측과 마지막 관측 사이의 빈칸(거래정지 등)만 + 직전 종가로 채운다. + """ if not symbols: return pd.DataFrame() closes_list = [] @@ -141,9 +151,61 @@ def _build_equal_weight_panel( if not closes_list: return pd.DataFrame() try: - return pd.concat(closes_list, axis=1, join="inner") + panel = pd.concat(closes_list, axis=1, join="outer").sort_index() except Exception: return pd.DataFrame() + return panel.ffill().where(panel.bfill().notna()) + + +def _staggered_equal_weight_equity(panel: pd.DataFrame, initial_capital: float) -> pd.Series: + """동일 비중 매수·보유 자산 곡선 — 가격이 늦게 시작하는 종목은 첫 가격일에 편입한다. + + 첫날 가격이 있는 종목들을 같은 금액씩 사서 그대로 보유한다(매일 재조정 없음). 늦게 상장한 + 종목은 첫 가격일에 '그 시점 평가액 ÷ 편입 후 종목 수'만큼 사고, 기존 보유 종목들은 서로의 + 비중을 유지한 채 같은 비율로 줄여 자금을 마련한다. 데이터가 끝난 종목은 마지막 가격으로 + 평가한다(그 가격에 현금화된 것으로 본다). + """ + if panel is None or panel.empty: + return pd.Series(dtype=float) + prices = panel.sort_index().ffill() + px = prices.to_numpy(dtype=float) + n_rows, n_cols = px.shape + shares = np.zeros(n_cols) + entered = np.zeros(n_cols, dtype=bool) + equity = np.empty(n_rows) + for i in range(n_rows): + row = px[i] + value = float(shares[entered] @ row[entered]) if entered.any() else float(initial_capital) + entrants = ~entered & ~np.isnan(row) + if entrants.any(): + n_before = int(entered.sum()) + n_after = n_before + int(entrants.sum()) + shares[entered] *= n_before / n_after + shares[entrants] = (value / n_after) / row[entrants] + entered |= entrants + equity[i] = value + return pd.Series(equity, index=prices.index) + + +def _equal_weight_panel_coverage(panel: pd.DataFrame, n_requested: int) -> dict: + """벤치마크 패널의 종목 편입 범위 (시작일 보유 종목 수, 늦은 편입·중도 종료 종목).""" + start, end = panel.index.min(), panel.index.max() + late_entries = {} + ended_early = {} + for sym in panel.columns: + first = panel[sym].first_valid_index() + last = panel[sym].last_valid_index() + if first is not None and first > start: + late_entries[str(sym)] = str(pd.Timestamp(first).date()) + if last is not None and last < end: + ended_early[str(sym)] = str(pd.Timestamp(last).date()) + return { + "requested": int(n_requested), + "with_data": int(panel.shape[1]), + "at_start": int(panel.iloc[0].notna().sum()), + "late_entries": late_entries, + "ended_early": ended_early, + } def _equal_weight_buy_and_hold_metrics( @@ -157,7 +219,7 @@ def _equal_weight_buy_and_hold_metrics( panel = _build_equal_weight_panel(collector, symbols, start_date, end_date) if panel.empty or len(panel) < 2: return {} - equity = (panel / panel.iloc[0]).mean(axis=1) * initial_capital + equity = _staggered_equal_weight_equity(panel, initial_capital) return _portfolio_metrics_from_equity(equity, initial_capital) @@ -223,10 +285,14 @@ def run( strategy_name=strategy_name, strict_lookahead=True, ) + # OOS는 인샘플 구간을 지표 워밍업으로 함께 넣고 거래·지표는 OOS 구간만 잰다. + # OOS만 잘라 넣으면 60~200일 지표가 구간 앞부분에서 꺼져 있어 전략이 아니라 + # 워밍업을 평가하게 된다. out_sample_result = self.backtester.run( - strategy_df.iloc[split_idx:].copy(), + strategy_df.copy(), strategy_name=strategy_name, strict_lookahead=True, + trade_start_date=strategy_df.index[split_idx], ) benchmark = { @@ -250,20 +316,31 @@ def run( s0, s1 = str(strategy_df.index[0].date()), str(strategy_df.index[-1].date()) panel = _build_equal_weight_panel(self.collector, symbols_top50, s0, s1) if not panel.empty and len(panel) >= 2: + coverage = _equal_weight_panel_coverage(panel, len(symbols_top50)) + benchmark_top50["coverage"] = coverage + if coverage["at_start"] < 0.8 * coverage["requested"]: + logger.warning( + "Top50 벤치마크: 시작일에 가격이 있는 종목 {}/{}개 — 나머지는 첫 가격일부터 " + "동일비중으로 편입합니다(늦은 편입 {}개, 중도 데이터 종료 {}개).", + coverage["at_start"], coverage["requested"], + len(coverage["late_entries"]), len(coverage["ended_early"]), + ) + # 각 구간 벤치마크는 전략과 같은 기간을 잰다. 늦게 상장한 종목은 첫 가격일에 + # 편입하고(매수·보유), 패널 전체를 그 종목 상장일로 자르지 않는다. cap_full = full_result["initial_capital"] - equity_full = (panel / panel.iloc[0]).mean(axis=1) * cap_full + equity_full = _staggered_equal_weight_equity(panel, cap_full) benchmark_top50["full"] = _portfolio_metrics_from_equity(equity_full, cap_full) - panel_is = panel.loc[strategy_df.index[0] : strategy_df.index[split_idx - 1]].dropna(how="any") - if not panel_is.empty and len(panel_is) >= 2: + panel_is = panel.loc[strategy_df.index[0] : strategy_df.index[split_idx - 1]] + if len(panel_is) >= 2: cap_is = in_sample_result["initial_capital"] - equity_is = (panel_is / panel_is.iloc[0]).mean(axis=1) * cap_is + equity_is = _staggered_equal_weight_equity(panel_is, cap_is) benchmark_top50["in_sample"] = _portfolio_metrics_from_equity(equity_is, cap_is) else: benchmark_top50["in_sample"] = {} - panel_oos = panel.loc[strategy_df.index[split_idx] : strategy_df.index[-1]].dropna(how="any") - if not panel_oos.empty and len(panel_oos) >= 2: + panel_oos = panel.loc[strategy_df.index[split_idx] : strategy_df.index[-1]] + if len(panel_oos) >= 2: cap_oos = out_sample_result["initial_capital"] - equity_oos = (panel_oos / panel_oos.iloc[0]).mean(axis=1) * cap_oos + equity_oos = _staggered_equal_weight_equity(panel_oos, cap_oos) benchmark_top50["out_sample"] = _portfolio_metrics_from_equity(equity_oos, cap_oos) else: benchmark_top50["out_sample"] = {} @@ -335,8 +412,11 @@ def run_walk_forward( ) -> dict: """ 워크포워드(슬라이딩 윈도우) 검증. - train_days 기간 훈련 구간 다음 test_days 기간을 테스트로 사용하고, step_days만큼 슬라이드해 반복. - 예: train_days=504(2년), test_days=252(1년), step_days=252 → 2019~2020 훈련→2021 테스트, 2020~2021→2022 테스트, ... + train_days 구간 다음 test_days 구간을 테스트로 사용하고, step_days만큼 슬라이드해 반복. + 예: train_days=504(2년), test_days=252(1년), step_days=252 → 2019~2020 워밍업→2021 테스트, ... + + train_days 구간은 파라미터를 학습하지 않고 지표 워밍업으로만 쓴다(결과의 warmup_period). + 각 창은 워밍업+테스트 구간을 함께 넣고 거래·지표는 테스트 구간만 잰다. """ if validation_years < 3: logger.warning("검증 연수는 최소 3년 권장. {}년 → 3년으로 적용합니다.", validation_years) @@ -364,18 +444,23 @@ def run_walk_forward( test_end = test_start + test_days if test_end > len(strategy_df): break - test_df = strategy_df.iloc[test_start:test_end].copy() + # 평가 구간(벤치마크 슬라이스도 이 구간 기준) + test_df = strategy_df.iloc[test_start:test_end] + # 창 직전 train_days 구간을 지표 워밍업으로 함께 넣는다. 테스트 구간만 잘라 넣으면 + # 60~200일 지표를 쓰는 전략이 창 앞부분 내내 신호를 못 내 통과율이 워밍업을 잰다. + window_df = strategy_df.iloc[train_start:test_end].copy() try: test_result = self.backtester.run( - test_df, + window_df, strategy_name=strategy_name, strict_lookahead=True, + trade_start_date=strategy_df.index[test_start], ) except Exception as e: logger.warning("워크포워드 창 {} 백테스트 실패: {}", i + 1, e) windows.append({ "window": i + 1, - "train_period": f"{strategy_df.index[train_start].date()} ~ {strategy_df.index[train_end - 1].date()}", + "warmup_period": f"{strategy_df.index[train_start].date()} ~ {strategy_df.index[train_end - 1].date()}", "test_period": f"{strategy_df.index[test_start].date()} ~ {strategy_df.index[test_end - 1].date()}", "metrics": None, "passed": False, @@ -391,7 +476,7 @@ def run_walk_forward( bench = self._buy_and_hold_metrics(bench_slice, test_result["initial_capital"]) windows.append({ "window": i + 1, - "train_period": f"{strategy_df.index[train_start].date()} ~ {strategy_df.index[train_end - 1].date()}", + "warmup_period": f"{strategy_df.index[train_start].date()} ~ {strategy_df.index[train_end - 1].date()}", "test_period": f"{strategy_df.index[test_start].date()} ~ {strategy_df.index[test_end - 1].date()}", "metrics": metrics, "benchmark": bench, @@ -476,7 +561,7 @@ def _notify_walk_forward(self, result: dict) -> None: verdict = "통과" if result.get("wf_passed") else "실패" body = ( f"[전략 검증] {strat} 전략 워크포워드 결과: {npass}/{ntot} 통과\n" - f"OOS 평균 샤프: {avg_s} | OOS 평균 MDD: {avg_m}%\n" + f"OOS 평균 샤프({BACKTEST_RISK_FREE_LABEL}): {avg_s} | OOS 평균 MDD: {avg_m}%\n" f"판정: {verdict}" ) try: @@ -510,11 +595,12 @@ def _render_walk_forward_report(self, result: dict) -> str: "=" * 70, f"워크포워드 검증 리포트 | {result['strategy']} | {result['symbol']}", f"기간: {result['period']}", - f"train_days={result['train_days']} test_days={result['test_days']} step_days={result['step_days']}", - f"기준: 샤프 ≥ {result['min_sharpe']}, MDD ≤ {abs(result['max_mdd']):.0f}% (지표값 max_drawdown ≥ {result['max_mdd']})", + f"워밍업(train_days)={result['train_days']} test_days={result['test_days']} step_days={result['step_days']} " + "— 워밍업 구간은 지표 예열에만 쓰고 파라미터 학습은 하지 않는다", + f"기준: 샤프({BACKTEST_RISK_FREE_LABEL}) ≥ {result['min_sharpe']}, MDD ≤ {abs(result['max_mdd']):.0f}% (지표값 max_drawdown ≥ {result['max_mdd']})", f"창별 통과: {result['n_passed']}/{result['n_total']} | 기준 미달 창: {result.get('n_failed', 0)}", f"통과율: {result.get('pass_rate', 0) * 100:.1f}% | 80% 이상 워크포워드 통과: {result.get('wf_passed', False)}", - f"OOS 평균 샤프: {result.get('avg_oos_sharpe', 0)} | OOS 평균 MDD: {result.get('avg_oos_mdd', 0)}%", + f"OOS 평균 샤프({BACKTEST_RISK_FREE_LABEL}): {result.get('avg_oos_sharpe', 0)} | OOS 평균 MDD: {result.get('avg_oos_mdd', 0)}%", f"전체 창 통과: {result['all_passed']}", "=" * 70, "", @@ -572,9 +658,18 @@ def render_text_report(self, result: dict) -> str: ] top50 = result.get("benchmark_top50") or {} if top50: + coverage = top50.get("coverage") or {} + coverage_line = ( + f"편입 범위: 시작일 {coverage.get('at_start', 0)}/{coverage.get('requested', 0)}종목 보유 | " + f"늦은 상장 {len(coverage.get('late_entries') or {})}종목은 첫 가격일에 동일비중 편입 | " + f"중도 데이터 종료 {len(coverage.get('ended_early') or {})}종목" + if coverage + else "편입 범위: 정보 없음" + ) lines.extend([ "", - "벤치마크(코스피 상위 50종목 동일비중)", + "벤치마크(코스피 상위 50종목 동일비중, 매수·보유)", + coverage_line, self._format_section("FULL", result["full"]["metrics"], top50.get("full", {})), self._format_section("IN_SAMPLE", result["in_sample"]["metrics"], top50.get("in_sample", {})), self._format_section("OUT_OF_SAMPLE", result["out_sample"]["metrics"], top50.get("out_sample", {})), @@ -589,7 +684,7 @@ def render_text_report(self, result: dict) -> str: lines.extend([ "-" * 70, f"손익비(Profit Factor): FULL {validation.get('full_profit_factor', 0):.2f} | OOS {validation.get('oos_profit_factor', 0):.2f}", - f"샤프 기준({validation['min_sharpe']:.2f}) 충족: {validation['full_passed']}", + f"샤프({BACKTEST_RISK_FREE_LABEL}) 기준({validation['min_sharpe']:.2f}) 충족: {validation['full_passed']}", f"Out-of-sample 기준 통과: {validation['out_sample_passed']}", ]) if validation.get("warnings"): @@ -689,7 +784,7 @@ def _buy_and_hold_metrics(self, df: pd.DataFrame, initial_capital: float) -> dic if len(daily_returns) > 0 and daily_returns.std() > 0: annual_return = daily_returns.mean() * 252 annual_std = daily_returns.std() * np.sqrt(252) - sharpe = (annual_return - 0.03) / annual_std + sharpe = (annual_return - BACKTEST_RISK_FREE_ANNUAL) / annual_std else: sharpe = 0 @@ -719,12 +814,13 @@ def _format_section(title: str, metrics: dict, benchmark: dict) -> str: turnover = metrics.get("annual_turnover_pct", 0) ev = metrics.get("ev_per_trade", 0) cost_drag = metrics.get("cost_drag_pct", 0) + rf = BACKTEST_RISK_FREE_LABEL return "\n".join([ f"[{title}]", - f"전략 수익률 {metrics.get('total_return', 0):>8.2f}% | CAGR {cagr:>6.2f}% | 샤프 {metrics.get('sharpe_ratio', 0):>5.2f} | 소르티노 {sortino:>5.2f}", + f"전략 수익률 {metrics.get('total_return', 0):>8.2f}% | CAGR {cagr:>6.2f}% | 샤프({rf}) {metrics.get('sharpe_ratio', 0):>5.2f} | 소르티노 {sortino:>5.2f}", f"MDD {metrics.get('max_drawdown', 0):>6.2f}% | 칼마 {calmar:>5.2f} | 턴오버 {turnover:>6.1f}%/y | 비용 드래그 {cost_drag:>5.2f}%", f"승률 {metrics.get('win_rate', 0):>5.1f}% | EV/거래 {ev:>8,.0f}원 | 거래 {metrics.get('total_trades', 0)}건", - f"벤치 수익률 {benchmark_return:>8.2f}% | 샤프 {benchmark_sharpe:>5.2f} | MDD {benchmark_mdd:>6.2f}% | 초과수익 {metrics.get('total_return', 0) - benchmark_return:>+.2f}%", + f"벤치 수익률 {benchmark_return:>8.2f}% | 샤프({rf}) {benchmark_sharpe:>5.2f} | MDD {benchmark_mdd:>6.2f}% | 초과수익 {metrics.get('total_return', 0) - benchmark_return:>+.2f}%", ]) # ═══════════════════════════════════════════════════════════ @@ -760,13 +856,19 @@ def run_ablation( split_idx = max(60, int(len(df) * 0.7)) split_idx = min(split_idx, len(df) - 30) - oos_df = df.iloc[split_idx:].copy() + oos_start = df.index[split_idx] results = {} for strat in strategies: try: full = self.backtester.run(df.copy(), strategy_name=strat, strict_lookahead=True) - oos = self.backtester.run(oos_df.copy(), strategy_name=strat, strict_lookahead=True) + # OOS도 앞 구간을 지표 워밍업으로 넣고 거래·지표는 OOS 구간만 잰다 (run()과 같은 규약). + oos = self.backtester.run( + df.copy(), + strategy_name=strat, + strict_lookahead=True, + trade_start_date=oos_start, + ) results[strat] = { "full": full["metrics"], "oos": oos["metrics"], @@ -843,7 +945,7 @@ def _save_ablation_report(self, result: dict) -> Path: lines = [ "=" * 70, f"Strategy Ablation Report | {result['symbol']}", - f"기간: {result['period']} | OOS 분할: {result['split_date']}~", + f"기간: {result['period']} | OOS 분할: {result['split_date']}~ | 샤프는 {BACKTEST_RISK_FREE_LABEL} 기준", "=" * 70, "", f"{'전략':<25} | {'FULL Sharpe':>12} | {'OOS Sharpe':>11} | {'FULL 수익률':>12} | {'OOS 수익률':>11} | {'OOS EV/거래':>12}", diff --git a/config/baskets.yaml b/config/baskets.yaml index 59a9485d..04be0531 100644 --- a/config/baskets.yaml +++ b/config/baskets.yaml @@ -6,7 +6,9 @@ # - 리밸런싱 = 실제 비중 vs 목표 비중 드리프트 감지 → 주문 생성 # # 여러 바스켓을 정의할 수 있으며, enabled: true인 바스켓만 실행됩니다. -# --mode rebalance 또는 스케줄러 장전 단계에서 실행됩니다. +# 실행 경로는 일일 CLI `python main.py --mode rebalance`(평일 10:07 자동화) 하나뿐입니다. +# 스케줄러(--mode schedule/live)는 바스켓을 거래하지 않습니다 — 장전 시각에는 live 주문이 +# 거래 시간 가드에 막히고 paper는 전일 종가로 체결되던 결함 때문에 제거했습니다. # ============================================================= baskets: @@ -38,6 +40,10 @@ baskets: enabled: true primary: false purpose: "장기 관찰용" + promotion: + # 지금 설계(주식 60%, 9종목)로 바꾼 날. 실행·구성 격차는 이날부터 잰다 — + # 6/10~8/06은 80%·10종목 설계였다. + design_effective_from: "2026-08-07" target_stock_weight: 0.60 # 주식 60% / 현금 40% — 낙폭 완충을 설계로 고정 risk: # buy&hold 트랙의 손절은 '추세가 꺾였다'가 아니라 '한 종목이 포트폴리오를 @@ -160,6 +166,8 @@ baskets: # 이전 규칙으로 쌓은 운영 일수만으로 새 규칙을 실전에 적용하지 않는다. paper_only: true review_note: "9월 17일 변경한 위험 관리 규칙을 모의투자로 검증하고 있습니다." + # 새 규칙의 검증 기간은 이날부터 따로 센다(평가 결과의 rules_window, 카드의 '새 규칙 N/60일'). + rules_effective_from: "2026-09-17" contribution_plan: enabled: true cadence: "monthly" diff --git a/config/holidays.yaml b/config/holidays.yaml index 354a29d3..808e246b 100644 --- a/config/holidays.yaml +++ b/config/holidays.yaml @@ -6,13 +6,22 @@ # 05-01(근로자의 날), 06-03(지방선거), 07-17 # 제거: 01-27·01-28·01-29 — 이 날들은 실제로 개장했다 # 07-17이 빠져 있어 거래일로 판정됐고, 그만큼 -# 07-17이 빠져 있어 거래일로 판정됐고, 그만큼 # 스냅샷 커버리지 분모가 부풀려져 두 트랙이 실제보다 낮게 나왔다 # (kr_diversified_hold 96.3% → 94.5%, kr_pocket 96.9% → 93.9%). # 근거: KOSPI 지수와 005930·105560·069500 전부 그날 봉이 없고 07-16·07-20은 있다. # pykrx 영업일 API는 이 환경에서 깨져 있어(RangeIndex) 자동 갱신이 놓쳤다. # 달력 오류는 양방향으로 조용히 해롭다 — 분모를 부풀리거나, 진짜 결측을 가린다. # tools/verify_trading_calendar.py 로 시장 데이터와 대조할 것. +# +# 2026-09-23 수정 (아직 오지 않은 날짜라 대조 도구로는 못 잡는 오류): +# 제거: 2026-09-28 — 추석 연휴(9/24~26)가 '토요일'과 겹친 것이라 대체공휴일이 아니다. +# 설·추석 대체공휴일은 일요일 또는 다른 공휴일과 겹칠 때만 생긴다. +# 이 날을 휴장으로 두면 9/28 실행이 9/23 스냅샷을 덮어쓰고 9/28은 +# 커버리지 분모에서 사라진다(결측이 숨는 쪽의 오류). +# 2027: 설(2/7 일)·추석(9/15 수) 날짜가 틀려 있었다 — 평일 휴장 6일이 잘못 들어가고 +# 실제 휴장 7일이 빠져 있었다. 월력요항 기준으로 다시 적었다. +# 노동절(5/1 토)·제헌절(7/17 토)의 대체공휴일 여부는 KRX 12월 공지로 확인할 것 +# (확인 전에는 넣지 않는다 — 틀린 휴장일은 결측을 가린다). holidays: - '2026-01-01' @@ -33,27 +42,29 @@ holidays: - '2026-09-24' - '2026-09-25' - '2026-09-26' -- '2026-09-28' - '2026-10-03' - '2026-10-05' - '2026-10-09' - '2026-12-25' - '2026-12-31' - '2027-01-01' -- '2027-02-10' -- '2027-02-11' -- '2027-02-12' +- '2027-02-06' +- '2027-02-07' +- '2027-02-08' +- '2027-02-09' - '2027-03-01' +- '2027-05-01' - '2027-05-05' - '2027-05-13' - '2027-06-06' +- '2027-07-17' - '2027-08-15' - '2027-08-16' +- '2027-09-14' +- '2027-09-15' +- '2027-09-16' - '2027-10-03' - '2027-10-04' -- '2027-10-05' -- '2027-10-06' -- '2027-10-07' - '2027-10-09' - '2027-10-11' - '2027-12-25' diff --git a/config/risk_params.yaml b/config/risk_params.yaml index 2ac1fb4a..88752974 100644 --- a/config/risk_params.yaml +++ b/config/risk_params.yaml @@ -88,7 +88,9 @@ position_limits: drawdown: max_portfolio_mdd: 0.15 # 전체 포트폴리오 MDD 한도 (15%) max_daily_loss: 0.03 # 일일 최대 손실 한도 (3%) - recovery_scale: 0.5 # MDD 회복 후 복귀 시 축소 비율 (50%) + # MDD 한도 도달 후에는 한도의 절반 아래로 회복할 때까지 신규 매수를 막는다(히스테리시스). + # 예전의 recovery_scale(회복 후 50% 사이징)은 어떤 코드도 읽지 않아 삭제했다(2026-09-23). + # 목표 비중 바스켓 주문은 이 한도 대신 바스켓의 overlays.drawdown_guard를 따른다. # --- 블랙스완(급락) 감지 --- # 개별 종목·포트폴리오·연속 하락 세 가지 경로로 급락을 감지. diff --git a/config/strategies.yaml b/config/strategies.yaml index 8931572a..48c7098c 100644 --- a/config/strategies.yaml +++ b/config/strategies.yaml @@ -13,20 +13,21 @@ active_strategy: "scoring" # --- 시장 국면별 전략 파라미터 자동 조정 --- -# bullish/caution/bearish 국면에 따라 매수 임계값, 손절 배수, 익절 목표를 동적으로 변경. -# 하락장에서는 진입 조건을 엄격하게, 손절을 타이트하게, 익절을 빨리 가져가서 리스크를 줄인다. +# 국면(bullish/caution/bearish)에 따라 손절 배수와 익절 목표를 바꾼다. +# 하락장에서는 손절을 타이트하게, 익절을 빨리 가져가서 리스크를 줄인다. +# 매수 진입 임계값은 국면에 따라 바뀌지 않는다. 국면별 매수 억제는 시장 국면 필터 +# (settings.trading.market_regime_filter)의 allow_buys·position_scale이 맡는다. +# (예전 buy_threshold_offset 키는 어디서도 읽지 않아 지웠다 — 진입이 엄격해지는 줄 알기 쉬웠다) +# market_regime_filter가 꺼져 있으면 국면은 늘 bullish로 잡혀 배수는 1.0(변화 없음)이다. regime_adaptive: enabled: true bullish: - buy_threshold_offset: 0 # 기본 buy_threshold에 더할 값 (0 = 변경 없음) stop_loss_multiplier: 1.0 # 기본 손절 비율에 곱할 배수 (1.0 = 변경 없음) take_profit_multiplier: 1.0 caution: - buy_threshold_offset: 1 # 매수 조건을 +1 엄격하게 stop_loss_multiplier: 0.7 # 손절을 30% 타이트하게 take_profit_multiplier: 0.8 # 익절 목표를 20% 낮춰 빨리 실현 bearish: - buy_threshold_offset: 2 # 매수 조건을 +2 매우 엄격하게 (사실상 진입 차단 수준) stop_loss_multiplier: 0.5 # 손절을 50% 타이트하게 take_profit_multiplier: 0.6 # 익절 목표를 40% 낮춰 빠른 실현 @@ -140,6 +141,9 @@ scoring: # Z-Score < -2 는 "기술적 과매도"와 "악화 기업"을 구분하지 못하며, ADX < adx_filter 도 하락 추세 구간에서 낮게 나와 필터가 불완전함. # 실전 권장: exclude_52w_low_near 사용 + 유동성/퀄리티·손절 필수. 코스피200 대형주만 적용 시 상대적으로 안전. # fundamental_filter: Z-Score 매수 조건 충족 시 PER·부채비율 정상 범위 확인 (yfinance 기반, 한국 종목은 업데이트 느림·누락 많음 → pykrx/KRX 공시 병행 검토). +# 백테스트 반영 범위: 52주 필터(exclude_52w_low_near)는 백테스트·paper 공통 적용. +# fundamental_filter·restrict_to_kospi200은 '현재' 재무·구성 종목만 조회되므로 백테스트에는 반영하지 않는다 +# (과거 봉에 적용하면 미래 정보·생존 편향). 백테스트 성과는 이 두 필터가 없는 전략의 성과다. mean_reversion: z_score_buy: -2.0 # 매수 Z-Score 기준 z_score_sell: 2.0 # 매도 Z-Score 기준 @@ -170,7 +174,8 @@ trend_pullback: # --- 거래량 동반 돌파 전략 (Breakout + Volume, C-4) --- # 전고점 돌파 + 거래량 급증 + ADX 추세 확인. edge-trigger 진입. -# Exit: close < breakout_ref (최소 실패 신호). 나머지는 ATR 2.5 trailing stop 위임. +# Exit: close < entry_level (진입 봉에서 돌파한 breakout_ref, 다음 진입까지 고정) — 최소 실패 신호. +# 레벨 아래에 있는 동안 매 봉 SELL(최소 보유일에 막혀도 다음 봉에 다시 나옴). 나머지는 ATR 2.5 trailing stop 위임. breakout_volume: breakout_period: 10 # 돌파 기준 기간 (rolling max window) — C-4 OOS best surge_ratio: 1.5 # 거래량 급증 배수 (volume > avg_vol * surge_ratio) — C-4 OOS best @@ -193,9 +198,9 @@ relative_strength_rotation: # 한국 시장: 코스피/코스닥은 박스권·추세 단명 특성으로 미국(나스닥) 대비 추세 추종 실증 근거가 약함. 종목·기간별 검증 권장. trend_following: adx_threshold: 25 # 추세 존재 확인 ADX 기준 - trend_ma_period: 200 # 추세 판단 이동평균 기간 - atr_stop_multiplier: 2.0 # ATR 기반 손절 배수 - trailing_atr_multiplier: 3.0 # ATR 기반 트레일링 스탑 배수 + trend_ma_period: 200 # 추세 판단 이동평균 기간 (전략이 직접 계산) + # 손절·트레일링 배수는 risk_params.yaml의 stop_loss.atr_multiplier / trailing_stop.atr_multiplier를 따른다. + # (예전 atr_stop_multiplier·trailing_atr_multiplier 키는 어디서도 읽지 않아 지웠다) # --- 펀더멘털 팩터 전략 (가격 OHLC와 독립, pykrx 우선 → yfinance 폴백) --- fundamental_factor: @@ -231,6 +236,7 @@ ensemble: mode: "majority_vote" # majority_vote | weighted_sum | conservative auto_downgrade: true # true면 고상관 감지 시 majority_vote/weighted_sum → conservative 자동 전환 independence_threshold: 0.6 # 전략 신호 상관계수 |r| >= 이 값이면 고상관 판단 → 다수결 의미 퇴색 경고 + independence_window: 60 # 상관 판정 기간: 날짜마다 그날까지의 N행 신호만 본다 (미래 신호는 안 봄) components: # enabled/weight 로 구성 전략 켜고 끄기 (weighted_sum 시 weight 사용) - name: technical enabled: true diff --git a/core/basket_deploy.py b/core/basket_deploy.py index dcdebf4f..ba6fba52 100644 --- a/core/basket_deploy.py +++ b/core/basket_deploy.py @@ -47,13 +47,18 @@ def estimate_order_costs( *, commission_rate: float = DEFAULT_COMMISSION_RATE, tax_rate: float = DEFAULT_TAX_RATE, + tax_exempt_symbols: Any = None, ) -> dict[str, Any]: """주문 목록의 예상 거래비용(수수료+세금)과 총 거래액을 추정한다. orders는 .action('BUY'/'SELL'), .symbol, .quantity 속성을 가진 객체. + tax_exempt_symbols: 매도 거래세가 없는 종목(국내 주식형 ETF 등) — 체결 모델과 같은 + 목록(transaction_costs.tax_exempt_symbols)을 넘긴다. """ + exempt = {str(x) for x in (tax_exempt_symbols or [])} buy_amount = 0.0 sell_amount = 0.0 + taxable_sell = 0.0 for o in orders: price = float(prices.get(o.symbol, 0) or 0) amount = price * int(o.quantity) @@ -61,10 +66,12 @@ def estimate_order_costs( buy_amount += amount else: sell_amount += amount + if str(o.symbol) not in exempt: + taxable_sell += amount total_amount = buy_amount + sell_amount commission = total_amount * commission_rate # 매수·매도 양방향 수수료 - tax = sell_amount * tax_rate # 세금은 매도만 + tax = taxable_sell * tax_rate # 세금은 매도만(면제 종목 제외) total_cost = commission + tax return { "order_count": len(orders), @@ -85,6 +92,7 @@ def summarize_basket_deployment( prices: dict[str, float], *, portfolio_value: float | None = None, + tax_exempt_symbols: Any = None, ) -> dict[str, Any]: """바스켓 배포 점검 요약 — 계획·비용·회전율·활성화 절차. @@ -93,7 +101,7 @@ def summarize_basket_deployment( enabled = bool(basket_cfg.get("enabled", False)) holdings = basket_cfg.get("holdings", {}) rb = basket_cfg.get("rebalance", {}) or {} - costs = estimate_order_costs(orders, prices) + costs = estimate_order_costs(orders, prices, tax_exempt_symbols=tax_exempt_symbols) turnover_pct = None if portfolio_value and portfolio_value > 0: diff --git a/core/basket_evaluation.py b/core/basket_evaluation.py index 99a02f89..b3a48536 100644 --- a/core/basket_evaluation.py +++ b/core/basket_evaluation.py @@ -30,6 +30,8 @@ from typing import Any +from loguru import logger + TRADING_DAYS_PER_YEAR = 252 @@ -61,11 +63,18 @@ def evaluate_basket_paper_operation( issues: list[str] = [] # A. 운영 무결성 + # 합격 기준은 '제때 남긴' 스냅샷 비율로 본다. 이 기준은 일일 실행이 조용히 멈추지 + # 않았다는 증거인데, 나중에 채운 기록까지 세면 빠진 날을 매번 채워 100%가 되니 + # 기준이 의미가 없어진다(채우는 건 기록을 잇기 위한 것이지 돌았다는 증거가 아니다). + # 전체 커버리지(snapshot_coverage)는 표시용으로 그대로 둔다. coverage = (snapshot_days / trading_days_total) if trading_days_total > 0 else 0.0 - if trading_days_total > 0 and coverage < min_snapshot_coverage: + measured_coverage = (measured_days / trading_days_total) if trading_days_total > 0 else 0.0 + if trading_days_total > 0 and measured_coverage < min_snapshot_coverage: issues.append( - f"스냅샷 커버리지 {coverage:.0%} < {min_snapshot_coverage:.0%} " - f"({snapshot_days}/{trading_days_total} 영업일) — 일일 사이클 누락" + f"제때 남긴 스냅샷 {measured_coverage:.0%} < {min_snapshot_coverage:.0%} " + f"({measured_days}/{trading_days_total} 영업일" + + (f", 나중에 채운 {reconstructed_days}일 제외" if reconstructed_days else "") + + ") — 일일 실행이 빠졌다" ) if pending_failed_orders > 0: issues.append(f"미해결 실패 주문 {pending_failed_orders}건 (dead-letter)") @@ -97,9 +106,7 @@ def evaluate_basket_paper_operation( "snapshot_days": snapshot_days, "reconstructed_days": reconstructed_days, "measured_days": measured_days, - "measured_coverage": round( - (measured_days / trading_days_total) if trading_days_total > 0 else 0.0, 4 - ), + "measured_coverage": round(measured_coverage, 4), "min_trading_days": min_trading_days, "progress_pct": min(1.0, trading_days_total / min_trading_days) if min_trading_days > 0 else 1.0, "snapshot_coverage": round(coverage, 4), @@ -132,8 +139,8 @@ def format_evaluation_report(result: dict[str, Any], basket_name: str = "") -> s f" 운영 기간: {result['operation_start']} ~ {result['today']}", f" 스냅샷 커버리지: {result['snapshot_coverage']:.0%}" + ( - f" (실측 {result['measured_days']}일 {result['measured_coverage']:.0%}" - f" + 사후 복원 {result['reconstructed_days']}일)" + f" (제때 기록 {result['measured_days']}일 {result['measured_coverage']:.0%}" + f" + 나중에 채운 {result['reconstructed_days']}일)" if result.get("reconstructed_days") else "" ), f" 비용 드래그: 누적 {result['cost_drag_cum']:.4%} | 연환산 {result['cost_drag_annualized']:.4%}" @@ -158,6 +165,22 @@ def format_evaluation_report(result: dict[str, Any], basket_name: str = "") -> s lines.append(f" - 실행 격차 {exe:+.2f}%p (통제 가능: 미체결 슬롯·진입 타이밍·비용)") if comp is not None: lines.append(f" - 구성 격차 {comp:+.2f}%p (설계 수용: 균등가중 vs 시총지수)") + errors = m.get("paper_order_errors") + if errors: + lines.append( + f" 참고: 운영 중 주문·실행 오류 {errors}건 (합격 판단에는 넣지 않음 — 원인 확인 필요)" + ) + rw = result.get("rules_window") + if rw: + lines.append( + f" 새 규칙({rw['since']}~): {rw['trading_days']}/{rw['min_trading_days']} 영업일" + f" · 제때 기록 {rw['measured_coverage']:.0%}" + ) + if m.get("attribution_window"): + lines.append( + f" 귀속 분해 기간: {m['attribution_window'][0]} ~ {m['attribution_window'][1]}" + " (지금 설계로 바꾼 뒤)" + ) if result["issues"]: lines.append(" 이슈:") for issue in result["issues"]: @@ -229,6 +252,12 @@ def build_daily_report_extras( extras["progress"] = ( f"{progress_days}/{disp_denom}일 ({pct:.0%}) · 커버리지 {coverage:.0%} · 결측예산 {budget}일" ) + # 규칙을 바꾼 트랙은 새 규칙의 날수가 검토 근거다 — 전체 운영 일수 옆에 함께 적는다 + rw = eval_result.get("rules_window") + if rw and eval_result.get("paper_only"): + extras["progress"] += ( + f" · 새 규칙 {rw.get('trading_days', 0)}/{rw.get('min_trading_days', disp_denom)}일" + ) # 실행 품질 — 누적 비용 (연환산은 기간 미충족 시 과장되므로 라벨로 구분) cum = eval_result.get("cost_drag_cum") @@ -420,6 +449,14 @@ def collect_basket_paper_evaluation( def _d(v): return v.date() if hasattr(v, "date") and callable(getattr(v, "date")) else v + def _config_date(value): + if not value: + return None + try: + return value if isinstance(value, date) else date.fromisoformat(str(value)[:10]) + except ValueError: + return None + today = date.today() candidates = [_d(t.executed_at) for t in trades if getattr(t, "executed_at", None)] candidates += [_d(s.date) for s in snaps] @@ -519,35 +556,131 @@ def _d(v): min_trading_days=min_days, ) + promotion = basket_cfg.get("promotion") or {} + + # A2(미해결 실패 주문 0건)는 paper에서는 애초에 걸릴 수가 없다 — dead-letter는 증권사 + # 주문 경로에서만 쌓인다. 그래서 paper 트랙의 주문·사이클 오류 이벤트를 따로 세어 + # 보여 준다. 해결 표시가 없는 기록이라 판정에는 넣지 않는다(한 번의 일시 오류가 끝난 + # 트랙을 영영 떨어뜨리지 않게) — 운영자가 보고 판단할 참고 지표다. + paper_order_errors = None + try: + from database.models import OperationEvent + + session = get_session() + try: + paper_order_errors = ( + session.query(OperationEvent) + .filter( + OperationEvent.strategy == basket_key, + OperationEvent.mode == "paper", + OperationEvent.event_type.in_(("ORDER_ERROR", "CYCLE_ERROR")), + OperationEvent.created_at >= datetime.combine(operation_start, datetime.min.time()), + ) + .count() + ) + finally: + session.close() + except Exception as exc: + logger.warning("paper 주문 오류 집계 실패: {}", exc) + # 평가 결과에 metrics가 없을 수도 있다(판정 함수를 바꿔 끼운 경우 등) — 집계 실패가 + # 평가 전체를 깨뜨리지 않게 setdefault로 넣는다. + result.setdefault("metrics", {})["paper_order_errors"] = paper_order_errors + # 성과 귀속(실행 격차/구성 격차) — 종목별 조회(네트워크)라 기본 off. # 일일 사이클(리포트 부가필드)은 호출하지 않고, CLI 평가 도구에서만 켠다. + # + # promotion.design_effective_from이 있으면 그날부터만 격차를 잰다. 지금 설계 + # (비중·종목)를 운영 시작일부터 소급 적용하면, 설계를 바꾼 사실 자체가 '실행 격차 + # ≈ 0'으로 보이거나 엉뚱한 격차로 섞인다(kr_diversified_hold는 2026-08-07에 80% → + # 60%, 10 → 9종목으로 바뀌었다). NAV·설계·벤치마크를 같은 창으로 다시 잰다. if include_attribution: from core.basket_deploy import effective_stock_fraction design_fraction = effective_stock_fraction(basket_cfg, config.risk_params) holdings = basket_cfg.get("holdings") or {} + design_from = _config_date(promotion.get("design_effective_from")) + window_start = operation_start + window_nav = nav_return_pct + window_bench = benchmark_return_pct + if design_from and design_from > operation_start and snaps: + window_start = design_from + base = [s for s in snaps if _d(s.date) < design_from] + base_cum = float(base[-1].cumulative_return or 0.0) if base else 0.0 + end_cum = float(snaps[-1].cumulative_return or 0.0) + window_nav = ((1 + end_cum / 100) / (1 + base_cum / 100) - 1) * 100 + window_bench = None + if include_benchmark: + try: + from core.data_collector import DataCollector + window_bench = DataCollector.fetch_benchmark_return( + str(design_from), str(nav_end), symbol="KS11", + ) + except Exception as exc: + logger.warning("격차 계산 기간의 벤치마크 조회 실패: {}", exc) design_return_pct = None if snaps and holdings: try: - # 벤치마크·NAV와 같은 종료일(nav_end)로 조회 — 창 불일치 방지. + # 벤치마크·NAV와 같은 창(window_start ~ nav_end)으로 조회 — 창 불일치 방지. design_return_pct = compute_design_portfolio_return( - holdings, design_fraction, operation_start, nav_end, + holdings, design_fraction, window_start, nav_end, ) - except Exception: + except Exception as exc: + logger.warning("설계 포트폴리오 수익률 계산 실패: {}", exc) design_return_pct = None # 참고 지표 — 실패해도 평가는 진행 - gaps = decompose_return_gap(nav_return_pct, design_return_pct, benchmark_return_pct) + gaps = decompose_return_gap(window_nav, design_return_pct, window_bench) result["metrics"]["design_return_pct"] = design_return_pct result["metrics"]["execution_gap_pct"] = gaps["execution_gap_pct"] result["metrics"]["composition_gap_pct"] = gaps["composition_gap_pct"] result["metrics"]["total_gap_pct"] = gaps["total_gap_pct"] - - promotion = basket_cfg.get("promotion") or {} + if window_start > operation_start: + # 설계 적용일로 창을 줄였을 때만 기간을 따로 적는다(전체 기간이면 운영 기간과 같다) + result["metrics"]["attribution_window"] = [str(window_start), str(nav_end)] + result["metrics"]["attribution_nav_pct"] = window_nav + result["metrics"]["attribution_benchmark_pct"] = window_bench + + # promotion.rules_effective_from이 있으면 그날 이후만 따로 센 창을 더한다. 운용 규칙을 + # 바꾼 트랙(kr_pocket, 9/17 위험 관리 규칙)은 전체 운영 일수가 60일을 넘어도 새 규칙의 + # 기록은 그보다 훨씬 짧다 — 예전 규칙의 날수로 '100%'가 되면 검토 근거가 없다. + # 상위 판정·진행률 키는 그대로 두고(화면·게이트가 읽는다) 같은 정의로 추가만 한다. + rules_from = _config_date(promotion.get("rules_effective_from")) + if rules_from: + rw_start = max(rules_from, operation_start) + rw_total = rw_snap = rw_recon = 0 + d = rw_start + while d <= today: + if th.is_trading_day(datetime(d.year, d.month, d.day)): + if d == today and d not in snapshot_dates: + break + rw_total += 1 + if d in snapshot_dates: + rw_snap += 1 + if d in reconstructed_dates: + rw_recon += 1 + d += timedelta(days=1) + rw_costs = sum( + float(t.commission or 0) + float(t.tax or 0) + float(t.slippage or 0) + for t in trades + if getattr(t, "executed_at", None) and _d(t.executed_at) >= rw_start + ) + result["rules_window"] = { + "since": str(rw_start), + "trading_days": rw_total, + "min_trading_days": min_days, + "measured_days": rw_snap - rw_recon, + "reconstructed_days": rw_recon, + "measured_coverage": round((rw_snap - rw_recon) / rw_total, 4) if rw_total else 0.0, + "total_costs": round(rw_costs, 2), + } if promotion.get("paper_only", False): # 운영 일수는 남겨 두되, 이전 규칙의 기록으로 새 정책이 통과하지 않게 한다. result["operation_verdict"] = result["verdict"] result["paper_only"] = True if result["verdict"] == "PASS_CANDIDATE": result["verdict"] = "WAIT" - result["issues"].append(str(promotion.get("review_note") or - "변경한 운용 규칙을 모의투자로 검증하고 있습니다. 실전 전환은 제한됩니다.")) + note = str(promotion.get("review_note") or + "변경한 운용 규칙을 모의투자로 검증하고 있습니다. 실전 전환은 제한됩니다.") + result["issues"].append(note) + # 화면이 이 상시 안내를 '확인할 항목'과 구분할 수 있게 따로도 둔다 — 섞여 있으면 + # 적립금 미기록 같은 실제 할 일을 가린다(2026-09 적립 알림이 이 문구에 가려졌다). + result["review_note"] = note return result, basket_name diff --git a/core/basket_rebalancer.py b/core/basket_rebalancer.py index 22bff352..975fa85d 100644 --- a/core/basket_rebalancer.py +++ b/core/basket_rebalancer.py @@ -20,6 +20,14 @@ from loguru import logger _KST = ZoneInfo("Asia/Seoul") +# 추세 필터 지수 → 그 지수를 따라가는 ETF(지수 자료가 멈췄을 때 대신 쓴다) +_INDEX_PROXY = {"KS200": "069500"} + + +def _is_protective_exit(reason: str) -> bool: + """손실 방어 청산(손절·트레일링)인가. 익절은 여기에 들지 않는다.""" + text = str(reason or "").upper() + return "RISK_EXIT" in text and ("STOP_LOSS" in text or "TRAILING_STOP" in text) from config.config_loader import Config from core.basket_risk import ( @@ -177,6 +185,15 @@ def _stock_fraction(self) -> float: (self.basket.get("overlays") or {}).get("defensive_symbol"), ).values()) + def _has_drawdown_rule(self) -> bool: + """이 바스켓이 자기 낙폭 규칙(overlays.drawdown_guard)을 켜 두었는지.""" + cfg = getattr(self, "_overlay_cfg", None) + if cfg is None: + from core.risk_overlays import parse_overlay_config + + cfg = parse_overlay_config(getattr(self, "basket", None) or {}) + return bool(cfg.drawdown.enabled) + def _overlay_scale(self) -> float: decision = self.overlay_decision() return float(decision.scale) if decision is not None else 1.0 @@ -212,6 +229,7 @@ def overlay_decision(self): ) cumulative = None daily = None + self._overlay_peak_eligible = None if self._overlay_cfg.drawdown.enabled or self._overlay_cfg.volatility.enabled: cumulative, daily = self._nav_series_for_overlay() decision = compute_decision( @@ -220,6 +238,7 @@ def overlay_decision(self): cumulative_returns_pct=cumulative, daily_returns=daily, prev_state=prev, + peak_eligible=getattr(self, "_overlay_peak_eligible", None), ) decision.data_issues.extend(self._overlay_input_issues) decision.source_dates = dict(self._overlay_source_dates) @@ -229,6 +248,21 @@ def overlay_decision(self): logger.warning("바스켓 '{}' 오버레이 상태 저장 실패: {}", self.basket_name, exc) if decision.data_issues: logger.warning("바스켓 '{}' 오버레이 데이터 문제: {}", self.basket_name, "; ".join(decision.data_issues)) + # 로그만으로는 헬스·주간 리포트가 못 센다(9/17 이후 추세 필터 동결이 로그에만 + # 남아 있었다). 같은 날 같은 내용은 한 번만 남긴다. + try: + from core.cycle_observability import record_event_once_per_day + + record_event_once_per_day( + "OVERLAY_DATA_ISSUE", + f"바스켓 '{self.basket_name}' 위험 관리에 쓸 자료 문제: " + "; ".join(decision.data_issues), + severity="warning", + strategy=getattr(self, "account_key", None) or None, + mode=self._ledger_mode(), + dedupe_key="; ".join(decision.data_issues)[:500], + ) + except Exception as exc: + logger.warning("오버레이 데이터 문제 이벤트 기록 실패: {}", exc) if decision.reasons: logger.info("바스켓 '{}' 리스크 오버레이 발동: {} (배수 {})", self.basket_name, " · ".join(decision.reasons), decision.scale) else: @@ -297,9 +331,11 @@ def _fetch_index_closes(self, symbol: str, ma_days: int) -> list[float] | None: except Exception as exc: logger.warning("오버레이 지수 조회 실패 {}: {}", symbol, exc) return None - if df is None or df.empty or "close" not in df.columns: - return None - frame = self._overlay_dated_frame(df, "지수 종가") + frame = None + if df is not None and not df.empty and "close" in df.columns: + frame = self._overlay_dated_frame(df, "지수 종가") + if frame is None: + frame = self._index_proxy_frame(symbol, start, end) if frame is None: return None # 누락 봉을 삭제하면 이동평균 창이 과거로 밀려 잘못 복귀할 수 있다. @@ -309,6 +345,40 @@ def _fetch_index_closes(self, symbol: str, ma_days: int) -> list[float] | None: return None return closes or None + def _index_proxy_frame(self, symbol: str, start, end): + """지수 자료가 없거나 늦을 때 그 지수를 추종하는 ETF 종가로 대신 판단한다. + + 2026-09-17 이후 FDR의 KS200 자료가 멈춰 kr_pocket 추세 필터가 '직전 상태 + 유지'로 동결됐다. 추종 ETF(069500)는 같은 날에도 정상이었다. ETF 가격은 분배락 + 날 조금 내려가 이동평균 대비 위치가 약간 보수적으로(아래쪽으로) 잡힐 수 있다. + ETF로 대신 봤다는 사실은 data_issues와 source_dates에 남긴다 — 지수 자료가 다시 + 들어오는지는 따로 확인할 것. + """ + proxy = _INDEX_PROXY.get(str(symbol).upper()) + if not proxy: + return None + try: + pdf = self.data_collector.fetch_korean_stock(proxy, start, end) + except Exception as exc: + logger.warning("지수 대신 쓸 {} 조회 실패: {}", proxy, exc) + return None + if pdf is None or pdf.empty or "close" not in pdf.columns: + return None + issues_before = len(getattr(self, "_overlay_input_issues", []) or []) + frame = self._overlay_dated_frame(pdf, f"{proxy} 종가(지수 대신)") + if frame is None: + return None + # ETF로 판단할 수 있게 됐으니 '비중 확대 보류' 문구는 빼고 대신 썼다는 사실만 남긴다 + issues = getattr(self, "_overlay_input_issues", []) or [] + self._overlay_input_issues = [ + i for i in issues[:issues_before] if not i.startswith("지수 종가:") + ] + self._overlay_data_issue( + f"{symbol} 지수 자료가 늦어 {proxy} 종가로 추세를 봤음 — 지수 자료가 다시 들어오는지 확인 필요" + ) + logger.warning("바스켓 '{}' 추세 필터: {} 대신 {} 종가 사용", self.basket_name, symbol, proxy) + return frame + def _nav_series_for_overlay(self) -> tuple[list[float] | None, list[float] | None]: """이 바스켓 계정의 시간가중 누적수익률(%)과 일간 수익률.""" try: @@ -328,6 +398,9 @@ def _nav_series_for_overlay(self) -> tuple[list[float] | None, list[float] | Non cumulative = [float(v) if v is not None else float("nan") for v in snaps["cumulative_return"].tolist()] except (TypeError, ValueError): return None, None + # 나중에 채운 추정 기록은 낙폭 고점 후보에서 뺀다(값 자체는 흐름을 잇는 데 쓴다). + if "reconstructed" in snaps.columns: + self._overlay_peak_eligible = [not bool(v) for v in snaps["reconstructed"].tolist()] index = [1.0 + c / 100.0 for c in cumulative] daily = [index[i] / index[i - 1] - 1.0 if index[i - 1] > 0 else float("nan") for i in range(1, len(index))] return cumulative, daily @@ -567,7 +640,22 @@ def should_rebalance(self, prices: dict[str, float] = None) -> tuple[bool, str]: if trigger == "drift": threshold = self.rebalance_cfg.get("drift_threshold", 0.05) drifts = self.calculate_drift(prices) - max_drift = max(abs(d["drift"]) for d in drifts.values()) if drifts else 0 + # 재매수 차단(손절 쿨다운) 종목은 트리거에서 뺀다. 못 사는 빈 슬롯이 + # 드리프트 11%로 매일 트리거를 켜 두면, 전체 비중 조정이 매일 돌면서 보충으로 산 + # 종목을 다음 날 '비중 초과'로 되파는 왕복매매가 난다(2026-09-22 035720 + # 6주 매수 → 9-23 같은 6주 매도, 012330 손절 쿨다운 중). 집계 배치율 + # 트리거는 그대로 둔다. + try: + blocked = symbols_in_reentry_cooldown( + self.basket, self.account_key, self._ledger_mode(), + ) or {} + except Exception as exc: + logger.warning("재매수 차단 종목 조회 실패 — 트리거에 전 종목 반영: {}", exc) + blocked = {} + max_drift = max( + (abs(d["drift"]) for s, d in drifts.items() if s not in blocked), + default=0, + ) if max_drift >= threshold: return True, f"최대 드리프트 {max_drift:.1%} >= 임계값 {threshold:.1%}" # 종목별 드리프트가 전부 임계값 아래여도, 그 얇은 미달분의 합이 설계 배치율에서 @@ -653,9 +741,16 @@ def _policy_exposure_limits(self, symbol: str) -> dict | None: except (TypeError, ValueError): min_cash = float(div_cfg.get("min_cash_ratio", 0.20)) + # 슬리브 내 비중 상한을 총자산 기준으로 환산 + position_cap = (float(target_w) + drift) * stock_fraction + if symbol == (self.basket.get("overlays") or {}).get("defensive_symbol"): + # 방어 자산(CD 파킹 ETF)은 사실상 현금이다. 오버레이가 주식을 전부 줄이면 투자분 + # 전체가 이 자산이 되는 설계라 종목 상한 대신 투자 비중 상한만 건다 — 계획의 + # 배치율 보충이 파킹 ETF로 유휴 현금을 옮길 때 주문 단계가 막지 않도록 + # 같은 기준을 쓴다(2026-09-23). + position_cap = stock_fraction return { - # 슬리브 내 비중 상한을 총자산 기준으로 환산 - "max_position_ratio": min(1.0, (float(target_w) + drift) * stock_fraction), + "max_position_ratio": min(1.0, position_cap), "max_investment_ratio": min(1.0, stock_fraction + band), "min_cash_ratio": min_cash, } @@ -856,6 +951,7 @@ def plan_rebalance(self, prices: dict[str, float] = None) -> list[RebalanceOrder ) remaining = shortfall drift_limit = float(self.rebalance_cfg.get("drift_threshold", 0.05)) + defensive = (self.basket.get("overlays") or {}).get("defensive_symbol") topups: list[tuple[float, float, str, int, float]] = [] for symbol, target_w in targets.items(): if symbol in already or symbol in cooldown: @@ -881,11 +977,19 @@ def plan_rebalance(self, prices: dict[str, float] = None) -> list[RebalanceOrder if investable > 0 else 1.0 ) if projected_w > target_w + drift_limit: - logger.debug( - "종목 {} 보충 보류: 매수 후 비중 {:.1%} > 목표 {:.1%} + 허용 {:.1%}", - symbol, projected_w, target_w, drift_limit, - ) - continue + # 방어 자산(CD 파킹 ETF)은 사실상 현금이다 — 설계가 '이자 없는 현금 대신 + # 파킹 ETF'인데 종목 상한에 막히면 소액 계좌에서 현금이 그대로 논다 + # (2026-09-23 kr_pocket: 설계 95%인데 실제 73%, 놀고 있는 현금 10.4만 원이 + # 두 종목 모두 1주가 상한을 넘어 한 달째 그대로). 다음 사이클의 매도 + # 규칙이 되팔지 않는 범위(초과분 < max(min_trade, 1주))에서는 허용한다. + # 주식 종목에는 적용하지 않는다 — 주식 비중이 설계를 넘게 된다. + over_value = investable * (projected_w - target_w) + if not (symbol == defensive and over_value < max(min_trade, price)): + logger.info( + "종목 {} 보충 보류: 매수 후 비중 {:.1%} > 목표 {:.1%} + 허용 {:.1%}", + symbol, projected_w, target_w, drift_limit, + ) + continue topups.append((residual, notional, symbol, qty, price)) # 잔여 격차를 가장 많이 줄이는 순서로 집행한다(1주 단가가 낮을수록 정밀). topups.sort() @@ -1041,6 +1145,12 @@ def execute( ) # 유동성 체크용 20일 평균 거래량 — plan 단계 캐시 재사용, 없으면 새로 조회. snapshot = getattr(self, "_market_snapshot", None) or self._fetch_market_snapshot() + # 주문 단계의 노출 상한·낙폭 가드도 계획과 같은 시가로 잰다(평균단가로 재면 + # 하락장에서 계획한 보충 매수가 '투자 비중 초과'로 거부된다). + mark_prices = { + s: float(v["price"]) for s, v in (snapshot or {}).items() + if isinstance(v, dict) and v.get("price") + } for order in orders: if dry_run: @@ -1053,7 +1163,13 @@ def execute( try: if order.action == "BUY": available_cash = self.portfolio_mgr.get_available_cash() - total_value = self.portfolio_mgr.get_current_capital() + if self._is_live() or not mark_prices: + # live는 증권사 잔고(시가)가 이미 총자산이다 + total_value = self.portfolio_mgr.get_current_capital() + else: + total_value = self.portfolio_mgr.get_portfolio_summary( + current_prices=mark_prices, + ).get("total_value") res = executor.execute_buy_quantity( symbol=order.symbol, price=order.price, @@ -1071,11 +1187,16 @@ def execute( risk_levels=basket_risk_levels(self.basket, order.price), # 노출 상한도 이 바스켓이 선언한 비중에서 파생한다. exposure_limits=self._policy_exposure_limits(order.symbol), + mark_prices=mark_prices or None, + # 계좌 낙폭 가드는 이 바스켓이 자기 낙폭 규칙을 켜 둔 경우에만 넘긴다. + basket_drawdown_rule=self._has_drawdown_rule(), ) else: res = executor.execute_sell( symbol=order.symbol, price=order.price, quantity=order.quantity, reason=f"리밸런싱: {order.reason}", strategy=self.execution_strategy, + # 손절·트레일링 청산은 최소 보유 기간보다 우선한다(익절은 아님) + emergency=_is_protective_exit(order.reason), ) if res.get("success"): @@ -1083,6 +1204,10 @@ def execute( results["details"].append({"order": repr(order), "status": "success"}) else: results["failed"] += 1 + logger.warning( + "리밸런싱 주문 거부 {} {} {}주: {}", + order.symbol, order.action, order.quantity, res.get("reason", "unknown"), + ) results["details"].append({ "order": repr(order), "status": "failed", "reason": res.get("reason", "unknown"), diff --git a/core/blackswan_detector.py b/core/blackswan_detector.py index dc9c37da..6efdb1e3 100644 --- a/core/blackswan_detector.py +++ b/core/blackswan_detector.py @@ -30,6 +30,25 @@ class BlackSwanDetector: - recovery_minutes 동안 recovery 기간 진입 (기본 120분) - recovery 기간 중 포지션 사이징을 recovery_scale(기본 0.5)로 축소 - recovery 종료 후 정상 복귀 + + ⚠️ 백테스트와 paper/live의 차이 (2026-09 감사 기록, 배선은 하지 않음): + - paper/live에서 실제로 호출되는 것은 check_stock()뿐이다(scheduler 포지션 감시). + check_portfolio()는 처음 만들어진 뒤로 호출하는 곳이 없다. 즉 '포트폴리오 일간 + -3%'와 '연속 하락' 규칙은 paper/live에서 한 번도 작동한 적이 없다. + - 반면 두 백테스터(backtest/backtester.py, backtest/portfolio_backtester.py)는 + 같은 risk_params.blackswan 값으로 포트폴리오 -3% 하락일에 보유 종목 전체를 + BLACKSWAN 청산하고 쿨다운 동안 신규 매수를 막으며, 연속 하락은 포트폴리오가 + 아니라 '종목별' 3일 연속 하락으로 판정한다. + - 그래서 신호형 전략의 백테스트 낙폭·꼬리 성과에는 paper/live가 하지 않는 보호 + 청산이 들어 있어 paper보다 좋게 나온다. backtest↔paper 차이를 다른 원인으로 + 돌리기 전에 이 차이를 먼저 감안할 것. + - 나중에 배선한다면: check_portfolio()는 호출할 때마다 _daily_returns에 한 건을 + 쌓으므로 거래일당 한 번만 불러야 한다(감시 틱마다 부르면 하루 안에서 '연속 + 하락'이 거짓 발동한다). 연속 규칙은 백테스트(종목별)와 이 메서드(포트폴리오)의 + 단위가 달라 어느 한쪽을 맞춰야 한다. scheduler와 OrderExecutor의 감지기는 서로 + 다른 인스턴스라 한쪽 쿨다운이 다른 쪽 매수를 막지 않는다. 바스켓 트랙 계좌에는 + 배선하지 말 것 — kr_diversified_hold의 설계 위험 정책은 -25% 손절이라 + -3% 하락일 전량 청산은 그 설계를 깨뜨린다. """ def __init__(self, config: Config = None): @@ -128,6 +147,10 @@ def check_portfolio(self, current_value: float, prev_value: float) -> dict: """ 포트폴리오 전체 급락 감지 + 현재 paper/live 경로에서 호출하는 곳이 없다(백테스터만 같은 규칙을 모델링). + 호출 시 _daily_returns에 한 건씩 쌓이므로 거래일당 한 번만 불러야 한다. + 자세한 차이는 클래스 docstring 참고. + Args: current_value: 현재 포트폴리오 평가금 prev_value: 전일 포트폴리오 평가금 diff --git a/core/cycle_observability.py b/core/cycle_observability.py index 6af1a7cc..deaee2de 100644 --- a/core/cycle_observability.py +++ b/core/cycle_observability.py @@ -198,3 +198,74 @@ def record_cycle_event( except Exception as e: # 관측 실패가 운영을 막으면 안 된다 logger.debug("사이클 이벤트 기록 실패(무시): {} {} — {}", event_type, message, e) return False + + +def record_event_once_per_day( + event_type: str, + message: str, + *, + severity: str = "warning", + strategy: str | None = None, + mode: str = "paper", + symbol: str | None = None, + dedupe_key: str | None = None, + now: Any = None, +) -> bool: + """같은 날 같은 원인의 이벤트가 이미 있으면 다시 남기지 않는다. + + 같은 원인(같은 결측 보충 실패, 같은 주문 거부 사유)이 매 사이클·매 주문마다 + 쌓이면 진짜 새 신호를 덮는다(운영 원칙 5 — 경보는 끄지 말고 울리는 조건을 + 정확하게). 같은 날의 기준은 (event_type, mode, strategy, symbol, dedupe_key). + dedupe_key는 detail 칸에 남는다. 중복 판정에 실패하면 남기는 쪽을 택한다. + + 반환: 새로 기록했으면 True, 중복이라 건너뛰었거나 기록 실패면 False. + """ + from datetime import datetime + from zoneinfo import ZoneInfo + + now = now or datetime.now(ZoneInfo("Asia/Seoul")).replace(tzinfo=None) + day_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + try: + from database.models import OperationEvent, get_session + + session = get_session() + try: + q = session.query(OperationEvent.id).filter( + OperationEvent.event_type == event_type, + OperationEvent.mode == mode, + OperationEvent.created_at >= day_start, + ) + for col, val in ( + (OperationEvent.strategy, strategy), + (OperationEvent.symbol, symbol), + (OperationEvent.detail, dedupe_key), + ): + q = q.filter(col.is_(None)) if val is None else q.filter(col == val) + if q.first() is not None: + return False + finally: + session.close() + except Exception as e: + logger.warning("이벤트 중복 판정 실패 — 그대로 기록: {} {}", event_type, e) + + try: + from database.models import OperationEvent, get_session + + session = get_session() + try: + session.add(OperationEvent( + event_type=event_type, + severity=severity, + strategy=strategy, + symbol=symbol, + message=message, + detail=dedupe_key, + mode=mode, + )) + session.commit() + finally: + session.close() + return True + except Exception as e: + logger.warning("이벤트 기록 실패: {} {} — {}", event_type, message, e) + return False diff --git a/core/data_collector.py b/core/data_collector.py index 6330f421..45108836 100644 --- a/core/data_collector.py +++ b/core/data_collector.py @@ -158,6 +158,53 @@ class DataCollectionError(RuntimeError): _USD_KRW_TTL_SEC = 300.0 +_YF_INDEX_TICKERS = {"KS11": "^KS11", "KS200": "^KS200", "KQ11": "^KQ11"} +_CALENDAR = None + + +def _exclusive_end(end_date) -> str: + """yfinance의 end는 그날을 포함하지 않는다 — 종료일 봉까지 받으려면 하루 뒤로.""" + return (pd.Timestamp(end_date) + pd.Timedelta(days=1)).strftime("%Y-%m-%d") + + +def _yf_download_flat(ticker: str, start, end) -> pd.DataFrame: + """단일 티커 일봉. yfinance 1.x는 기본으로 (필드, 티커) 2단 열을 돌려준다 — 1단으로 편다.""" + df = yf.download( + ticker, start=start, end=end, progress=False, auto_adjust=True, + multi_level_index=False, + ) + if df is not None and isinstance(df.columns, pd.MultiIndex): + df.columns = df.columns.get_level_values(0) + return df + + +def _freshness_target(end_date): + """자료의 마지막 봉이 최소 이 날짜여야 하는 기준 거래일. 최근 요청이 아니면 None. + + 오늘 봉은 장중이면 아직 없을 수 있으므로 요구하지 않는다 — 기준은 end_date와 + 어제 중 이른 날 이하의 마지막 거래일이다. end_date가 1주일보다 오래전이면 + (백테스트·과거 구간) 검사하지 않는다. + """ + global _CALENDAR + try: + from core.trading_hours import TradingHours, _now_kst + + today = _now_kst().date() + end = pd.Timestamp(end_date).date() + if end < today - timedelta(days=7): + return None + if _CALENDAR is None: + _CALENDAR = TradingHours() + day = min(end, today - timedelta(days=1)) + for _ in range(20): + if _CALENDAR.is_trading_day(datetime(day.year, day.month, day.day)): + return day + day -= timedelta(days=1) + except Exception as e: + logger.warning("최신 자료 기준일 계산 실패 — 확인 생략: {}", e) + return None + + class DataCollector: """ 주가 데이터 수집기 @@ -172,6 +219,7 @@ class DataCollector: SOURCE_ADJUSTED_MAP = { "FinanceDataReader": True, "yfinance": True, # auto_adjust=True + "FinanceDataReader+yfinance": True, # FDR이 늦어 최근 봉만 yfinance로 보충 "KIS": False, # 비수정(원시) 반환 가능 } @@ -220,36 +268,53 @@ def fetch_benchmark_return( Returns: 누적 수익률(%) 또는 None """ - try: - if HAS_FDR: - import FinanceDataReader as _fdr - df = _fdr.DataReader(symbol, start_date, end_date) - elif HAS_YF: - import yfinance as _yf - ticker = f"^{symbol}" if not symbol.startswith("^") else symbol - df = _yf.download(ticker, start=start_date, end=end_date, progress=False) - else: - logger.warning("벤치마크 fetch 불가: FDR/yfinance 미설치") - return None - - if df is None or df.empty or len(df) < 2: - logger.warning("벤치마크 {} 데이터 부족: {}~{}", symbol, start_date, end_date) - return None - - close_col = "Close" if "Close" in df.columns else "close" - if close_col not in df.columns: - logger.warning("벤치마크 {} Close 컬럼 없음", symbol) - return None - - first_close = float(df[close_col].iloc[0]) - last_close = float(df[close_col].iloc[-1]) - if first_close <= 0: - return None - - return (last_close / first_close - 1) * 100 - except Exception as e: - logger.warning("벤치마크 {} 수익률 계산 실패: {}", symbol, e) + # 마지막 봉이 기준 거래일보다 오래됐으면 그 값으로 계산하지 않는다. FDR 지수 + # 자료가 며칠씩 멈추는 일이 실제로 있었고(2026-09-17 이후 KS11·KS200), 그때 + # 수익률을 그대로 내면 'NAV vs 시장' 비교가 경고 없이 몇 %p씩 틀어진다. + expected = _freshness_target(end_date) + candidates = [] + if HAS_FDR: + candidates.append("fdr") + if HAS_YF: + candidates.append("yfinance") + if not candidates: + logger.warning("벤치마크 fetch 불가: FDR/yfinance 미설치") return None + for source in candidates: + try: + if source == "fdr": + import FinanceDataReader as _fdr + df = _fdr.DataReader(symbol, start_date, end_date) + else: + df = _yf_download_flat( + _YF_INDEX_TICKERS.get(str(symbol).upper(), + symbol if str(symbol).startswith("^") else f"^{symbol}"), + start_date, _exclusive_end(end_date), + ) + if df is None or df.empty or len(df) < 2: + logger.warning("벤치마크 {} 데이터 부족({}): {}~{}", symbol, source, start_date, end_date) + continue + close_col = "Close" if "Close" in df.columns else "close" + if close_col not in df.columns: + logger.warning("벤치마크 {} Close 컬럼 없음({})", symbol, source) + continue + # 값이 빈 봉(yfinance가 가끔 돌려준다)은 날짜 판정과 계산에서 뺀다 + closes = pd.to_numeric(df[close_col], errors="coerce").dropna() + closes = closes[closes > 0] + if len(closes) < 2: + logger.warning("벤치마크 {} 쓸 수 있는 종가 부족({})", symbol, source) + continue + last_day = pd.Timestamp(closes.index.max()).date() + if expected is not None and last_day < expected: + logger.warning( + "벤치마크 {} 자료가 {}에 멈춤(기준 거래일 {}, 소스 {}) — 이 값으로 계산하지 않는다", + symbol, last_day, expected, source, + ) + continue + return (float(closes.iloc[-1]) / float(closes.iloc[0]) - 1) * 100 + except Exception as e: + logger.warning("벤치마크 {} 수익률 계산 실패({}): {}", symbol, source, e) + return None def clear_krx_ohlcv_range_cache(self) -> None: """한국 주가 구간 캐시 비우기.""" @@ -430,9 +495,10 @@ def _fetch_korean_stock_uncached( if HAS_FDR and self._preferred_source in ("auto", "fdr"): df = self._try_fdr(symbol, start_date, end_date) if df is not None and not df.empty: - self._record_source(symbol, "FinanceDataReader") + df, source = self._fill_stale_tail(symbol, df, end_date) + self._record_source(symbol, source) if not self.quiet_ohlcv_log: - self._log_source_usage(symbol, "FinanceDataReader") + self._log_source_usage(symbol, source) return df # 2) yfinance @@ -464,6 +530,50 @@ def _fetch_korean_stock_uncached( self._log_source_usage(symbol, "KIS") return df + def _fill_stale_tail( + self, symbol: str, df: pd.DataFrame, end_date: str, + ) -> tuple[pd.DataFrame, str]: + """FDR 자료의 마지막 봉이 기준 거래일보다 오래됐으면 이후 구간을 yfinance로 채운다. + + 비어 있지 않으면 성공으로 보던 탓에, FDR 지수 자료가 2026-09-17에서 멈췄을 때 + 폴백이 한 번도 시도되지 않았다(kr_pocket 추세 필터가 그대로 동결). 최근 요청에만 + 적용한다 — 과거 구간 백테스트나 거래정지 종목에 매번 네트워크를 쓰지 않도록. + 이후 날짜의 봉만 덧붙이고 기존 봉은 바꾸지 않는다. + """ + expected = _freshness_target(end_date) + if expected is None or not HAS_YF: + return df, "FinanceDataReader" + try: + last = pd.Timestamp(df.index.max()).date() + except Exception: + return df, "FinanceDataReader" + if last >= expected: + return df, "FinanceDataReader" + logger.warning( + "종목 {} FDR 최근 봉 {} — 기준 거래일 {}보다 오래됨. yfinance로 이후 구간 보충 시도", + symbol, last, expected, + ) + extra = self._fetch_korean_stock_via_yfinance( + symbol, (last - timedelta(days=14)).isoformat(), end_date, + ) + if extra is None or extra.empty: + logger.warning("종목 {} yfinance도 자료 없음 — {} 봉까지로 진행", symbol, last) + return df, "FinanceDataReader" + tail = extra[extra.index > pd.Timestamp(last)] + tail = tail[[c for c in df.columns if c in tail.columns]] + if "close" in tail.columns: + tail = tail[pd.to_numeric(tail["close"], errors="coerce").fillna(0) > 0] + if tail.empty: + logger.warning("종목 {} yfinance에도 {} 이후 봉 없음", symbol, last) + return df, "FinanceDataReader" + merged = pd.concat([df, tail]).sort_index() + merged = merged[~merged.index.duplicated(keep="first")] + logger.warning( + "종목 {} FDR 자료에 yfinance 봉 {}개 보충 ({} ~ {})", + symbol, len(tail), tail.index.min().date(), tail.index.max().date(), + ) + return merged, "FinanceDataReader+yfinance" + def _try_fdr( self, symbol: str, start_date: str, end_date: str, ) -> pd.DataFrame | None: @@ -1039,23 +1149,36 @@ def _fetch_korean_stock_via_yfinance( start_date = (datetime.now() - timedelta(days=365 * 3)).strftime("%Y-%m-%d") if end_date is None: end_date = datetime.now().strftime("%Y-%m-%d") - ticker = "^KS11" if symbol.upper() == "KS11" else f"{symbol}.KS" - logger.info("한국 주식 데이터 수집 (yfinance): {} ({} ~ {})", ticker, start_date, end_date) - try: - df = yf.download(ticker, start=start_date, end=end_date, progress=False, auto_adjust=True) - if df.empty or len(df) < 2: - return pd.DataFrame(columns=["open", "high", "low", "close", "volume"]) - df = self._normalize_dataframe(df) - from core.data_validator import DataValidator - df = DataValidator.clean_dataframe(df, symbol) + # 지수는 ^ 티커, 종목은 코스피(.KS) → 코스닥(.KQ) 순으로 시도한다. + # yfinance의 end는 그날을 포함하지 않으므로 하루 뒤로 넘긴다(종료일 봉 누락 방지). + sym = str(symbol).strip().upper() + tickers = ( + [_YF_INDEX_TICKERS[sym]] if sym in _YF_INDEX_TICKERS + else [f"{symbol}.KS", f"{symbol}.KQ"] + ) + empty = pd.DataFrame(columns=["open", "high", "low", "close", "volume"]) + for ticker in tickers: + logger.info("한국 주식 데이터 수집 (yfinance): {} ({} ~ {})", ticker, start_date, end_date) + try: + df = _yf_download_flat(ticker, start_date, _exclusive_end(end_date)) + except Exception as e: + logger.warning("yfinance 한국 주식 수집 실패 ({}): {}", ticker, e) + continue + if df is None or df.empty or len(df) < 2: + continue + try: + df = self._normalize_dataframe(df) + from core.data_validator import DataValidator + df = DataValidator.clean_dataframe(df, symbol) + except Exception as e: + logger.warning("yfinance 한국 주식 정규화 실패 ({}): {}", ticker, e) + continue logger.info( - "종목 {} 데이터 수집 완료 (소스=yfinance, 수정주가=auto_adjust=True): {}건", - symbol, len(df), + "종목 {} 데이터 수집 완료 (소스=yfinance {}, 수정주가=auto_adjust=True): {}건", + symbol, ticker, len(df), ) return df - except Exception as e: - logger.warning("yfinance 한국 주식 수집 실패 ({}): {}", ticker, e) - return pd.DataFrame(columns=["open", "high", "low", "close", "volume"]) + return empty def fetch_korean_stock_via_kis(self, symbol: str) -> pd.DataFrame: """ diff --git a/core/holidays_updater.py b/core/holidays_updater.py index aa7ad4af..e7bcd260 100644 --- a/core/holidays_updater.py +++ b/core/holidays_updater.py @@ -7,7 +7,7 @@ from datetime import datetime, date, timedelta from pathlib import Path -from typing import Set +from typing import List, Optional, Set import yaml from loguru import logger @@ -16,10 +16,13 @@ _PROJECT_ROOT = Path(__file__).resolve().parent.parent # pykrx 미사용/실패 시 사용할 연도별 휴장일. -# 대체공휴일 규정(설·추석 연휴, 3·1절, 광복절, 개천절, 한글날, 어린이날, -# 부처님오신날, 성탄절은 주말 겹침 시 다음 평일 대체; 신정·현충일은 미적용)과 -# KRX 연말 휴장(마지막 거래일 12/31)을 포함한다 — 누락되면 휴장일이 거래일로 +# 대체공휴일 규정: 설·추석 연휴는 '일요일' 또는 다른 공휴일과 겹칠 때만 대체한다 +# (토요일 겹침은 대체 없음). 3·1절·광복절·개천절·한글날·어린이날·부처님오신날· +# 성탄절은 토·일 겹침 시 다음 평일 대체. 신정·현충일은 미적용. +# KRX 연말 휴장(마지막 거래일 12/31)도 포함한다 — 누락되면 휴장일이 거래일로 # 계산돼 트랙레코드 커버리지 분모가 부풀고 스냅샷 귀속(가격 기준일)이 어긋난다. +# 반대로 거래일을 휴장으로 넣으면 그날 스냅샷이 직전 거래일 행을 덮어써 결측이 +# 숨는다. config/holidays.yaml 과 평일 휴장 집합이 같아야 한다(테스트로 고정). FALLBACK_BY_YEAR = { 2025: { "2025-01-01", "2025-01-27", "2025-01-28", "2025-01-29", "2025-01-30", # 1/27 임시공휴일 @@ -30,26 +33,34 @@ "2025-10-09", "2025-12-25", "2025-12-31", }, 2026: { - "2026-01-01", "2026-01-27", "2026-01-28", "2026-01-29", + "2026-01-01", + "2026-02-16", "2026-02-17", "2026-02-18", # 설 연휴 "2026-03-01", "2026-03-02", # 3·1절(일) 대체 + "2026-05-01", # 근로자의 날 "2026-05-05", "2026-05-24", "2026-05-25", # 부처님오신날(일) 대체 + "2026-06-03", # 지방선거 "2026-06-06", # 현충일(토) — 대체 미적용 + "2026-07-17", # 제헌절(공휴일 재지정) "2026-08-15", "2026-08-17", # 광복절(토) 대체 - "2026-09-24", "2026-09-25", "2026-09-26", - "2026-09-28", # 추석 연휴 토요일 겹침 대체 + "2026-09-24", "2026-09-25", "2026-09-26", # 추석 연휴(토 겹침 — 대체 없음) "2026-10-03", "2026-10-05", # 개천절(토) 대체 "2026-10-09", "2026-12-25", "2026-12-31", # KRX 연말 휴장 }, 2027: { - "2027-01-01", "2027-02-10", "2027-02-11", "2027-02-12", - "2027-03-01", "2027-05-05", + "2027-01-01", + "2027-02-06", "2027-02-07", "2027-02-08", # 설 연휴(설날 2/7 일) + "2027-02-09", # 설 연휴 일요일 겹침 대체 + "2027-03-01", + "2027-05-01", # 노동절(토) — 대체 여부 KRX 공지 확인 전 + "2027-05-05", "2027-05-13", # 부처님오신날(목) "2027-06-06", # 현충일(일) — 대체 미적용 + "2027-07-17", # 제헌절(토) — 대체 여부 KRX 공지 확인 전 "2027-08-15", "2027-08-16", # 광복절(일) 대체 - "2027-10-03", "2027-10-04", "2027-10-05", "2027-10-06", - "2027-10-07", # 개천절(일) 대체(추석 연휴 직후 평일) + "2027-09-14", "2027-09-15", "2027-09-16", # 추석 연휴(추석 9/15 수) + "2027-10-03", "2027-10-04", # 개천절(일) 대체 "2027-10-09", "2027-10-11", # 한글날(토) 대체 "2027-12-25", "2027-12-27", # 성탄절(토) 대체 "2027-12-31", # KRX 연말 휴장 @@ -57,35 +68,43 @@ } -def _fetch_from_pykrx(year_from: int, year_to: int) -> Set[str]: - """pykrx로 거래일을 조회해 비거래일(주말+휴장)을 휴장일 세트로 반환.""" - out = set() +def _pykrx_trading_dates(start: str, end: str) -> Set[date]: + """pykrx 영업일 목록. 버전마다 함수가 달라 둘 다 시도한다(없으면 예외).""" + from pykrx import stock + + if hasattr(stock, "get_previous_business_days"): + days = stock.get_previous_business_days(fromdate=start, todate=end) + else: + days = stock.get_market_trading_date_by_date(start, end) + days = getattr(days, "index", days) + return {d.date() if hasattr(d, "date") else d for d in days} + + +def _fetch_from_pykrx(year_from: int, year_to: int) -> tuple[Set[str], Optional[date]]: + """pykrx 영업일로 확인된 구간의 평일 휴장일과, 확인된 마지막 거래일을 반환. + + pykrx에는 미래 영업일이 없다. '전체 평일 - 영업일'을 그대로 쓰면 오늘 이후 + 모든 평일이 휴장으로 찍히므로, 마지막으로 돌려받은 거래일까지만 판정한다. + 실패하면 (빈 집합, None). + """ + out: Set[str] = set() + verified_through: Optional[date] = None try: - from pykrx import stock for year in range(year_from, year_to + 1): - start = f"{year}0101" - end = f"{year}1231" - trading = stock.get_market_trading_date_by_date(start, end) - if trading is None or trading.empty: + trading = _pykrx_trading_dates(f"{year}0101", f"{year}1231") + if not trading: continue - all_days = set() - d = date(year, 1, 1) - end_d = date(year, 12, 31) - while d <= end_d: - all_days.add(d.strftime("%Y-%m-%d")) - d += timedelta(days=1) - trading_dates = set(trading.index.strftime("%Y-%m-%d").tolist()) - weekends = set() + last = max(trading) d = date(year, 1, 1) - while d <= end_d: - if d.weekday() >= 5: - weekends.add(d.strftime("%Y-%m-%d")) + while d <= last: + if d.weekday() < 5 and d not in trading: + out.add(d.isoformat()) d += timedelta(days=1) - out |= (all_days - trading_dates - weekends) - return out + verified_through = max(verified_through or last, last) + return out, verified_through except Exception as e: logger.warning("pykrx 휴장일 조회 실패: {} — fallback 사용", e) - return set() + return set(), None def _fetch_fallback(year_from: int, year_to: int) -> Set[str]: @@ -134,28 +153,75 @@ def update_holidays_yaml( year_to = year_to if year_to is not None else now.year + 1 existing = _read_existing(path) if merge_existing else set() - - fetched = _fetch_from_pykrx(year_from, year_to) - if not fetched: - fetched = _fetch_fallback(year_from, year_to) - logger.info("휴장일 fallback 사용 ({}~{}년, {}일)", year_from, year_to, len(fetched)) + fallback = _fetch_fallback(year_from, year_to) + fetched, verified_through = _fetch_from_pykrx(year_from, year_to) + + def _in_range(d: str) -> bool: + return year_from <= int(d[:4]) <= year_to + + if verified_through is not None: + # 시장 영업일로 확인된 구간(≤ verified_through)은 pykrx가 기준이다 — 틀린 기존 + # 항목도 여기서 바로잡힌다. 주말 항목은 거래일 판단에 영향이 없으니 그대로 둔다. + # 확인되지 않은 구간(미래)은 기존 파일 ∪ fallback을 유지한다. + vt = verified_through.isoformat() + merged = {d for d in existing if not _in_range(d) or d > vt or _is_weekend(d)} + merged |= {d for d in fallback if d > vt} + merged |= fetched + logger.info( + "pykrx 휴장일 조회 성공 ({}~{}년, {}까지 확인, 평일 휴장 {}일)", + year_from, year_to, vt, len(fetched), + ) else: - logger.info("pykrx 휴장일 조회 성공 ({}~{}년, {}일)", year_from, year_to, len(fetched)) + # pykrx 실패. fallback 표는 사람이 시장 데이터와 대조해 고친 기존 파일보다 + # 오래됐을 수 있다(2026-08-26에 고친 달력이 이 경로로 되돌아갈 뻔했다). 그래서 + # 기존 항목은 절대 지우지 않고 합집합만 한다. + merged = set(existing) | fallback + logger.info("휴장일 fallback 사용 ({}~{}년) — 기존 항목은 유지", year_from, year_to) - # 대상 연도 밖 기존 항목은 유지, 대상 연도는 fetched로 덮기 - merged = {d for d in existing if int(d[:4]) < year_from or int(d[:4]) > year_to} - merged |= fetched + added = sorted(merged - existing) + removed = sorted(existing - merged) + if added or removed: + logger.warning("holidays.yaml 변경 — 추가 {} / 제거 {}", added, removed) sorted_dates = sorted(merged) - - content = { - "holidays": sorted_dates, - } + content = {"holidays": sorted_dates} + header = _read_header(path) if merge_existing else [] path.parent.mkdir(parents=True, exist_ok=True) with open(path, "w", encoding="utf-8") as f: - f.write("# 한국 증시 휴장일 — 자동 갱신 (pykrx + fallback). 수동 편집 가능.\n") - f.write("# 갱신: python main.py --update-holidays\n\n") + if header: + f.writelines(header) + f.write("\n") + else: + f.write("# 한국 증시 휴장일 — 자동 갱신 (pykrx + fallback). 수동 편집 가능.\n") + f.write("# 갱신: python main.py --update-holidays\n\n") yaml.dump(content, f, allow_unicode=True, default_flow_style=False, sort_keys=False) logger.info("holidays.yaml 저장 완료: {} ({}일)", path, len(sorted_dates)) return path + + +def _is_weekend(d: str) -> bool: + try: + return date.fromisoformat(str(d)[:10]).weekday() >= 5 + except ValueError: + return False + + +def _read_header(path: Path) -> List[str]: + """파일 맨 앞의 주석 블록. 파일을 다시 쓸 때 수정 이력 주석이 지워지지 않게 남긴다.""" + if not path.exists(): + return [] + lines: List[str] = [] + try: + with open(path, "r", encoding="utf-8") as f: + for line in f: + if line.startswith("#") or not line.strip(): + lines.append(line) + else: + break + except Exception as e: + logger.warning("holidays.yaml 머리 주석 읽기 실패: {}", e) + return [] + while lines and not lines[-1].strip(): + lines.pop() + return lines diff --git a/core/live_readiness.py b/core/live_readiness.py index 85f56432..3a154a20 100644 --- a/core/live_readiness.py +++ b/core/live_readiness.py @@ -2,8 +2,20 @@ from __future__ import annotations +from datetime import date, datetime, timedelta + from loguru import logger +# 데이터 소스 신선도 점검 대상: 벤치마크는 바스켓 평가와 같은 KOSPI 지수. +_DATA_HEALTH_BENCHMARK = "KS11" +# 신호 전략 게이트의 대표 종목(바스켓처럼 설정에 보유 종목 목록이 없으므로). +_DATA_HEALTH_DEFAULT_SYMBOL = "005930" +# 마지막 봉이 직전 거래일보다 이만큼(거래일) 늦는 것까지는 허용한다 — 거래일 달력 +# 오류 하루(예: 2026-09-28 대체공휴일 오표기 의심) 때문에 멀쩡한 피드를 막거나, +# 반대로 틀린 달력에 맞춰 묵은 피드를 통과시키지 않도록 여유는 1거래일로 둔다. +_DATA_FRESHNESS_TOLERANCE_TRADING_DAYS = 1 +_DATA_HEALTH_WINDOW_CALENDAR_DAYS = 10 + def check_basket_live_readiness(config, strategy_name: str) -> list[str]: """바스켓 승인 단위(basket_rebalance:)의 live 전환 게이트. @@ -120,18 +132,123 @@ def check_live_readiness_gate(config, strategy_name: str) -> list[str]: return issues try: + issues.extend(check_data_source_freshness(config, strategy_name)) + except Exception as exc: + issues.append(f"데이터 소스 health check 오류: {exc}") + + return issues + + +def _health_check_symbols(strategy_name: str) -> list[str]: + """신선도를 볼 종목: 바스켓이면 보유 종목 전부, 아니면 대표 종목. 끝에 벤치마크.""" + symbols: list[str] = [] + parts = str(strategy_name or "").split(":", 1) + if parts[0] == "basket_rebalance" and len(parts) == 2 and parts[1].strip(): + from core.basket_rebalancer import BasketRebalancer + + basket = BasketRebalancer._load_baskets_config().get(parts[1].strip()) or {} + symbols = [str(symbol) for symbol in (basket.get("holdings") or {})] + if not symbols: + symbols = [_DATA_HEALTH_DEFAULT_SYMBOL] + return symbols + [_DATA_HEALTH_BENCHMARK] + + +def _recent_trading_days(trading_hours, today: date, count: int) -> list[date]: + """today 직전의 KRX 거래일 count개(최근 → 과거). 달력 이상이면 더 적을 수 있다.""" + found: list[date] = [] + day = today - timedelta(days=1) + for _ in range(62): + if trading_hours.is_trading_day(datetime(day.year, day.month, day.day)): + found.append(day) + if len(found) >= count: + break + day -= timedelta(days=1) + return found + + +def _last_bar_date(df) -> date | None: + import pandas as pd + + index = df.index + if isinstance(index, pd.DatetimeIndex) and len(index): + return index.max().date() + if "date" in df.columns: + values = pd.to_datetime(df["date"], errors="coerce").dropna() + if len(values): + return values.max().date() + return None + + +def check_data_source_freshness( + config, + strategy_name: str, + *, + today: date | None = None, + collector=None, +) -> list[str]: + """live 게이트의 데이터 소스 점검 — 최근 봉이 직전 거래일 수준으로 갱신되는지 본다. + + 예전에는 005930의 고정 구간(2026-01-01~03-26)만 받아 봐서, 과거 이력은 주지만 + 갱신이 멈춘 피드(live 사이징이 묵은 가격을 쓰게 되는 바로 그 고장)와 바스켓 + 자신의 종목(069500/357870 등) 문제를 잡지 못했다. 이제 바스켓 보유 종목 전부와 + 벤치마크의 최근 약 10일을 받아, 마지막 봉이 직전 거래일에서 1거래일 이내인지 + 확인하고 실제 마지막 봉 날짜를 로그로 남긴다. 빈 리스트 = 통과. + """ + from core.trading_hours import TradingHours + + today = today or datetime.now().date() + recent = _recent_trading_days( + TradingHours(config), today, 1 + _DATA_FRESHNESS_TOLERANCE_TRADING_DAYS, + ) + if len(recent) <= _DATA_FRESHNESS_TOLERANCE_TRADING_DAYS: + return [ + "데이터 소스 health check 실패: 거래일 달력에서 최근 거래일을 찾지 못했습니다 " + "— config/holidays.yaml을 확인하세요." + ] + previous_trading_day = recent[0] + oldest_allowed = recent[-1] + start = min( + today - timedelta(days=_DATA_HEALTH_WINDOW_CALENDAR_DAYS), + oldest_allowed - timedelta(days=3), + ) + + if collector is None: from core.data_collector import DataCollector - dc = DataCollector() - test_df = dc.fetch_korean_stock("005930", "2026-01-01", "2026-03-26") - if test_df.empty: - issues.append("데이터 소스 health check 실패: 005930 데이터 수집 불가.") - source_info = dc.get_last_source_info() - if source_info.get("source") == "KIS": + collector = DataCollector() + + issues: list[str] = [] + for symbol in _health_check_symbols(strategy_name): + try: + df = collector.fetch_korean_stock(symbol, start.isoformat(), today.isoformat()) + except Exception as exc: + issues.append(f"데이터 소스 health check 오류: {symbol} 수집 실패 — {exc}") + continue + if df is None or df.empty: issues.append( - "데이터 소스가 KIS(비수정주가) — FDR 또는 yfinance 사용을 권장합니다." + f"데이터 소스 health check 실패: {symbol} 최근 데이터 없음 ({start}~{today})." + ) + continue + last_bar = _last_bar_date(df) + if last_bar is None: + issues.append(f"데이터 소스 health check 실패: {symbol} 마지막 봉 날짜를 읽지 못함.") + continue + logger.info( + "데이터 소스 health check: {} 마지막 봉 {} (직전 거래일 {}, 허용 하한 {})", + symbol, last_bar, previous_trading_day, oldest_allowed, + ) + if last_bar < oldest_allowed: + issues.append( + f"데이터 소스 갱신 멈춤 의심: {symbol} 마지막 봉 {last_bar} < 허용 하한 " + f"{oldest_allowed} (직전 거래일 {previous_trading_day}, " + f"{_DATA_FRESHNESS_TOLERANCE_TRADING_DAYS}거래일 허용)." ) - except Exception as exc: - issues.append(f"데이터 소스 health check 오류: {exc}") + history = (collector.get_last_source_info() or {}).get("history") or {} + kis_symbols = sorted(symbol for symbol, source in history.items() if source == "KIS") + if kis_symbols: + issues.append( + "데이터 소스가 KIS(비수정주가) — FDR 또는 yfinance 사용을 권장합니다: " + + ", ".join(kis_symbols) + ) return issues diff --git a/core/market_regime.py b/core/market_regime.py index 69a61fe1..1735d9e8 100644 --- a/core/market_regime.py +++ b/core/market_regime.py @@ -15,10 +15,16 @@ - bullish : 모두 미충족 → 정상 """ +import math from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +import numpy as np +import pandas as pd from loguru import logger +_KST = ZoneInfo("Asia/Seoul") + def _cfg_dict(config, name: str) -> dict: """Config 객체와 dict 설정을 모두 지원한다.""" @@ -72,10 +78,45 @@ def _override(key: str, runtime_key: str, default): } +def _completed_index_bars(df: pd.DataFrame, today) -> pd.DataFrame: + """날짜 오름차순 일봉에서 오늘(KST) 이후 날짜의 봉을 뺀다. + + 장중에 받은 오늘 봉은 확정 종가가 아니다. 백테스트 국면도 T-1까지의 종가로 + 판단하므로(backtester._precompute_regime_series) 운영도 전일까지만 쓴다. + """ + frame = df + if "date" in frame.columns: + frame = frame.set_index("date") + dates = pd.to_datetime(frame.index) + if dates.tz is not None: + dates = dates.tz_convert(_KST).tz_localize(None) + frame = frame.set_axis(dates.normalize(), axis=0).sort_index() + return frame.loc[frame.index < pd.Timestamp(today)] + + +def _missing_sessions(last_bar_date, today) -> int | None: + """마지막 봉 다음 날부터 어제까지 빠진 거래일 수 (0이면 최신). 달력 확인 불가면 None.""" + from core.trading_hours import _load_holidays + + try: + holidays = sorted(_load_holidays()) + return int(np.busday_count( + (pd.Timestamp(last_bar_date) + pd.Timedelta(days=1)).date(), + pd.Timestamp(today).date(), + holidays=holidays, + )) + except (TypeError, ValueError) as e: + logger.warning("시장 국면 필터: 휴장일 달력 확인 실패 — 지수 신선도 미표시: {}", e) + return None + + def check_market_regime(config, collector=None) -> dict: """ 시장 국면을 판별하고 단계적 position_scale 을 반환한다. + 지수 일봉은 전일(KST 기준 오늘 이전)까지의 확정 봉만 쓴다. 조회 구간은 + 필요한 거래일 수를 달력일로 환산해(약 1.5배 + 30일) 잡는다. + Returns: { "regime": "bullish" | "caution" | "bearish" | "unknown", @@ -87,6 +128,8 @@ def check_market_regime(config, collector=None) -> dict: "momentum_triggered": bool, "last_close": float, "ma_value": float, + "last_bar_date": "YYYY-MM-DD", # 판정에 쓴 마지막 확정 봉 날짜 + "missing_sessions": int | None, # 그 뒤 빠진 거래일 수 (지수 자료 지연) }, } """ @@ -127,7 +170,11 @@ def fail_closed(reason: str, *, error: str = "") -> dict: ma_short = regime_cfg["ma_short"] ma_mid = regime_cfg["ma_mid"] - need_days = max(ma_days, ma_mid, short_days) + 50 + # 필요한 거래일 수(여유 50봉 포함). 조회는 달력일이라 환산이 필요하다 — 예전에는 + # 거래일 수를 그대로 달력일로 써서(250+30=280일 ≈ 190거래일 < 200) 필터를 켜면 + # 항상 'index_data_insufficient'로 모든 매수가 막혔다. + need_days = max(ma_days, ma_mid, short_days + 1) + 50 + lookback_calendar_days = int(need_days * 1.5) + 30 if collector is None: try: @@ -137,25 +184,40 @@ def fail_closed(reason: str, *, error: str = "") -> dict: logger.warning("시장 국면 필터: DataCollector 생성 실패 — 신규 매수 차단: {}", e) return fail_closed("data_collector_unavailable", error=str(e)) - end_d = datetime.now() - start_d = (end_d - timedelta(days=need_days + 30)).strftime("%Y-%m-%d") - end_str = end_d.strftime("%Y-%m-%d") + now_kst = datetime.now(_KST) + today = now_kst.date() + start_d = (now_kst - timedelta(days=lookback_calendar_days)).strftime("%Y-%m-%d") + end_str = now_kst.strftime("%Y-%m-%d") try: df = collector.fetch_korean_stock(index_symbol, start_date=start_d, end_date=end_str) - if df is None or df.empty or len(df) < ma_days: - logger.warning("시장 국면 필터: 지수 {} 데이터 부족 — 신규 매수 차단", index_symbol) + if df is None or df.empty or "close" not in df.columns: + logger.warning("시장 국면 필터: 지수 {} 데이터 없음 — 신규 매수 차단", index_symbol) + return fail_closed("index_data_insufficient") + df = _completed_index_bars(df, today) + if len(df) < ma_days: + logger.warning( + "시장 국면 필터: 지수 {} 확정 봉 {}개 < MA {}일 — 신규 매수 차단", + index_symbol, len(df), ma_days, + ) return fail_closed("index_data_insufficient") close = df["close"].astype(float) ma = close.rolling(ma_days, min_periods=ma_days).mean() last_close = float(close.iloc[-1]) - last_ma = ma.iloc[-1] + last_ma = float(ma.iloc[-1]) - if last_ma is None or (hasattr(last_ma, "item") and (last_ma != last_ma)): + if not (math.isfinite(last_ma) and math.isfinite(last_close)): logger.warning("시장 국면 필터: 지수 {} MA 계산 실패 — 신규 매수 차단", index_symbol) return fail_closed("ma_unavailable") - last_ma = float(last_ma) + + last_bar_date = df.index[-1].date() + missing_sessions = _missing_sessions(last_bar_date, today) + if missing_sessions: + logger.warning( + "시장 국면 필터: 지수 {} 최근 봉 {} — 그 뒤 거래일 {}일 자료 없음 (지연된 종가로 판정)", + index_symbol, last_bar_date, missing_sessions, + ) below_ma = last_close < last_ma @@ -190,6 +252,8 @@ def fail_closed(reason: str, *, error: str = "") -> dict: "ma_mid_val": round(ma_mid_val, 1) if ma_mid_val is not None else None, "last_close": round(last_close, 1), "ma_value": round(last_ma, 1), + "last_bar_date": str(last_bar_date), + "missing_sessions": missing_sessions, } # 국면 판별: 3개 신호 중 2개 이상 → bearish, 1개 → caution @@ -239,12 +303,16 @@ def get_regime_adjusted_params(config, collector=None) -> dict: """ 시장 국면에 따라 전략 파라미터 조정값을 반환. + 국면별로 조정하는 것은 손절·익절 배수뿐이다. 매수 진입 임계값은 국면에 따라 + 바뀌지 않는다 — 국면별 매수 억제는 allow_buys·position_scale이 맡는다. + (예전 regime_adaptive.*.buy_threshold_offset 키는 계산만 되고 어디서도 읽히지 + 않아 '진입이 국면별로 엄격해진다'는 오해를 만들었으므로 제거했다.) + Returns: { "regime": "bullish" | "caution" | "bearish" | "unknown", "allow_buys": bool, "position_scale": float, - "buy_threshold_offset": int, "stop_loss_multiplier": float, "take_profit_multiplier": float, } @@ -264,7 +332,6 @@ def get_regime_adjusted_params(config, collector=None) -> dict: if not ra_cfg.get("enabled", False): return { **base, - "buy_threshold_offset": 0, "stop_loss_multiplier": 1.0, "take_profit_multiplier": 1.0, } @@ -272,7 +339,6 @@ def get_regime_adjusted_params(config, collector=None) -> dict: regime_params = ra_cfg.get(regime, {}) return { **base, - "buy_threshold_offset": int(regime_params.get("buy_threshold_offset", 0)), "stop_loss_multiplier": float(regime_params.get("stop_loss_multiplier", 1.0)), "take_profit_multiplier": float(regime_params.get("take_profit_multiplier", 1.0)), } diff --git a/core/notifier.py b/core/notifier.py index 37ae3373..805c2527 100644 --- a/core/notifier.py +++ b/core/notifier.py @@ -250,13 +250,22 @@ def _discord_send_embed( # ------------------------------------------------------------------ # 공개 API # ------------------------------------------------------------------ + def _report_lost_alert(self, title: str, text: str) -> None: + """디스코드도 이메일도 전달하지 못한 경보. 로그 말고는 흔적이 남지 않으므로 + CRITICAL로 남긴다 — 이메일이 죽어 있는 동안 디스코드까지 막히면 경보가 전부 + 사라지는데, 예전엔 이미 알려진 이메일 실패 로그와 구분되지 않았다.""" + logger.critical("[ALERT_LOST] 모든 경보 채널 전달 실패 — {}: {}", title, str(text)[:300]) + def send_message(self, text: str, critical: bool = False) -> None: level = "CRITICAL" if critical else "INFO" discord_ok = self._discord_send_message(text) + email_ok = None if critical: - self._send_email_tracked("알림", level, body_text=text) + email_ok = self._send_email_tracked("알림", level, body_text=text) elif not discord_ok: - self._send_email_tracked("알림", "WARNING", body_text=text) + email_ok = self._send_email_tracked("알림", "WARNING", body_text=text) + if not discord_ok and email_ok is False and self._discord_deliverable(): + self._report_lost_alert("알림", text) send = send_message @@ -272,10 +281,13 @@ def send_embed( plain = self._embed_to_plain(title, description, fields) rows = self._embed_to_table_rows(title, description, fields) discord_ok = self._discord_send_embed(title, description, color, fields) + email_ok = None if critical: - self._send_email_tracked(title, level, body_text=plain, table_rows=rows) + email_ok = self._send_email_tracked(title, level, body_text=plain, table_rows=rows) elif not discord_ok: - self._send_email_tracked(title, "WARNING", body_text=plain, table_rows=rows) + email_ok = self._send_email_tracked(title, "WARNING", body_text=plain, table_rows=rows) + if not discord_ok and email_ok is False and self._discord_deliverable(): + self._report_lost_alert(title, plain) def send_trade_alert(self, trade_info: dict) -> None: action = trade_info.get("action", "") @@ -308,7 +320,10 @@ def send_daily_report(self, report: dict) -> None: fields = [ {"name": "💰 총 평가금", "value": f"{report.get('total_value', 0):,.0f}원", "inline": True}, {"name": "💵 현금", "value": f"{report.get('cash', 0):,.0f}원", "inline": True}, - {"name": "📈 일일 수익률", "value": f"{report.get('daily_return', 0):.2f}%", "inline": True}, + # 계산하지 못한 날(첫 기록·입금 조회 실패)은 0.00%가 아니라 '—'로 둔다. + {"name": "📈 일일 수익률", "value": ( + "—" if report.get("daily_return") is None else f"{report['daily_return']:.2f}%" + ), "inline": True}, {"name": "📊 누적 수익률", "value": f"{report.get('cumulative_return', 0):.2f}%", "inline": True}, {"name": "📉 MDD", "value": f"{report.get('mdd', 0):.2f}%", "inline": True}, {"name": "📋 보유 종목", "value": f"{report.get('position_count', 0)}개", "inline": True}, @@ -329,6 +344,7 @@ def send_daily_report(self, report: dict) -> None: ("deployment", "🎯 주식 배치율", False), ("progress", "📅 진행률", False), ("cost", "💸 누적 비용", True), + ("risk", "📉 리스크", False), ("slot_warning", "⚠️ 미체결 슬롯", False), ): val = report.get(key) diff --git a/core/operator_health.py b/core/operator_health.py index 7cb0c2bb..d6bfe3ae 100644 --- a/core/operator_health.py +++ b/core/operator_health.py @@ -22,7 +22,8 @@ from __future__ import annotations -from typing import Any +import math +from typing import Any, Iterable # verdict 우선순위 (높을수록 심각) _VERDICT_RANK = {"OK": 0, "ATTENTION": 1, "BLOCKED": 2} @@ -141,15 +142,12 @@ def structural_deployment_tolerance( ) -> float: """배치율 허용 오차의 구조 하한 — 정수 주식 절사가 만드는 불가피한 미달을 반영(순수). - 적립 직후에는 '부족분 < 1주 가격'인 동안 매수가 보류되므로(설계), 미달폭이 - 슬롯마다 최대 1주 가격까지 벌어진다. truncation_unit_value는 그 상한의 합 — - **보유 슬롯별 1주 가격의 합**이다(예: 잔고 40만·ETF 1주 12.8만 단일 슬롯 → 32%p; - 지수 12.4만+파킹 5.8만 2슬롯 잔고 70만 → 26%p). 최고가 1주만 재면 다중 슬롯 - 바스켓이 정상 적립 중(모든 슬롯이 부족분 < 1주)에도 허용을 초과해 몇 주씩 - 거짓 ATTENTION이 울린다. 잔고가 커지면 구조 하한이 저절로 조여져 floor가 다시 - 지배한다 — 진짜 이상(미달이 절사 상한을 초과)은 규모가 커지는 즉시 잡힌다. - 완전히 빈 슬롯은 절사가 아니라 실패이므로 합산에 넣지 않는다(호출부는 '보유' - 포지션의 1주 가격만 합산할 것). + truncation_unit_value는 '자동으로 메울 수 없는 미달 금액'이다. 헬스는 + unfixable_deployment_gap(max(band, 가장 싼 집행 가능 묶음의 절반))을 넘긴다 — + 보충 매수가 생긴 뒤로는 그 이상 남는 미달은 절사가 아니라 누수다(2026-09-23 수정: + 예전 호출부는 보유 슬롯별 1주 가격의 합을 넘겨 9종목 바스켓의 허용이 20%p까지 + 벌어졌다). + 잔고가 커지면 구조 하한이 저절로 조여져 floor가 다시 지배한다. 반환: max(floor_tolerance, truncation_unit_value/total_value). 입력 불충분 시 floor. """ @@ -163,20 +161,65 @@ def structural_deployment_tolerance( return max(float(floor_tolerance), unit / total) +def unfixable_deployment_gap( + lot_prices: Iterable[Any], + total_value: Any, + *, + min_trade: float = 0.0, + band: float = 0.0, +) -> float: + """배치율 보충 매수가 메우지 못하고 남길 수 있는 최대 미달 금액(원). 순수 함수. + + 2026-08-26부터 리밸런서는 집계 미달이 band를 넘으면 min_trade를 넘기는 최소 + 정수주 묶음을 산다. 한 묶음은 '사면 격차가 줄어들 때'(묶음 < 남은 격차 × 2)만 사고, + 남은 격차가 band 이하가 되면 멈춘다. 그래서 막힌 슬롯이 없는 정상 상태의 미달은 + max(band, 가장 싼 묶음의 절반)을 넘지 않는다 — 그 이상 남아 있으면 절사가 아니라 + 누수(또는 종목 상한에 막힌 상태)다. + + 예전 허용 하한은 보유 슬롯별 1주 가격을 전부 더했다(보충 매수가 없던 시절의 근거). + 9종목 바스켓에서 그 합은 총자산의 20%에 달해 5%p 감시가 사실상 꺼져 있었다 — + 8/07~8/26 현금 래칫(61% → 54.9%)이 그 상태로도 OK였다. + + 반환: max(band × total, 가장 싼 묶음 / 2). 가격이나 총액이 없으면 0(설정 허용값만 적용). + """ + try: + total = float(total_value) + except (TypeError, ValueError): + return 0.0 + if not math.isfinite(total) or total <= 0: + return 0.0 + lots = [] + for raw in lot_prices or []: + try: + price = float(raw) + except (TypeError, ValueError): + continue + if not math.isfinite(price) or price <= 0: + continue + qty = max(1, math.ceil(float(min_trade or 0) / price)) + lots.append(qty * price) + if not lots: + return 0.0 + return max(max(0.0, float(band or 0)) * total, min(lots) / 2) + + def summarize_deployment( deployment_ratio: float | None, design_fraction: float | None, *, tolerance: float = 0.05, ) -> dict[str, Any]: - """집계 배치율(총자산 중 실제 주식비중)이 설계 대비 크게 미달인지 판정(순수 함수). + """집계 배치율(총자산 중 실제 주식비중)이 설계에서 크게 벗어났는지 판정(순수 함수). 한 달 운영 리뷰(docs/PAPER_MONTH1_REVIEW_AND_PLAN.md P1-5)의 배경: 종목별 드리프트 트리거는 '집계 배치율' 이탈(예: 실효 61% vs 설계 80%)을 영영 못 본다. 여기서 그 이탈을 - 운영자 헬스로 표면화한다. 실제가 설계보다 tolerance(기본 5%p) 초과로 낮으면 ATTENTION. - (초과 배치는 리밸런서가 자연 교정하므로 '미달'만 감시한다.) + 운영자 헬스에 보여 준다. 실제가 설계에서 tolerance(기본 5%p)를 넘게 벗어나면 ATTENTION. + + 초과 배치도 본다. 전 종목이 같이 오르면 종목별 비중은 그대로라 리밸런서가 줄이지 + 않는다 — 60/40 설계가 조용히 70/30이 된다(현금 래칫의 반대 방향, 운영 원칙 4). 반환: {verdict(OK|ATTENTION), note(str|None), deployment_ratio, design_fraction, shortfall} + shortfall = 설계 - 실제 (음수면 초과 배치) """ if deployment_ratio is None or design_fraction is None: return { @@ -191,6 +234,14 @@ def summarize_deployment( note = ( f"주식 배치율 {deployment_ratio:.0%} < 설계 {design_fraction:.0%} " f"({-shortfall * 100:.1f}%p) — 미체결 슬롯/자본 점검" + " (1주 단위라 못 사는 경우면 다음 적립 뒤에 채워진다)" + ) + elif -shortfall > tolerance: + verdict = "ATTENTION" + note = ( + f"주식 배치율 {deployment_ratio:.0%} > 설계 {design_fraction:.0%} " + f"(+{-shortfall * 100:.1f}%p) — 주가가 올라 주식 비중이 설계보다 높다. 종목마다 " + "조금씩이라 리밸런서가 줄이지 않으니 비중을 조정할지 판단할 것" ) return { "verdict": verdict, "note": note, @@ -268,6 +319,7 @@ def summarize_basket_operation( design_fraction: float | None = None, deployment_tolerance: float = 0.05, contribution_notes: list[str] | None = None, + data_notes: list[str] | None = None, ) -> dict[str, Any]: """바스켓 paper 운영(트랙레코드 축적) 상태를 verdict + 요약으로 환원한다. @@ -328,6 +380,12 @@ def _as_date(v: Any): verdict = "ATTENTION" notes.append(note) + # 위험 관리(오버레이) 입력 문제 — 켜 둔 추세 필터가 오래된 자료로 동결되면 설정상 + # 켜져 있어도 실제로는 꺼진 것과 같다. 로그에만 있으면 아무도 못 본다. + for note in data_notes or []: + verdict = "ATTENTION" + notes.append(note) + return { "verdict": verdict, "enabled_baskets": list(enabled_baskets), diff --git a/core/order_executor.py b/core/order_executor.py index 2e997492..eb2b1746 100644 --- a/core/order_executor.py +++ b/core/order_executor.py @@ -12,6 +12,7 @@ import os import time as time_mod from datetime import datetime, timedelta +from types import SimpleNamespace from loguru import logger from config.config_loader import Config @@ -1087,8 +1088,49 @@ def _is_real_money_live(self) -> bool: ) return self.mode == "live" and not confirmed_mock - def _drawdown_pre_order_check(self, action: str = "BUY") -> dict: - """MDD/일일 손실 한도 도달 시 신규 BUY만 차단한다.""" + # 목표 비중 주문이면 바스켓 규칙을 대신 따르는 낙폭 가드 판정(정책 판단). 설정 오류· + # 평가 불가 같은 인프라 실패는 넘기지 않는다 — 판단 근거가 없으면 여전히 막는다. + _DELEGABLE_DRAWDOWN_TYPES = frozenset({"mdd", "mdd_hysteresis", "daily_loss"}) + + def _drawdown_pre_order_check( + self, + action: str = "BUY", + *, + mark_prices: dict | None = None, + delegated: bool = False, + ) -> dict: + """MDD/일일 손실 한도 도달 시 신규 BUY만 차단한다. + + mark_prices: 보유 종목 현재가. 주어지면 평가금액을 시가로 잰다. 없으면 기존처럼 + 평균단가로 잰다 — paper에서는 시장이 움직여도 값이 변하지 않아, 하락장에선 + 낙폭을 작게 보고(9/22 kr_diversified_hold 9.7%, 실제로는 12.9%) 평가익이 + 쌓이면 가짜 '일일 손실'로 매수를 막는다(6/15 원장 재현 -5.5%). + delegated: 자기 낙폭 규칙(overlays.drawdown_guard — 리밸런서가 배수로 적용)을 + 켜 둔 바스켓의 목표 비중 주문. 계좌 단위 MDD·일일 손실 가드는 재량 매매용 + 안전판이라, 이런 주문은 그 바스켓 규칙을 따른다. 상관 거부권(#456)·노출 + 상한(#457)과 같은 원칙이다. 규칙을 꺼 둔 바스켓(kr_diversified_hold)은 + 넘기지 않는다 — 넘기면 계좌 가드를 아무도 적용하지 않게 된다. 판정은 계속 + 계산해 로그로 남긴다. + """ + decision = self._drawdown_guard_decision(action, mark_prices=mark_prices) + if ( + delegated + and not decision.get("allowed", True) + and decision.get("drawdown_guard_type") in self._DELEGABLE_DRAWDOWN_TYPES + ): + logger.info( + "목표 비중 주문이라 계좌 낙폭 가드 대신 바스켓 규칙을 따름: {}", + decision.get("reason"), + ) + return { + "allowed": True, + "reason": "", + "drawdown_guard_delegated": decision.get("reason"), + } + return decision + + def _drawdown_guard_decision(self, action: str = "BUY", *, mark_prices: dict | None = None) -> dict: + """계좌 MDD/일일 손실 가드의 판정(목표 비중 주문 여부와 상관없이 계산한 원래 결과).""" if str(action).upper() != "BUY": return {"allowed": True, "reason": ""} @@ -1123,10 +1165,11 @@ def _drawdown_pre_order_check(self, action: str = "BUY") -> dict: try: from core.portfolio_manager import PortfolioManager - summary = PortfolioManager( - self.config, - account_key=self.account_key, - ).get_portfolio_summary() + guard_pm = PortfolioManager(self.config, account_key=self.account_key) + summary = ( + guard_pm.get_portfolio_summary(current_prices=mark_prices) + if mark_prices else guard_pm.get_portfolio_summary() + ) if self.mode == "live" and summary.get("broker_balance_ok") is False: reason = ( "손실 한도 확인 실패: KIS 잔고 조회가 확인되지 않아 " @@ -1443,6 +1486,24 @@ def _finite_amount(value, *, positive: bool = False) -> float | None: return None return amount + def _marked_position_value(self, position, mark_prices: dict | None) -> float: + """보유 평가액. 현재가가 주어지면 시가, 없으면 투자원금(기존 기준). + + 자본(capital)을 시가 총자산으로 넘기는 호출부는 보유분도 시가로 재야 비율이 + 맞는다 — 분자는 원가, 분모는 시가면 하락장에서 비중이 부풀어 보인다. + """ + if mark_prices: + symbol = str(getattr(position, "symbol", "")) + try: + mark = float(mark_prices.get(symbol) or 0) + qty = float(getattr(position, "quantity", 0) or 0) + except (TypeError, ValueError): + mark, qty = 0.0, 0.0 + if math.isfinite(mark) and mark > 0 and math.isfinite(qty): + return mark * qty + logger.warning("종목 {} 현재가 없음 — 노출 판단에 투자원금 사용", symbol) + return self._position_invested_value(position) + @staticmethod def _position_invested_value(position) -> float: value = getattr(position, "total_invested", None) @@ -1954,6 +2015,8 @@ def execute_buy_quantity( weight_policy_managed: bool = False, risk_levels: dict = None, exposure_limits: dict = None, + mark_prices: dict = None, + basket_drawdown_rule: bool = False, ) -> dict: """Execute a fixed-quantity buy (paper or live). @@ -1969,9 +2032,17 @@ def execute_buy_quantity( risk_levels: {"stop_loss_price", "take_profit_price", "trailing_stop_price"}로 진입 시 리스크 레벨을 명시 지정한다(None인 키는 미설정). 미전달 시 전역 risk_params 기준으로 계산한다. + mark_prices: 보유 종목 현재가. 주어지면 노출 상한·낙폭 가드를 시가로 잰다. + 호출부(리밸런서)는 시가로 주문을 계획하는데 주문 단계가 평균단가로 재면 + 하락장에서 계획한 보충 매수가 '투자 비중 초과'로 거부된다(재현: 시장 -15%에서 + 시가 60.0% 주문이 원가 62.1%로 계산돼 거부). + basket_drawdown_rule: 호출한 바스켓이 자기 낙폭 규칙(overlays.drawdown_guard)을 + 켜 두었는지. 켜 둔 바스켓의 목표 비중 주문만 계좌 MDD·일일 손실 가드를 그 + 규칙에 맡긴다. 규칙이 없는 바스켓까지 넘기면 계좌 가드가 계산만 되고 아무도 + 적용하지 않는다(live에서는 원래 작동하던 안전판이 사라진다). """ with PositionLock(): - return self._execute_buy_quantity_impl( + result = self._execute_buy_quantity_impl( symbol=symbol, price=price, quantity=quantity, @@ -1986,7 +2057,33 @@ def execute_buy_quantity( weight_policy_managed=weight_policy_managed, risk_levels=risk_levels, exposure_limits=exposure_limits, + mark_prices=mark_prices, + basket_drawdown_rule=basket_drawdown_rule, + ) + if not result.get("success"): + self._report_buy_rejection(symbol, strategy, result) + return result + + def _report_buy_rejection(self, symbol: str, strategy: str, result: dict) -> None: + """매수 거부 사유를 남긴다 — 그동안 요약의 '실패 N건'만 보여 보호 동작과 + 계획·실행 불일치를 구분할 수 없었다(kr_pocket 같은 주문이 3주간 7회 거부됐는데 + 사유가 어디에도 없었다). 같은 날 같은 사유는 이벤트를 한 번만 남긴다.""" + reason = str(result.get("reason") or "사유 미상") + logger.warning("매수 거부 {} ({}): {}", symbol, strategy or "-", reason) + try: + from core.cycle_observability import record_event_once_per_day + + record_event_once_per_day( + "ORDER_REJECTED", + f"매수 거부 {symbol}: {reason}", + severity="warning", + strategy=strategy or None, + mode=self.mode, + symbol=str(symbol), + dedupe_key=reason[:300], ) + except Exception as exc: + logger.warning("매수 거부 이벤트 기록 실패: {}", exc) def _execute_buy_quantity_impl( self, @@ -2004,6 +2101,8 @@ def _execute_buy_quantity_impl( weight_policy_managed: bool = False, risk_levels: dict = None, exposure_limits: dict = None, + mark_prices: dict = None, + basket_drawdown_rule: bool = False, ) -> dict: # live 고정수량 BUY도 일반 BUY와 동일하게 canonical live gate 통과 executor에서만 허용. # (기존 paper-only 차단을 제거하면서 이 게이트가 그 안전 역할을 승계한다.) @@ -2082,6 +2181,9 @@ def _execute_buy_quantity_impl( candidate_notional=fill_price * quantity, price=price, avg_daily_volume=avg_daily_volume, + mark_prices=mark_prices, + weight_policy_managed=weight_policy_managed, + drawdown_delegable=weight_policy_managed and bool(basket_drawdown_rule), ) if not pre_check["allowed"]: result = {"success": False, **pre_check} @@ -2094,7 +2196,8 @@ def _execute_buy_quantity_impl( mode=self.mode, ) invested_values = [ - self._position_invested_value(position) for position in positions + self._marked_position_value(position, mark_prices) + for position in positions ] except Exception as exc: return { @@ -2133,7 +2236,18 @@ def _execute_buy_quantity_impl( current_invested=sum(invested_values), symbol=symbol, sector_map=sector_map, - positions=positions if need_sector_map else None, + # 업종 비중도 같은 기준으로 잰다 — 총자산을 시가로 넘기면서 업종 보유분만 + # 원가로 두면 손실 구간에서 업종 비중이 부풀어 보인다. + positions=( + None if not need_sector_map + else [ + SimpleNamespace( + symbol=getattr(p, "symbol", ""), total_invested=value, + quantity=getattr(p, "quantity", 0), avg_price=getattr(p, "avg_price", 0), + ) + for p, value in zip(positions, invested_values) + ] if mark_prices else positions + ), existing_position_value=existing_position_value, is_new_position=not any( str(getattr(position, "symbol", "")) == str(symbol) @@ -2534,6 +2648,7 @@ def execute_sell( strategy: str = "", avg_daily_volume: float = None, execution_session_id: str = "", + emergency: bool = False, ) -> dict: """ 매도 주문 실행 @@ -2546,12 +2661,18 @@ def execute_sell( reason: 매매 사유 strategy: 전략명 avg_daily_volume: 일평균 거래량 (제공 시 거래량 기반 동적 슬리피지 적용) + emergency: 손실 방어 청산(손절·트레일링)임을 호출부가 명시한다. 사유 문자열 + 판정은 정확히 'STOP_LOSS' 같은 값만 알아봐서, 바스켓의 'RISK_EXIT + STOP_LOSS: ...' 사유는 최소 보유 기간에 막히고 live에서는 지정가로 나갔다. Returns: 주문 결과 딕셔너리 """ with PositionLock(): - return self._execute_sell_impl(symbol, price, quantity, signal_score, reason, strategy, avg_daily_volume, execution_session_id) + return self._execute_sell_impl( + symbol, price, quantity, signal_score, reason, strategy, + avg_daily_volume, execution_session_id, emergency=emergency, + ) def _execute_sell_impl( self, @@ -2563,6 +2684,7 @@ def _execute_sell_impl( strategy: str = "", avg_daily_volume: float = None, execution_session_id: str = "", + emergency: bool = False, ) -> dict: """매도 주문 실제 로직 (Lock 내부에서 호출).""" position = get_position(symbol, account_key=self.account_key, mode=self.mode) @@ -2573,7 +2695,7 @@ def _execute_sell_impl( # 손실 방어 청산은 지정가가 현재가를 뒤쫓지 못해 미체결되는 위험보다 # 체결 확률을 우선한다. 현재가가 끊겨도 실전 시장가 주문은 평균단가를 # 손익 추정 기준으로 삼아 계속 진행한다. - is_emergency = self._is_emergency_sell_reason(reason) + is_emergency = bool(emergency) or self._is_emergency_sell_reason(reason) order_price = self._positive_order_price(price) if order_price is None: fallback_price = self._positive_order_price( @@ -3415,6 +3537,9 @@ def _pre_order_check( candidate_notional: float | None = None, price: float | None = None, avg_daily_volume: float | None = None, + mark_prices: dict | None = None, + weight_policy_managed: bool = False, + drawdown_delegable: bool = False, ) -> dict: """ 주문 전 안전 체크 (거래 시간 + 블랙스완) @@ -3439,7 +3564,12 @@ def _pre_order_check( if not monthly_cap["allowed"]: return monthly_cap - drawdown_check = self._drawdown_pre_order_check(action) + if mark_prices or drawdown_delegable: + drawdown_check = self._drawdown_pre_order_check( + action, mark_prices=mark_prices, delegated=drawdown_delegable, + ) + else: + drawdown_check = self._drawdown_pre_order_check(action) if not drawdown_check["allowed"]: return drawdown_check diff --git a/core/paper_evidence.py b/core/paper_evidence.py index 0cb65a3c..e726a1c4 100644 --- a/core/paper_evidence.py +++ b/core/paper_evidence.py @@ -482,6 +482,21 @@ def _probe_portfolio_metrics(account_key: str, date: datetime) -> dict: # 4) 거래 없음 + 직전 snapshot 존재 → cash-only carry-forward # 포트폴리오 가치 불변이므로 daily_return=0.0 + # 단, 보유 종목이 있으면 가치가 불변이 아니다 — 시장이 움직인 날을 0%로 채우면 + # 변동성이 작게, paper 샤프가 크게 잡히고 빠진 날이 증거 일수로 셈해진다. + # 그때는 추론하지 않고 결측으로 둔다. + if (prev_snap.position_count or 0) > 0 or (prev_snap.invested or 0) > 0: + return _portfolio_probe_metadata( + account_key=ak, + date=date, + source="missing_current_snapshot_with_positions", + reason="positions were held since previous snapshot; value cannot be inferred", + current_snapshot_found=False, + previous_snapshot_found=True, + previous_snapshot_at=prev_snap.date, + trades_today=trades_today, + trades_since_previous=trades_since, + ) return { **_portfolio_probe_metadata( account_key=ak, @@ -789,7 +804,9 @@ def _compute_benchmark_excess( universe_return = sum(returns) / len(returns) invested_ratio = 1.0 - cash_ratio - rf_daily = RF_ANNUAL / 252 + # 다른 항(daily_return·universe_return)은 퍼센트 단위다 — rf도 퍼센트/일로 맞춘다. + # 예전엔 소수(0.000139)를 그대로 빼서 현금분 무위험수익이 100분의 1로 줄었다. + rf_daily = RF_ANNUAL / 252 * 100 result["same_universe_excess"] = round(daily_return - universe_return, 4) result["exposure_matched_excess"] = round( diff --git a/core/paper_pilot.py b/core/paper_pilot.py index 260b0ee6..46e8becc 100644 --- a/core/paper_pilot.py +++ b/core/paper_pilot.py @@ -75,24 +75,45 @@ def _coerce_date(value: str | datetime | None = None) -> datetime: return datetime.strptime(value, "%Y-%m-%d") -def _business_days_between(start_date: str, end_date: str) -> int: - """start 다음 날부터 end까지의 한국장 영업일 수를 센다.""" - start = datetime.strptime(start_date, "%Y-%m-%d").date() - end = datetime.strptime(end_date, "%Y-%m-%d").date() - if end <= start: - return 0 +def _as_calendar_date(value: str | datetime | Date) -> Date: + if isinstance(value, datetime): + return value.date() + if isinstance(value, Date): + return value + return datetime.strptime(str(value), "%Y-%m-%d").date() + + +def _krx_trading_day_checker(): + """KRX 거래일 판정 함수(core.trading_hours.TradingHours.is_trading_day)를 돌려준다. + + 거래일 달력을 읽지 못하면 주말만 빼는 판정으로 대체하되 경고를 남긴다 — + 조용히 평일 수로 떨어지면 연휴 뒤 staleness가 부풀어도 원인이 보이지 않는다. + """ try: - from core.trading_hours import _load_holidays + from core.trading_hours import TradingHours - holidays = _load_holidays() - except Exception: - holidays = set() + return TradingHours().is_trading_day + except Exception as exc: + logger.warning("KRX 거래일 달력 로드 실패 — 주말만 제외하고 셉니다(휴장일 미반영): {}", exc) + return lambda day: day.weekday() < 5 + + +def _business_days_between(start_date: str | datetime | Date, end_date: str | datetime | Date) -> int: + """start 다음 날부터 end까지의 KRX 거래일 수를 센다. 같은 날이면 0. + + paper_runtime의 evidence staleness 판정도 이 함수를 쓴다 — 두 판정이 서로 다른 + 달력(평일 수 vs 거래일 수)으로 갈라지지 않게 한 곳에 둔다. + """ + start = _as_calendar_date(start_date) + end = _as_calendar_date(end_date) + if end <= start: + return 0 + is_trading_day = _krx_trading_day_checker() count = 0 day = start + timedelta(days=1) while day <= end: - day_text = day.strftime("%Y-%m-%d") - if day.weekday() < 5 and day_text not in holidays: + if is_trading_day(datetime(day.year, day.month, day.day)): count += 1 day += timedelta(days=1) return count diff --git a/core/paper_runtime.py b/core/paper_runtime.py index 452701d9..41b2616e 100644 --- a/core/paper_runtime.py +++ b/core/paper_runtime.py @@ -35,6 +35,8 @@ # ─── 상수 ─────────────────────────────────────────────────── RUNTIME_DIR = Path("reports/paper_runtime") +# 레거시 전략 등록 파일 — 테스트가 임시 경로로 돌릴 수 있게 모듈 상수로 둔다 +APPROVED_STRATEGIES_PATH = Path("reports/approved_strategies.json") CLEAN_DAYS_FOR_UNFREEZE = 3 # 자동 unfreeze에 필요한 연속 clean final days CURRENT_SCHEMA_VERSION = 2 # 현재 DailyEvidence schema 버전 PAPER_RUNTIME_MAX_EVIDENCE_STALE_TRADING_DAYS = 1 @@ -156,18 +158,19 @@ def _coerce_date(value: str | datetime | None) -> datetime: def _trading_days_between(start_date: str | datetime, end_date: str | datetime | None = None) -> int: - """start 다음 날부터 end까지의 평일 수. 같은 날이면 0.""" + """start 다음 날부터 end까지의 KRX 거래일 수. 같은 날이면 0. + + 평일만 세면 추석·설 연휴 뒤 첫 거래일에 증거가 '며칠 밀렸다'고 오판해 신규 진입이 + 막히고(blocked_insufficient_evidence) critical 알림이 나간다. 휴장일은 + core.trading_hours의 거래일 달력을 쓰며, paper_pilot의 staleness 판정과 같은 함수다. + """ start = _coerce_date(start_date).date() end = _coerce_date(end_date).date() if end <= start: return 0 - days = 0 - current = start + timedelta(days=1) - while current <= end: - if current.weekday() < 5: - days += 1 - current += timedelta(days=1) - return days + from core.paper_pilot import _business_days_between + + return _business_days_between(start, end) # ═══════════════════════════════════════════════════════════════ @@ -655,7 +658,7 @@ def generate_rebuild_report(strategy: str, history: list[dict]) -> Path: def _is_strategy_registered(strategy: str) -> bool: """legacy approved_strategies.json 또는 config에서 전략 등록 여부 확인. 없으면 True (paper 모드에서는 기본 허용 — research_disabled는 명시적 비활성만).""" - approved_path = Path("reports/approved_strategies.json") + approved_path = APPROVED_STRATEGIES_PATH if not approved_path.exists(): return True diff --git a/core/performance_lens.py b/core/performance_lens.py index f7029443..af60d782 100644 --- a/core/performance_lens.py +++ b/core/performance_lens.py @@ -165,8 +165,14 @@ def split_by_regime( mine_pct 그 국면 구간들만 이어 붙인 복리 수익률 bench_pct 같은 구간 벤치마크 복리 수익률 gap_pct mine - bench - capture 벤치 대비 포착률(bench_pct가 0이 아닐 때). 상승 1.0=완전 추종, - 하락 1.0=완전 노출(낮을수록 방어). 방향 해석은 호출부가 한다. + mine_daily_pct / bench_daily_pct 그 국면 하루 평균(기하평균) 수익률 + capture 벤치 대비 포착률 = 하루 평균(기하평균) 수익률의 비. 상승 1.0=완전 + 추종, 하락 1.0=완전 노출(낮을수록 방어). 방향 해석은 호출부가 한다. + + 포착률을 국면 전체 복리 수익률의 비로 재면 기간이 길어질수록 값이 한쪽으로 쏠린다 — + 상승일만 몇 년치 이어 붙이면 지수가 수천 %가 돼서 60% 주식 계좌도 상승 포착 1%, + 하락 포착 96%처럼 나온다(2026-09-23 연구 재계산에서 드러남). 하루 평균의 비는 + 기간 길이와 무관하다(업계 표준 up/down capture 정의). """ up_mine: list[float] = [] up_bench: list[float] = [] @@ -207,12 +213,17 @@ def _agg(mine: list[float], bench: list[float]) -> dict[str, Any]: "gap_pct": None, "capture": None, } ms, bs = _compound(mine), _compound(bench) + n = len(bench) + md = ((1 + ms / 100.0) ** (1.0 / n) - 1) * 100.0 if ms > -100 else -100.0 + bd = ((1 + bs / 100.0) ** (1.0 / n) - 1) * 100.0 if bs > -100 else -100.0 return { - "days": len(bench), + "days": n, "mine_pct": ms, "bench_pct": bs, "gap_pct": ms - bs, - "capture": (ms / bs) if bs != 0 else None, + "mine_daily_pct": md, + "bench_daily_pct": bd, + "capture": (md / bd) if bd != 0 else None, } return {"up": _agg(up_mine, up_bench), "down": _agg(down_mine, down_bench)} @@ -223,9 +234,9 @@ def format_regime_line(regime: dict[str, dict[str, Any]]) -> str: parts: list[str] = [] # 포착률을 앞세운다 — 이게 행동을 바꾸는 숫자다. 상승 포착이 낮으면 '방어의 대가'가 # 크다는 뜻이고, 하락 포착이 높으면 방어가 실제로 작동하지 않는다는 뜻이다. - for key, label, verb in ( - ("up", "상승", "따라감"), - ("down", "하락", "맞음"), + for key, label, verb, move in ( + ("up", "상승", "따라감", "오를"), + ("down", "하락", "맞음", "내릴"), ): r = regime.get(key) or {} if not r.get("days"): @@ -234,10 +245,17 @@ def format_regime_line(regime: dict[str, dict[str, Any]]) -> str: if cap is None: parts.append(f"{label} {r['days']}일: 지수 무변동") continue - parts.append( - f"{label} {r['days']}일 포착 {cap * 100:.0f}% " - f"(지수 {r['bench_pct']:+.1f}% 중 {r['mine_pct']:+.1f}%만 {verb})" - ) + md, bd = r.get("mine_daily_pct"), r.get("bench_daily_pct") + if md is not None and bd is not None: + parts.append( + f"{label} {r['days']}일 포착 {cap * 100:.0f}% " + f"(지수가 하루 평균 {bd:+.2f}% {move} 때 {md:+.2f}%)" + ) + else: + parts.append( + f"{label} {r['days']}일 포착 {cap * 100:.0f}% " + f"(지수 {r['bench_pct']:+.1f}% 중 {r['mine_pct']:+.1f}%만 {verb})" + ) return " · ".join(parts) if parts else "국면 분해 불가(표본 부족)" @@ -248,7 +266,9 @@ def format_risk_line(metrics: dict[str, Any]) -> str: return f"표본 {n}일 — 변동성 산출 불가" parts = [f"연변동성 {metrics['vol_annual_pct']:.1f}%"] if metrics.get("sharpe_annual") is not None: - parts.append(f"샤프 {metrics['sharpe_annual']:+.2f}") + # 무위험수익률 0% 기준이다. 백테스트 리포트의 샤프는 금리 3%를 빼고 계산하므로 + # 같은 수익 흐름이면 이 값보다 0.1~0.3 낮게 나온다 — 기준을 적어 둬야 비교 착오가 없다. + parts.append(f"샤프(금리 0% 기준) {metrics['sharpe_annual']:+.2f}") if metrics.get("down_day_ratio") is not None: parts.append(f"하락일 {metrics['down_day_ratio'] * 100:.0f}%") if metrics.get("worst_day_pct") is not None: diff --git a/core/portfolio_manager.py b/core/portfolio_manager.py index 0350db2d..782c69fe 100644 --- a/core/portfolio_manager.py +++ b/core/portfolio_manager.py @@ -205,6 +205,11 @@ def _build_position_state( "invested": invested, "current_value": current_value, "position_count": len(positions), + # 보유 종목이 전부 현재가로 평가됐는가(없으면 평균단가로 대체됨) + "market_priced": all( + str(getattr(p, "symbol", "") or "").strip() in current_prices + for p in positions + ), } def _get_db_financials(self, invested: float, current_value: float, mode: str) -> dict: @@ -324,13 +329,22 @@ def get_portfolio_summary( mode=self._ledger_mode, ) + # 피크는 시가로 평가된 값으로만 올린다. 가격 없이(평균단가로) 부른 요약이 피크를 + # 올리면, 손실 구간의 원가 총액이 피크로 굳어 다음 스냅샷이 그 값을 저장하고 + # 이후 MDD가 가짜로 커진다(주문 가드·자본 조회가 가격 없이 이 함수를 부른다). + market_priced = bool(state.get("market_priced", True)) or broker_balance_ok is True + if not account_has_flows: # 무입금 계정: 기존 산식 그대로 (하위 호환 — 결과 불변) total_return = ((total_value / self.initial_capital) - 1) * 100 if self.initial_capital > 0 else 0 - if total_value > self._peak_value: + if market_priced and total_value > self._peak_value: self._peak_value = total_value - mdd = ((self._peak_value - total_value) / self._peak_value) * 100 if self._peak_value > 0 else 0 + # 가격 없이 잰 총액이 피크보다 크면 피크는 그대로 두되, 낙폭은 그 총액 기준으로 + # 잰다. 옛 피크로 재면 음수 MDD가 나오고 주문 가드가 abs()로 받아 가짜 한도 도달로 + # 매수를 막는다(피크 10.0M, 원가 총액 10.6M → -6%). + peak_for_mdd = max(self._peak_value, total_value) + mdd = ((peak_for_mdd - total_value) / peak_for_mdd) * 100 if peak_for_mdd > 0 else 0 else: # 적립식 계정: 시간가중수익률(TWR) — 입금은 수익이 아니다. # 직전 스냅샷과 이번 측정 사이 유입(flow)을 분모에 더해 중화하고, @@ -375,7 +389,7 @@ def get_portfolio_summary( peak_index = max(1.0, index_now, 1 + hist_max / 100) mdd = ((peak_index - index_now) / peak_index) * 100 if peak_index > 0 else 0 # 원화 피크(peak_value 컬럼)는 스냅샷 연속성 위해 기존대로 계속 기록 - if total_value > self._peak_value: + if market_priced and total_value > self._peak_value: self._peak_value = total_value calculated = { diff --git a/core/promotion_engine.py b/core/promotion_engine.py index 2daa9cf7..fc5104f1 100644 --- a/core/promotion_engine.py +++ b/core/promotion_engine.py @@ -102,8 +102,26 @@ class PromotionResult: # ── 승격 규칙 테이블 ── +def _invalid_metrics(m: StrategyMetrics, fields: tuple[str, ...]) -> list[str]: + """값이 없거나 NaN/Inf인 지표 이름. NaN은 모든 비교에서 False라, 그대로 두면 + '수익 ≤ 0'·'MDD < -20%' 같은 탈락 조건을 전부 통과해 승격으로 새어 나간다.""" + bad = [] + for name in fields: + value = getattr(m, name, None) + try: + ok = value is not None and math.isfinite(float(value)) + except (TypeError, ValueError): + ok = False + if not ok: + bad.append(name) + return bad + + def _check_paper_only(m: StrategyMetrics) -> tuple[bool, str]: """paper_only 조건: 절대수익>0, PF≥1.0, WF positive≥50%.""" + bad = _invalid_metrics(m, ("total_return", "profit_factor", "wf_positive_rate")) + if bad: + return False, "paper_only 미달: 지표 값이 유효하지 않음(" + ", ".join(bad) + ")" fails = [] if m.total_return <= 0: fails.append(f"return {m.total_return}% ≤ 0") @@ -121,6 +139,9 @@ def _check_provisional_candidate(m: StrategyMetrics) -> tuple[bool, str]: ok, reason = _check_paper_only(m) if not ok: return False, reason + bad = _invalid_metrics(m, ("sharpe", "mdd", "wf_sharpe_positive_rate")) + if bad: + return False, "provisional 미달: 지표 값이 유효하지 않음(" + ", ".join(bad) + ")" fails = [] if m.sharpe < MIN_PROVISIONAL_SHARPE: diff --git a/core/risk_overlays.py b/core/risk_overlays.py index d0b02ad8..d56e1ef1 100644 --- a/core/risk_overlays.py +++ b/core/risk_overlays.py @@ -72,6 +72,9 @@ class OverlayDecision: realized_vol: float | None = None evaluated_at: str = "" source_dates: dict[str, str | None] = field(default_factory=dict) + # 판단에 쓸 자료가 없어 직전보다 비중을 늘리지 못하게 막았는지. data_issues에는 + # '지수 대신 069500 종가를 썼다' 같은 참고 사항도 들어가므로 둘을 구분해 둔다. + held_back: bool = False def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -156,11 +159,18 @@ def trend_below_ma( return bool(prev_below), rel -def drawdown_from_cumulative_returns(cumulative_returns_pct: Sequence[float]) -> float | None: - """시간가중 누적수익률(%) 시계열 → 현재 고점 대비 낙폭(음수 비율). 고점은 시작 자본(지수 1.0)부터.""" +def drawdown_from_cumulative_returns( + cumulative_returns_pct: Sequence[float], + peak_eligible: Sequence[bool] | None = None, +) -> float | None: + """시간가중 누적수익률(%) 시계열 → 현재 고점 대비 낙폭(음수 비율). 고점은 시작 자본(지수 1.0)부터. + + peak_eligible이 주어지면 False인 날(나중에 채운 추정 기록)은 고점 후보에서 뺀다. + 추정치가 고점이 되면 그 뒤 낙폭이 계속 부풀어 규칙이 일찍 발동한다. + """ peak = 1.0 last = None - for cr in cumulative_returns_pct: + for i, cr in enumerate(cumulative_returns_pct): if cr is None: return None try: @@ -169,11 +179,12 @@ def drawdown_from_cumulative_returns(cumulative_returns_pct: Sequence[float]) -> return None if not math.isfinite(idx) or idx <= 0: return None - peak = max(peak, idx) + if peak_eligible is None or i >= len(peak_eligible) or peak_eligible[i]: + peak = max(peak, idx) last = idx if last is None: return None - return last / peak - 1.0 + return min(0.0, last / peak - 1.0) def drawdown_guard_active(drawdown: float | None, cfg: DrawdownGuardConfig, prev_active: bool | None) -> bool | None: @@ -229,8 +240,12 @@ def compute_decision( daily_returns: Sequence[float] | None = None, prev_state: dict[str, Any] | None = None, now: datetime | None = None, + peak_eligible: Sequence[bool] | None = None, ) -> OverlayDecision: - """설정한 결합 방식에 따라 하나의 위험 배수를 계산한다.""" + """설정한 결합 방식에 따라 하나의 위험 배수를 계산한다. + + peak_eligible: cumulative_returns_pct와 같은 길이. False인 날은 낙폭 고점에서 뺀다. + """ prev = prev_state or {} decision = OverlayDecision(evaluated_at=(now or datetime.now()).isoformat(timespec="seconds")) scale = 1.0 @@ -240,8 +255,14 @@ def compute_decision( below, rel = trend_below_ma(index_closes or [], cfg.trend, prev.get("trend_below")) decision.trend_rel_to_ma = None if rel is None else round(rel, 4) if rel is None: + # 원인을 구분해 적는다. 자료가 오래돼 입력이 비었는데 '200일치 부족'이라고 + # 쓰면 운영자가 엉뚱한 곳(기간 설정)을 보게 된다(2026-09-17 이후 실제 사례). + cause = ( + f"종가 {cfg.trend.ma_days}일치 부족" if index_closes + else "종가를 쓸 수 없음(조회 실패 또는 오래된 자료)" + ) decision.data_issues.append( - f"추세 필터: {cfg.trend.index_symbol} 종가 {cfg.trend.ma_days}일치 부족 — 직전 상태 유지" + f"추세 필터: {cfg.trend.index_symbol} {cause} — 직전 상태 유지" ) decision.trend_below = below if below: @@ -254,7 +275,7 @@ def compute_decision( ) if cfg.drawdown.enabled: - dd = drawdown_from_cumulative_returns(cumulative_returns_pct or []) + dd = drawdown_from_cumulative_returns(cumulative_returns_pct or [], peak_eligible) active = drawdown_guard_active(dd, cfg.drawdown, prev.get("drawdown_active")) decision.drawdown = None if dd is None else round(dd, 4) if dd is None: @@ -294,6 +315,7 @@ def compute_decision( + ([cfg.volatility.min_scale] if cfg.volatility.enabled else []) )) scale = min(scale, previous_scale) + decision.held_back = True decision.scale = round(min(1.0, max(0.0, scale)), 4) return decision @@ -306,8 +328,12 @@ def describe_decision(decision: OverlayDecision | dict[str, Any] | None, cfg: Ov scale = float(d.get("scale", 1.0)) parts = list(d.get("reasons") or []) issues = list(d.get("data_issues") or []) + # 예전 상태 파일에는 held_back이 없다 — 그때는 자료 문제가 있으면 보류로 본다. + held_back = d.get("held_back") + if held_back is None: + held_back = bool(issues) if not parts: - text = "자료 확인 전 비중 확대 보류" if issues else "기본 투자 비중 유지" + text = "자료 확인 전 비중 확대 보류" if held_back else "기본 투자 비중 유지" else: text = " · ".join(parts) if issues: @@ -411,6 +437,30 @@ def save_overlay_state( return path +def invested_fraction( + weights: dict[str, float], + design_fraction: float, + state: dict[str, Any] | None, + defensive_symbol: str | None = None, +) -> float: + """리밸런서가 실제로 맞추는 투자 비중(방어 자산 포함) — BasketRebalancer._stock_fraction과 같은 규칙. + + 방어 자산(CD ETF)이 있으면 오버레이가 주식을 줄인 만큼 방어 자산을 사므로 투자 + 비중은 설계 그대로다. 헬스가 applied_stock_fraction(설계 × 배수)으로 감시하면 + 오버레이가 켜진 날 기준이 절반으로 내려가, 방어 자산 매수가 실패해 현금이 쌓여도 + '정상'으로 읽힌다. 방어 자산이 없으면 두 값은 같다. + """ + scale = 1.0 + if state: + try: + scale = min(1.0, max(0.0, float(state.get("scale", 1.0)))) + except (TypeError, ValueError): + scale = 1.0 + if not weights: + return float(design_fraction) * scale + return sum(overlay_target_weights(weights, float(design_fraction), scale, defensive_symbol).values()) + + def applied_stock_fraction(design_fraction: float, state: dict[str, Any] | None) -> float: """설계 주식 비중 × 마지막 오버레이 배수. 상태가 없으면 설계 그대로.""" if not state: diff --git a/core/runtime_lock.py b/core/runtime_lock.py index a89fb528..4ab7630b 100644 --- a/core/runtime_lock.py +++ b/core/runtime_lock.py @@ -182,7 +182,14 @@ def scheduler_lock(lock_path: str | Path) -> Generator[bool, None, None]: @contextmanager def live_runtime_lock(project_root: str | Path) -> Generator[bool, None, None]: - """Global lock shared by every non-emergency live order runtime.""" - lock_path = Path(project_root) / "data" / LIVE_RUNTIME_LOCK_FILENAME + """Global lock shared by every non-emergency live order runtime. + + QUANT_RUNTIME_LOCK_DIR가 있으면 그 디렉터리에 둔다 — 테스트가 운영 락 파일을 + 잡으면 같은 시각의 실제 live 실행이 '다른 런타임 실행 중'으로 중단된다. + """ + override = os.environ.get("QUANT_RUNTIME_LOCK_DIR") + lock_path = ( + Path(override) if override else Path(project_root) / "data" + ) / LIVE_RUNTIME_LOCK_FILENAME with process_runtime_lock(lock_path, label="실전 주문 런타임") as acquired: yield acquired diff --git a/core/scheduler.py b/core/scheduler.py index 0a629d23..7f88a31c 100644 --- a/core/scheduler.py +++ b/core/scheduler.py @@ -11,7 +11,8 @@ import time as time_mod import shutil from collections import deque -from datetime import datetime, timedelta +from datetime import datetime, time as dtime, timedelta +from pathlib import Path from loguru import logger from config.config_loader import Config @@ -21,7 +22,6 @@ from core.portfolio_manager import PortfolioManager from core.notifier import Notifier from core.strategy_diagnostics import diagnose_live_post_market -from core.live_readiness import check_live_readiness_gate try: from monitoring.paper_monitor import log_event as _log_op @@ -36,6 +36,28 @@ def _log_op(*a, **kw): pass ) from core.position_lock import PositionLock +# 직전 거래일 탐색 상한(달력 오류로 무한 루프가 되지 않게). KRX 최장 연휴보다 넉넉히. +_TRADING_DAY_LOOKBACK_DAYS = 31 +# 장마감 처리 시작 시각(장 종료 15:30 + 체결·종가 확정 여유 5분) +_POST_MARKET_START = dtime(15, 35) +# 휴장일 파일은 실행 위치(CWD)와 무관하게 프로젝트 루트 기준으로 찾는다. +_HOLIDAYS_PATH = Path(__file__).resolve().parent.parent / "config" / "holidays.yaml" + + +def _pykrx_holiday_source_available() -> tuple[bool, str]: + """holidays_updater가 쓰는 pykrx 거래일 API를 쓸 수 있는지(네트워크 호출 없음). + + 설치돼 있어도 버전에 따라 API가 없을 수 있다(현재 환경: pykrx 1.0.51에 + get_market_trading_date_by_date 없음) — 그때 갱신기는 대체 목록으로 떨어진다. + """ + try: + from pykrx import stock + except Exception as exc: # ImportError 외에 pykrx 내부 초기화 오류도 같은 의미 + return False, f"pykrx import 실패: {type(exc).__name__}: {exc}" + if not hasattr(stock, "get_market_trading_date_by_date"): + return False, "pykrx.stock.get_market_trading_date_by_date 없음(설치된 pykrx 버전 불일치)" + return True, "" + class LoopMetrics: """10분 루프 모니터링 지표 수집기.""" @@ -379,16 +401,7 @@ def run(self): # 장전 헬스체크 (10분 주기) self._maybe_run_healthcheck() - if self.trading_hours.is_pre_market() and not self._pre_market_done: - self._run_pre_market() - self._pre_market_done = True - elif self.trading_hours.is_market_open(): - if self._should_monitor(): - self._run_monitoring() - self._last_monitor_time = now - elif now.hour == 15 and now.minute >= 35 and not self._post_market_done: - self._run_post_market() - self._post_market_done = True + self._run_trading_day_phase(now) except Exception as loop_exc: logger.exception( @@ -408,32 +421,32 @@ def run(self): logger.info("⏹️ 스케줄러 종료 (Ctrl+C)") self.discord.send_message("⏹️ 퀀트 트레이더 스케줄러 종료") + def _run_trading_day_phase(self, now: datetime) -> None: + """거래일 루프 1회분: 장전(1회) → 장중 모니터링 → 장마감(15:35 이후 1회).""" + if self.trading_hours.is_pre_market(now) and not self._pre_market_done: + self._run_pre_market() + self._pre_market_done = True + elif self.trading_hours.is_market_open(now): + if self._should_monitor(): + self._run_monitoring() + self._last_monitor_time = now + elif now.time() >= _POST_MARKET_START and not self._post_market_done: + # 15:35 '이후'면 시각과 무관하게 그날 한 번 실행한다. 예전엔 hour==15만 봐서 + # 16시 이후 시작·재시작하거나 루프가 15:59를 넘겨 멈추면 그날 스냅샷· + # 리포트·evidence·DB 백업이 조용히 사라졌다. 저녁에 켜도 그날 장마감을 + # 따라잡는다. _post_market_done은 날짜가 바뀔 때만 초기화된다. + self._run_post_market() + self._post_market_done = True + def _run_pre_market(self): """장전 준비: 데이터 수집 + 전략 분석.""" logger.info("=" * 50) logger.info("📋 장전 준비 시작 ({})", datetime.now().strftime("%H:%M:%S")) logger.info("=" * 50) - # 전일 provisional evidence finalize (장전에 benchmark 종가 확정) + # 직전 거래일 provisional evidence finalize (장전에 benchmark 종가 확정) if self._is_paper_like_mode(): - try: - from core.paper_evidence import finalize_daily_evidence - yesterday = datetime.now() - timedelta(days=1) - # 주말이면 금요일로 - while yesterday.weekday() >= 5: - yesterday -= timedelta(days=1) - watchlist = WatchlistManager(self.config).resolve() - result = finalize_daily_evidence( - strategy=self.strategy_name, - mode=self._mode, - account_key=self.strategy_name, - date=yesterday, - watchlist_symbols=watchlist, - ) - if result: - logger.info("전일 evidence finalized: {} bench={}", yesterday.strftime("%Y-%m-%d"), result.benchmark_status) - except Exception as fin_err: - logger.warning("전일 evidence finalize 실패: {}", fin_err) + self._finalize_evidence_for(self._previous_trading_day()) try: from core.data_collector import DataCollector @@ -527,11 +540,118 @@ def _run_pre_market(self): for w in source_warnings: logger.warning(w) - self._run_basket_rebalance_check() + self._log_basket_execution_owner() except Exception as e: logger.error("장전 준비 실패: {}", e) + # ============================================================= + # KRX 거래일 기준 evidence 기록 + # ============================================================= + + def _previous_trading_day(self, now: datetime | None = None) -> datetime | None: + """now 직전의 KRX 거래일(주말·휴장일 제외). 달력 이상으로 못 찾으면 None. + + 주말만 건너뛰면 추석·설·대체공휴일 같은 평일 휴장일이 '전일'로 잡혀 그 날짜의 + 가짜 real_paper evidence가 만들어지고, 실제 직전 거래일은 영영 finalize되지 않는다. + 거래일 판정은 TradingHours(config/holidays.yaml)를 따른다 — 달력이 틀리면 이 + 판정도 틀리므로 달력 수정은 tools/verify_trading_calendar.py로 검증한다. + """ + day = (now or datetime.now()) - timedelta(days=1) + for _ in range(_TRADING_DAY_LOOKBACK_DAYS): + if self.trading_hours.is_trading_day(day): + return day + day -= timedelta(days=1) + logger.warning( + "최근 {}일 안에 KRX 거래일이 없습니다 — 거래일 달력(config/holidays.yaml) 확인 필요", + _TRADING_DAY_LOOKBACK_DAYS, + ) + return None + + def _is_evidence_trading_day(self, date: datetime | None, step: str) -> bool: + """evidence 기록 대상 날짜가 KRX 거래일인지 확인하고, 아니면 경고 후 False.""" + if date is None: + logger.warning("{}: 대상 거래일을 정하지 못해 evidence를 기록하지 않습니다", step) + return False + if self.trading_hours.is_trading_day(date): + return True + logger.warning( + "{}: {}은 KRX 거래일이 아니어서 evidence를 기록하지 않습니다 " + "(휴장일 기록은 execution_backed 가짜 거래일로 일수·샤프·승률을 왜곡한다)", + step, date.strftime("%Y-%m-%d"), + ) + return False + + def _finalize_evidence_for(self, date: datetime | None): + """date(직전 거래일)의 provisional evidence를 final로 확정. 비거래일은 거부(None).""" + if not self._is_evidence_trading_day(date, "전일 evidence finalize"): + return None + try: + from core.paper_evidence import finalize_daily_evidence + + watchlist = WatchlistManager(self.config).resolve() + result = finalize_daily_evidence( + strategy=self.strategy_name, + mode=self._mode, + account_key=self.strategy_name, + date=date, + watchlist_symbols=watchlist, + ) + if result: + logger.info( + "전일 evidence finalized: {} bench={}", + date.strftime("%Y-%m-%d"), result.benchmark_status, + ) + return result + except Exception as fin_err: + logger.warning("전일 evidence finalize 실패: {}", fin_err) + return None + + def _collect_post_market_evidence(self, date: datetime): + """장마감 evidence 수집(DailyEvidence JSONL + anomaly). 비거래일은 거부(None). + + pilot session이면 pilot provenance가 자동 전달된다. + """ + if not self._is_evidence_trading_day(date, "장마감 evidence 수집"): + return None + try: + from core.paper_evidence import collect_daily_evidence, generate_weekly_summary + + watchlist = WatchlistManager(self.config).resolve() + ps = self._pilot_session + result = collect_daily_evidence( + strategy=self.strategy_name, + mode=self._mode, + account_key=self.strategy_name, + date=date, + watchlist_symbols=watchlist, + evidence_mode=ps.get("evidence_mode", "real_paper"), + pilot_authorized=ps.get("pilot_authorized", False), + pilot_caps_snapshot=ps.get("pilot_caps_snapshot"), + ) + logger.info( + "Paper evidence 기록 완료 (전략: {}, session_mode: {})", + self.strategy_name, ps.get("session_mode", "normal_paper"), + ) + # pilot session artifact 저장 + if ps.get("active"): + try: + from core.paper_pilot import save_pilot_session_artifact + save_pilot_session_artifact( + strategy=self.strategy_name, + date=date.strftime("%Y-%m-%d"), + pilot_session=ps, + ) + except Exception as artifact_err: + logger.warning("pilot session artifact 저장 실패: {}", artifact_err) + # 금요일이면 evidence 기반 주간 요약도 생성 + if date.weekday() == 4: + generate_weekly_summary(self.strategy_name) + return result + except Exception as ev_err: + logger.warning("Paper evidence 기록 실패: {}", ev_err) + return None + def _get_kis_max_calls_per_sec(self) -> float: """KIS 설정 초당 호출 한도 (예상 소요 계산용). 실패 시 10.""" try: @@ -810,13 +930,20 @@ def _run_post_cooldown_rescan(self): avg_vol = float(df["volume"].rolling(20, min_periods=1).mean().iloc[-1]) signal_info["symbol"] = symbol self._maybe_record_dashboard_signal(signal_info, "post_cooldown_rescan") + # timestamp가 없으면 30분 경과 후보 폐기가 적용되지 않고(기본값 + # now), _signal_at이 없으면 신호→주문 지연이 기록되지 않는다. + # 국면 스케일은 다른 후보들처럼 신호 시점 값(장전·2시간 재확인)을 쓴다. + signal_time = datetime.now() new_candidates.append({ "symbol": symbol, "price": signal_info.get("close", 0), "atr": signal_info.get("atr"), "score": signal_info.get("score", 0), + "_signal_at": signal_time, "reason": "post-cooldown rescan", "avg_daily_volume": avg_vol, + "market_regime_scale": self._market_regime_scale, + "timestamp": signal_time, }) except Exception as e: logger.debug("쿨다운 후 재스캔 {} 실패: {}", symbol, e) @@ -1336,7 +1463,7 @@ def _run_post_market(self): self.discord.send_daily_report({ "total_value": summary["total_value"], "cash": summary["cash"], - "daily_return": 0, + "daily_return": self._report_daily_return(), "cumulative_return": summary["total_return"], "mdd": summary["mdd"], "position_count": summary["position_count"], @@ -1371,41 +1498,8 @@ def _run_post_market(self): logger.warning("주간 리포트 생성 실패: {}", wr_err) # Paper evidence 수집 (DailyEvidence JSONL 누적 + anomaly 기록) - # pilot session이면 자동으로 pilot provenance가 전달됨 - try: - from core.paper_evidence import collect_daily_evidence, generate_weekly_summary - watchlist = WatchlistManager(self.config).resolve() - ps = self._pilot_session - collect_daily_evidence( - strategy=self.strategy_name, - mode=self._mode, - account_key=self.strategy_name, - date=datetime.now(), - watchlist_symbols=watchlist, - evidence_mode=ps.get("evidence_mode", "real_paper"), - pilot_authorized=ps.get("pilot_authorized", False), - pilot_caps_snapshot=ps.get("pilot_caps_snapshot"), - ) - logger.info( - "Paper evidence 기록 완료 (전략: {}, session_mode: {})", - self.strategy_name, ps.get("session_mode", "normal_paper"), - ) - # pilot session artifact 저장 - if ps.get("active"): - try: - from core.paper_pilot import save_pilot_session_artifact - save_pilot_session_artifact( - strategy=self.strategy_name, - date=datetime.now().strftime("%Y-%m-%d"), - pilot_session=ps, - ) - except Exception: - pass - # 금요일이면 evidence 기반 주간 요약도 생성 - if datetime.now().weekday() == 4: - generate_weekly_summary(self.strategy_name) - except Exception as ev_err: - logger.warning("Paper evidence 기록 실패: {}", ev_err) + # pilot session이면 자동으로 pilot provenance가 전달됨. 비거래일은 거부. + self._collect_post_market_evidence(datetime.now()) # 당일 executor + pilot session 리셋 (다음 날 fresh) self._order_executor = None @@ -1432,6 +1526,48 @@ def _run_post_market(self): except Exception as e: logger.error("장마감 리포트 실패: {}", e) + def _report_daily_return(self) -> float | None: + """직전 스냅샷 대비 일간 수익률(%) — 일일 CLI 리포트(main.py)와 같은 TWR 산식. + + 예전 스케줄러 리포트는 일간 수익률을 0으로 하드코딩해 매일 '일간 0.00%'였다. + 입금·출금은 수익이 아니므로 직전 스냅샷의 실제 측정 시각(created_at) 이후 + 현금 흐름을 분모에 더해 중화한다(twr_period_return). 스냅샷이 2개 미만이거나 + 계산에 실패하면 None — 리포트에는 0.00%가 아니라 '—'로 나온다. 실패는 리포트 + 발송을 막지 않되 경고로 남긴다. + """ + try: + import pandas as pd + + from core.portfolio_manager import twr_period_return + from database.repositories import ( + get_cash_flow_total_between, + get_portfolio_snapshots, + ) + + mode = self._resolved_ledger_mode() + snaps = get_portfolio_snapshots(days=7, account_key=self.strategy_name, mode=mode) + if snaps is None or len(snaps) < 2: + return None + ordered = snaps.sort_values("date") + prev_total = float(ordered["total_value"].iloc[-2]) + last_total = float(ordered["total_value"].iloc[-1]) + if prev_total <= 0: + return None + # 유입 경계는 자정 귀속(date)이 아니라 실제 측정 시각(created_at) — 직전 + # 스냅샷 '이전'의 같은 날 입금을 이중으로 중화하지 않게. + boundary = ordered["created_at"].iloc[-2] if "created_at" in ordered.columns else None + if boundary is None or pd.isna(boundary): + boundary = ordered["date"].iloc[-2] + if hasattr(boundary, "to_pydatetime"): + boundary = boundary.to_pydatetime() + flow = get_cash_flow_total_between( + self.strategy_name, boundary, datetime.now(), mode=mode, + ) + return twr_period_return(prev_total, last_total, flow) * 100 + except Exception as exc: + logger.warning("일간 수익률 계산 실패 — 리포트에는 '—'로 표기: {}", exc) + return None + def _check_live_readiness(self): """paper 모드 장마감 시 실전 전환 준비 자동 평가.""" readiness_cfg = ( @@ -1531,11 +1667,15 @@ def _run_healthcheck(self) -> list[str]: issues = [] # 1) DB 연결 검사 + # SQLAlchemy 2.0은 문자열 SQL을 그대로 실행하지 않고(text() 필요), get_session()이 + # 돌려주는 Session에는 remove()가 없다(scoped_session 레지스트리의 메서드). + # 예전 코드는 그래서 정상 DB에서도 10분마다 'DB 연결 실패' critical 알림을 냈다. try: - from database.models import get_session - session = get_session() - session.execute("SELECT 1") - session.remove() + from sqlalchemy import text + from database.models import db_session + + with db_session() as session: + session.execute(text("SELECT 1")) except Exception as e: issues.append(f"DB 연결 실패: {e}") @@ -1551,13 +1691,7 @@ def _run_healthcheck(self) -> list[str]: # 3) KIS API 인증 상태 (live 모드) if self.config.trading.get("mode") == "live": - try: - from api.kis_api import KISApi - kis = KISApi() - if not getattr(kis, "_access_token", None): - issues.append("KIS API 토큰 없음 — 재인증 필요") - except Exception as e: - issues.append(f"KIS API 초기화 실패: {e}") + issues.extend(self._kis_token_health_issues()) # 4) 메모리 사용량 검사 try: @@ -1574,6 +1708,34 @@ def _run_healthcheck(self) -> list[str]: logger.debug("헬스체크 정상") return issues + def _kis_token_health_issues(self) -> list[str]: + """live KIS 인증 상태 — 프로세스 공유 토큰 캐시만 본다(새로 발급하지 않는다). + + 새 KISApi 인스턴스의 토큰은 발급 전이라 늘 비어 있어, 예전 검사는 매번 + '토큰 없음'을 보고했다. 그렇다고 10분마다 발급을 시도하면 그 자체가 1분 1회 + 발급 한도를 소모한다. 첫 요청 전이라 토큰이 없는 것은 정상(첫 요청 때 + 발급)이고, 직전 발급이 실패한 상태만 이상으로 본다. + """ + try: + from api.kis_api import KISApi + + status = KISApi().token_status() + except Exception as e: + return [f"KIS API 상태 확인 실패: {e}"] + + if status.get("valid"): + return [] + last_error = str(status.get("last_error") or "") + if last_error: + remaining = float(status.get("cooldown_remaining") or 0.0) + suffix = f" (재발급 억제 {remaining:.0f}초 남음)" if remaining > 0 else "" + return [f"KIS API 토큰 발급 실패 상태 — {last_error}{suffix}"] + logger.debug( + "KIS 토큰 {} — 다음 요청 때 발급/갱신 예정", + "만료" if status.get("has_token") else "아직 발급 전", + ) + return [] + # ============================================================= # 휴장일 자동 갱신 # ============================================================= @@ -1582,9 +1744,13 @@ def _maybe_update_holidays(self): """ 새해 또는 holidays.yaml이 오래된 경우 자동 갱신. 연초(1/1~1/7) 또는 파일 수정일이 90일 이상 지난 경우 트리거. + + 경로는 실행 위치(CWD)가 아니라 프로젝트 루트 기준이다 — 다른 폴더에서 띄우면 + 파일이 '없다'고 보고 매일 갱신을 시도했다. pykrx 휴장일 조회를 쓸 수 없으면 + 갱신하지 않는다: 그때 갱신기는 대체 목록(FALLBACK)을 쓰는데, 검증된 달력을 + 틀린 날짜로 되돌릴 수 있다(2026-08-27 달력 교정분 유실 위험). """ - from pathlib import Path - holidays_path = Path("config/holidays.yaml") + holidays_path = _HOLIDAYS_PATH needs_update = False now = datetime.now() @@ -1605,104 +1771,58 @@ def _maybe_update_holidays(self): except Exception: needs_update = True - if needs_update: - try: - from core.holidays_updater import update_holidays_yaml - result_path = update_holidays_yaml() - logger.info("휴장일 자동 갱신 완료: {}", result_path) - self.trading_hours = TradingHours(self.config) - except Exception as e: - logger.warning("휴장일 자동 갱신 실패 (기존 파일 유지): {}", e) + if not needs_update: + return + + available, reason = _pykrx_holiday_source_available() + if not available: + logger.warning( + "휴장일 자동 갱신 생략 — pykrx 휴장일 조회 불가({}). 대체 목록으로 덮어쓰면 " + "검증된 달력이 틀린 날짜로 되돌아갈 수 있어 기존 {}을 유지합니다. " + "새 연도 달력은 KRX 휴장일 공지로 직접 갱신한 뒤 " + "tools/verify_trading_calendar.py로 검증하세요.", + reason, holidays_path, + ) + return - def _run_basket_rebalance_check(self): - """장전 단계에서 enabled 바스켓의 리밸런싱 필요 여부를 체크하고 실행.""" try: - from core.basket_rebalancer import ( - BasketRebalancer, - check_basket_account_isolation, - rebalance_live_strategy_id, + from core.holidays_updater import update_holidays_yaml + result_path = update_holidays_yaml(path=holidays_path) + logger.info( + "휴장일 자동 갱신 완료: {} — tools/verify_trading_calendar.py로 검증 권장", + result_path, ) + self.trading_hours = TradingHours(self.config) + except Exception as e: + logger.warning("휴장일 자동 갱신 실패 (기존 파일 유지): {}", e) + + def _log_basket_execution_owner(self): + """바스켓은 스케줄러가 거래하지 않는다 — 실행 경로가 일일 CLI 하나뿐임을 남긴다. + + 예전에는 장전 단계(08:50~09:00)에서 바스켓 리밸런싱을 실행했다. 그 시각엔 + live 주문이 거래 시간 가드에 전부 거부되고(가드는 의도된 안전장치라 완화하지 + 않는다), paper는 전일 종가로 체결됐다. CLI 사이클의 안전 단계(-25% 손절· + 재매수 쿨다운, 하루 1회 거래 가드, 스냅샷 보충, CYCLE_/SNAPSHOT_ 이벤트)도 + 빠져 있었고, 신호 전략용 live 게이트 통과만으로 모든 바스켓을 live 거래할 수 + 있었다. 스케줄러를 CLI와 함께 돌리면 10:07 CLI가 '오늘 이미 체결 있음'으로 + 건너뛰어, 문서화된 사이클이 조용히 거래를 멈췄다. + """ + try: + from core.basket_rebalancer import BasketRebalancer enabled = BasketRebalancer.get_enabled_baskets() - if not enabled: - return - - # 여러 바스켓이 같은 계좌(자본 풀)를 공유하면 각자 목표 비중을 독립 배분해 - # 과배분되고 트랙레코드가 섞인다 — fail-closed로 이번 사이클을 중단한다. - isolation_issues = check_basket_account_isolation(enabled, self.config, self._mode) - if isolation_issues: - msg = "바스켓 리밸런싱 중단 — 계좌 격리 실패: " + "; ".join(isolation_issues) - logger.error(msg) - self.discord.send_message(msg, critical=True) - return - - logger.info("🔄 바스켓 리밸런싱 체크: {}", enabled) - for name in enabled: - try: - is_live = self._mode == "live" - live_strategy_name = rebalance_live_strategy_id(name) - if is_live: - gate_issues = check_live_readiness_gate( - self.config, - live_strategy_name, - ) - if gate_issues: - msg = ( - f"바스켓 '{name}' live 리밸런싱 검증 실패: " - + "; ".join(gate_issues) - ) - logger.error(msg) - self.discord.send_message(msg, critical=True) - continue - - # 계정·귀속 키는 paper/live·CLI/스케줄러 공통(basket_rebalance:) - # — 트랙레코드가 바스켓별 한 곳에 쌓인다. - rebalancer = BasketRebalancer( - basket_name=name, config=self.config, - account_key=live_strategy_name, - execution_strategy=live_strategy_name, - ) - if is_live: - sync_result = rebalancer.portfolio_mgr.sync_with_broker() - if not sync_result.get("ok"): - msg = ( - f"바스켓 '{name}' live 리밸런싱 전 포지션 동기화 실패: " - f"{sync_result.get('message', 'sync failed')}" - ) - logger.error(msg) - self.discord.send_message(msg, critical=True) - continue - - should, reason = rebalancer.should_rebalance() - if should: - orders = rebalancer.plan_rebalance() - if orders: - result = rebalancer.execute( - orders, - live_confirmed=( - is_live and self._live_gate_validated - ), - ) - summary = ( - f"🔄 바스켓 '{name}' 리밸런싱 완료: " - f"실행 {result['executed']}건, 실패 {result['failed']}건" - ) - logger.info(summary) - self.discord.send_message(summary) - else: - logger.info("바스켓 '{}' 리밸런싱 불필요: {}", name, reason) - - # 트랙레코드: 스케줄러 단독 운영(상시 구동)에서도 바스켓 계정의 - # 일일 NAV 스냅샷이 쌓이도록 거래 여부와 무관하게 저장(멱등 upsert). - rebalancer.save_daily_nav_snapshot() - - except Exception as e: - logger.error("바스켓 '{}' 리밸런싱 오류: {}", name, e) - - except ImportError: - pass except Exception as e: - logger.error("바스켓 리밸런싱 체크 실패: {}", e) + logger.warning( + "enabled 바스켓 목록 확인 실패 — 스케줄러는 어차피 바스켓을 거래하지 않음: {}", e, + ) + return + if not enabled: + return + logger.warning( + "바스켓 {}은(는) 스케줄러가 거래하지 않습니다 — 바스켓 리밸런싱·NAV 스냅샷은 " + "일일 CLI `python main.py --mode rebalance`(평일 10:07 자동화)가 유일한 실행 경로입니다.", + enabled, + ) def _log_rate_limit_preflight(self, est_requests: int, n_symbols: int, phase: str): """API 요청 사전 예측: 예상 건수와 소요 시간을 로그하고, 분당 한도 초과 시 경고.""" @@ -1881,14 +2001,18 @@ def _rescan_for_new_entries(self): if "volume" in df.columns and not df["volume"].empty: avg_vol = float(df["volume"].rolling(20, min_periods=1).mean().iloc[-1]) + # timestamp/_signal_at: 30분 경과 후보 폐기와 신호→주문 지연 기록용. + signal_time = datetime.now() self._entry_candidates.append({ "symbol": symbol, "price": signal_info.get("close", 0), "atr": signal_info.get("atr"), "score": signal_info.get("score", 0), + "_signal_at": signal_time, "reason": "intraday rescan", "avg_daily_volume": avg_vol, "market_regime_scale": regime["position_scale"], + "timestamp": signal_time, }) logger.info("장중 재스캔: {} 매수 신호 감지 (score={})", symbol, signal_info.get("score", 0)) except Exception: diff --git a/core/signal_generator.py b/core/signal_generator.py index 3fd07ac5..6612bf40 100644 --- a/core/signal_generator.py +++ b/core/signal_generator.py @@ -453,6 +453,8 @@ def _score_macd(self, df: pd.DataFrame) -> pd.Series: - 데드크로스 당일: sell_weight (기본 -2) - MACD < Signal 유지 중: sell_weight × 0.5 (기본 -1) — 하락 추세 지속 - 히스토그램 방향 전환 보너스: ±0.5 (기존과 동일) + - 워밍업(MACD 또는 시그널선이 NaN) 구간은 0점. 크로스는 전일과 당일이 + 모두 유효한 봉일 때만 인정한다. """ weights = self._get_weights() buy_weight = weights["macd_golden_cross"] @@ -461,20 +463,24 @@ def _score_macd(self, df: pd.DataFrame) -> pd.Series: score = pd.Series(0.0, index=df.index) if "macd" in df.columns and "macd_signal" in df.columns: - macd_above = df["macd"] > df["macd_signal"] - macd_above_prev = macd_above.shift(1, fill_value=False) - golden_cross = macd_above & (~macd_above_prev) - dead_cross = (~macd_above) & macd_above_prev - - # 기본: MACD > Signal 유지 중 절반 점수 + # NaN 비교는 항상 False라서, 예전에는 워밍업 구간(시그널선 첫 유효값 전 약 33봉)이 + # 'Signal 아래 유지'(-1)로 채점되고 첫 유효 봉이 가짜 골든크로스(+2)로 잡혔다. + # 위/아래 판정과 크로스는 유효한 행에서만 한다. + valid = df["macd"].notna() & df["macd_signal"].notna() + macd_above = valid & (df["macd"] > df["macd_signal"]) + macd_below = valid & ~(df["macd"] > df["macd_signal"]) + golden_cross = macd_above & macd_below.shift(1, fill_value=False) + dead_cross = macd_below & macd_above.shift(1, fill_value=False) + + # 기본: MACD > Signal 유지 중 절반 점수 (무효 행은 0점 그대로) score[macd_above] = buy_weight * 0.5 - score[~macd_above] = sell_weight * 0.5 + score[macd_below] = sell_weight * 0.5 # 크로스 당일은 풀 점수로 덮어쓰기 score[golden_cross] = buy_weight score[dead_cross] = sell_weight - # 히스토그램 방향 보너스 (크로스 아닌 날만) + # 히스토그램 방향 보너스 (크로스 아닌 유효 행만) if "macd_histogram" in df.columns: hist_positive = df["macd_histogram"] > 0 hist_turning_up = ( @@ -484,9 +490,9 @@ def _score_macd(self, df: pd.DataFrame) -> pd.Series: df["macd_histogram"] < df["macd_histogram"].shift(1) ) & (~hist_positive) - is_cross_day = golden_cross | dead_cross - score = score.where(~(~is_cross_day & hist_turning_up), score + 0.5) - score = score.where(~(~is_cross_day & hist_turning_down), score - 0.5) + bonus_day = valid & ~(golden_cross | dead_cross) + score = score.where(~(bonus_day & hist_turning_up), score + 0.5) + score = score.where(~(bonus_day & hist_turning_down), score - 0.5) return score @@ -531,9 +537,11 @@ def _score_volume(self, df: pd.DataFrame) -> pd.Series: def _score_ma(self, df: pd.DataFrame) -> pd.Series: """ - 이동평균 점수 계산 - - 5일선이 20일선을 상향 돌파 (골든크로스) → +1점 - - 5일선이 20일선을 하향 돌파 (데드크로스) → -1점 + 이동평균 점수 계산 (SMA 기준) + - 단기선이 중기선을 상향 돌파 (골든크로스) → +1점 + - 단기선이 중기선을 하향 돌파 (데드크로스) → -1점 + - 기간은 indicators.moving_average.short_period/mid_period (기본 5일/20일) + - 두 선 중 하나라도 NaN인 워밍업 구간은 크로스로 보지 않는다. """ weights = self._get_weights() buy_weight = weights["ma_golden_cross"] @@ -541,23 +549,35 @@ def _score_ma(self, df: pd.DataFrame) -> pd.Series: score = pd.Series(0.0, index=df.index) - sma_short = None - sma_mid = None - - # SMA 컬럼 찾기 - for col in df.columns: - if col.startswith("sma_5") or col.startswith("ema_5"): - sma_short = col - if col.startswith("sma_20") or col.startswith("ema_20"): - sma_mid = col - - if sma_short and sma_mid and sma_short in df.columns and sma_mid in df.columns: - short_above = df[sma_short] > df[sma_mid] - short_above_prev = short_above.shift(1, fill_value=False) - golden_cross = short_above & (~short_above_prev) - dead_cross = (~short_above) & short_above_prev - - score[golden_cross] = buy_weight - score[dead_cross] = sell_weight + # 예전에는 'sma_5'/'ema_5', 'sma_20'/'ema_20' 접두어로 찾고 마지막 일치 컬럼을 썼다. + # 그래서 실제로는 EMA(5)/EMA(20) 크로스를 봤고('sma_200'도 'sma_20'에 걸림), + # 기간 설정을 바꾸면 아무 경고 없이 0점이 됐다. IndicatorEngine과 같은 설정으로 + # 정확한 SMA 컬럼 이름을 만든다. + ma_params = self.indicator_params.get("moving_average", {}) or {} + short_col = f"sma_{ma_params.get('short_period', 5)}" + mid_col = f"sma_{ma_params.get('mid_period', 20)}" + if short_col not in df.columns or mid_col not in df.columns: + # 지표가 아직 없는 프레임(30행 미만 등)은 조용히 0점. 다른 SMA는 있는데 + # 설정한 기간만 없으면 설정 불일치이므로 한 번 경고한다. + has_other_sma = any(str(c).startswith("sma_") for c in df.columns) + if has_other_sma and not getattr(self, "_ma_columns_warned", False): + logger.warning( + "[SignalGenerator] MA 점수 컬럼({}, {})이 없어 MA 점수를 0으로 둡니다. " + "indicators.moving_average 설정과 지표 계산 설정이 같은지 확인하세요.", + short_col, mid_col, + ) + self._ma_columns_warned = True + return score + + short_ma = df[short_col] + mid_ma = df[mid_col] + valid = short_ma.notna() & mid_ma.notna() + short_above = valid & (short_ma > mid_ma) + short_below = valid & ~(short_ma > mid_ma) + golden_cross = short_above & short_below.shift(1, fill_value=False) + dead_cross = short_below & short_above.shift(1, fill_value=False) + + score[golden_cross] = buy_weight + score[dead_cross] = sell_weight return score diff --git a/core/snapshot_backfill.py b/core/snapshot_backfill.py index ed37a475..f81b971f 100644 --- a/core/snapshot_backfill.py +++ b/core/snapshot_backfill.py @@ -130,6 +130,37 @@ def historical_mark(symbol: str, day: date, collector: Any = None) -> float | No return None +def first_activity_date(account_key: str, mode: str = "paper") -> date | None: + """이 계정의 첫 활동일(첫 체결·첫 입출금·첫 스냅샷 중 가장 이른 날). 없으면 None. + + 보충은 '돌았어야 했는데 빠진 날'만 채워야 한다. 계정이 생기기 전 날을 채우면 + 거래 기록이 없으니 초기자본 그대로 계산돼 수익률 0%의 가짜 운영일이 생기고, 평가의 + 운영 시작일(60영업일 시계)과 벤치마크 구간이 앞당겨진다. 트랙을 재시작한 직후 + 첫 10일이 정확히 이 경우다(2026-07-07 kr_pocket 재시작 이력). + """ + from sqlalchemy import func + + from database.models import CashFlow, PortfolioSnapshot, TradeHistory, get_session + + session = get_session() + try: + firsts = [ + session.query(func.min(TradeHistory.executed_at)).filter( + TradeHistory.mode == mode, TradeHistory.account_key == account_key, + ).scalar(), + session.query(func.min(CashFlow.occurred_at)).filter( + CashFlow.mode == mode, CashFlow.account_key == account_key, + ).scalar(), + session.query(func.min(PortfolioSnapshot.date)).filter( + PortfolioSnapshot.mode == mode, PortfolioSnapshot.account_key == account_key, + ).scalar(), + ] + finally: + session.close() + days = [_as_date(v) for v in firsts if v is not None] + return min(days) if days else None + + def find_missing_trading_days( config: Any, account_key: str, @@ -177,6 +208,8 @@ def reconstruct_snapshot( 반환: save_portfolio_snapshot에 넘길 수 있는 dict. """ + from sqlalchemy import func + from core.portfolio_manager import twr_period_return from database.models import CashFlow, PortfolioSnapshot, get_session @@ -214,6 +247,31 @@ def reconstruct_snapshot( prev_cum = float(prev.cumulative_return or 0.0) if prev else None prev_peak = float(prev.peak_value or 0.0) if prev else 0.0 prev_date = _as_date(prev.date) if prev else None + # 입금을 어느 구간에 넣을지는 직전 스냅샷을 '실제로 찍은 시각'(created_at)으로 가른다. + # 날짜 끝(자정 직전)을 쓰면 직전 스냅샷(10:07 측정) 이후 같은 날 들어온 입금이 + # 어느 구간에도 안 잡혀, 그 입금이 복원일의 수익(+25~33%)으로 영구 기록된다. + prev_measured = getattr(prev, "created_at", None) if prev else None + if prev_date is not None and ( + prev_measured is None or _as_date(prev_measured) < prev_date + ): + prev_measured = datetime.combine(prev_date, datetime.max.time()) + # 예전 복원 행은 created_at이 '저장한 날'(다음 사이클)이다. 복원하려는 날보다 + # 늦은 시각에 찍혔을 리는 없으므로 그날 0시로 자른다. + if prev_measured is not None: + prev_measured = min( + prev_measured, datetime.combine(_as_date(day), datetime.min.time()), + ) + # 이 날 이전의 최대 누적수익률 — 흐름 계정의 MDD를 TWR 지수로 재기 위한 피크. + # 복원일 이후의 스냅샷은 보지 않는다(미래 정보로 과거 낙폭을 재면 안 된다). + hist_max_cum = ( + session.query(func.max(PortfolioSnapshot.cumulative_return)) + .filter( + PortfolioSnapshot.mode == mode, + PortfolioSnapshot.account_key == account_key, + PortfolioSnapshot.date < datetime.combine(_as_date(day), datetime.min.time()), + ) + .scalar() + ) flows_before = sum( float(f.amount or 0) for f in session.query(CashFlow).filter( @@ -223,13 +281,13 @@ def reconstruct_snapshot( ).all() ) flow_between = 0.0 - if prev_date is not None: + if prev_measured is not None: flow_between = sum( float(f.amount or 0) for f in session.query(CashFlow).filter( CashFlow.mode == mode, CashFlow.account_key == account_key, - CashFlow.occurred_at > datetime.combine(prev_date, datetime.max.time()), + CashFlow.occurred_at > prev_measured, CashFlow.occurred_at <= boundary, ).all() ) @@ -252,8 +310,16 @@ def reconstruct_snapshot( if denom > 0: daily_return = (total_value / denom - 1) * 100 + # 원화 피크(peak_value 컬럼)는 스냅샷 연속성을 위해 기존대로 기록한다. peak = max(prev_peak, total_value, float(initial_capital)) - mdd = ((peak - total_value) / peak) * 100 if peak > 0 else 0.0 + if flows_before == 0: + mdd = ((peak - total_value) / peak) * 100 if peak > 0 else 0.0 + else: + # 입출금이 있는 계정은 원화 피크로 재면 입금이 낙폭을 가짜로 회복시킨다 — + # PortfolioManager와 같은 TWR 지수 기준으로 잰다. + index_now = 1 + cumulative / 100 + peak_index = max(1.0, index_now, 1 + float(hist_max_cum or 0.0) / 100) + mdd = ((peak_index - index_now) / peak_index) * 100 if peak_index > 0 else 0.0 return { "total_value": round(total_value, 0), @@ -280,6 +346,13 @@ def backfill_account( """결측 영업일을 찾아 복원한다. 반환: 채운(또는 채울) 날의 요약 목록.""" from database.repositories import save_portfolio_snapshot + first = first_activity_date(account_key, mode=mode) + if first is None: + return [] # 활동이 전혀 없는 계정 — 채울 '빠진 날'도 없다 + since = max(_as_date(since), first) + if since > _as_date(until): + return [] + missing = find_missing_trading_days(config, account_key, since, until, mode=mode) if not missing: return [] @@ -290,8 +363,12 @@ def backfill_account( if snap is None: continue if not dry_run: + # 찍은 시각은 복원한 날의 끝으로 남긴다. 저장한 시각(다음 날 실행)으로 두면 + # 다음 구간의 입금 경계가 하루 밀려, 그 사이 입금이 수익으로 잡힌다. ok = save_portfolio_snapshot( - account_key=account_key, mode=mode, reconstructed=True, **snap, + account_key=account_key, mode=mode, reconstructed=True, + measured_at=datetime.combine(_as_date(day), datetime.max.time()), + **snap, ) if not ok: logger.warning("복원 스냅샷 저장 실패 {} {}", account_key, day) diff --git a/core/strategy_ensemble.py b/core/strategy_ensemble.py index fdc84cd2..3d4384ea 100644 --- a/core/strategy_ensemble.py +++ b/core/strategy_ensemble.py @@ -63,7 +63,11 @@ class StrategyEnsemble: fundamental_factor는 데이터 부재 시 ensemble_skip=True 로 집계에서 제외. 모드: majority_vote | weighted_sum | conservative - auto_downgrade: 고상관 감지 시 majority_vote/weighted_sum → conservative 자동 전환 + auto_downgrade: 행마다 직전 independence_window행(당일 포함)의 구성 신호 상관이 + |r| >= independence_threshold이면 그 행만 conservative로 판정한다. + (예전에는 첫 analyze 호출의 신호 전체로 인스턴스 모드를 한 번 바꿨다 — + 포트폴리오 백테스트에서는 첫 종목의 전 기간(미래 신호 포함)이 모든 날짜·종목의 + 모드를 정해 결과가 종목 순서에 따라 달라졌다.) """ def __init__(self, config: Config = None, skip_independence_check: bool = False): @@ -72,11 +76,14 @@ def __init__(self, config: Config = None, skip_independence_check: bool = False) ensemble_cfg = self.strategies_config.get("ensemble", {}) self._ensemble_cfg = ensemble_cfg self._configured_mode = ensemble_cfg.get("mode", "majority_vote") + # self.mode는 설정 모드 그대로 둔다. 행별 전환은 analyze()의 ensemble_mode 컬럼에 남는다. self.mode = self._configured_mode self.auto_downgrade = ensemble_cfg.get("auto_downgrade", True) self.confidence_weights = ensemble_cfg.get("confidence_weight", {}) - self._independence_checked = False - self._downgraded = False + self._independence_window = max(2, int(ensemble_cfg.get("independence_window", 60))) + self._independence_threshold = float(ensemble_cfg.get("independence_threshold", 0.6)) + self._downgraded = False # 한 행이라도 conservative로 전환된 적 있음 (진단용) + self._check_error_logged = False self._strategies: list[tuple[str, object, float]] = [] self._load_strategies() self._skip_independence_check = skip_independence_check @@ -155,15 +162,19 @@ def analyze(self, df: pd.DataFrame) -> pd.DataFrame: if col not in base_df.columns: base_df[col] = False - # 첫 analyze 호출 시 독립성 검사 → 고상관이면 conservative로 자동 다운그레이드 - if not self._independence_checked and not self._skip_independence_check and len(base_df) >= 60: - self._run_independence_check(base_df) + # 고상관 자동 전환: 행마다 그 행까지의 직전 window행 신호만 본다 (미래 신호·종목 순서 무관) + row_modes = self._row_modes(base_df) + base_df["ensemble_mode"] = row_modes skip_cols = [f"ensemble_skip_{n}" for n, _, _ in self._strategies] meta = base_df[skip_cols] if skip_cols else pd.DataFrame(index=base_df.index) base_df["signal"] = [ - self._resolve_row_signal(signal_frame.iloc[i], meta.iloc[i] if len(meta.columns) else None) + self._resolve_row_signal( + signal_frame.iloc[i], + meta.iloc[i] if len(meta.columns) else None, + row_modes.iloc[i], + ) for i in range(len(signal_frame)) ] base_df["strategy_score"] = [ @@ -189,60 +200,69 @@ def _mean_participating_score( vals.append(0.0) return float(sum(vals) / len(vals)) if vals else 0.0 - def _run_independence_check(self, analyzed_df: pd.DataFrame): - """첫 analyze 호출 시 전략 신호 독립성을 검사하고, 필요 시 모드를 다운그레이드한다.""" - self._independence_checked = True + def _row_modes(self, base_df: pd.DataFrame) -> pd.Series: + """행별 판정 모드. auto_downgrade 조건을 만족하는 행만 conservative.""" + modes = pd.Series(self.mode, index=base_df.index, dtype=object) + if ( + self._skip_independence_check + or not self.auto_downgrade + or self.mode not in ("majority_vote", "weighted_sum") + ): + return modes try: - from core.ensemble_correlation import SIGNAL_TO_NUM, ENSEMBLE_SIGNAL_COLS, ENSEMBLE_LABELS - - cols = [c for c in ENSEMBLE_SIGNAL_COLS if c in analyzed_df.columns] - if len(cols) < 2: - return - - numeric = pd.DataFrame(index=analyzed_df.index) - for c in cols: - numeric[c] = analyzed_df[c].map(lambda s: SIGNAL_TO_NUM.get(str(s).strip().upper(), 0)) - - corr = numeric[cols].corr() - threshold = self.strategies_config.get("ensemble", {}).get("independence_threshold", 0.6) - high_pairs = [] - for i, c1 in enumerate(cols): - for j, c2 in enumerate(cols): - if i >= j: - continue - r = corr.loc[c1, c2] - if pd.notna(r) and abs(r) >= threshold: - high_pairs.append((c1, c2, float(r))) - - if not high_pairs: - logger.info("앙상블 독립성 검사 통과: 모든 전략 쌍 |r| < {:.1f}", threshold) - return - - for c1, c2, r in high_pairs: - l1 = ENSEMBLE_LABELS.get(c1, c1) - l2 = ENSEMBLE_LABELS.get(c2, c2) + high = self._trailing_high_correlation(base_df) + except Exception as e: # 진단 실패로 분석 전체를 멈추지 않되, 조용히 넘기지 않는다 + if not self._check_error_logged: logger.warning( - "⚠️ 앙상블 독립성 위반: {}–{} 신호 상관계수 {:.2f} (>= {:.1f}). " - "다수결 의미 퇴색 위험.", - l1, - l2, - r, - threshold, + "앙상블 독립성 검사 실패 — 설정 모드({})로 판정합니다 (auto_downgrade 미적용): {}", + self.mode, e, ) - - if self.auto_downgrade and self.mode in ("majority_vote", "weighted_sum"): - old_mode = self.mode - self.mode = "conservative" - self._downgraded = True + self._check_error_logged = True + return modes + if high.any(): + modes[high] = "conservative" + if not self._downgraded: logger.warning( - "앙상블 모드 자동 전환: {} → conservative (고상관 쌍 {}개 감지). " - "참여 전략이 모두 동의할 때만 BUY/SELL. 전략 구성 재검토 권장. " - "auto_downgrade: false로 비활성화 가능.", - old_mode, - len(high_pairs), + "앙상블 모드 자동 전환: 직전 {}행 구성 신호 상관 |r| >= {:.2f}인 {}개 행을 " + "{} 대신 conservative로 판정 (참여 전략이 모두 동의할 때만 BUY/SELL). " + "전략 구성 재검토 권장. auto_downgrade: false로 비활성화 가능.", + self._independence_window, + self._independence_threshold, + int(high.sum()), + self.mode, ) - except Exception as e: - logger.debug("앙상블 독립성 검사 중 오류 (무시): {}", e) + self._downgraded = True + return modes + + def _trailing_high_correlation(self, base_df: pd.DataFrame) -> pd.Series: + """행마다 직전 window행(당일 포함) 신호 상관이 임계 이상인 구성 쌍이 있는지. + + 롤링 창이라 각 행의 판정은 그 행까지의 신호만 쓴다. 창 안에서 한쪽 신호가 + 변하지 않으면(예: 데이터 없는 펀더멘털 HOLD) 상관을 정의할 수 없어 제외한다. + """ + from core.ensemble_correlation import SIGNAL_TO_NUM, ENSEMBLE_SIGNAL_COLS + + high = pd.Series(False, index=base_df.index) + cols = [c for c in ENSEMBLE_SIGNAL_COLS if c in base_df.columns] + if len(cols) < 2: + return high + + window = self._independence_window + numeric = { + c: base_df[c].map(lambda s: SIGNAL_TO_NUM.get(str(s).strip().upper(), 0)).astype(float) + for c in cols + } + # 신호값이 -1/0/1이라 한 번이라도 바뀐 창의 표준편차는 0.1 이상이다. 부동소수 + # 잔차(1e-17 수준)를 '변동'으로 읽어 엉뚱한 상관이 나오지 않게 문턱을 둔다. + varies = { + c: numeric[c].rolling(window, min_periods=window).std() > 1e-9 for c in cols + } + for i, c1 in enumerate(cols): + for c2 in cols[i + 1:]: + r = numeric[c1].rolling(window, min_periods=window).corr(numeric[c2]) + valid = varies[c1] & varies[c2] & r.notna() + high = high | (valid & (r.abs() >= self._independence_threshold)) + return high def generate_signal(self, df: pd.DataFrame, **kwargs) -> dict: """각 전략 신호를 수집 후 앙상블 모드에 따라 통합 신호 반환""" @@ -254,7 +274,7 @@ def generate_signal(self, df: pd.DataFrame, **kwargs) -> dict: return {"signal": "HOLD", "score": 0, "details": {"ensemble": "분석 결과 없음"}} last = analyzed.iloc[-1] - details = {} + details = {"ensemble_mode": last.get("ensemble_mode", self.mode)} for name, _, _ in self._strategies: details[name] = last.get(f"signal_{name}", "ERR") sk = last.get(f"ensemble_skip_{name}", False) @@ -281,13 +301,19 @@ def _participating( out.append((name, sig, weight)) return out - def _resolve_row_signal(self, signal_row: pd.Series, skip_row: pd.Series | None) -> str: + def _resolve_row_signal( + self, + signal_row: pd.Series, + skip_row: pd.Series | None, + mode: str | None = None, + ) -> str: parts = self._participating(signal_row, skip_row) if not parts: return "HOLD" - if self.mode == "conservative": + mode = mode or self.mode + if mode == "conservative": return self._resolve_conservative(parts) - if self.mode == "weighted_sum": + if mode == "weighted_sum": return self._resolve_weighted_sum(parts) return self._resolve_majority_vote(parts) diff --git a/core/trading_hours.py b/core/trading_hours.py index c25b20af..e7618ac3 100644 --- a/core/trading_hours.py +++ b/core/trading_hours.py @@ -24,6 +24,18 @@ KR_HOLIDAYS_FALLBACK = set().union(*_FALLBACK_BY_YEAR.values()) +_KST = ZoneInfo("Asia/Seoul") + + +def _now_kst() -> datetime: + """현재 한국 시각(naive). + + 인자 없는 datetime.now()는 호스트 TZ를 따른다. 문서화된 리눅스 배포처럼 TZ가 UTC면 + 거래일이 09:00 KST에 넘어가고 장 시간 판정이 9시간 밀린다. 반환값은 naive로 + 유지한다 — 호출부가 DB의 naive 시각과 비교한다. + """ + return datetime.now(_KST).replace(tzinfo=None) + def _load_holidays() -> set: """ @@ -56,7 +68,7 @@ def _load_holidays() -> set: # 2) pykrx (거래일 제외 = 휴장일) try: from pykrx import stock - now = datetime.now() + now = _now_kst() start = f"{now.year}0101" end = f"{now.year + 1}1231" trading = stock.get_market_trading_date_by_date(start, end) @@ -153,7 +165,7 @@ def is_trading_day(self, date: datetime = None) -> bool: Returns: 거래일이면 True """ - dt = date or datetime.now() + dt = date or _now_kst() # 주말 체크 (토=5, 일=6) if dt.weekday() >= 5: @@ -173,7 +185,7 @@ def is_market_open(self, dt: datetime = None) -> bool: Returns: True: 현재 거래 가능 """ - dt = dt or datetime.now() + dt = dt or _now_kst() if not self.is_trading_day(dt): return False @@ -183,7 +195,7 @@ def is_market_open(self, dt: datetime = None) -> bool: def is_pre_market(self, dt: datetime = None) -> bool: """장전 준비 시간인지 확인""" - dt = dt or datetime.now() + dt = dt or _now_kst() if not self.is_trading_day(dt): return False @@ -198,7 +210,7 @@ def can_place_order(self, dt: datetime = None) -> dict: Returns: {"allowed": True/False, "reason": 사유} """ - dt = dt or datetime.now() + dt = dt or _now_kst() if not self.is_trading_day(dt): weekday = dt.strftime("%A") @@ -216,7 +228,7 @@ def can_place_order(self, dt: datetime = None) -> dict: def time_until_market_open(self) -> timedelta: """장 시작까지 남은 시간""" - now = datetime.now() + now = _now_kst() today_open = datetime.combine(now.date(), self.market_open) if now < today_open: @@ -232,7 +244,7 @@ def time_until_market_open(self) -> timedelta: def time_until_market_close(self) -> timedelta: """장 종료까지 남은 시간""" - now = datetime.now() + now = _now_kst() today_close = datetime.combine(now.date(), self.market_close) if now < today_close: @@ -244,7 +256,7 @@ def time_until_market_close(self) -> timedelta: def to_us_eastern(self, dt: datetime = None) -> datetime: """주어진 시각을 미국 동부(뉴욕) 타임존으로 변환.""" - d = dt or datetime.now() + d = dt or _now_kst() if d.tzinfo is None: d = d.replace(tzinfo=ZoneInfo("Asia/Seoul")) return d.astimezone(self._ny_tz) diff --git a/core/weekly_report.py b/core/weekly_report.py index 3ce82715..a6fce286 100644 --- a/core/weekly_report.py +++ b/core/weekly_report.py @@ -24,6 +24,8 @@ def build_weekly_summary( cycle_errors: int = 0, regime: dict[str, Any] | None = None, risk: dict[str, Any] | None = None, + reconstructed_days: int = 0, + regime_note: str | None = None, ) -> dict[str, Any]: """주간 다이제스트를 만든다(순수 함수). @@ -31,6 +33,9 @@ def build_weekly_summary( 원시 카운트가 아니다(P0-1이 미복구 결측을 매 사이클 재경보하므로 이벤트 수는 하루 결측을 여러 건으로 부풀린다 → 고유 일수로 집계해야 정확). cycle_errors: 이번 주 CYCLE_ERROR 발생 건수. + reconstructed_days: 이번 주 스냅샷 중 나중에 채운 날 수. 채운 기록은 빈칸을 + 잇는 것이지 사이클이 돈 증거가 아니므로 '무사고'로 세지 않는다. + regime_note: 국면 분해의 한계(지수 자료 지연, 기록 시각 차이 등) 한 줄. 반환: {"title": str, "fields": [{"name","value","inline"}...], "text": str} - fields: notifier.send_embed용 @@ -76,9 +81,12 @@ def build_weekly_summary( # 실측(2026-08): 전체 +7.81%p 초과성과가 반등 구간 -8.97%p 미스를 덮고 있었다. if regime: from core.performance_lens import format_regime_line + value = format_regime_line(regime) + if regime_note: + value += f" ({regime_note})" fields.append({ "name": "🌗 국면 분해", - "value": format_regime_line(regime), + "value": value, "inline": False, }) @@ -107,8 +115,11 @@ def build_weekly_summary( # 4) 주간 운영 이벤트 — 결측은 '고유 일수'(이벤트 재경보로 부풀지 않게), 오류는 건수. md = int(missing_days or 0) ce = int(cycle_errors or 0) + rd = int(reconstructed_days or 0) ev_line = f"결측 {md}일 · 사이클 오류 {ce}건" - if md == 0 and ce == 0: + if rd: + ev_line += f" · 나중에 채운 기록 {rd}일(그날은 자동매매가 돌지 않았음)" + if md == 0 and ce == 0 and rd == 0: ev_line += " (무사고)" fields.append({"name": "🛠 주간 이벤트", "value": ev_line, "inline": False}) diff --git a/database/models.py b/database/models.py index 40b27c22..bef9bde9 100644 --- a/database/models.py +++ b/database/models.py @@ -1124,6 +1124,115 @@ def _migrate_position_unique_constraint(engine): ) +def _sqlite_index_map(conn, table_name): + """{(컬럼 튜플): unique 여부} — 물리 인덱스(자동 생성 UNIQUE 포함).""" + from sqlalchemy import text + + out = {} + for row in conn.execute(text(f'PRAGMA index_list("{table_name}")')).fetchall(): + name = str(row[1]).replace('"', '""') + cols = tuple( + str(r[2]) for r in conn.execute(text(f'PRAGMA index_info("{name}")')).fetchall() + ) + if cols: + out[cols] = out.get(cols, False) or bool(row[2]) + return out + + +def _model_uniques(table): + """모델이 선언한 UNIQUE 컬럼 튜플(제약·unique 인덱스·unique 컬럼).""" + uniques = set() + for c in table.constraints: + if isinstance(c, UniqueConstraint): + uniques.add(tuple(col.name for col in c.columns)) + for ix in table.indexes: + if ix.unique: + uniques.add(tuple(col.name for col in ix.columns)) + for col in table.columns: + if col.unique: + uniques.add((col.name,)) + return uniques + + +def check_schema_drift(engine=None) -> list[str]: + """모델과 실제 SQLite 스키마가 어긋난 곳을 사람이 읽는 문장으로 돌려준다. + + create_all은 이미 있는 테이블을 고치지 않는다. 그래서 모델에서 제약을 바꿔도 예전 + DB에는 옛 제약이 남는다 — 2026-07-10 kr_pocket 가짜 낙폭(-41%)이 정확히 이 경우였다 + (positions의 옛 UNIQUE(symbol)이 다른 장부의 같은 종목 포지션을 조용히 삼켰다). + 시작할 때 비교해서 로그와 헬스에 드러낸다. SQLite가 아니면 빈 목록. + """ + engine = engine or get_engine() + if "sqlite" not in engine.url.drivername: + return [] + issues: list[str] = [] + with engine.connect() as conn: + for table in Base.metadata.sorted_tables: + columns = _sqlite_table_columns(conn, table.name) + if not columns: + issues.append(f"{table.name}: 테이블 없음") + continue + missing_cols = [c.name for c in table.columns if c.name not in columns] + if missing_cols: + issues.append(f"{table.name}: 컬럼 없음 {missing_cols}") + physical = _sqlite_index_map(conn, table.name) + model_uniques = _model_uniques(table) + pk = tuple(c.name for c in table.primary_key.columns) + for cols in sorted(model_uniques): + if not physical.get(cols): + issues.append(f"{table.name}: UNIQUE{cols} 없음(모델에는 있음)") + for cols, unique in sorted(physical.items()): + if unique and cols not in model_uniques and cols != pk: + issues.append(f"{table.name}: 옛 UNIQUE{cols}가 남아 있음(모델에는 없음)") + for ix in table.indexes: + cols = tuple(col.name for col in ix.columns) + if not ix.unique and cols not in physical: + issues.append(f"{table.name}: 인덱스 {ix.name}{cols} 없음") + return issues + + +def _repair_schema_drift(engine) -> None: + """어긋난 것 중 데이터를 잃을 위험이 없는 것만 고친다. + + - 모델의 일반 인덱스가 없으면 만든다(checkfirst — 데이터는 건드리지 않는다). + - daily_reports의 옛 UNIQUE(date): 계정별 리포트가 같은 날 두 번째부터 실패한다. + 비어 있을 때만 테이블을 다시 만든다. 행이 있으면 로그만 남긴다(운영자 판단). + """ + if "sqlite" not in engine.url.drivername: + return + from sqlalchemy import text + + with engine.connect() as conn: + for table in Base.metadata.sorted_tables: + if not _sqlite_table_columns(conn, table.name): + continue + physical = _sqlite_index_map(conn, table.name) + for ix in table.indexes: + cols = tuple(col.name for col in ix.columns) + if ix.unique or cols in physical: + continue + try: + ix.create(bind=engine, checkfirst=True) + logger.warning("스키마 맞춤: 인덱스 생성 {} {}", table.name, cols) + except Exception as exc: + logger.error("스키마 맞춤 실패: 인덱스 {} — {}", ix.name, exc) + + daily = Base.metadata.tables.get("daily_reports") + if daily is not None and _sqlite_table_columns(conn, "daily_reports"): + physical = _sqlite_index_map(conn, "daily_reports") + if physical.get(("date",)) and ("date",) not in _model_uniques(daily): + rows = conn.execute(text('SELECT COUNT(*) FROM "daily_reports"')).scalar() + if rows == 0: + conn.execute(text('DROP TABLE "daily_reports"')) + conn.commit() + daily.create(bind=engine, checkfirst=True) + logger.warning("스키마 맞춤: 비어 있던 daily_reports를 계정별 UNIQUE로 다시 만듦") + else: + logger.error( + "daily_reports에 옛 UNIQUE(date)가 남아 있지만 행이 {}개라 자동으로 고치지 않음", rows, + ) + + def init_database(): """ 데이터베이스 초기화 @@ -1165,6 +1274,13 @@ def init_database(): # 포지션 mode 격리 재구축. partial_tp_done 컬럼 추가 이후에 실행해 # 구버전 테이블을 완전히 복사한다. 신 스키마에서는 no-op. _migrate_position_unique_constraint(engine) + # 모델과 실제 스키마 대조 — 안전한 것만 고치고, 남은 어긋남은 ERROR로 남긴다. + try: + _repair_schema_drift(engine) + for issue in check_schema_drift(engine): + logger.error("스키마가 코드와 다름: {}", issue) + except Exception as exc: + logger.error("스키마 대조 실패: {}", exc) if "sqlite" in engine.url.drivername: from sqlalchemy import text diff --git a/database/repositories.py b/database/repositories.py index 129cf992..0d91e58d 100644 --- a/database/repositories.py +++ b/database/repositories.py @@ -11,7 +11,7 @@ from typing import List, Optional import pandas as pd -from sqlalchemy import and_, text +from sqlalchemy import and_, or_, text from sqlalchemy.exc import IntegrityError from loguru import logger @@ -785,11 +785,14 @@ def save_portfolio_snapshot( snapshot_date: datetime = None, mode: str = "paper", reconstructed: bool = False, + measured_at: datetime = None, ): """일일 포트폴리오 스냅샷 저장 (mode+account_key 장부별 격리). snapshot_date: 스냅샷 귀속 날짜(자정으로 정규화). 미지정 시 오늘. 비거래일 보충 실행에서 NAV의 가격 기준일(직전 거래일)로 귀속할 때 사용. + measured_at: 이 값을 찍은 시각(created_at). 안 주면 지금. 나중에 채운 행은 + 복원한 날의 끝을 넘긴다 — TWR 체인의 입금 경계가 이 시각을 쓰기 때문이다. """ session = get_session() try: @@ -813,6 +816,8 @@ def save_portfolio_snapshot( position_count=position_count, reconstructed=bool(reconstructed), ) + if measured_at is not None: + snapshot.created_at = measured_at # merge by (mode, account_key, date) existing = session.query(PortfolioSnapshot).filter( PortfolioSnapshot.mode == md, @@ -835,7 +840,7 @@ def save_portfolio_snapshot( # created_at은 '이 값이 마지막으로 측정된 시각'이다 — TWR 체인의 유입 경계가 # 이 시각을 쓰므로, 같은 날 재실행(upsert) 때 갱신하지 않으면 재실행 전에 # 반영된 입금이 다음 날 구간에 이중 산입돼 수익률이 영구 왜곡된다(적대적 리뷰 HIGH). - existing.created_at = datetime.now() + existing.created_at = measured_at or datetime.now() else: session.add(snapshot) session.commit() @@ -1001,21 +1006,25 @@ def get_cash_flows(account_key: str = "", mode: str = "paper") -> list: @with_retry def get_recent_cash_flows( - account_key: str = "", limit: int = 12, mode: str = "paper" + account_key: str = "", limit: Optional[int] = 12, mode: str = "paper" ) -> list: - """최근 입금/출금 내역 [{occurred_at, amount, note}...] 최신순 — 대시보드 표시용.""" + """최근 입금/출금 내역 [{occurred_at, amount, note}...] 최신순 — 대시보드 표시용. + + limit=None이면 전체. 차트·CSV의 누적 원금은 전체 기록이 있어야 맞는다. + """ session = get_session() try: - rows = ( + query = ( session.query(CashFlow) .filter( CashFlow.mode == _ledger_mode(mode), CashFlow.account_key == (account_key or ""), ) .order_by(CashFlow.occurred_at.desc()) - .limit(int(limit)) - .all() ) + if limit is not None: + query = query.limit(int(limit)) + rows = query.all() return [ { "occurred_at": str(r.occurred_at)[:16], @@ -1147,7 +1156,11 @@ def get_snapshot_before( def get_max_cumulative_return( account_key: str = "", mode: str = "paper" ) -> Optional[float]: - """계정 스냅샷의 최대 누적수익률(%). TWR 지수 기준 MDD의 피크 복원용.""" + """계정 스냅샷의 최대 누적수익률(%). TWR 지수 기준 MDD의 피크 복원용. + + 나중에 채운 기록(reconstructed)은 뺀다. 시가·종가 중간값으로 만든 추정치라 최대 + 2%가량 틀릴 수 있는데, 그 값이 고점이 되면 이후 낙폭이 계속 부풀어 보인다. + """ session = get_session() try: row = ( @@ -1156,6 +1169,10 @@ def get_max_cumulative_return( PortfolioSnapshot.mode == _ledger_mode(mode), PortfolioSnapshot.account_key == (account_key or ""), PortfolioSnapshot.cumulative_return.isnot(None), + or_( + PortfolioSnapshot.reconstructed.is_(None), + PortfolioSnapshot.reconstructed.is_(False), + ), ) .order_by(PortfolioSnapshot.cumulative_return.desc()) .first() @@ -1196,6 +1213,7 @@ def get_portfolio_snapshots( "cumulative_return": r.cumulative_return, "mdd": r.mdd, "position_count": r.position_count, + "reconstructed": bool(r.reconstructed), # 나중에 채운 추정 기록 } for r in results] return pd.DataFrame(data) diff --git a/deploy/quant_trader.service b/deploy/quant_trader.service index be39885d..d0153037 100644 --- a/deploy/quant_trader.service +++ b/deploy/quant_trader.service @@ -7,6 +7,8 @@ Type=simple User=ubuntu WorkingDirectory=/home/ubuntu/quant_trader Environment=PATH=/home/ubuntu/quant_trader/.venv/bin +# 장 시간·거래일·1일 1매매 판정은 한국 시각 기준이다. 서버 TZ가 UTC면 9시간 어긋난다. +Environment=TZ=Asia/Seoul # .env 가 없으면 유닛 시작 실패를 막기 위해 '-' 접두(선택 파일) EnvironmentFile=-/home/ubuntu/quant_trader/.env ExecStart=/home/ubuntu/quant_trader/.venv/bin/python main.py --mode schedule diff --git a/docs/OPERATING_PRINCIPLES.md b/docs/OPERATING_PRINCIPLES.md index 32e35c6c..12df6f37 100644 --- a/docs/OPERATING_PRINCIPLES.md +++ b/docs/OPERATING_PRINCIPLES.md @@ -126,14 +126,14 @@ QUANT_DB_PATH=<사본> QUANT_BACKUP_PATH=<임시> \ | 순위 | 항목 | 원칙 | 상태 | |---|---|---|---| | P0 | 집계 배치율 트리거 + 보충 매수 | 1, 4 | ✅ 2026-08-26 | -| P0 | 배치율 감시 복원 | 1, 2 | ✅ 2026-08-26 | +| P0 | 배치율 감시 복원 | 1, 2 | ✅ 2026-08-26 (허용치를 종목별 1주 가격의 합으로 잡아 실제로는 울리지 않았다 — 2026-09-23 수정) | | P0 | 결측 경보 중복 억제 | 5 | ✅ 2026-08-26 | | P0 | 적립 이행 점검 | 2 | ✅ 2026-08-26 | -| P1 | **국면 분해 리포팅** — 주간/평가 리포트에 상승·하락 구간 성과를 분리 표기 | 9 | 미착수 | -| P1 | **리스크 지표 표면화** — `daily_return` 복구(8/10~)로 이제 계산 가능해진 변동성·샤프·하락일 비율을 일일/주간 리포트에 | 2 | 미착수 | +| P1 | **국면 분해 리포팅** — 주간/평가 리포트에 상승·하락 구간 성과를 분리 표기 | 9 | ✅ 주간 2026-08-27, 포착률을 하루 평균 수익률의 비로 수정 2026-09-23 | +| P1 | **리스크 지표 표면화** — `daily_return` 복구(8/10~)로 이제 계산 가능해진 변동성·샤프·하락일 비율을 일일/주간 리포트에 | 2 | ✅ 주간 2026-08-27, 일일 카드 2026-09-23 | | P2 | **정책 도입 유예 장치** — `risk:` 블록에 `effective_from`을 두고, 그 이전 진입 포지션은 자동 청산 대신 운영자 결정 목록으로 | 6 | 미착수 | | P2 | **1회 손실 예산 정합** — `_execute_buy_quantity_impl`의 per-trade 1% 예산이 아직 전역 -3% 손절로 계산된다. 트랙 정책(-25%)을 넣으면 10% 슬롯이 예산을 초과해 재동결되므로, 목표비중 주문에는 별도 기준이 필요 | 3 | 미착수 | -| P3 | **노출 정책 결정 지원** — 60% vs 80%의 상승/하락 비대칭을 실측 데이터로 시뮬레이션하는 도구 | 9 | 미착수 | +| P3 | **노출 정책 결정 지원** — 60% vs 80%의 상승/하락 비대칭을 실측 데이터로 시뮬레이션하는 도구 | 9 | ✅ 2026-09-23 `tools/risk_overlay_backtest.py --track exposure` (60/70/80% 비교는 연구 기록 참고) | --- diff --git a/docs/PROJECT_GUIDE.md b/docs/PROJECT_GUIDE.md index 66374fc9..3dc3e175 100644 --- a/docs/PROJECT_GUIDE.md +++ b/docs/PROJECT_GUIDE.md @@ -103,8 +103,8 @@ quant_trader/ │ ├── risk_manager.py # 포지션 사이징(1% 룰), 분산(업종 비중 포함), 성과 열화, 손절/익절/트레일링, MDD/일일 손실, 거래 비용 │ ├── order_executor.py # 매수/매도 (paper: DB만, live: KIS), paper BUY/SELL 모델 슬리피지 체결가 반영, 운영 손실 한도 신규 BUY 차단, PositionLock, OrderGuard, 유동성·어닝 필터, 매수 직전 재검증, Dead-letter 큐(재시도 실패 시 FailedOrder 저장) │ ├── portfolio_manager.py # 포지션·잔고·수익률, 체결가 기준 현금 요약, sync_with_broker(KIS↔DB 크로스체크), save_daily_snapshot() -│ ├── basket_rebalancer.py # 바스켓 리밸런싱: 목표 비중 vs 실제 비중 드리프트 감지, 주문 생성·실행, 신호 가중 모드, 스케줄러 장전 자동 통합 -│ ├── scheduler.py # 무한 루프: 장전/장중(10분)/장마감, 시장 국면 필터, 블랙스완 recovery, 바스켓 리밸런싱, paper 실전전환 자동 평가 +│ ├── basket_rebalancer.py # 바스켓 리밸런싱: 목표 비중 vs 실제 비중 드리프트 감지, 주문 생성·실행, 신호 가중 모드. 실행은 일일 CLI(--mode rebalance)에서만 +│ ├── scheduler.py # 무한 루프: 장전/장중(10분)/장마감, 시장 국면 필터, 블랙스완 recovery, paper 실전전환 자동 평가 (바스켓은 거래하지 않음) │ ├── runtime_lock.py # schedule 모드 단일 인스턴스 락 (`data/.scheduler.lock`) │ ├── trading_hours.py # 한국 장·휴장일 + 미국(NYSE 정규장, us_holidays.yaml) │ ├── holidays_updater.py # 휴장일 YAML 자동 갱신 (pykrx 또는 fallback) @@ -159,7 +159,7 @@ quant_trader/ ├── api/ │ ├── __init__.py │ ├── kis_api.py # KIS REST API: 토큰·시세·주문·잔고·일봉. 이중 Rate Limiter(초당+분당) + 지수 백오프+지터 + SSL/커넥션 에러 핸들러 + 토큰 쿨다운 + 사용량 모니터링 + Circuit Breaker(HALF_OPEN 단일 probe) -│ ├── websocket_handler.py # KIS 웹소켓 실시간 체결/호가 (asyncio, Heartbeat 45초, 자동 재연결, 갭 보충/대시보드 노출) +│ ├── websocket_handler.py # KIS 웹소켓 실시간 체결/호가 (asyncio, Heartbeat 45초, 자동 재연결, 갭 보충/대시보드 노출) — 현재 어떤 런타임에도 연결 안 됨 │ └── circuit_breaker.py # CLOSED → OPEN → HALF_OPEN, API 연속 5회 실패 시 60초 차단 ├── backtest/ │ ├── __init__.py @@ -277,8 +277,8 @@ quant_trader/ | **risk_manager.py** | 포지션 사이징(1% 룰), `check_diversification`(**업종 비중 포함**: `max_sector_ratio`, FDR/KRX KIND 업종 매핑), `check_recent_performance`, 손절/익절/트레일링, MDD·일일 손실 한도 계산. `calculate_transaction_costs`. | | **order_executor.py** | `trading.mode`: paper면 DB만, live면 KIS API. 거래 시간·블랙스완 쿨다운·**실적 발표일 필터**(`skip_earnings_days`) 검사, 재시도(지수 백오프+지터). PositionLock, OrderGuard·KIS 미체결 조회. `max_monthly_roundtrips`로 종목·모드·계좌별 월간 신규 BUY 횟수를 운영 주문에서도 제한하고, `drawdown.max_portfolio_mdd`/`max_daily_loss`에 닿으면 신규 BUY만 fail-closed 차단한다. MDD 한도 돌파 후에는 **히스테리시스**(RiskManager.check_mdd의 `_is_halted`)가 한도의 절반 아래로 회복할 때까지 신규 BUY 차단을 유지한다 — 한도 경계 깜빡 회복 시 재진입→재돌파 반복 방지(상시 스케줄러에서 누적 동작, 1회성 CLI는 정적 체크와 동일). live 손실 한도 확인에서 KIS 잔고가 확인되지 않으면 DB fallback 평가금액으로 신규 BUY를 판단하지 않고 차단한다. 시장 국면 필터가 `allow_buys=false`를 반환하면 직접 executor BUY 호출도 주문 전 중단한다. SELL/exit는 손실 축소 경로라 월간 cap·손실 한도와 무관하게 계속 허용한다. 긴급 SELL 전에는 KIS 정정취소 가능 BUY를 조회·취소하고 재조회로 잔량 소멸을 확인한다. 부분 체결·취소 불명·로컬 주문상태 정리 실패는 매도 수량을 추측하지 않고 HALT한다. live 주문 전 미체결 조회 실패는 `live_unfilled_check.checked=False`로 fail-closed 차단한다. live 주문 ACK 뒤 체결가·체결수량 확인이 안 되거나 부분체결이면 `ACKED`/`PARTIAL_FILLED` pending으로 `order_records`에 저장하고 DB 거래·포지션 반영을 보류한다. `SUBMITTED`/`ACKED`/`PARTIAL_FILLED` 상태가 남아 있으면 OrderGuard TTL이 지나도 같은 종목 신규 주문을 차단한다. 이때 반환값의 `success=False`는 브로커 주문 없음이 아니라 reconcile 필요 상태다. **Dead-letter 큐**: 모든 재시도 실패 시 `FailedOrder` 테이블에 영구 저장. | | **portfolio_manager.py** | 보유 포지션·잔고·수익률. `sync_with_broker()`로 KIS 잔고↔DB 크로스체크. live `get_portfolio_summary()`는 조회용 DB fallback 여부를 `broker_balance_ok/source/error`로 표시하고, 주문 sizing에 쓰는 `get_current_capital()`/`get_available_cash()`는 KIS 잔고 미확인 시 예외로 fail-closed 처리한다. 자동보정 시 KIS 기준 수량을 절대값으로 반영하고, 복구 포지션에는 손절·익절·트레일링 스탑을 재생성한다. | -| **basket_rebalancer.py** | 바스켓 리밸런싱 엔진. `baskets.yaml`에서 바스켓 로드. `get_target_weights()`(신호 가중 지원), `get_current_weights()`, `calculate_drift()`, `should_rebalance()`(drift/weekly/monthly 트리거), `plan_rebalance()`(SELL→BUY 순서, max_turnover 제한), `execute()`(dry_run 지원). paper BUY 실행은 `PortfolioManager.get_current_capital()`/`get_available_cash()`로 자본과 현금을 조회한 뒤 `OrderExecutor.execute_buy_quantity()`에 넘긴다. live 실행은 `basket_rebalance:` 승인 단위가 account_key와 주문 strategy에 동일하게 전달되고, KIS↔DB 포지션 동기화가 성공해야 주문 실행부에 도달한다. CLI와 스케줄러 자동 체크 모두 같은 승인 단위를 사용한다. `get_status_report()`로 현황 리포트 생성. | -| **scheduler.py** | 실전 무한 루프. 장전/장중/장마감. **시장 국면 필터**(단계적: bearish→매수 중단, caution→사이징 축소). 장중 10분 간격. 루프 10분 초과 시 다음 사이클 스킵. 장전 단계에서 **바스켓 리밸런싱 자동 체크** (`_run_basket_rebalance_check`). live에서는 바스켓별 `basket_rebalance:` readiness gate와 KIS↔DB 동기화를 통과한 뒤에만 주문 계획과 실행으로 넘어간다. live 신규 진입 중 `requires_reconcile` 또는 `order_pending` 결과가 나오면 남은 BUY 후보와 같은 루프 재스캔을 중단하고, 다음 성공적인 KIS↔DB 동기화 전까지 미확정 체결분을 무시한 추가 진입을 막는다. **전략 레지스트리** 기반 `_get_strategy()`. | +| **basket_rebalancer.py** | 바스켓 리밸런싱 엔진. `baskets.yaml`에서 바스켓 로드. `get_target_weights()`(신호 가중 지원), `get_current_weights()`, `calculate_drift()`, `should_rebalance()`(drift/weekly/monthly 트리거), `plan_rebalance()`(SELL→BUY 순서, max_turnover 제한), `execute()`(dry_run 지원). paper BUY 실행은 `PortfolioManager.get_current_capital()`/`get_available_cash()`로 자본과 현금을 조회한 뒤 `OrderExecutor.execute_buy_quantity()`에 넘긴다. live 실행은 `basket_rebalance:` 승인 단위가 account_key와 주문 strategy에 동일하게 전달되고, KIS↔DB 포지션 동기화가 성공해야 주문 실행부에 도달한다. 바스켓 실행 경로는 일일 CLI 하나뿐이다(2026-09부터 스케줄러는 바스켓을 거래하지 않는다). `get_status_report()`로 현황 리포트 생성. | +| **scheduler.py** | 실전 무한 루프. 장전/장중/장마감. **시장 국면 필터**(단계적: bearish→매수 중단, caution→사이징 축소). 장중 10분 간격. 루프 10분 초과 시 다음 사이클 스킵. 장마감 처리는 15:35 이후 그날 한 번 실행한다(늦게 켜도 따라잡음). **바스켓은 거래하지 않는다** — 장전 시각에는 live 주문이 거래 시간 가드에 막히고 paper는 전일 종가로 체결되던 문제로 빼고, 일일 CLI(`--mode rebalance`)가 유일한 바스켓 실행 경로라는 경고만 남긴다. live 신규 진입 중 `requires_reconcile` 또는 `order_pending` 결과가 나오면 남은 BUY 후보와 같은 루프 재스캔을 중단하고, 다음 성공적인 KIS↔DB 동기화 전까지 미확정 체결분을 무시한 추가 진입을 막는다. **전략 레지스트리** 기반 `_get_strategy()`. | | **runtime_lock.py** | `scheduler_lock(lock_file)` 컨텍스트: 스케줄 프로세스 중복 실행 방지. | | **trading_hours.py** | 한국 장·휴장일(holidays.yaml → pykrx → fallback). **미국**: `us_holidays.yaml`, 동부 09:30~16:00 (`is_us_trading_day`, `is_us_market_open` 등). 주문 가능 시간 검사. | | **holidays_updater.py** | pykrx(또는 fallback)로 휴장일 조회 → `config/holidays.yaml` 저장. `update_holidays_yaml()`. | @@ -325,7 +325,7 @@ quant_trader/ | 파일 | 역할 | |------|------| | **kis_api.py** | OAuth 토큰 발급·갱신, 시세·주문·잔고·일봉 조회. **이중 Rate Limiter**: Token Bucket(초당) + 슬라이딩 윈도우(분당). **지수 백오프+지터**: `_backoff_with_jitter()`로 thundering-herd 방지. **SSL/커넥션 에러 전용 핸들러**: ConnectionError, SSLError 등 별도 처리 + `_total_conn_errors` 추적. **토큰 쿨다운**: 인증 실패 시 60초간 요청 차단(`_token_error_until`). `get_rate_limit_stats()`: 사용량 모니터링(최근 60초 활용률, 429 누적, 커넥션 에러 누적, 쿨다운 상태). CircuitBreaker 연동. | -| **websocket_handler.py** | KIS 웹소켓 실시간 체결/호가. asyncio 기반, Heartbeat 45초 타임아웃, 자동 재연결, 콜백으로 가격 전달. 재연결 갭은 대시보드 상태로 노출하고 1분↑ 분봉 보충·급변 보고, 2분↑ REST 현재가/일봉 캐시 갱신·BlackSwan 즉시 점검, 디스코드 경고를 수행한다. | +| **websocket_handler.py** | KIS 웹소켓 실시간 체결/호가. asyncio 기반, Heartbeat 45초 타임아웃, 자동 재연결, 콜백으로 가격 전달. 한 프레임에 여러 건(`data_count`)이 오면 레코드별로 나눠 모두 전달한다. 재연결 갭은 대시보드 상태로 노출하고 1분↑ 분봉 보충·급변 보고, 2분↑ REST 현재가/일봉 캐시 갱신·BlackSwan 즉시 점검, 디스코드 경고를 수행한다. **단, 현재 어떤 런타임(일일 CLI `main.py`·스케줄러·웹 대시보드)도 이 핸들러를 시작하지 않는다 — 생성하는 곳은 테스트뿐이다.** 따라서 위 갭 보충·BlackSwan 재점검은 어떤 모드에서도 실제로 돌지 않으며, 대시보드 `ws_gap` 패널은 '정보 없음'이 정상이다. | | **circuit_breaker.py** | API 연속 5회 실패 시 CLOSED → OPEN(60초 차단) → HALF_OPEN. 요청 차단으로 계정 제재 방지. Notifier 알림. | ### 3.6 backtest/ @@ -428,7 +428,7 @@ quant_trader/ | **indicators** | rsi(period, oversold, overbought), macd, bollinger, moving_average, stochastic, adx, atr, volume | 지표 파라미터 | | **scoring** | buy_threshold(3), sell_threshold(-3), collinearity_mode(representative_only), weights | 스코어링 가중치·임계값 | | **mean_reversion** | z_score_buy(-2), z_score_sell(2), lookback_period(20), adx_filter, exclude_52w_low_near, fundamental_filter(PER·부채비율), restrict_to_kospi200 | 평균 회귀 | -| **trend_following** | adx_threshold, trend_ma_period, atr_stop_multiplier, trailing_atr_multiplier | 추세 추종 | +| **trend_following** | adx_threshold, trend_ma_period(200) | 추세 추종 (손절·트레일링 배수는 risk_params.yaml) | | **momentum_factor** | lookback_days(20), buy_threshold_pct, sell_threshold_pct | 모멘텀 팩터 (앙상블용) | | **volatility_condition** | lookback_days(60), low_vol_max_pct, high_vol_min_pct | 변동성 조건 (앙상블용) | | **fundamental_factor** | per_sector_relative, roe_min, debt_ratio_max, earnings_growth_min, data_cache_hours 등 | 펀더멘털 단독 전략·앙상블 구성 공통 설정 | @@ -446,7 +446,7 @@ quant_trader/ | **liquidity_filter** | enabled(true), min_avg_trading_value_20d_krw(5e9), strict(true), check_on_entry(true) | 유동성 필터 (20일 평균 거래대금) | | **diversification** | max_position_ratio(0.20), max_investment_ratio(0.70), max_positions(10), min_cash_ratio, max_sector_ratio(0.40) | 분산 투자·업종 비중 제한 | | **position_limits** | max_holding_days(30), min_holding_days(5) | 최대/최소 보유 기간. 손절·갭다운·블랙스완·수동 긴급 청산은 최소 보유 기간 예외 | -| **drawdown** | max_portfolio_mdd(0.15), max_daily_loss(0.03), recovery_scale | MDD 제한 | +| **drawdown** | max_portfolio_mdd(0.15), max_daily_loss(0.03) | MDD 제한(회복은 한도 절반까지 히스테리시스). 목표 비중 바스켓 주문은 이 한도 대신 바스켓 낙폭 규칙을 따름 | | **performance_degradation** | enabled, recent_trades(20), min_win_rate(0.35) | 성과 열화 감지 | | **paper_backtest_compare** | live_readiness(min_direction_agreement_pct, max_return_diff_pct, min_trading_days, min_trades) | 실전 전환 기준 | | **transaction_costs** | commission_rate(0.00015), tax_rate(0.0020), slippage(0.0005), dynamic_slippage, capital_gains_tax | 거래 비용 | @@ -455,7 +455,7 @@ quant_trader/ | 섹션 | 주요 키 | 설명 | |------|---------|------| -| **baskets.\** | name, enabled | 바스켓 정의. `enabled: true`인 바스켓만 스케줄러/CLI에서 실행 | +| **baskets.\** | name, enabled | 바스켓 정의. `enabled: true`인 바스켓만 일일 CLI(`--mode rebalance`)에서 실행 | | **rebalance** | trigger(drift/weekly/monthly), drift_threshold(0.05), min_trade_amount, max_turnover_ratio | 리밸런싱 트리거·제약 조건 | | **holdings** | symbol: weight | 종목별 목표 비중 (합계 1.0) | | **signal_weighted** | signal_weighted, signal_strategy, signal_weight_range | 전략 점수로 비중 동적 조정 (선택) | @@ -690,7 +690,7 @@ full paper 신규 BUY는 preflight status artifact와 runtime state가 모두 | ✅ KIS 호출 제어 강화 | 지수 백오프+지터, SSL/커넥션 에러 핸들러, 토큰 쿨다운 60초, Circuit Breaker `HALF_OPEN` 단일 probe | | ✅ 주문 실패 Dead-letter 큐 | FailedOrder 테이블에 영구 저장, 재처리 API 지원 | | ✅ 전략 레지스트리(플러그인형) | `create_strategy(name)`으로 동적 로딩 | -| ✅ 바스켓 리밸런싱 | 목표 비중 관리, 드리프트/주기 트리거, 신호 가중, CLI+스케줄러 통합 | +| ✅ 바스켓 리밸런싱 | 목표 비중 관리, 드리프트/주기 트리거, 신호 가중, 일일 CLI 실행(스케줄러 경로는 2026-09 제거) | | ✅ schedule 모드 + 런타임 락 | 모의 무한 루프, `data/.scheduler.lock` 중복 방지 | | ✅ 미국 티커·휴장일 | `fetch_stock`, `us_holidays.yaml`, NYSE 장세션 헬퍼 | | ✅ DART(선택) | `DART_API_KEY` / `settings.dart` 시 실적일 폴백 | @@ -809,7 +809,7 @@ full paper 신규 BUY는 preflight status artifact와 runtime state가 모두 | ✅ **target-weight rank-risk 보강 검증 완료** | exp75 계열에 `rankrisk90_dd75`, `rankrisk120`을 추가하고 tol4+pdd10/floor40 guard와 결합했다. top-200 후보 16개 재검증에서는 `rankrisk120_tol4`가 return +84.41%, raw excess +52.52%p, MDD -19.30%, turnover/year 919.4%, `rankrisk90_dd75_tol4`가 return +82.56%, raw excess +50.67%p, MDD -19.54%, turnover/year 896.0%로 기존 tol4보다 개선됐다. canonical에서는 두 후보 모두 turnover는 통과했지만 benchmark excess -23.70%p/-23.40%p와 MDD -20.74%/-20.76%로 `paper_only`다 | | ✅ **포트폴리오 백테스트 이벤트 guard 추가** | `backtest/portfolio_backtester.py`에 gap-up 신규 매수 차단, gap-down `GAP_DOWN` 청산, 어닝 윈도우 신규 매수 차단, BlackSwan 청산·쿨다운·recovery 사이징과 `gap_*`/`earnings_*`/`blackswan_*` 진단 카운터를 추가 | | ✅ **비용 전/후 성과 비교 리포트 추가** | `backtest.cost_impact`가 수수료·세금·슬리피지를 표준 집계해 비용 차감 전 추정 수익률, 비용 차감 후 수익률, 비용 드래그, 비용/순손익, cost impact status를 단일/포트폴리오 백테스트 metrics와 txt/html 리포트에 자동 노출한다 | -| ✅ **WebSocket 갭 상태/보충 처리** | `api/websocket_handler.py`가 웹소켓 끊김·재연결 갭을 ring buffer와 대시보드 `ws_gap`에 기록한다. 재연결 시 REST 현재가/일봉 캐시 갱신, 분봉 보충 기반 급변 보고, BlackSwan 즉시 점검, 디스코드 경고를 수행하며, 연결 성공·명시 종료 상태도 대시보드 스냅샷에 즉시 반영한다 | +| ⚠️ **WebSocket 갭 상태/보충 처리 — 코드만 있음, 런타임 미연결** | `api/websocket_handler.py`에 웹소켓 끊김·재연결 갭을 ring buffer와 대시보드 `ws_gap`에 기록하고, 재연결 시 REST 현재가/일봉 캐시 갱신, 분봉 보충 기반 급변 보고, BlackSwan 즉시 점검, 디스코드 경고를 수행하는 코드가 있다. 그러나 일일 CLI·스케줄러·대시보드 어디에서도 핸들러를 시작하지 않아 어떤 모드에서도 동작하지 않는다(2026-09 감사). 연결하려면 스케줄러 asyncio 태스크·가격 캐시 콜백을 새로 만들어야 하고, 재연결 루프가 스케줄러 프로세스의 새 장애 지점이 되는 점을 함께 검토해야 한다 | | ✅ **pilot entry fail-closed audit 추가** | `check_pilot_entry()`의 모든 blocked/allowed 결과를 `pilot_audit.jsonl`에 기록하고, runtime/evidence/notifier/order-count/position-count/gross-exposure guard 예외는 pilot entry 차단으로 처리. 기존 pilot auth가 있더라도 entry 직전에 전략 상태와 artifact eligibility를 다시 확인해 승인 이후 강등된 전략은 차단한다. gross exposure cap은 현재 노출이 아직 한도 미만이어도 이번 주문 후 예상 노출이 한도를 넘으면 차단한다 | | ✅ **generic paper entry guard 추가** | `main.py --mode paper`, scheduler auto-entry, `execute_buy_quantity()` 모두 preflight/runtime 확인 실패 시 BUY를 fail-closed 차단. blocked runtime의 pilot override는 `check_pilot_entry()` 재검증을 통과해야 하며 SELL은 exit-safe 유지 | | ✅ **canonical 평가 입력 snapshot 추가** | `tools/evaluate_and_promote.py --canonical`이 선정 종목의 유동성 데이터 범위, 벤치마크 데이터 범위, 데이터 수집 오류, `data_snapshot_hash`를 `run_metadata.json`에 기록한다. 평가 예외는 단순 0% 성과와 구분해 `evaluation_status=failed`로 남긴다 | @@ -858,7 +858,7 @@ full paper 신규 BUY는 preflight status artifact와 runtime state가 모두 | DART·어닝 필터 고도화 | 기본 연동 완료(`dart_loader`+`earnings_filter`). 공시 키워드·커버리지·폴백 정책 확대 | 중기 (3~6개월) | | 펀더멘털 신호 고도화 | `fundamental_factor`·앙상블 구성 반영됨. 지표·해외 종목·공시 연계 강화 | 중기 | | 웹 대시보드 강화 | 전략별 신호, 주문 목록, API 사용량 표시 | 중기 | -| WebSocket 갭 처리 고도화 | 기본 갭 보충·급변 감지는 완료. 향후 종목별 체결 공백 재구성, 브로커 체결 이벤트와의 교차 검증, 운영 알림 SLA를 확장 | 중기 | +| WebSocket 런타임 연결·갭 처리 고도화 | 갭 보충·급변 감지 코드는 있으나 어떤 런타임에도 연결되지 않았다. 먼저 스케줄러에 연결(공유 KIS 토큰 사용)한 뒤 종목별 체결 공백 재구성, 브로커 체결 이벤트와의 교차 검증, 운영 알림 SLA를 확장 | 중기 | ### 다음 연구 방향 — 2026-04-29 기준 diff --git a/docs/RESEARCH_LOG.md b/docs/RESEARCH_LOG.md index 6bab8d58..3b1dbec2 100644 --- a/docs/RESEARCH_LOG.md +++ b/docs/RESEARCH_LOG.md @@ -236,3 +236,43 @@ Target-weight finalize 성과 진단: `--finalize-pilot-evidence`가 `total_valu - 변동성 목표(15~20%)는 고변동 구간이 고수익 구간이라 연수익률을 2~9%p 깎는다. 옵션으로만 남기고 기본 off. 반영: `core/risk_overlays.py`(순수 함수 + 상태 파일 `data/overlay_state/.json`) + `BasketRebalancer._stock_fraction = 설계 × 배수`. 배치 진단·트리거·헬스(`main.py --mode health`)·대시보드 API가 전부 '그날의 적용 비중'을 본다. 데이터가 없으면 직전 상태를 유지하고 `data_issues`로 표면화한다. `kr_pocket`은 추세 필터 + 낙폭 제어를 켰고(2026-09-17 현재 코스피200 200일선 +12%, 낙폭 −5%라 즉시 바뀌는 주문 없음), `kr_diversified_hold`는 실전 전환 승인 대기 중이라 권장값만 기록하고 껐다. 신호형 전략은 손대지 않았다 — 구조(회전율 1,860%/년, 익절 상한) 문제라 오버레이 대상이 아니다. + +## 2026-09-23 관찰 트랙 오버레이 다시 계산 — 9/17 결론 취소 + +9/17 표에서는 관찰 트랙에 낙폭 제어를 켜면 칼마가 0.70에서 0.97로 좋아진다고 나왔는데, +운영 트랙과 조건이 달랐다. 표는 000660(SK하이닉스)을 넣은 10종목으로 돌렸고 현금에 연 3% +이자를 붙였다. 운영 트랙은 8/07부터 000660을 뺀 9종목이고, paper 계좌 현금에는 이자가 없다. +주식 부분도 첫날 비중을 끝까지 들고 가서, 8%p 넘게 벌어지면 되돌리는 운영 규칙과 달랐다. +`tools/risk_overlay_backtest.py`가 baskets.yaml 보유 종목, 되돌림 규칙, 이자 없는 현금을 +쓰도록 고쳐서 다시 돌렸다(2021-12~2026-09-16). + +- 고정 비중: 연 +6.07%, 최대낙폭 −19.5%, 샤프(금리 0%) 0.45, 칼마 0.31 +- 낙폭 제어(−10%에서 절반): 연 +3.10%, −15.0%, 0.32, 0.21 +- 추세 필터(200일선 아래면 절반): 연 +4.58%, −18.1%, 0.39, 0.25 + +어떤 오버레이를 켜도 샤프와 칼마가 떨어진다. 낙폭 제어는 최대낙폭을 4.5%p 줄이는 대신 +연수익률을 절반 가까이(연 3%p) 깎는다. 9/17에 적어 둔 권장값은 근거가 없어졌으니 +kr_diversified_hold 오버레이는 계속 꺼 둔다. 같은 코드로 예전 조건(10종목, 현금 3%)을 돌려도 +고정 +13.59%, 낙폭 제어 +10.70%에 칼마 0.78 → 0.77이라 좋아지는 효과는 보이지 않는다. + +주식 비중별 비교(P3, 같은 9종목, 고정 비중): + +| 주식 비중 | 연수익률 | 최대낙폭 | 샤프(금리 0%) | 상승 포착 | 하락 포착 | +|---|---:|---:|---:|---:|---:| +| 60% | +6.07% | −19.5% | 0.45 | 45% | 48% | +| 70% | +7.02% | −22.0% | 0.46 | 53% | 56% | +| 80% | +8.28% | −23.6% | 0.49 | 60% | 64% | + +포착률은 하루 평균(기하평균) 수익률끼리 나눈 값이다. 9종목 동일비중은 비중을 얼마로 잡든 +오를 때보다 내릴 때 조금 더 따라간다. 이 기간 지수를 끌어올린 대형 반도체 비중이 작아서다. +주식 비중을 높이면 수익과 낙폭이 거의 같이 늘 뿐 공짜로 좋아지는 건 없다. + +기대 수익 정정: 지금 설계(주식 60%, 9종목)로 2021-12부터 실제로 낸 연수익률은 약 6%다 +(2022 약세장, 2026 여름 급락 포함). 예전에 적은 '기본 13~14%'는 주식 80%, 10종목을 사 두기만 +한 경우를 2026-06까지 계산하고 하이닉스를 뺀 숫자였다. + +포착률 계산도 고쳤다. 국면별로 복리 수익률을 이어 붙여 나누면 기간이 길수록 한쪽으로 +쏠린다(위 표를 그 방식으로 내면 상승 1%, 하락 96%). 몇 달씩 쌓이는 주간 리포트 수치도 같은 +식으로 틀어지기 때문에 `core/performance_lens.split_by_regime`의 capture를 하루 평균 수익률의 +비로 바꿨다. 적립 트랙 표도 지금 코드로 다시 만들었다. 커밋돼 있던 표는 9/22에 백테스트를 +고치기 전 코드로 만든 것이었다. diff --git a/docs/RISK_OVERLAY_FINDINGS.md b/docs/RISK_OVERLAY_FINDINGS.md index d763df37..ef60738f 100644 --- a/docs/RISK_OVERLAY_FINDINGS.md +++ b/docs/RISK_OVERLAY_FINDINGS.md @@ -7,3 +7,11 @@ ![실제 ETF 비교](images/risk-review-20260917.png) 원본 수치는 [결과 JSON](../reports/research/risk_review_20260917.json), 재현 코드는 [tools/risk_review.py](../tools/risk_review.py)에 있습니다. 이전 문서는 Git 이력에서 확인할 수 있습니다. + +## 2026-09-23 관찰 트랙 표 다시 계산 + +대형주 분산 계좌 표를 실제로 들고 있는 9종목(000660 제외), 8%p 벗어나면 되돌리는 규칙, 이자 없는 +현금으로 다시 계산했습니다. 이 조건에서는 추세 필터와 낙폭 제어 모두 샤프와 칼마를 떨어뜨립니다 +(고정 비중 샤프 0.45, 낙폭 제어 0.32). 예전 표에서 좋아 보였던 건 하이닉스를 넣고 현금에 이자를 +붙인 가정 때문이었으므로, 이 계좌의 위험 조절 규칙은 계속 꺼 둡니다. 결과는 +[연구 리포트](../reports/research/risk_overlay_backtest.md), 정리는 [연구 기록 2026-09-23](RESEARCH_LOG.md)에 있습니다. diff --git a/docs/images/overlay-basket.png b/docs/images/overlay-basket.png index f8b0403f..d8fc53b1 100644 Binary files a/docs/images/overlay-basket.png and b/docs/images/overlay-basket.png differ diff --git a/docs/images/overlay-pocket-etf.png b/docs/images/overlay-pocket-etf.png index 38b9efed..87d06d1b 100644 Binary files a/docs/images/overlay-pocket-etf.png and b/docs/images/overlay-pocket-etf.png differ diff --git a/docs/images/overlay-pocket-ks200.png b/docs/images/overlay-pocket-ks200.png index 89c84a5a..34e58186 100644 Binary files a/docs/images/overlay-pocket-ks200.png and b/docs/images/overlay-pocket-ks200.png differ diff --git a/main.py b/main.py index c08a8544..7637daa2 100644 --- a/main.py +++ b/main.py @@ -626,9 +626,26 @@ def run_deploy_check(args) -> int: rb = BasketRebalancer(basket_name=basket_name, config=config) orders = rb.plan_rebalance(prices=prices) if prices else [] + # 회전율 분모는 이 바스켓 계정의 실제 평가액이다. 예전엔 1,000만 원으로 고정돼 + # kr_pocket(약 39만 원) 점검에서 회전율이 약 25배 작게 나왔다. + portfolio_value = getattr(args, "initial_capital", None) + if not portfolio_value: + try: + portfolio_value = rb.portfolio_mgr.get_portfolio_summary( + current_prices=prices or None, + ).get("total_value") + except Exception as exc: + logger.warning("바스켓 평가액 조회 실패 — 설정 자본 사용: {}", exc) + portfolio_value = None + portfolio_value = float( + portfolio_value or basket_cfg.get("initial_capital") or 10_000_000 + ) summary = summarize_basket_deployment( basket_name, basket_cfg, orders, prices, - portfolio_value=float(getattr(args, "initial_capital", None) or 10_000_000), + portfolio_value=portfolio_value, + tax_exempt_symbols=(config.risk_params.get("transaction_costs") or {}).get( + "tax_exempt_symbols" + ), ) if getattr(args, "json", False): @@ -679,6 +696,17 @@ def run_rebalance(args): return _run_rebalance_impl(args) +def _market_closed_today(config, now) -> bool: + """now(KST)가 휴장일인가. 판정에 실패하면 False — 평소대로 진행한다.""" + try: + from core.trading_hours import TradingHours + + return not TradingHours(config).is_trading_day(now) + except Exception as exc: + logger.warning("거래일 판정 실패 — 평소대로 진행: {}", exc) + return False + + def _run_rebalance_impl(args): """바스켓 포트폴리오 리밸런싱 모드.""" from datetime import datetime @@ -691,6 +719,7 @@ def _run_rebalance_impl(args): unreported_snapshot_gaps, format_gap_alert, record_cycle_event, + record_event_once_per_day, ) config = Config.get() @@ -758,6 +787,25 @@ def _run_rebalance_impl(args): ) cycle_snapshots_saved = 0 + # 휴장일 실행은 스냅샷(직전 거래일 날짜로 저장)과 빠진 날 채우기만 하고 매매는 하지 않는다. + # 휴장일의 '현재가'는 직전 거래일 종가라, 그 가격으로 체결을 남기면 장이 닫힌 + # 날의 가짜 체결이 트랙레코드와 비용 통계에 섞인다. 평일 휴장(추석 등)에도 + # 일일 태스크(월~금 10시)는 돈다. 판정 실패 시 평소대로 진행한다(1일 1매매 + # 가드와 같은 원칙 — 방어선이지 게이트가 아니다). + kst_now = datetime.now(ZoneInfo("Asia/Seoul")).replace(tzinfo=None) + market_closed_today = _market_closed_today(config, kst_now) + holiday_skip = market_closed_today and not dry_run and not force_rebalance + if holiday_skip: + logger.info( + "오늘({})은 휴장일 — 리스크 청산·리밸런싱 매매를 건너뛰고 스냅샷만 남긴다 " + "(우회: --force-rebalance)", kst_now.date(), + ) + record_cycle_event( + "REBALANCE_SKIPPED_MARKET_CLOSED", + f"휴장일({kst_now.date()}) 실행 — 매매 생략, 스냅샷만 기록", + mode=mode, + ) + for name in basket_names: try: live_strategy_name = _rebalance_live_strategy_id(name) @@ -786,48 +834,32 @@ def _run_rebalance_impl(args): ) sys.exit(1) - report = rebalancer.get_status_report() - logger.info("\n{}", report) - - # 1일 1매매 패스 원칙 강제: 같은 날 두 번째 실행(중복 스케줄·수동 재실행)이 - # 회전 상한(1회 15%)을 사실상 2배로 만드는 것을 차단한다 — 6/10 진입 뭉침 - # (하루 4회 실행 → 61% 집중 매입, 타이밍 비용 -1%p)의 재발 방지를 코드로. - # 실측: 7/3·7/7 일일 태스크가 사이클을 2회 연속 실행함. 결측 복구 재시도 - # 크론은 목적이 스냅샷이라 이 가드로 오히려 더 안전해진다(매매 없이 복구). - # 판정 실패 시 기존 동작 유지(가드는 방어선이지 게이트가 아님). - # 우회: --force-rebalance (운영자 명시 결정). - already_traded_today = False - if not dry_run and not force_rebalance: - try: - from database.repositories import get_trade_history - today0 = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) - already_traded_today = bool(get_trade_history( - mode=mode, start_date=today0, account_key=live_strategy_name, - )) - except Exception as guard_exc: - logger.debug("바스켓 '{}' 당일 체결 판정 실패(가드 생략): {}", name, guard_exc) - - # 최근 결측 스냅샷 자동 보충 — 매매보다 먼저. 매매를 먼저 하면 원장이 - # 바뀌어 과거 날짜 재구성이 헷갈릴 여지가 생긴다(재생 자체는 시각 기준이라 - # 정확하지만, 순서를 고정해 두는 편이 읽기 쉽다). + # 최근 결측 스냅샷 자동 보충 — 상태 보고·매매보다 먼저 한다. # # 왜 필요한가: _nav_attribution_date가 '오늘이 거래일이면 오늘'로 귀속하므로 # 어제 사이클이 안 돌면 그 하루는 영원히 빈다. 실제로 2026-08-18 결측이 # 8/19~8/26 내내 그대로 남아 커버리지를 승격 기준(95%) 아래로 끌어내렸다. # 복원분은 reconstructed=True로 표시돼 평가가 실측과 나눠 표기한다. - if not dry_run: + # + # 왜 상태 보고보다 먼저인가: get_status_report가 리스크 오버레이 판단을 + # 계산해 캐시하는데, 낙폭 가드 입력(NAV 스냅샷)에 어제가 비어 있으면 그날 + # 판단이 '자료 확인 전 비중 확대 보류'로 굳는다. 보충이 먼저 돌아야 한다. + # + # 자본은 리밸런서가 이미 해석한 값(baskets.yaml initial_capital → 전역)을 쓴다. + # 2026-08-27 도입 때 이 자리에 정의되지 않은 이름(baskets_cfg)을 써서 + # NameError가 매 사이클 났고, 경고 로그로만 남아 한 달간 아무도 몰랐다. + # + # paper에서만 한다. live 기록은 증권사 잔고를 따라야 하는데, 복원분은 로컬 + # 원장과 시가·종가 중간값으로 만든 추정치라 live 트랙에 섞으면 안 된다. + if not dry_run and mode == "paper": try: from core.snapshot_backfill import backfill_account - _cap = (baskets_cfg.get(name) or {}).get("initial_capital") - _today = datetime.now(ZoneInfo("Asia/Seoul")).replace(tzinfo=None).date() + _today = kst_now.date() filled = backfill_account( config, live_strategy_name, - float(_cap) if _cap is not None else float( - (config.risk_params.get("position_sizing") or {}) - .get("initial_capital", 10_000_000) - ), + float(rebalancer.portfolio_mgr.initial_capital), _today - timedelta(days=BACKFILL_LOOKBACK_DAYS), _today - timedelta(days=1), # 오늘은 아래 스냅샷 단계가 찍는다 mode=mode, @@ -842,8 +874,36 @@ def _run_rebalance_impl(args): "SNAPSHOT_BACKFILLED", msg, severity="warning", strategy=live_strategy_name, mode=mode, ) + notifier.send_message(msg) except Exception as bf_exc: - logger.warning("바스켓 '{}' 결측 보충 생략: {}", name, bf_exc) + # 이벤트로 남겨야 주간 리포트·헬스가 센다(로그 경고만으로는 안 보인다). + logger.warning("바스켓 '{}' 결측 보충 실패: {}", name, bf_exc) + record_event_once_per_day( + "SNAPSHOT_BACKFILL_FAILED", + f"바스켓 '{name}' 결측 스냅샷 보충 실패: {bf_exc}", + severity="warning", strategy=live_strategy_name, mode=mode, + ) + + report = rebalancer.get_status_report() + logger.info("\n{}", report) + + # 1일 1매매 패스 원칙 강제: 같은 날 두 번째 실행(중복 스케줄·수동 재실행)이 + # 회전 상한(1회 15%)을 사실상 2배로 만드는 것을 차단한다 — 6/10 진입 뭉침 + # (하루 4회 실행 → 61% 집중 매입, 타이밍 비용 -1%p)의 재발 방지를 코드로. + # 실측: 7/3·7/7 일일 태스크가 사이클을 2회 연속 실행함. 결측 복구 재시도 + # 크론은 목적이 스냅샷이라 이 가드로 오히려 더 안전해진다(매매 없이 복구). + # 판정 실패 시 기존 동작 유지(가드는 방어선이지 게이트가 아님). + # 우회: --force-rebalance (운영자 명시 결정). '오늘'은 KST 자정 기준. + already_traded_today = False + if not dry_run and not force_rebalance: + try: + from database.repositories import get_trade_history + today0 = kst_now.replace(hour=0, minute=0, second=0, microsecond=0) + already_traded_today = bool(get_trade_history( + mode=mode, start_date=today0, account_key=live_strategy_name, + )) + except Exception as guard_exc: + logger.debug("바스켓 '{}' 당일 체결 판정 실패(가드 생략): {}", name, guard_exc) # 리스크 청산(손절/익절/트레일링)을 리밸런싱보다 먼저 평가한다. # 그동안 이 사이클은 비중 교정만 했고 손절/익절은 장중 스케줄러 @@ -854,14 +914,19 @@ def _run_rebalance_impl(args): # '1일 1매매 패스' 가드는 적용하지 않는다 — 그 가드는 회전율 상한 우회를 # 막으려는 것이고, 리스크 청산은 회전 예산이 아니라 손실 제한이다. # 청산이 이미 끝났으면 포지션이 없어 재평가가 비어 자연히 멱등이다. - try: - # list로 좁힌다 — 평가 결과가 주문 목록이 아니면 청산을 시도하지 않는다 - # (빈 목록과 '목록이 아닌 무언가'를 구분하지 않으면 유령 청산이 난다). - planned = rebalancer.plan_risk_exits() - exit_orders = list(planned) if isinstance(planned, (list, tuple)) else [] - except Exception as exit_exc: - exit_orders = [] - logger.error("바스켓 '{}' 리스크 청산 평가 실패: {}", name, exit_exc) + exit_orders = [] + if holiday_skip: + # 휴장일 가격은 직전 거래일 종가다 — 손절선 판단 근거가 새로 생기지 않았다. + logger.info("바스켓 '{}' 휴장일 — 리스크 청산 평가 생략", name) + else: + try: + # list로 좁힌다 — 평가 결과가 주문 목록이 아니면 청산을 시도하지 않는다 + # (빈 목록과 '목록이 아닌 무언가'를 구분하지 않으면 유령 청산이 난다). + planned = rebalancer.plan_risk_exits() + exit_orders = list(planned) if isinstance(planned, (list, tuple)) else [] + except Exception as exit_exc: + exit_orders = [] + logger.error("바스켓 '{}' 리스크 청산 평가 실패: {}", name, exit_exc) if exit_orders: exit_result = rebalancer.execute( exit_orders, @@ -882,7 +947,12 @@ def _run_rebalance_impl(args): notifier.send_message(exit_summary, critical=True) executed = False - if already_traded_today: + if holiday_skip: + logger.info( + "바스켓 '{}' 휴장일 — 리밸런싱 매매 건너뜀 " + "(스냅샷은 직전 거래일 날짜로 저장, 우회: --force-rebalance)", name, + ) + elif already_traded_today: logger.info( "바스켓 '{}' 오늘 이미 체결 있음 — 1일 1매매 패스 원칙으로 매매 건너뜀 " "(스냅샷·리포트는 진행, 우회: --force-rebalance)", name, @@ -996,8 +1066,9 @@ def _run_rebalance_impl(args): ) # 일간 수익률: 직전 스냅샷 대비 (summary에는 누적치만 있다). # 적립식 계정은 입금을 분모에 더해 중화한다 — 입금일에 +33% 같은 - # 가짜 일간 수익 방지(docs/POCKET_TRACK_PLAN.md §4). - daily_ret = 0.0 + # 가짜 일간 수익 방지(docs/POCKET_TRACK_PLAN.md §4). 계산하지 못하면(첫 + # 기록·입금 조회 실패) None으로 둬 카드에 '—'로 나온다 — 0.00%는 틀린 값이다. + daily_ret = None try: from database.repositories import ( get_portfolio_snapshots, @@ -1020,17 +1091,15 @@ def _run_rebalance_impl(args): else: prev_boundary = sdf["date"].iloc[-2] if prev > 0: - flow = 0.0 - try: - flow = get_cash_flow_total_between( - live_strategy_name, prev_boundary, datetime.now(), - mode=ledger_mode, - ) - except Exception: - pass + # 입금 조회에 실패하면 일간 수익률을 비워 둔다 — 입금을 빼지 않고 + # 계산하면 입금한 날이 통째로 수익으로 보인다. + flow = get_cash_flow_total_between( + live_strategy_name, prev_boundary, datetime.now(), + mode=ledger_mode, + ) daily_ret = twr_period_return(prev, last, flow) * 100 - except Exception: - pass + except Exception as dr_exc: + logger.warning("바스켓 '{}' 일간 수익률 계산 실패: {}", name, dr_exc) report_card = { "total_value": summary_data.get("total_value", 0), "cash": summary_data.get("cash", 0), @@ -1062,10 +1131,44 @@ def _run_rebalance_impl(args): ) ) except Exception as e: - logger.debug("바스켓 '{}' 리포트 v2 부가필드 생략: {}", name, e) + logger.warning("바스켓 '{}' 리포트 v2 부가필드 생략: {}", name, e) + # 리스크 한 줄(변동성·샤프·하락일) — 수익률 한 숫자만 보면 '방어의 + # 대가'가 안 보인다(운영 원칙 9). 입금은 그 입금이 처음 반영된 스냅샷 + # 구간에서 뺀다(날짜로만 묶으면 입금한 날이 +25~36% 수익으로 잡힌다). + try: + from core.performance_lens import ( + daily_returns_from_nav, format_risk_line, risk_metrics, + ) + from database.models import PortfolioSnapshot, get_session + + _sess = get_session() + try: + _snaps = ( + _sess.query(PortfolioSnapshot) + .filter( + PortfolioSnapshot.mode == ledger_mode, + PortfolioSnapshot.account_key == live_strategy_name, + ) + .order_by(PortfolioSnapshot.date.asc()) + .all() + ) + finally: + _sess.close() + if len(_snaps) >= 3: + _flows = account_flows_by_snapshot( + live_strategy_name, _snaps, mode=ledger_mode, + ) + _daily = daily_returns_from_nav( + [(s.date, s.total_value) for s in _snaps], flows=_flows, + ) + report_card["risk"] = format_risk_line( + risk_metrics([r for _, r in _daily]) + ) + except Exception as e: + logger.warning("바스켓 '{}' 리스크 지표 생략: {}", name, e) notifier.send_daily_report(report_card) except Exception as e: - logger.debug("바스켓 '{}' 일일 리포트 발송 실패(무시): {}", name, e) + logger.warning("바스켓 '{}' 일일 리포트 발송 실패: {}", name, e) except Exception as e: logger.error("바스켓 '{}' 리밸런싱 실패: {}", name, e) @@ -1909,16 +2012,23 @@ def run_health_check() -> int: def _design_fraction(cfg: dict, basket_name: str | None = None) -> float: """BasketRebalancer._stock_fraction과 동일 규칙(인스턴스 없이 계산). - 리스크 오버레이가 켜진 바스켓은 마지막 실행이 남긴 배수를 곱한 '적용 비중'을 - 쓴다 — 오버레이가 비중을 절반으로 줄인 날 설계 비중과 비교하면 매일 거짓 - 미달 경보가 울리기 때문이다(선언한 숫자마다 감시가 붙는 원칙의 대상은 - 그날 실제로 유효한 목표다). + 리스크 오버레이가 켜진 바스켓은 마지막 실행이 남긴 배수를 반영한 '그날 + 실제로 쓰는 목표'를 쓴다(적어 둔 숫자마다 감시가 붙는다는 원칙). 방어 + 자산(CD ETF)이 있으면 줄인 주식만큼 그 자산을 사므로 투자 비중은 설계 + 그대로다 — 리밸런서와 같은 헬퍼(invested_fraction)로 계산해야 기준이 같다. + 상태 파일은 이 함수의 다른 조회와 같은 paper 장부 것을 읽는다(모드 없이 + 읽으면 더 이상 갱신되지 않는 예전 공용 파일을 본다). """ from core.basket_deploy import effective_stock_fraction - from core.risk_overlays import applied_stock_fraction, load_overlay_state, parse_overlay_config + from core.risk_overlays import invested_fraction, load_overlay_state, parse_overlay_config design = effective_stock_fraction(cfg, config.risk_params) if basket_name and parse_overlay_config(cfg).any_enabled: - return applied_stock_fraction(design, load_overlay_state(basket_name)) + return invested_fraction( + cfg.get("holdings") or {}, + design, + load_overlay_state(basket_name, mode="paper"), + (cfg.get("overlays") or {}).get("defensive_symbol"), + ) return design # 바스켓별 전용 계정 키(basket_rebalance:) 기준으로 조회한다. @@ -1960,29 +2070,37 @@ def _design_fraction(cfg: dict, basket_name: str | None = None) -> float: if snap and snap.total_value and snap.total_value > 0: from core.operator_health import structural_deployment_tolerance + from core.operator_health import unfixable_deployment_gap + cfg_b = baskets_cfg.get(name) or {} dep_ratio = max(0.0, (snap.total_value - (snap.cash or 0)) / snap.total_value) design = _design_fraction(cfg_b, name) floor_tol = float( (cfg_b.get("monitoring") or {}).get("deployment_tolerance", 0.05) ) - # 보유 슬롯별 1주 가격의 합 — 슬롯마다 '부족분 < 1주' 절사가 - # 동시에 존재할 수 있으므로 최고가 1주만 재면 다중 슬롯 바스켓이 - # 정상 적립 중에 거짓 ATTENTION을 울린다. 빈 슬롯은 실패이므로 제외. - truncation_unit = sum( - float(p.avg_price or 0) for p in positions_b + # 보충 매수가 메우지 못하고 남길 수 있는 미달 = band + 가장 싼 + # 집행 가능 묶음. 예전엔 보유 슬롯별 1주 가격을 전부 더해서 + # 9종목 바스켓의 허용이 20%p까지 벌어졌고, 8/26에 '복원'한 5%p + # 감시가 실제로는 울릴 수 없었다(8/07~8/26 현금 래칫 54.9%도 OK). + reb_b = cfg_b.get("rebalance") or {} + unit = unfixable_deployment_gap( + [float(p.avg_price or 0) for p in positions_b], + float(snap.total_value), + min_trade=float(reb_b.get("min_trade_amount", 100000)), + band=float(reb_b.get("deployment_band", 0.03)), ) tol_b = structural_deployment_tolerance( - truncation_unit, float(snap.total_value), floor_tol, + unit, float(snap.total_value), floor_tol, ) - violation = (design - dep_ratio) - tol_b + # 미달·초과 양쪽을 본다(원칙 4 — 한쪽으로만 도는 장치를 의심한다). + violation = abs(design - dep_ratio) - tol_b if worst_shortfall is None or violation > worst_shortfall: worst_shortfall = violation worst_dep_ratio = dep_ratio worst_design = design worst_tolerance = tol_b except Exception as dep_exc: - logger.debug("바스켓 '{}' 배치율 계산 생략: {}", name, dep_exc) + logger.warning("바스켓 '{}' 배치율 계산 실패 — 배치율 감시 생략: {}", name, dep_exc) finally: session.close() oldest_last = ( @@ -2030,7 +2148,36 @@ def _design_fraction(cfg: dict, basket_name: str | None = None) -> float: if plan_state["note"]: contribution_notes.append(plan_state["note"]) except Exception as plan_exc: - logger.debug("적립 계획 점검 생략: {}", plan_exc) + logger.warning("적립 계획 점검 실패: {}", plan_exc) + + # 위험 관리(오버레이) 입력 문제: 마지막 사이클이 남긴 상태 파일의 data_issues. + data_notes: list[str] = [] + try: + from core.risk_overlays import load_overlay_state, parse_overlay_config + + for name in enabled_baskets: + cfg_b = baskets_cfg.get(name) or {} + if not parse_overlay_config(cfg_b).any_enabled: + continue + state = load_overlay_state(name, mode="paper") or {} + issues = [str(i) for i in (state.get("data_issues") or [])] + if issues: + more = f" 외 {len(issues) - 1}건" if len(issues) > 1 else "" + data_notes.append( + f"바스켓 '{name}' 위험 관리에 쓸 자료 문제({state.get('evaluated_at', '?')}): " + f"{issues[0]}{more}" + ) + except Exception as ov_exc: + logger.warning("위험 관리 입력 점검 실패: {}", ov_exc) + # 모델과 실제 DB 스키마가 어긋났는지 — 7/10 가짜 낙폭(옛 UNIQUE가 포지션을 삼킴)과 + # 같은 부류는 오류 없이 숫자만 틀리게 만든다. + try: + from database.models import check_schema_drift + + for issue in check_schema_drift(): + data_notes.append(f"DB 구조가 코드와 다름: {issue}") + except Exception as sc_exc: + logger.warning("스키마 대조 실패: {}", sc_exc) basket_operation = { "enabled_baskets": enabled_baskets, @@ -2041,6 +2188,7 @@ def _design_fraction(cfg: dict, basket_name: str | None = None) -> float: "design_fraction": worst_design, "deployment_tolerance": worst_tolerance, "contribution_notes": contribution_notes, + "data_notes": data_notes, } except Exception as exc: logger.warning("바스켓 운영 상태 조회 실패: {}", exc) @@ -2095,12 +2243,49 @@ def account_flows_by_day(account_key: str, mode: str = "paper") -> dict: return out +def account_flows_by_snapshot(account_key: str, snapshots, mode: str = "paper") -> dict: + """입출금을 '그 돈이 처음 반영된 스냅샷'의 날짜로 모은다. + + 흐름은 발생 시각 이후 처음 측정된 스냅샷 구간에 속한다(created_at >= occurred_at) + — PortfolioManager의 TWR 체인·일일 카드와 같은 경계다. 달력 날짜로 묶으면(예전 + account_flows_by_day) 그날 10:07 스냅샷 뒤에 들어온 입금이나 주말·휴장일 입금이 + 엉뚱한 구간에 들어가 그 구간이 +25~36% 수익으로 잡힌다(#461과 같은 증상이 적립 + 주기마다 재발). 아직 측정되지 않은 흐름(마지막 스냅샷 이후)은 넣지 않는다. + """ + from bisect import bisect_left + from datetime import datetime as _dt + from database.repositories import get_cash_flows + + points = [] + for s in snapshots or []: + day = s.date.date() if hasattr(s.date, "date") else s.date + measured = getattr(s, "created_at", None) or _dt.combine(day, _dt.max.time()) + points.append((measured, day)) + points.sort() + measured_at = [p[0] for p in points] + + out: dict = {} + for occurred_at, amount in get_cash_flows(account_key=account_key, mode=mode): + if occurred_at is None: + continue + i = bisect_left(measured_at, occurred_at) + if i >= len(points): + continue # 다음 실행 때 반영될 입금 — 아직 어느 구간에도 들어가지 않는다 + day = points[i][1] + out[day] = out.get(day, 0.0) + float(amount or 0) + return out + + def _fetch_benchmark_closes(start, end) -> dict: """벤치마크(KS11) 종가를 {date: close}로. 실패하면 빈 dict. - 일간 수익률이 아니라 **종가 레벨**을 준다 — 스냅샷이 빠진 날이 있으면 NAV 수익률은 + 일간 수익률이 아니라 **레벨**을 준다 — 스냅샷이 빠진 날이 있으면 NAV 수익률은 여러 날 구간이 되므로, 벤치마크도 같은 구간으로 다시 계산해야 비교가 성립한다 (core.performance_lens.aligned_returns 참고). + + NAV는 10:07 장중 마크라 지수 종가(15:30)와 비교하면 하루의 절반가량이 어긋나 + 포착률이 양쪽 다 0 쪽으로 쏠린다. 시가(09:00)가 기록 시각에 훨씬 가깝다 — + 완전히 맞출 수는 없으니 결과는 근사로 표기한다. 시가가 없으면 종가를 쓴다. """ from datetime import timedelta @@ -2110,10 +2295,17 @@ def _fetch_benchmark_closes(start, end) -> dict: s = (start.date() if hasattr(start, "date") else start) - timedelta(days=7) e = end.date() if hasattr(end, "date") else end df = fdr.DataReader("KS11", s.isoformat(), e.isoformat()) - if df is None or df.empty or "Close" not in df.columns: + if df is None or df.empty: return {} - return {idx.date(): float(v) for idx, v in df["Close"].items()} - except Exception: + col = "Open" if "Open" in df.columns else ("Close" if "Close" in df.columns else None) + if col is None: + return {} + return { + idx.date(): float(v) for idx, v in df[col].items() + if v is not None and float(v) > 0 + } + except Exception as exc: + logger.warning("국면 분해용 지수 조회 실패: {}", exc) return {} @@ -2189,7 +2381,14 @@ def _d(v): if last_date - timedelta(days=10) <= _d(s.date) <= last_date - timedelta(days=4) ] if band: - ref = band[-1] + # 정확히 1주 전에 가장 가까운 스냅샷(같으면 더 최근). 예전엔 밴드의 + # 마지막(= 4일 전 근처)을 써서 금요일 리포트가 월→금만 재고 + # 금 10:07~월 10:07 구간(주말 갭 포함)을 매주 빠뜨렸다. + target = last_date - timedelta(days=7) + ref = min( + band, + key=lambda s: (abs((_d(s.date) - target).days), -_d(s.date).toordinal()), + ) ref_val = float(ref.total_value) if ref_val > 0: from core.portfolio_manager import twr_period_return @@ -2204,9 +2403,11 @@ def _d(v): last_boundary, mode="paper", ) - except Exception: - pass - week_change = twr_period_return(ref_val, last_val, flow) * 100 + except Exception as flow_exc: + logger.warning("주간 변화 계산 중 입금 조회 실패 — 주간 항목 생략: {}", flow_exc) + ref_val = 0.0 + if ref_val > 0: + week_change = twr_period_return(ref_val, last_val, flow) * 100 # 결측은 '고유 일수'로 집계(SNAPSHOT_GAP 이벤트는 미복구 결측을 매 사이클 # 재경보해 부풀려짐 → 최근 7일 실제 빠진 영업일 수를 직접 센다). @@ -2214,13 +2415,20 @@ def _d(v): missing_days = len(detect_snapshot_gaps_for_account( config, key, now_kst, lookback_calendar_days=7, )) - except Exception: + except Exception as gap_exc: + logger.warning("주간 결측 집계 실패: {}", gap_exc) missing_days = 0 + reconstructed_days = sum( + 1 for s in snaps + if bool(getattr(s, "reconstructed", False)) + and _d(s.date) >= (now_kst - timedelta(days=7)).date() + ) # 국면 분해 + 리스크 지표 — 수익률 한 숫자로는 '방어의 대가'가 안 보인다 # (docs/OPERATING_PRINCIPLES.md 원칙 9). 스냅샷의 daily_return과 같은 날의 # 벤치마크 일간 수익률을 짝지어 상승/하락 국면을 나눠 잰다. regime = risk = None + regime_note = None try: from core.performance_lens import ( aligned_returns, daily_returns_from_nav, risk_metrics, @@ -2236,7 +2444,7 @@ def _d(v): # 10만원을 넣자 NAV가 284,499 → 385,460이 되면서 그날이 +35% 수익으로 # 계산돼 연환산 변동성 109%, 샤프 +2.28이라는 허구가 나왔다. # 적립식 트랙은 이 경로가 상시라 한 번 새면 계속 샌다. - flows = account_flows_by_day(key, mode="paper") + flows = account_flows_by_snapshot(key, snaps, mode="paper") daily = daily_returns_from_nav(nav_points, flows=flows) risk = risk_metrics([r for _, r in daily]) @@ -2251,14 +2459,20 @@ def _d(v): ] if pairs: regime = split_by_regime(pairs) + regime_note = "근사치 — 지수 시가 기준" + last_bench = max(closes) + last_nav = _d(nav_points[-1][0]) + if last_bench < last_nav: + regime_note += f", 지수 자료 {last_bench}까지만 반영" except Exception as lens_exc: - logger.debug("국면/리스크 지표 생략: {}", lens_exc) + logger.warning("국면/리스크 지표 생략: {}", lens_exc) summary = build_weekly_summary( basket_name=basket_name, eval_result=eval_result, week_nav_change_pct=week_change, missing_days=missing_days, cycle_errors=cycle_errors, regime=regime, risk=risk, + reconstructed_days=reconstructed_days, regime_note=regime_note, ) logger.info("\n{}", summary["text"]) try: diff --git a/monitoring/dashboard_runtime_state.py b/monitoring/dashboard_runtime_state.py index f5ef7139..0cc72afb 100644 --- a/monitoring/dashboard_runtime_state.py +++ b/monitoring/dashboard_runtime_state.py @@ -8,6 +8,7 @@ from __future__ import annotations import json +import os import threading from datetime import date, datetime from pathlib import Path @@ -18,6 +19,11 @@ def _state_path() -> Path: + # QUANT_DASHBOARD_STATE_PATH가 있으면 그 파일을 쓴다 — 테스트가 운영 대시보드에 + # 가짜 신호·루프 지표를 남기지 않도록(conftest가 임시 경로로 지정). + override = os.environ.get("QUANT_DASHBOARD_STATE_PATH") + if override: + return Path(override) return Path(__file__).resolve().parent.parent / "data" / "dashboard_runtime_state.json" diff --git a/monitoring/discord_bot.py b/monitoring/discord_bot.py index d9aeeffd..14dd1a0d 100644 --- a/monitoring/discord_bot.py +++ b/monitoring/discord_bot.py @@ -63,12 +63,7 @@ def send_message(self, text: str) -> bool: if self.avatar_url: payload["avatar_url"] = self.avatar_url - response = req.post( - self.webhook_url, - json=payload, - timeout=10, - ) - return response.status_code in (200, 204) + return self._post(payload) except Exception as e: logger.error("[ALERT_FAILED] 디스코드 발송 실패: {}", e) return False @@ -107,16 +102,41 @@ def send_embed(self, title: str, description: str, color: int = 0x4F9EF8, fields if self.avatar_url: payload["avatar_url"] = self.avatar_url - response = req.post( - self.webhook_url, - json=payload, - timeout=10, - ) - return response.status_code in (200, 204) + return self._post(payload) except Exception as e: - logger.error("디스코드 Embed 발송 실패: {}", e) + logger.error("[ALERT_FAILED] 디스코드 Embed 발송 실패: {}", e) return False + def _post(self, payload: dict) -> bool: + """웹훅 전송. 2xx가 아니면 로그를 남기고, 429면 한 번만 짧게 기다려 다시 보낸다. + + 예전에는 거부 응답(토큰 폐기 404, 페이로드 오류 400, 속도 제한 429)을 아무 로그 + 없이 False로만 돌려줬다. 이메일이 죽어 있는 동안 디스코드가 유일한 경보 채널이라, + 이게 막히면 모든 경보가 흔적 없이 사라진다. 웹훅 주소는 토큰이 들어 있어 남기지 않는다. + """ + import time as _time + + for attempt in range(2): + response = req.post(self.webhook_url, json=payload, timeout=10) + status = int(getattr(response, "status_code", 0) or 0) + if 200 <= status < 300: + return True + if status == 429 and attempt == 0: + try: + wait = float((response.json() or {}).get("retry_after", 1)) + except Exception: + wait = 1.0 + _time.sleep(max(0.0, min(wait, 5.0))) + continue + body = "" + try: + body = str(getattr(response, "text", "") or "")[:200] + except Exception: + pass + logger.error("[ALERT_FAILED] 디스코드 HTTP {} {}", status, body) + return False + return False + def send_trade_alert(self, trade_info: dict): """매매 알림 발송""" action = trade_info.get("action", "") @@ -152,7 +172,10 @@ def send_daily_report(self, report: dict): fields = [ {"name": "💰 총 평가금", "value": f"{report.get('total_value', 0):,.0f}원", "inline": True}, {"name": "💵 현금", "value": f"{report.get('cash', 0):,.0f}원", "inline": True}, - {"name": "📈 일일 수익률", "value": f"{report.get('daily_return', 0):.2f}%", "inline": True}, + # 계산하지 못한 날(첫 기록·입금 조회 실패)은 0.00%가 아니라 '—'로 둔다. + {"name": "📈 일일 수익률", "value": ( + "—" if report.get("daily_return") is None else f"{report['daily_return']:.2f}%" + ), "inline": True}, {"name": "📊 누적 수익률", "value": f"{report.get('cumulative_return', 0):.2f}%", "inline": True}, {"name": "📉 MDD", "value": f"{report.get('mdd', 0):.2f}%", "inline": True}, {"name": "📋 보유 종목", "value": f"{report.get('position_count', 0)}개", "inline": True}, diff --git a/monitoring/static/dashboard.js b/monitoring/static/dashboard.js index 1fed31ae..bcdfe3eb 100644 --- a/monitoring/static/dashboard.js +++ b/monitoring/static/dashboard.js @@ -38,12 +38,6 @@ if (!Number.isFinite(n) || Math.abs(n) < 1e-9) return "flat"; return n > 0 ? "up" : "down"; } - function sinceCopy(minutes) { - if (minutes == null) return ""; - if (minutes < 60) return `${Math.max(1, Math.round(minutes))}분째`; - if (minutes < 1440) return `${Math.round(minutes / 60)}시간째`; - return `${Math.round(minutes / 1440)}일째`; - } function parseDate(value) { if (!value) return null; if (value instanceof Date) @@ -134,6 +128,8 @@ mode: "unknown", baskets: null, evaluations: null, + // 검증 결과 조회 상태. 처음 불러오는 동안을 '실패'로 보이지 않게 따로 둔다. + evaluationsStatus: "loading", runtime: null, legacy: null, flows: new Map(), @@ -532,14 +528,18 @@ $("overviewDate").dateTime = snap?.date || ""; $("overviewTotal").textContent = won(snap?.total_value); $("overviewCash").textContent = `현금 ${won(snap?.cash)}`; - const pnl = snap ? Number(snap.total_value) - Number(b.principal) : null; + // 평가액은 스냅샷을 찍은 때의 값이라 원금도 그때 기준으로 비교한다. 방금 기록한 + // 적립금까지 원금에 넣으면 다음 실행 전까지 손실이 난 것처럼 보인다. + const basePrincipal = Number(b?.principal_at_snapshot ?? b?.principal); + const pending = Number(b?.pending_deposits || 0); + const pnl = snap ? Number(snap.total_value) - basePrincipal : null; $("overviewPnl").textContent = snap - ? `원금 대비 ${signedWon(pnl)} (${pct(b.principal > 0 ? (pnl / b.principal) * 100 : null)})` + ? `원금 대비 ${signedWon(pnl)} (${pct(basePrincipal > 0 ? (pnl / basePrincipal) * 100 : null)})` : "모의투자를 실행하면 자산 기록이 표시됩니다."; $("overviewPnl").className = tone(pnl); $("overviewPrincipal").textContent = won(b?.principal); $("overviewDeposits").textContent = b?.deposits_total - ? `적립금 ${won(b.deposits_total)} 포함` + ? `적립금 ${won(b.deposits_total)} 포함${pending > 0 ? ` · 최근 적립한 ${won(pending)}은 다음 자동매매 때 반영됩니다` : ""}` : "초기 투자금"; $("overviewReturn").textContent = pct(snap?.cumulative_return); $("overviewReturn").className = tone(snap?.cumulative_return); @@ -646,11 +646,14 @@ const baskets = state.baskets; const halt = state.runtime && state.runtime.trading_halt; const latest = latestSnapshotDate(); - const ageDays = calendarAgeDays(latest); const primary = selectedBasket(); - const issues = (state.evaluations || []) - .filter((item) => item.basket === primary?.basket) - .flatMap((item) => item.issues || []); + const evaluation = (state.evaluations || []).find( + (item) => item.basket === primary?.basket, + ); + // 검증 중 상시 안내(review_note)는 '확인할 항목'과 섞지 않는다 — 섞여 있으면 + // 이번 달 적립금 미기록 같은 실제 할 일이 가려진다. + const reviewNote = evaluation?.review_note || null; + const issues = (evaluation?.issues || []).filter((item) => item !== reviewNote); const contribution = currentMonthContributionState(primary); el.decisionMeta.textContent = `${modeLabel()}${latest ? ` · 마지막 자산 기록 ${fmtLong(latest)}` : " · 자산 기록 없음"}`; @@ -712,46 +715,36 @@ }); return; } - if (ageDays != null && ageDays > 4) { + // 기록이 빠졌는지는 거래일 기준으로 센다(서버에서 계산). 그냥 날짜로 세면 연휴 뒤에는 + // 잘못된 경보가 뜨고, 평일에 이틀 빠져도 '정상'으로 나온다. + const stalled = (baskets || []).filter( + (b) => Number(b.missed_trading_days || 0) >= 1, + ); + if (stalled.length) { + const worst = stalled.reduce((a, b) => + Number(b.missed_trading_days) > Number(a.missed_trading_days) ? b : a, + ); setDecision({ - title: `자산 기록이 ${ageDays}일째 멈춰 있습니다`, - description: `마지막 기록은 ${fmtLong(latest)}입니다. 자동매매가 멈췄을 수 있으니 운영 상태부터 확인하세요.`, + title: `자산 기록이 ${worst.missed_trading_days}거래일 빠졌습니다`, + description: `${worst.display_name || worst.basket}의 마지막 기록은 ${fmtLong(worst.snapshot?.date)}입니다. 자동매매가 제대로 실행됐는지 운영 상태부터 확인하세요.`, action: "operations", actionLabel: "운영 상태 보기", }); return; } - const loopMetrics = state.runtime && state.runtime.loop_metrics; - const loopLastAt = - parseDate(loopMetrics && loopMetrics.last_success) || - parseDate(state.runtime && state.runtime.runtime_file_updated_at); - const runtimeAge = loopLastAt - ? Math.max(0, Math.round((Date.now() - loopLastAt.getTime()) / 60_000)) - : null; - if (runtimeAge == null || runtimeAge > 720) { + if (state.runtime && state.runtime.scheduler_stale) { setDecision({ - title: - runtimeAge == null - ? "자동매매 실행 기록이 없습니다" - : `자동매매가 ${sinceCopy(runtimeAge)} 실행되지 않았습니다`, + title: "장이 열려 있는데 자동매매가 멈춰 있습니다", description: - runtimeAge == null - ? "스케줄러 기록이 보이지 않습니다. 오늘 사이클이 돌았는지 먼저 확인하세요." - : `마지막 실행은 ${fmtDT(loopLastAt)}입니다. 적립보다 스케줄러 상태를 먼저 확인하세요.`, + "스케줄러가 한 시간 넘게 실행되지 않았습니다. 운영 상태를 확인하세요.", action: "operations", actionLabel: "운영 상태 보기", }); return; } if (issues.length) { - const paperOnly = (state.evaluations || []).find( - (item) => item.basket === primary?.basket, - )?.paper_only; setDecision({ - title: - paperOnly && issues.length === 1 - ? "새 운용 규칙을 검증하고 있습니다" - : `확인이 필요한 항목이 ${issues.length}건 있습니다`, + title: `확인이 필요한 항목이 ${issues.length}건 있습니다`, description: issues[0], action: "review", actionLabel: "검토 항목 보기", @@ -778,6 +771,30 @@ }); return; } + if (state.evaluationsStatus === "error") { + setDecision({ + title: "검증 결과를 불러오지 못했습니다", + description: + "계좌 기록은 정상입니다. 모의투자 검증 결과만 불러오지 못했으니 잠시 후 다시 확인하세요.", + }); + return; + } + if (state.evaluationsStatus !== "ready") { + setDecision({ + title: "검증 결과를 확인하고 있습니다", + description: "계좌 기록은 정상입니다. 모의투자 검증 결과를 불러오는 중입니다.", + }); + return; + } + if (reviewNote) { + setDecision({ + title: "새 운용 규칙을 검증하고 있습니다", + description: reviewNote, + action: "review", + actionLabel: "검토 항목 보기", + }); + return; + } setDecision({ title: "현재 확인할 항목이 없습니다", description: "최근 계좌 기록과 자동매매 상태가 정상입니다.", @@ -878,7 +895,7 @@ .map((b) => { const snap = b.snapshot; const total = snap ? Number(snap.total_value) : null; - const principal = Number(b.principal || 0); + const principal = Number(b.principal_at_snapshot ?? b.principal ?? 0); const profit = total == null ? null : total - principal; const cr = snap ? Number(snap.cumulative_return) : null; const cashRatio = @@ -1648,6 +1665,7 @@ function renderEvaluations(evaluations) { const items = Array.isArray(evaluations) ? evaluations : []; state.evaluations = items; + state.evaluationsStatus = "ready"; el.basketEval.setAttribute("aria-busy", "false"); if (!items.length) { el.basketEval.innerHTML = @@ -1672,13 +1690,33 @@ item.snapshot_coverage == null ? null : Math.round(Number(item.snapshot_coverage) * 100); + if (item.error) { + return `
+

${escapeHtml(name)}${basket?.is_primary ? " · 주력" : ""}

+

확인 불가

+
  • ${escapeHtml(item.error)}
+
`; + } const [copy, cls] = verdictCopy[item.verdict] || ["관찰 중", "warn"]; const issues = (item.issues || []).slice(0, 3); + const restored = Number(item.reconstructed_days || 0); + const measured = + item.measured_coverage == null + ? null + : Math.round(Number(item.measured_coverage) * 100); + const coverageText = + coverage == null + ? "기록 누락 확인 중" + : coverage >= 100 + ? restored + ? `기록 누락 없음 (나중에 채운 ${restored}일 포함, 제때 기록 ${measured}%)` + : "기록 누락 없음" + : `기록 누락 ${Math.max(0, 100 - coverage)}%`; return `

${escapeHtml(name)}${basket?.is_primary ? " · 주력" : ""}

-

${item.paper_only ? `전체 운영 ${days}거래일` : `${days} / ${minimum} 거래일`}${coverage == null ? "기록 누락 확인 중" : coverage >= 100 ? "기록 누락 없음" : `기록 누락 ${Math.max(0, 100 - coverage)}%`}

+

${item.paper_only ? `전체 운영 ${days}거래일` : `${days} / ${minimum} 거래일`}${coverageText}

${copy}

${issues.length ? `
    ${issues.map((i) => `
  • ${escapeHtml(i)}
  • `).join("")}
` : ""} @@ -1700,6 +1738,7 @@ if (v === "bearish") return ["하락 추세", "시장 추세 기준 매수 제한", "warning"]; if (v === "caution") return ["주의", "포지션 축소 구간", "warning"]; + if (v === "disabled") return ["사용 안 함", "시장 추세 판단을 꺼 두었습니다", "info"]; return ["확인 불가", "시장 상태 데이터 없음", "warning"]; } const strategyCopy = (s) => @@ -1791,16 +1830,22 @@ const loopAge = loopLast ? Math.max(0, Math.round((Date.now() - loopLast.getTime()) / 60_000)) : null; - const loopFresh = loopAge != null && loopAge <= 720; - const autoValue = loopElapsed - ? loopFresh - ? "정상" - : `${formatAge(loopAge)} 실행` - : "기록 없음"; - const autoDetail = loopElapsed - ? `마지막 실행 ${fmtDT(loopLast)} · 루프 ${loopElapsed}` - : "스케줄러 기록 없음"; - const autoState = loop && loopFresh ? "ok" : "warning"; + // 멈춤 판정은 서버가 장 운영 시간 기준으로 한다(밤·주말·휴장일은 멈춘 게 아니다). + const schedulerUnused = runtime.scheduler_in_use === false; + const loopFresh = !runtime.scheduler_stale && loopAge != null; + const autoValue = schedulerUnused + ? "사용 안 함" + : loopElapsed + ? loopFresh + ? "정상" + : `${formatAge(loopAge)} 실행` + : "기록 없음"; + const autoDetail = schedulerUnused + ? "매매는 평일 오전 10시쯤 한 번 실행됩니다" + : loopElapsed + ? `마지막 실행 ${fmtDT(loopLast)} · 루프 ${loopElapsed}` + : "스케줄러 기록 없음"; + const autoState = schedulerUnused ? "info" : loop && loopFresh ? "ok" : "warning"; const kisValue = kis && kis.minute_utilization_pct != null ? `${Number(kis.minute_utilization_pct).toFixed(1)}% 사용` @@ -1939,6 +1984,14 @@ function renderLegacy(portfolio) { state.legacy = portfolio || null; + const legacyEmpty = Boolean(portfolio && portfolio.empty); + $("legacyHeading").hidden = legacyEmpty; + $("summary").hidden = legacyEmpty; + if (legacyEmpty) { + $("positionsWrap").hidden = true; + $("noPositions").hidden = true; + return; + } if (!portfolio) { $("summary").innerHTML = statusRowItem( "이전 계좌", @@ -2199,9 +2252,13 @@ }) .then((data) => renderEvaluations((data && data.evaluations) || [])) .catch(() => { + // 이전에 성공한 결과로 계속 판단하지 않는다(그 사이 생긴 문제를 못 본다) + state.evaluations = null; + state.evaluationsStatus = "error"; el.basketEval.setAttribute("aria-busy", "false"); el.basketEval.innerHTML = '

검증 상태를 불러오지 못했습니다. 잠시 후 다시 확인하세요.

'; + renderDecision(); }); const runtimeTask = fetchJson("/api/runtime", { timeout: 30_000, diff --git a/monitoring/templates/dashboard.html b/monitoring/templates/dashboard.html index 7c7f1baf..2512867e 100644 --- a/monitoring/templates/dashboard.html +++ b/monitoring/templates/dashboard.html @@ -462,7 +462,7 @@

거래 중지 상태에서 먼저 할 일

웹소켓 상태를 아직 받지 못했습니다.

-

이전 기본 계좌

+

이전 기본 계좌

dict: - """레거시 기본 계정의 현재 포트폴리오 요약을 반환한다.""" - global _DASH + """이전 기본 계정('' 계정)의 요약 — DB만 읽는다. + + 예전에는 계정 키 ''를 '전 계정 합산'으로 해석하는 경로를 거쳐, 바스켓 두 계정의 + 체결을 섞은 가짜 계좌(-4.44%, ETF 행 중복)를 '이전 기본 계좌'로 보여 줬다. live에서는 + 조회할 때마다 증권사 토큰을 새로 받을 수 있었다. 이 화면은 증권사에 연결하지 않는다. + 기본 계정에 아무 기록이 없으면 {"empty": True}를 돌려주고 화면은 패널을 숨긴다. + """ + from database.repositories import ( + get_all_positions, + get_cash_flow_total, + get_trade_cash_summary, + get_trade_history, + ) + config = Config.get() - if _DASH is None: - _DASH = Dashboard(config=config) - dash = _DASH - summary = dash.portfolio_manager.get_portfolio_summary(current_prices or {}) + ledger_mode = _active_ledger_mode(config) + base = {"timestamp": datetime.now().isoformat(), "mode": ledger_mode} + positions = [ + p for p in (get_all_positions(account_key="", mode=ledger_mode) or []) + if (p.quantity or 0) > 0 + ] + trades = get_trade_history(mode=ledger_mode, account_key="") or [] + if not positions and not trades: + return {**base, "empty": True} + + initial = float( + (config.risk_params.get("position_sizing") or {}).get("initial_capital", 10_000_000) + ) + prices = current_prices or {} + deposits = float(get_cash_flow_total(account_key="", mode=ledger_mode) or 0) + cash_delta = float( + (get_trade_cash_summary(mode=ledger_mode, account_key="") or {}).get("cash_delta") or 0 + ) + cash = initial + deposits + cash_delta + details = [] + invested = current_value = 0.0 + for p in positions: + qty = int(p.quantity or 0) + avg = float(p.avg_price or 0) + price = float(prices.get(p.symbol, avg) or avg) + invested += avg * qty + current_value += price * qty + details.append({ + "symbol": p.symbol, "quantity": qty, "avg_price": avg, + "current_price": price, "invested": avg * qty, + "current_value": price * qty, "pnl": (price - avg) * qty, + "pnl_rate": ((price / avg) - 1) * 100 if avg > 0 else 0.0, + }) + total_value = cash + current_value + principal = initial + deposits return { - "timestamp": datetime.now().isoformat(), - "mode": _active_ledger_mode(config), - "initial_capital": dash.initial_capital, - "total_value": summary["total_value"], - "cash": summary["cash"], - "invested": summary["invested"], - "current_value": summary["current_value"], - "total_return": summary["total_return"], - "mdd": summary["mdd"], - "position_count": summary["position_count"], - "realized_pnl": summary["realized_pnl"], - "unrealized_pnl": summary["unrealized_pnl"], - "positions": summary["positions"], + **base, + "empty": False, + "initial_capital": initial, + "total_value": total_value, + "cash": cash, + "invested": invested, + "current_value": current_value, + "total_return": ((total_value / principal) - 1) * 100 if principal > 0 else 0.0, + "mdd": None, + "position_count": len(details), + "realized_pnl": cash + invested - principal, + "unrealized_pnl": current_value - invested, + "positions": details, } +_CALENDAR = None + + +def _calendar(): + """KRX 거래일 달력(프로세스당 한 번 로드).""" + global _CALENDAR + if _CALENDAR is None: + from core.trading_hours import TradingHours + + _CALENDAR = TradingHours() + return _CALENDAR + + +def _snapshot_freshness(last_date, now: Optional[datetime] = None) -> dict: + """일일 사이클이 남겼어야 할 마지막 기록일과, 그 뒤로 빠진 거래일 수(KRX 달력 기준). + + 예전 화면 규칙은 달력 날짜(4일)와 스케줄러 루프 나이(720분)였다. 이 배포에서는 + 스케줄러가 돌지 않아 루프 경보가 밤·주말·휴장일마다 울렸고, 반대로 이틀 연속 + 사이클이 죽어도 '정상'이었다. 사이클은 평일 10시대에 돌므로 10:30 전의 오늘은 + 아직 기대하지 않는다. + """ + from datetime import time as _time, timedelta as _td + from core.trading_hours import _now_kst + + now = now or _now_kst() + th = _calendar() + day = now.date() + if not (th.is_trading_day(datetime(day.year, day.month, day.day)) and now.time() >= _time(10, 30)): + day -= _td(days=1) + for _ in range(20): + if th.is_trading_day(datetime(day.year, day.month, day.day)): + break + day -= _td(days=1) + expected = day + if last_date is None: + return {"expected_snapshot_date": expected.isoformat(), "missed_trading_days": None} + last = last_date.date() if hasattr(last_date, "date") else last_date + missed = 0 + d = last + _td(days=1) + while d <= expected and missed < 60: + if th.is_trading_day(datetime(d.year, d.month, d.day)): + missed += 1 + d += _td(days=1) + return {"expected_snapshot_date": expected.isoformat(), "missed_trading_days": missed} + + def get_snapshots_json(days: int = 30, account_key: Optional[str] = None) -> dict: """최근 N일 스냅샷을 활성 장부 모드에서 반환한다.""" config = Config.get() @@ -154,7 +239,13 @@ def get_baskets_json() -> dict: ) snapshot = None deployment_ratio = None + latest_measured = None if latest is not None: + # 스냅샷을 실제로 찍은 시각. 이 시각 뒤의 입금은 아직 평가액에 없다. + latest_measured = latest.created_at or datetime.combine( + latest.date.date() if hasattr(latest.date, "date") else latest.date, + datetime.max.time(), + ) total_value = float(latest.total_value or 0) cash = float(latest.cash or 0) deployment_ratio = ( @@ -172,6 +263,25 @@ def get_baskets_json() -> dict: finally: session.close() + # 원금 대비 손익은 스냅샷 시점의 원금으로 잰다. 전체 원금(방금 넣은 적립 포함)과 + # 비교하면 적립 직후 다음 사이클까지 '원금 대비 -21%' 같은 가짜 손실이 보인다 + # (연휴 앞 적립이면 며칠씩). 아직 반영 안 된 적립은 따로 알려 준다. + principal_at_snapshot = principal + pending_deposits = 0.0 + if latest_measured is not None: + deposits_at_snapshot = float( + get_cash_flow_total( + account_key=account_key, until=latest_measured, mode=ledger_mode, + ) or 0 + ) + principal_at_snapshot = initial_capital + deposits_at_snapshot + pending_deposits = deposits_total - deposits_at_snapshot + try: + freshness = _snapshot_freshness(latest.date if latest is not None else None) + except Exception as exc: + logger.warning("바스켓 '{}' 기록 공백 계산 실패: {}", name, exc) + freshness = {"expected_snapshot_date": None, "missed_trading_days": None} + holding_names = basket_config.get("holding_names") or {} positions = [ { @@ -244,10 +354,14 @@ def get_baskets_json() -> dict: "initial_capital": initial_capital, "deposits_total": deposits_total, "principal": principal, + "principal_at_snapshot": principal_at_snapshot, + "pending_deposits": pending_deposits, "snapshot": snapshot, "profit_vs_principal": ( - snapshot["total_value"] - principal if snapshot else None + snapshot["total_value"] - principal_at_snapshot if snapshot else None ), + "expected_snapshot_date": freshness["expected_snapshot_date"], + "missed_trading_days": freshness["missed_trading_days"], "deployment_ratio": deployment_ratio, "design_fraction": design_fraction, "base_stock_fraction": base_fraction, @@ -304,13 +418,20 @@ def get_runtime_json() -> dict: from core.data_collector import DataCollector from core.market_regime import check_market_regime + from core.market_regime import resolve_market_regime_config + config = Config.get() - regime = check_market_regime(config, DataCollector()) - out["market_regime"] = { - "regime": regime.get("regime"), - "position_scale": regime.get("position_scale"), - "allow_buys": regime.get("allow_buys"), - } + if not resolve_market_regime_config(config).get("enabled"): + # 필터가 꺼져 있으면 check_market_regime은 매수 허용용 기본값(상승)을 돌려준다. + # 그걸 그대로 '상승 추세'로 보여 주면 시장과 무관하게 늘 초록불이다. + out["market_regime"] = {"regime": "disabled", "position_scale": None, "allow_buys": None} + else: + regime = check_market_regime(config, DataCollector()) + out["market_regime"] = { + "regime": regime.get("regime"), + "position_scale": regime.get("position_scale"), + "allow_buys": regime.get("allow_buys"), + } except Exception as exc: logger.debug("get_runtime_json market_regime: {}", exc) @@ -333,9 +454,43 @@ def get_runtime_json() -> dict: logger.debug("get_runtime_json read_state: {}", exc) out["signals_today"] = None + out.update(_scheduler_freshness(out)) return out +def _scheduler_freshness(runtime: dict, now: Optional[datetime] = None) -> dict: + """상시 스케줄러를 쓰는 경우에만, 장중에 루프가 멈췄는지 판정한다. + + 이 배포의 매매는 일일 CLI 사이클이 한다(스케줄러 없음). 스케줄러 기록이 최근 + 7일 안에 없으면 '사용 안 함'으로 보고 경보하지 않는다. 쓰는 중이면 장이 열려 + 있는 동안 60분 넘게 루프 기록이 없을 때만 멈춘 것으로 본다(밤·주말·휴장일 제외). + """ + from datetime import timedelta as _td + from core.trading_hours import _now_kst + + now = now or _now_kst() + + def _parse(value): + if not value: + return None + try: + parsed = datetime.fromisoformat(str(value)) + except ValueError: + return None + return parsed.replace(tzinfo=None) + + loop = runtime.get("loop_metrics") or {} + last = _parse(loop.get("last_success")) or _parse(runtime.get("runtime_file_updated_at")) + in_use = last is not None and now - last <= _td(days=7) + stale = False + if in_use: + try: + stale = bool(_calendar().is_market_open(now)) and now - last > _td(minutes=60) + except Exception as exc: + logger.warning("스케줄러 멈춤 여부 계산 실패: {}", exc) + return {"scheduler_in_use": in_use, "scheduler_stale": stale} + + def _html_page() -> str: """파일 기반 템플릿을 읽어 UI와 Python 데이터 계층을 분리한다.""" return _TEMPLATE_PATH.read_text(encoding="utf-8") @@ -398,8 +553,38 @@ async def handle_api_baskets(_request: web.Request) -> web.Response: ) +_LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1", "[::1]"} + + +def _is_local_origin(request) -> bool: + """Host가 루프백이고, Origin/Referer가 있으면 같은 호스트여야 한다. + + 커스텀 헤더만으로는 DNS 리바인딩(외부 도메인이 127.0.0.1을 가리키게 하는 공격)을 + 막지 못한다 — 그 경우 브라우저는 같은 출처로 보고 헤더를 붙여 보낸다. Host가 + 루프백이 아니면 이 대시보드로 온 요청이 아니다. 포트는 테스트·캡처마다 달라 보지 않는다. + """ + from urllib.parse import urlsplit + + host = (request.host or "").rsplit(":", 1)[0] if not (request.host or "").startswith("[") \ + else (request.host or "").split("]", 1)[0] + "]" + if host.lower() not in _LOOPBACK_HOSTS: + return False + for header in ("Origin", "Referer"): + value = request.headers.get(header) + if value: + origin_host = (urlsplit(value).hostname or "").lower() + if origin_host not in {h.strip("[]") for h in _LOOPBACK_HOSTS}: + return False + return True + + async def handle_api_deposit(request: web.Request) -> web.Response: """적립금 기록. 커스텀 헤더로 cross-site 브라우저 요청을 차단한다.""" + if not _is_local_origin(request): + return web.json_response( + {"ok": False, "error": "이 컴퓨터에서 연 대시보드에서만 기록할 수 있습니다"}, + status=403, + ) if request.headers.get("X-Requested-With") != "quant-dashboard": return web.json_response( {"ok": False, "error": "대시보드 외 요청 차단(CSRF 방어)"}, @@ -418,8 +603,14 @@ async def handle_api_deposit(request: web.Request) -> web.Response: ) try: + from core.basket_rebalancer import BasketRebalancer from tools.record_deposit import record_basket_deposit + basket_name = str(body.get("basket") or "") + if basket_name not in BasketRebalancer.get_enabled_baskets(): + return web.json_response( + {"ok": False, "error": "운용 중인 계좌가 아닙니다"}, status=400 + ) result = record_basket_deposit( str(body.get("basket") or ""), body.get("amount"), @@ -459,8 +650,10 @@ async def handle_api_cash_flows(request: web.Request) -> web.Response: { "basket": basket, "mode": ledger_mode, + # 차트·CSV의 누적 원금은 전체 기록으로 계산해야 한다(12건 제한이면 + # 13번째 적립부터 원금이 적게 잡힌다). "flows": await asyncio.to_thread( - get_recent_cash_flows, account_key, mode=ledger_mode + get_recent_cash_flows, account_key, None, ledger_mode ), } ) @@ -541,18 +734,27 @@ def _collect_all() -> dict: evaluations = [] for name in BasketRebalancer.get_enabled_baskets(): - result, basket_name = collect_basket_paper_evaluation( - include_benchmark=False, - basket_name=name, - ) + try: + result, basket_name = collect_basket_paper_evaluation( + include_benchmark=False, + basket_name=name, + ) + except Exception as exc: + # 한 계좌의 실패가 다른 계좌의 판정까지 가리지 않게 따로 남긴다 + logger.warning("바스켓 '{}' 검증 상태 조회 실패: {}", name, exc) + evaluations.append({"basket": name, "error": "검증 상태를 확인할 수 없습니다"}) + continue evaluations.append( { "basket": basket_name, "verdict": result.get("verdict"), "paper_only": bool(result.get("paper_only", False)), + "review_note": result.get("review_note"), "progress_days": result.get("progress_days"), "min_trading_days": result.get("min_trading_days"), "snapshot_coverage": result.get("snapshot_coverage"), + "measured_coverage": result.get("measured_coverage"), + "reconstructed_days": result.get("reconstructed_days", 0), "issues": result.get("issues", []), } ) diff --git a/quant_trader_design.md b/quant_trader_design.md index dc661f5f..af4f35f9 100644 --- a/quant_trader_design.md +++ b/quant_trader_design.md @@ -437,7 +437,7 @@ STEP 2에서 찾은 가중치를 `strategies.yaml`에 반영한 뒤 실행합니 ### 4.3 추세 추종 전략 (중급 ⭐⭐) - **구현**: `strategies/trend_following.py` -- **설정**: `trend_following` (adx_threshold, trend_ma_period, atr_stop_multiplier, trailing_atr_multiplier) +- **설정**: `trend_following` (adx_threshold, trend_ma_period). 손절·트레일링 배수는 `risk_params.yaml`의 `stop_loss.atr_multiplier` / `trailing_stop.atr_multiplier`를 따른다 - **이용(가정)하는 시장 비효율성**: **모멘텀 효과(Momentum)** — "좋은 주식이 일정 기간 계속 좋다"는 현상. 상대적으로 강한 추세가 지속되는 구간에서 추세를 따라가는 방식으로, 미국(나스닥) 등에서 **모멘텀 팩터**로 실증된 비효율성에 기반합니다. 한국 시장에서는 추세 지속성이 약해 해당 비효율성이 weaker할 수 있습니다(아래 "한국 시장 추세 지속성" 참고). **로직**: ADX > adx_threshold, 가격 > trend_ma(200일), MACD 골든크로스(히스토그램 양수 전환) 시 매수. ATR 기반 손절·트레일링 스탑. @@ -745,7 +745,7 @@ STEP 2에서 찾은 가중치를 `strategies.yaml`에 반영한 뒤 실행합니 ### 5.7 MDD 제한 -- **설정**: `drawdown.max_portfolio_mdd`, `max_daily_loss`, `recovery_scale` +- **설정**: `drawdown.max_portfolio_mdd`, `max_daily_loss` (회복 사이징 `recovery_scale`은 구현되지 않아 2026-09-23 삭제) ### 5.8 전략 성과 열화 감지 @@ -863,7 +863,7 @@ STEP 2에서 찾은 가중치를 `strategies.yaml`에 반영한 뒤 실행합니 ### 5.16 시장 국면 적응형 전략 파라미터 (`regime_adaptive`) — v3.0 - **목적**: `check_market_regime()` 결과(bullish / caution / bearish)에 따라 **손절·익절 배수**를 바꿔 하락장에서 손실 속도를 줄이고 익절을 빨리 가져감. -- **설정**: `config/strategies.yaml` → `regime_adaptive` (`enabled`, `bullish` / `caution` / `bearish` 각각 `buy_threshold_offset`, `stop_loss_multiplier`, `take_profit_multiplier`) +- **설정**: `config/strategies.yaml` → `regime_adaptive` (`enabled`, `bullish` / `caution` / `bearish` 각각 `stop_loss_multiplier`, `take_profit_multiplier`). 매수 진입 기준은 국면에 따라 바뀌지 않는다 — 국면별 매수 억제는 `market_regime_filter`의 `allow_buys`·`position_scale`이 맡는다 - **구현**: `core/market_regime.py` → `get_regime_adjusted_params(config, collector)` **OrderExecutor**가 매수 시 `calculate_stop_loss` / `calculate_take_profit`에 국면 배수 전달. @@ -933,8 +933,8 @@ quant_trader/ │ ├── risk_manager.py # 포지션 사이징(1% 룰·신호 강도 스케일), check_diversification(업종), **check_correlation_risk**, check_recent_performance, 손절/익절/트레일링(국면 배수), 거래비용 │ ├── order_executor.py # 매수/매도. 국면 손절·익절, 상관 축소, **갭업 매수 차단**, 유동성·어닝·분산, Dead-letter │ ├── portfolio_manager.py # 보유 포지션·잔고·수익률. sync_with_broker(KIS 잔고↔DB 크로스체크), save_daily_snapshot() -│ ├── basket_rebalancer.py # 바스켓 리밸런싱: 목표 비중 vs 실제 비중 드리프트 감지, 주문 생성·실행, 신호 가중 모드, 스케줄러 장전 자동 통합 -│ ├── scheduler.py # 장전/장중(10분)/장마감. **갭다운 즉시 청산**, 동적 손절 갱신, auto_entry 시 장중 재스캔, 블랙스완 recovery, 바스켓 리밸런싱, paper 실전 전환 평가 +│ ├── basket_rebalancer.py # 바스켓 리밸런싱: 목표 비중 vs 실제 비중 드리프트 감지, 주문 생성·실행, 신호 가중 모드. 실행은 일일 CLI(--mode rebalance)에서만 +│ ├── scheduler.py # 장전/장중(10분)/장마감. **갭다운 즉시 청산**, 동적 손절 갱신, auto_entry 시 장중 재스캔, 블랙스완 recovery, paper 실전 전환 평가 (바스켓은 거래하지 않음) │ ├── runtime_lock.py # `data/.scheduler.lock` — schedule 모드 단일 인스턴스(중복 실행 방지) │ ├── trading_hours.py # 한국 장·휴장일(holidays.yaml → pykrx → fallback). 미국: us_holidays.yaml + 동부 09:30~16:00 (`is_us_trading_day` 등) │ ├── holidays_updater.py # 휴장일 YAML 자동 갱신 (pykrx 또는 fallback) @@ -1353,7 +1353,7 @@ quant_trader/ - [x] KIS 호출 제어 강화 — 지수 백오프+지터, SSL/커넥션 에러 전용 핸들러, 토큰 오류 쿨다운 (§9.1) - [x] 주문 실패 Dead-letter 큐 — FailedOrder 테이블에 실패 주문 영구 저장, 재처리 지원 (§9.1) - [x] 전략 등록 레지스트리(플러그인형) — `strategies/__init__.py`에서 `create_strategy(name)` 호출로 전략 동적 로딩 (§4.5) -- [x] 바스켓 포트폴리오 리밸런싱 — `BasketRebalancer`로 종목별 목표 비중 관리, 드리프트/주기 기반 리밸런싱, 신호 가중 모드 지원. `--mode rebalance --basket ` CLI 및 스케줄러 장전 단계 자동 통합 (§10) +- [x] 바스켓 포트폴리오 리밸런싱 — `BasketRebalancer`로 종목별 목표 비중 관리, 드리프트/주기 기반 리밸런싱, 신호 가중 모드 지원. `--mode rebalance --basket ` 일일 CLI로 실행 (§10). 스케줄러 장전 경로는 2026-09에 제거 - [x] **`--mode schedule`** — 모의 매매 전용 무한 스케줄 루프, `core/runtime_lock.py`로 단일 인스턴스 락 - [x] **미국 티커·장시간** — `DataCollector.fetch_stock` 미국 분기, `config/us_holidays.yaml`, `TradingHours` NYSE 구간 - [x] **DART(선택)** — `dart_loader` + `earnings_filter` 폴백, `DART_API_KEY` / `settings.dart` diff --git a/reports/research/risk_overlay_backtest.json b/reports/research/risk_overlay_backtest.json index bcf024e3..72b6585b 100644 --- a/reports/research/risk_overlay_backtest.json +++ b/reports/research/risk_overlay_backtest.json @@ -5,9 +5,10 @@ "end": "2026-09-16", "results": { "static": { - "label": "고정 비중(현행)", + "label": "고정 비중", "years": 24.7, "cagr_pct": 7.45, + "period_return_pct": 489.65, "vol_pct": 12.15, "sharpe": 0.42, "mdd_pct": -28.75, @@ -15,14 +16,14 @@ "worst_year_pct": -18.67, "losing_years": 7, "total_years": 25, - "negative_months_pct": 40.5, + "negative_months_pct": 40.4, "avg_exposure_pct": 51.3, "turnover_per_year_pct": 8.8, - "final_value": 91098365, + "final_value": 91101012, "contributed": 29900000, - "profit": 61198365, + "profit": 61201012, "yearly": { - "2002": -4.11, + "2002": -4.13, "2003": 16.82, "2004": 6.62, "2005": 26.76, @@ -53,6 +54,7 @@ "label": "추세 필터 · 200일선 아래면 절반", "years": 24.7, "cagr_pct": 7.19, + "period_return_pct": 455.74, "vol_pct": 10.14, "sharpe": 0.45, "mdd_pct": -21.8, @@ -60,15 +62,15 @@ "worst_year_pct": -12.27, "losing_years": 7, "total_years": 25, - "negative_months_pct": 40.9, + "negative_months_pct": 40.7, "avg_exposure_pct": 41.1, "turnover_per_year_pct": 54.2, - "final_value": 88131441, + "final_value": 88134581, "contributed": 29900000, - "profit": 58231441, + "profit": 58234581, "yearly": { - "2002": -2.48, - "2003": 16.92, + "2002": -2.5, + "2003": 16.93, "2004": 4.39, "2005": 26.74, "2006": 1.3, @@ -85,12 +87,12 @@ "2017": 13.71, "2018": -4.4, "2019": 4.49, - "2020": 17.53, + "2020": 17.52, "2021": 3.51, - "2022": -4.95, + "2022": -4.96, "2023": 7.02, "2024": -5.42, - "2025": 39.81, + "2025": 39.82, "2026": 40.97 } }, @@ -98,6 +100,7 @@ "label": "추세 필터 · 200일선 아래면 0", "years": 24.7, "cagr_pct": 6.79, + "period_return_pct": 406.57, "vol_pct": 9.43, "sharpe": 0.44, "mdd_pct": -21.8, @@ -105,21 +108,21 @@ "worst_year_pct": -6.76, "losing_years": 10, "total_years": 25, - "negative_months_pct": 29.4, + "negative_months_pct": 29.3, "avg_exposure_pct": 31.4, "turnover_per_year_pct": 101.7, - "final_value": 82972040, + "final_value": 82978993, "contributed": 29900000, - "profit": 53072040, + "profit": 53078993, "yearly": { - "2002": -1.0, + "2002": -1.02, "2003": 16.68, "2004": 1.95, "2005": 26.71, "2006": -1.34, "2007": 16.34, - "2008": -4.23, - "2009": 16.06, + "2008": -4.24, + "2009": 16.07, "2010": 9.67, "2011": -1.63, "2012": -5.64, @@ -133,9 +136,9 @@ "2020": 15.14, "2021": 4.41, "2022": 2.93, - "2023": 1.29, + "2023": 1.3, "2024": -6.76, - "2025": 36.8, + "2025": 36.82, "2026": 40.96 } }, @@ -143,6 +146,7 @@ "label": "변동성 목표 15%", "years": 24.7, "cagr_pct": 5.79, + "period_return_pct": 302.07, "vol_pct": 8.18, "sharpe": 0.38, "mdd_pct": -17.82, @@ -150,14 +154,14 @@ "worst_year_pct": -12.33, "losing_years": 6, "total_years": 25, - "negative_months_pct": 40.5, + "negative_months_pct": 40.4, "avg_exposure_pct": 40.9, "turnover_per_year_pct": 58.3, - "final_value": 64275336, + "final_value": 64277749, "contributed": 29900000, - "profit": 34375336, + "profit": 34377749, "yearly": { - "2002": 5.39, + "2002": 5.37, "2003": 10.83, "2004": 4.96, "2005": 22.86, @@ -188,6 +192,7 @@ "label": "변동성 목표 20%", "years": 24.7, "cagr_pct": 6.32, + "period_return_pct": 353.99, "vol_pct": 9.38, "sharpe": 0.39, "mdd_pct": -20.86, @@ -195,14 +200,14 @@ "worst_year_pct": -14.71, "losing_years": 6, "total_years": 25, - "negative_months_pct": 40.2, + "negative_months_pct": 40.1, "avg_exposure_pct": 46.1, "turnover_per_year_pct": 30.2, - "final_value": 70835709, + "final_value": 70836842, "contributed": 29900000, - "profit": 40935709, + "profit": 40936842, "yearly": { - "2002": 3.41, + "2002": 3.39, "2003": 12.8, "2004": 6.29, "2005": 25.89, @@ -211,7 +216,7 @@ "2008": -14.71, "2009": 18.35, "2010": 12.66, - "2011": -5.3, + "2011": -5.31, "2012": 5.23, "2013": 1.66, "2014": -2.41, @@ -232,97 +237,146 @@ "dd10": { "label": "낙폭 제어 · -10%에서 절반", "years": 24.7, - "cagr_pct": 6.91, - "vol_pct": 10.69, - "sharpe": 0.41, - "mdd_pct": -23.77, - "calmar": 0.29, - "worst_year_pct": -14.64, - "losing_years": 7, + "cagr_pct": 6.06, + "period_return_pct": 327.65, + "vol_pct": 9.19, + "sharpe": 0.37, + "mdd_pct": -19.39, + "calmar": 0.31, + "worst_year_pct": -12.45, + "losing_years": 9, "total_years": 25, - "negative_months_pct": 39.9, - "avg_exposure_pct": 48.2, - "turnover_per_year_pct": 32.4, - "final_value": 80557524, + "negative_months_pct": 38.4, + "avg_exposure_pct": 38.7, + "turnover_per_year_pct": 38.2, + "final_value": 73459024, "contributed": 29900000, - "profit": 50657524, + "profit": 43559024, "yearly": { - "2002": -4.11, - "2003": 16.82, - "2004": 6.62, - "2005": 26.76, - "2006": 3.86, - "2007": 16.37, - "2008": -14.64, - "2009": 23.61, - "2010": 12.58, - "2011": -5.14, - "2012": 6.99, - "2013": 1.66, + "2002": -1.06, + "2003": 9.85, + "2004": 2.63, + "2005": 25.46, + "2006": 3.9, + "2007": 16.35, + "2008": -12.45, + "2009": 17.06, + "2010": 12.6, + "2011": -4.89, + "2012": 4.85, + "2013": -1.12, "2014": -2.43, - "2015": 0.83, - "2016": 5.52, - "2017": 13.81, - "2018": -8.76, - "2019": 7.67, - "2020": 12.69, - "2021": 2.59, - "2022": -9.97, - "2023": 9.22, - "2024": -4.24, - "2025": 39.54, - "2026": 31.8 + "2015": -0.89, + "2016": 4.23, + "2017": 12.54, + "2018": -8.46, + "2019": 5.34, + "2020": 14.78, + "2021": 2.9, + "2022": -7.25, + "2023": 7.84, + "2024": -4.33, + "2025": 36.23, + "2026": 31.9 } }, "trend50_dd10": { "label": "추세 절반 + 낙폭 제어", "years": 24.7, - "cagr_pct": 6.66, - "vol_pct": 9.46, - "sharpe": 0.43, - "mdd_pct": -20.26, - "calmar": 0.33, - "worst_year_pct": -13.06, - "losing_years": 7, + "cagr_pct": 6.58, + "period_return_pct": 382.69, + "vol_pct": 8.71, + "sharpe": 0.45, + "mdd_pct": -16.53, + "calmar": 0.4, + "worst_year_pct": -8.71, + "losing_years": 8, "total_years": 25, - "negative_months_pct": 41.2, - "avg_exposure_pct": 40.6, - "turnover_per_year_pct": 68.2, - "final_value": 79158022, + "negative_months_pct": 38.4, + "avg_exposure_pct": 36.0, + "turnover_per_year_pct": 61.7, + "final_value": 80484835, "contributed": 29900000, - "profit": 49258022, + "profit": 50584835, "yearly": { - "2002": -2.48, - "2003": 16.92, - "2004": 4.39, - "2005": 26.74, - "2006": 1.3, - "2007": 16.36, - "2008": -13.06, - "2009": 20.67, - "2010": 11.1, - "2011": -3.14, - "2012": 0.7, - "2013": 0.59, + "2002": -0.27, + "2003": 10.47, + "2004": 4.42, + "2005": 26.7, + "2006": 1.32, + "2007": 16.35, + "2008": -8.71, + "2009": 14.06, + "2010": 11.16, + "2011": -3.94, + "2012": 1.87, + "2013": 1.39, "2014": -2.59, + "2015": -0.42, + "2016": 3.87, + "2017": 13.67, + "2018": -4.38, + "2019": 4.49, + "2020": 10.68, + "2021": 3.84, + "2022": -4.95, + "2023": 7.02, + "2024": -5.41, + "2025": 39.81, + "2026": 38.32 + } + }, + "trend50_dd10_min": { + "label": "추세·낙폭 중 더 낮은 비중", + "years": 24.7, + "cagr_pct": 6.72, + "period_return_pct": 399.02, + "vol_pct": 9.14, + "sharpe": 0.44, + "mdd_pct": -19.22, + "calmar": 0.35, + "worst_year_pct": -12.26, + "losing_years": 7, + "total_years": 25, + "negative_months_pct": 40.4, + "avg_exposure_pct": 38.5, + "turnover_per_year_pct": 60.5, + "final_value": 84121940, + "contributed": 29900000, + "profit": 54221940, + "yearly": { + "2002": -1.06, + "2003": 9.85, + "2004": 4.41, + "2005": 26.69, + "2006": 1.32, + "2007": 16.35, + "2008": -12.26, + "2009": 17.06, + "2010": 11.16, + "2011": -3.13, + "2012": 0.5, + "2013": 0.64, + "2014": -1.79, "2015": 0.36, - "2016": 5.08, - "2017": 13.71, + "2016": 3.87, + "2017": 13.7, "2018": -4.39, "2019": 4.49, - "2020": 12.83, + "2020": 17.52, "2021": 3.51, "2022": -4.95, "2023": 7.02, "2024": -5.42, - "2025": 39.81, - "2026": 31.8 + "2025": 39.82, + "2026": 38.33 } }, "trend50_vol20": { "label": "추세 절반 + 변동성 20%", "years": 24.7, "cagr_pct": 6.22, + "period_return_pct": 344.2, "vol_pct": 7.9, "sharpe": 0.44, "mdd_pct": -13.79, @@ -330,14 +384,14 @@ "worst_year_pct": -8.92, "losing_years": 7, "total_years": 25, - "negative_months_pct": 40.5, + "negative_months_pct": 40.4, "avg_exposure_pct": 37.4, "turnover_per_year_pct": 68.9, - "final_value": 70273594, + "final_value": 70270133, "contributed": 29900000, - "profit": 40373594, + "profit": 40370133, "yearly": { - "2002": 4.41, + "2002": 4.39, "2003": 13.18, "2004": 3.95, "2005": 25.88, @@ -347,7 +401,7 @@ "2009": 16.0, "2010": 11.21, "2011": -3.43, - "2012": -0.46, + "2012": -0.45, "2013": 0.6, "2014": -2.59, "2015": 0.36, @@ -359,7 +413,7 @@ "2021": 3.48, "2022": -4.73, "2023": 7.06, - "2024": -5.11, + "2024": -5.12, "2025": 34.41, "2026": 22.46 } @@ -369,12 +423,13 @@ "pocket_etf": { "symbol": "069500", "start": "2014-06-30", - "end": "2026-09-17", + "end": "2026-09-16", "results": { "static": { - "label": "고정 비중(현행)", - "years": 12.22, - "cagr_pct": 9.62, + "label": "고정 비중", + "years": 12.21, + "cagr_pct": 9.59, + "period_return_pct": 206.13, "vol_pct": 12.06, "sharpe": 0.59, "mdd_pct": -21.73, @@ -382,32 +437,33 @@ "worst_year_pct": -11.21, "losing_years": 4, "total_years": 13, - "negative_months_pct": 41.5, + "negative_months_pct": 41.9, "avg_exposure_pct": 50.9, - "turnover_per_year_pct": 16.6, - "final_value": 35522386, + "turnover_per_year_pct": 16.7, + "final_value": 35409900, "contributed": 15000000, - "profit": 20522386, + "profit": 20409900, "yearly": { - "2014": -1.57, + "2014": -1.59, "2015": 1.53, "2016": 6.54, "2017": 14.43, "2018": -7.34, "2019": 8.81, "2020": 21.17, - "2021": 3.1, + "2021": 3.11, "2022": -11.21, "2023": 13.89, "2024": -3.35, "2025": 44.44, - "2026": 41.78 + "2026": 41.33 } }, "trend50": { "label": "추세 필터 · 200일선 아래면 절반", - "years": 12.22, - "cagr_pct": 9.32, + "years": 12.21, + "cagr_pct": 9.29, + "period_return_pct": 195.84, "vol_pct": 10.85, "sharpe": 0.62, "mdd_pct": -21.66, @@ -415,80 +471,82 @@ "worst_year_pct": -4.58, "losing_years": 4, "total_years": 13, - "negative_months_pct": 40.1, - "avg_exposure_pct": 39.5, - "turnover_per_year_pct": 57.4, - "final_value": 34630217, + "negative_months_pct": 40.5, + "avg_exposure_pct": 39.4, + "turnover_per_year_pct": 57.5, + "final_value": 34514834, "contributed": 15000000, - "profit": 19630217, + "profit": 19514834, "yearly": { - "2014": -1.81, + "2014": -1.83, "2015": 0.7, "2016": 6.05, "2017": 14.43, "2018": -3.49, - "2019": 5.34, + "2019": 5.33, "2020": 18.43, "2021": 3.91, "2022": -4.28, - "2023": 7.87, + "2023": 7.85, "2024": -4.58, - "2025": 40.76, - "2026": 42.62 + "2025": 40.77, + "2026": 42.17 } }, "trend0": { "label": "추세 필터 · 200일선 아래면 0", - "years": 12.22, - "cagr_pct": 8.86, - "vol_pct": 10.41, + "years": 12.21, + "cagr_pct": 8.84, + "period_return_pct": 181.26, + "vol_pct": 10.42, "sharpe": 0.6, "mdd_pct": -21.66, "calmar": 0.41, "worst_year_pct": -6.09, "losing_years": 3, "total_years": 13, - "negative_months_pct": 25.9, + "negative_months_pct": 26.4, "avg_exposure_pct": 28.1, - "turnover_per_year_pct": 100.8, - "final_value": 33369808, + "turnover_per_year_pct": 100.9, + "final_value": 33266744, "contributed": 15000000, - "profit": 18369808, + "profit": 18266744, "yearly": { - "2014": -2.08, + "2014": -2.1, "2015": -0.23, "2016": 5.54, "2017": 14.43, - "2018": 0.31, + "2018": 0.3, "2019": 1.8, "2020": 15.99, "2021": 4.47, "2022": 2.93, "2023": 2.09, "2024": -6.09, - "2025": 37.35, - "2026": 42.61 + "2025": 37.37, + "2026": 42.15 } }, "vol15": { "label": "변동성 목표 15%", - "years": 12.22, - "cagr_pct": 6.59, + "years": 12.21, + "cagr_pct": 6.58, + "period_return_pct": 117.71, "vol_pct": 8.15, - "sharpe": 0.48, + "sharpe": 0.47, "mdd_pct": -15.65, "calmar": 0.42, "worst_year_pct": -9.97, "losing_years": 4, "total_years": 13, - "negative_months_pct": 40.8, + "negative_months_pct": 41.2, "avg_exposure_pct": 43.7, - "turnover_per_year_pct": 69.1, - "final_value": 26260789, + "turnover_per_year_pct": 69.2, + "final_value": 26217419, "contributed": 15000000, - "profit": 11260789, + "profit": 11217419, "yearly": { - "2014": -1.57, + "2014": -1.59, "2015": 1.53, "2016": 6.43, "2017": 14.43, @@ -500,13 +558,14 @@ "2023": 11.56, "2024": -3.26, "2025": 29.63, - "2026": 21.71 + "2026": 21.5 } }, "vol20": { "label": "변동성 목표 20%", - "years": 12.22, - "cagr_pct": 7.44, + "years": 12.21, + "cagr_pct": 7.43, + "period_return_pct": 139.84, "vol_pct": 9.14, "sharpe": 0.52, "mdd_pct": -17.0, @@ -514,14 +573,14 @@ "worst_year_pct": -10.96, "losing_years": 4, "total_years": 13, - "negative_months_pct": 40.8, + "negative_months_pct": 41.2, "avg_exposure_pct": 47.6, "turnover_per_year_pct": 36.0, - "final_value": 28779405, + "final_value": 28732150, "contributed": 15000000, - "profit": 13779405, + "profit": 13732150, "yearly": { - "2014": -1.57, + "2014": -1.59, "2015": 1.53, "2016": 6.54, "2017": 14.43, @@ -533,111 +592,410 @@ "2023": 13.9, "2024": -3.17, "2025": 38.27, - "2026": 24.13 + "2026": 23.92 } }, "dd10": { "label": "낙폭 제어 · -10%에서 절반", - "years": 12.22, - "cagr_pct": 8.84, - "vol_pct": 10.68, - "sharpe": 0.59, - "mdd_pct": -17.29, - "calmar": 0.51, - "worst_year_pct": -11.2, + "years": 12.21, + "cagr_pct": 8.08, + "period_return_pct": 158.25, + "vol_pct": 9.83, + "sharpe": 0.56, + "mdd_pct": -16.26, + "calmar": 0.5, + "worst_year_pct": -7.51, "losing_years": 4, "total_years": 13, - "negative_months_pct": 40.8, - "avg_exposure_pct": 49.9, - "turnover_per_year_pct": 42.9, - "final_value": 33256706, + "negative_months_pct": 41.2, + "avg_exposure_pct": 42.7, + "turnover_per_year_pct": 55.1, + "final_value": 31136106, "contributed": 15000000, - "profit": 18256706, + "profit": 16136106, "yearly": { - "2014": -1.57, + "2014": -1.59, "2015": 1.53, "2016": 6.54, "2017": 14.43, - "2018": -7.34, - "2019": 8.81, - "2020": 14.98, + "2018": -7.51, + "2019": 6.52, + "2020": 13.08, "2021": 3.26, - "2022": -11.2, - "2023": 13.89, - "2024": -3.34, - "2025": 44.4, - "2026": 36.63 + "2022": -7.51, + "2023": 9.17, + "2024": -3.17, + "2025": 37.82, + "2026": 36.76 } }, "trend50_dd10": { "label": "추세 절반 + 낙폭 제어", - "years": 12.22, - "cagr_pct": 8.76, - "vol_pct": 9.47, + "years": 12.21, + "cagr_pct": 8.65, + "period_return_pct": 175.32, + "vol_pct": 9.27, "sharpe": 0.64, - "mdd_pct": -16.26, - "calmar": 0.54, + "mdd_pct": -16.42, + "calmar": 0.53, + "worst_year_pct": -4.57, + "losing_years": 4, + "total_years": 13, + "negative_months_pct": 39.9, + "avg_exposure_pct": 38.2, + "turnover_per_year_pct": 72.2, + "final_value": 32804251, + "contributed": 15000000, + "profit": 17804251, + "yearly": { + "2014": -1.83, + "2015": 0.7, + "2016": 6.05, + "2017": 14.43, + "2018": -3.49, + "2019": 5.33, + "2020": 12.72, + "2021": 3.92, + "2022": -4.27, + "2023": 7.87, + "2024": -4.57, + "2025": 40.75, + "2026": 38.96 + } + }, + "trend50_dd10_min": { + "label": "추세·낙폭 중 더 낮은 비중", + "years": 12.21, + "cagr_pct": 9.08, + "period_return_pct": 189.17, + "vol_pct": 9.41, + "sharpe": 0.68, + "mdd_pct": -16.42, + "calmar": 0.55, "worst_year_pct": -4.58, "losing_years": 4, "total_years": 13, - "negative_months_pct": 39.5, + "negative_months_pct": 39.9, "avg_exposure_pct": 38.7, - "turnover_per_year_pct": 82.6, - "final_value": 32857861, + "turnover_per_year_pct": 71.0, + "final_value": 33748221, "contributed": 15000000, - "profit": 17857861, + "profit": 18748221, "yearly": { - "2014": -1.81, + "2014": -1.83, "2015": 0.7, "2016": 6.05, "2017": 14.43, "2018": -3.49, - "2019": 5.34, - "2020": 15.86, + "2019": 5.33, + "2020": 18.43, "2021": 3.91, - "2022": -4.27, - "2023": 7.87, + "2022": -4.28, + "2023": 7.85, "2024": -4.58, - "2025": 40.75, - "2026": 36.93 + "2025": 40.77, + "2026": 38.96 } }, "trend50_vol20": { "label": "추세 절반 + 변동성 20%", - "years": 12.22, - "cagr_pct": 7.45, + "years": 12.21, + "cagr_pct": 7.43, + "period_return_pct": 139.98, "vol_pct": 7.66, "sharpe": 0.61, "mdd_pct": -11.9, - "calmar": 0.63, + "calmar": 0.62, "worst_year_pct": -4.27, "losing_years": 4, "total_years": 13, - "negative_months_pct": 39.5, + "negative_months_pct": 39.9, "avg_exposure_pct": 36.5, "turnover_per_year_pct": 69.0, - "final_value": 28620391, + "final_value": 28568318, "contributed": 15000000, - "profit": 13620391, + "profit": 13568318, "yearly": { - "2014": -1.81, + "2014": -1.83, "2015": 0.7, "2016": 6.05, "2017": 14.43, "2018": -3.49, - "2019": 5.34, + "2019": 5.33, "2020": 13.93, "2021": 3.88, "2022": -4.09, - "2023": 7.93, + "2023": 7.9, "2024": -4.27, "2025": 35.44, - "2026": 24.13 + "2026": 23.92 } } } }, "basket": { + "symbol": "EW9", + "symbols": [ + "005930", + "035420", + "005380", + "051910", + "005490", + "055550", + "035720", + "012330", + "105560" + ], + "rf_annual": 0.0, + "start": "2021-12-01", + "end": "2026-09-16", + "results": { + "static": { + "label": "고정 비중", + "years": 4.79, + "cagr_pct": 6.07, + "period_return_pct": 32.64, + "vol_pct": 16.47, + "sharpe": 0.45, + "mdd_pct": -19.53, + "calmar": 0.31, + "worst_year_pct": -13.42, + "losing_years": 3, + "total_years": 6, + "negative_months_pct": 46.6, + "avg_exposure_pct": 59.4, + "turnover_per_year_pct": 14.5, + "final_value": 13263508, + "contributed": 10000000, + "profit": 3263508, + "yearly": { + "2021": -0.04, + "2022": -13.42, + "2023": 13.72, + "2024": -4.68, + "2025": 28.48, + "2026": 10.05 + } + }, + "trend50": { + "label": "추세 필터 · 200일선 아래면 절반", + "years": 4.79, + "cagr_pct": 4.58, + "period_return_pct": 23.92, + "vol_pct": 14.79, + "sharpe": 0.39, + "mdd_pct": -18.09, + "calmar": 0.25, + "worst_year_pct": -12.61, + "losing_years": 3, + "total_years": 6, + "negative_months_pct": 46.6, + "avg_exposure_pct": 52.0, + "turnover_per_year_pct": 82.5, + "final_value": 12391645, + "contributed": 10000000, + "profit": 2391645, + "yearly": { + "2021": -0.04, + "2022": -12.61, + "2023": 7.25, + "2024": -7.98, + "2025": 29.21, + "2026": 11.24 + } + }, + "trend0": { + "label": "추세 필터 · 200일선 아래면 0", + "years": 4.79, + "cagr_pct": 2.44, + "period_return_pct": 12.26, + "vol_pct": 14.34, + "sharpe": 0.25, + "mdd_pct": -24.94, + "calmar": 0.1, + "worst_year_pct": -11.64, + "losing_years": 4, + "total_years": 6, + "negative_months_pct": 46.6, + "avg_exposure_pct": 44.0, + "turnover_per_year_pct": 152.9, + "final_value": 11226213, + "contributed": 10000000, + "profit": 1226213, + "yearly": { + "2021": -0.04, + "2022": -11.64, + "2023": -0.01, + "2024": -11.53, + "2025": 29.16, + "2026": 11.24 + } + }, + "vol15": { + "label": "변동성 목표 15%", + "years": 4.79, + "cagr_pct": 2.62, + "period_return_pct": 13.17, + "vol_pct": 10.7, + "sharpe": 0.3, + "mdd_pct": -15.78, + "calmar": 0.17, + "worst_year_pct": -12.12, + "losing_years": 3, + "total_years": 6, + "negative_months_pct": 51.7, + "avg_exposure_pct": 42.3, + "turnover_per_year_pct": 108.0, + "final_value": 11316835, + "contributed": 10000000, + "profit": 1316835, + "yearly": { + "2021": -0.04, + "2022": -12.12, + "2023": 10.1, + "2024": -4.29, + "2025": 15.33, + "2026": 6.0 + } + }, + "vol20": { + "label": "변동성 목표 20%", + "years": 4.79, + "cagr_pct": 3.87, + "period_return_pct": 19.96, + "vol_pct": 12.93, + "sharpe": 0.37, + "mdd_pct": -18.48, + "calmar": 0.21, + "worst_year_pct": -14.14, + "losing_years": 3, + "total_years": 6, + "negative_months_pct": 50.0, + "avg_exposure_pct": 52.0, + "turnover_per_year_pct": 92.8, + "final_value": 11996038, + "contributed": 10000000, + "profit": 1996038, + "yearly": { + "2021": -0.04, + "2022": -14.14, + "2023": 12.82, + "2024": -4.73, + "2025": 21.03, + "2026": 7.44 + } + }, + "dd10": { + "label": "낙폭 제어 · -10%에서 절반", + "years": 4.79, + "cagr_pct": 3.1, + "period_return_pct": 15.77, + "vol_pct": 12.1, + "sharpe": 0.32, + "mdd_pct": -15.0, + "calmar": 0.21, + "worst_year_pct": -11.05, + "losing_years": 3, + "total_years": 6, + "negative_months_pct": 46.6, + "avg_exposure_pct": 39.2, + "turnover_per_year_pct": 57.6, + "final_value": 11577422, + "contributed": 10000000, + "profit": 1577422, + "yearly": { + "2021": -0.04, + "2022": -11.05, + "2023": 7.1, + "2024": -5.36, + "2025": 19.02, + "2026": 7.93 + } + }, + "trend50_dd10": { + "label": "추세 절반 + 낙폭 제어", + "years": 4.79, + "cagr_pct": 2.68, + "period_return_pct": 13.5, + "vol_pct": 11.38, + "sharpe": 0.3, + "mdd_pct": -15.0, + "calmar": 0.18, + "worst_year_pct": -10.59, + "losing_years": 3, + "total_years": 6, + "negative_months_pct": 46.6, + "avg_exposure_pct": 33.2, + "turnover_per_year_pct": 80.2, + "final_value": 11349921, + "contributed": 10000000, + "profit": 1349921, + "yearly": { + "2021": -0.04, + "2022": -10.59, + "2023": 3.61, + "2024": -4.02, + "2025": 18.26, + "2026": 7.99 + } + }, + "trend50_dd10_min": { + "label": "추세·낙폭 중 더 낮은 비중", + "years": 4.79, + "cagr_pct": 3.1, + "period_return_pct": 15.77, + "vol_pct": 12.1, + "sharpe": 0.32, + "mdd_pct": -15.0, + "calmar": 0.21, + "worst_year_pct": -11.05, + "losing_years": 3, + "total_years": 6, + "negative_months_pct": 46.6, + "avg_exposure_pct": 39.2, + "turnover_per_year_pct": 57.6, + "final_value": 11577422, + "contributed": 10000000, + "profit": 1577422, + "yearly": { + "2021": -0.04, + "2022": -11.05, + "2023": 7.1, + "2024": -5.36, + "2025": 19.02, + "2026": 7.93 + } + }, + "trend50_vol20": { + "label": "추세 절반 + 변동성 20%", + "years": 4.79, + "cagr_pct": 2.2, + "period_return_pct": 11.01, + "vol_pct": 11.51, + "sharpe": 0.25, + "mdd_pct": -18.93, + "calmar": 0.12, + "worst_year_pct": -13.06, + "losing_years": 3, + "total_years": 6, + "negative_months_pct": 51.7, + "avg_exposure_pct": 44.7, + "turnover_per_year_pct": 140.8, + "final_value": 11101108, + "contributed": 10000000, + "profit": 1101108, + "yearly": { + "2021": -0.04, + "2022": -13.06, + "2023": 5.8, + "2024": -7.45, + "2025": 21.41, + "2026": 7.44 + } + } + } + }, + "basket_legacy10": { "symbol": "EW10", "symbols": [ "005930", @@ -651,217 +1009,362 @@ "012330", "105560" ], + "rf_annual": 0.03, "start": "2021-12-01", - "end": "2026-09-17", + "end": "2026-09-16", "results": { "static": { - "label": "고정 비중(현행)", + "label": "고정 비중", "years": 4.79, - "cagr_pct": 18.32, - "vol_pct": 19.98, - "sharpe": 0.82, - "mdd_pct": -26.06, - "calmar": 0.7, - "worst_year_pct": -13.48, + "cagr_pct": 13.59, + "period_return_pct": 84.13, + "vol_pct": 16.86, + "sharpe": 0.68, + "mdd_pct": -17.38, + "calmar": 0.78, + "worst_year_pct": -13.49, "losing_years": 2, "total_years": 6, - "negative_months_pct": 40.4, + "negative_months_pct": 41.4, "avg_exposure_pct": 61.0, - "turnover_per_year_pct": 21.5, - "final_value": 22389785, + "turnover_per_year_pct": 18.6, + "final_value": 18412540, "contributed": 10000000, - "profit": 12389785, + "profit": 8412540, "yearly": { - "2021": 0.84, - "2022": -13.48, - "2023": 19.75, - "2024": -2.25, - "2025": 49.54, - "2026": 46.64 + "2021": 0.82, + "2022": -13.49, + "2023": 19.78, + "2024": -0.78, + "2025": 41.03, + "2026": 25.96 } }, "trend50": { "label": "추세 필터 · 200일선 아래면 절반", "years": 4.79, - "cagr_pct": 16.36, - "vol_pct": 19.1, - "sharpe": 0.76, - "mdd_pct": -24.85, - "calmar": 0.66, - "worst_year_pct": -12.61, + "cagr_pct": 11.32, + "period_return_pct": 67.15, + "vol_pct": 15.61, + "sharpe": 0.59, + "mdd_pct": -15.8, + "calmar": 0.72, + "worst_year_pct": -12.62, "losing_years": 2, "total_years": 6, - "negative_months_pct": 42.1, - "avg_exposure_pct": 52.6, - "turnover_per_year_pct": 85.5, - "final_value": 20669841, + "negative_months_pct": 43.1, + "avg_exposure_pct": 52.2, + "turnover_per_year_pct": 80.9, + "final_value": 16715161, "contributed": 10000000, - "profit": 10669841, + "profit": 6715161, "yearly": { - "2021": 0.84, - "2022": -12.61, - "2023": 11.52, - "2024": -6.32, - "2025": 49.02, - "2026": 50.69 + "2021": 0.82, + "2022": -12.62, + "2023": 10.85, + "2024": -5.54, + "2025": 41.44, + "2026": 28.12 } }, "trend0": { "label": "추세 필터 · 200일선 아래면 0", "years": 4.79, - "cagr_pct": 13.92, - "vol_pct": 18.74, - "sharpe": 0.65, - "mdd_pct": -24.85, - "calmar": 0.56, - "worst_year_pct": -11.28, + "cagr_pct": 8.9, + "period_return_pct": 50.42, + "vol_pct": 15.18, + "sharpe": 0.46, + "mdd_pct": -20.12, + "calmar": 0.44, + "worst_year_pct": -11.29, "losing_years": 2, "total_years": 6, - "negative_months_pct": 35.1, - "avg_exposure_pct": 44.7, - "turnover_per_year_pct": 150.4, - "final_value": 18676094, + "negative_months_pct": 39.7, + "avg_exposure_pct": 44.3, + "turnover_per_year_pct": 148.6, + "final_value": 15042292, "contributed": 10000000, - "profit": 8676094, + "profit": 5042292, "yearly": { - "2021": 0.84, - "2022": -11.28, - "2023": 4.39, - "2024": -9.88, - "2025": 47.29, - "2026": 50.69 + "2021": 0.82, + "2022": -11.29, + "2023": 3.1, + "2024": -9.71, + "2025": 41.03, + "2026": 28.12 } }, "vol15": { "label": "변동성 목표 15%", "years": 4.79, - "cagr_pct": 9.7, - "vol_pct": 11.91, - "sharpe": 0.61, + "cagr_pct": 7.78, + "period_return_pct": 43.18, + "vol_pct": 10.84, + "sharpe": 0.49, "mdd_pct": -15.14, - "calmar": 0.64, + "calmar": 0.51, "worst_year_pct": -11.83, "losing_years": 2, "total_years": 6, - "negative_months_pct": 42.1, - "avg_exposure_pct": 41.0, - "turnover_per_year_pct": 96.7, - "final_value": 15579191, + "negative_months_pct": 44.8, + "avg_exposure_pct": 41.9, + "turnover_per_year_pct": 97.9, + "final_value": 14317874, "contributed": 10000000, - "profit": 5579191, + "profit": 4317874, "yearly": { - "2021": 0.84, + "2021": 0.82, "2022": -11.83, - "2023": 15.12, - "2024": -3.35, - "2025": 26.76, - "2026": 24.28 + "2023": 14.44, + "2024": -1.87, + "2025": 23.76, + "2026": 15.9 } }, "vol20": { "label": "변동성 목표 20%", "years": 4.79, - "cagr_pct": 12.4, - "vol_pct": 14.17, - "sharpe": 0.71, + "cagr_pct": 9.45, + "period_return_pct": 54.11, + "vol_pct": 13.11, + "sharpe": 0.55, "mdd_pct": -17.98, - "calmar": 0.69, + "calmar": 0.53, "worst_year_pct": -14.23, "losing_years": 2, "total_years": 6, - "negative_months_pct": 42.1, - "avg_exposure_pct": 51.1, - "turnover_per_year_pct": 100.1, - "final_value": 17511837, + "negative_months_pct": 43.1, + "avg_exposure_pct": 51.5, + "turnover_per_year_pct": 101.0, + "final_value": 15411410, "contributed": 10000000, - "profit": 7511837, + "profit": 5411410, "yearly": { - "2021": 0.84, + "2021": 0.82, "2022": -14.23, - "2023": 17.82, - "2024": -3.65, - "2025": 39.19, - "2026": 28.17 + "2023": 17.74, + "2024": -1.71, + "2025": 32.61, + "2026": 16.14 } }, "dd10": { "label": "낙폭 제어 · -10%에서 절반", "years": 4.79, - "cagr_pct": 15.84, - "vol_pct": 15.71, - "sharpe": 0.85, - "mdd_pct": -16.41, - "calmar": 0.97, + "cagr_pct": 10.7, + "period_return_pct": 62.74, + "vol_pct": 13.48, + "sharpe": 0.62, + "mdd_pct": -13.84, + "calmar": 0.77, "worst_year_pct": -10.62, "losing_years": 2, "total_years": 6, - "negative_months_pct": 38.6, - "avg_exposure_pct": 43.3, - "turnover_per_year_pct": 78.4, - "final_value": 20228356, + "negative_months_pct": 39.7, + "avg_exposure_pct": 43.6, + "turnover_per_year_pct": 73.8, + "final_value": 16273695, "contributed": 10000000, - "profit": 10228356, + "profit": 6273695, "yearly": { - "2021": 0.84, + "2021": 0.82, "2022": -10.62, - "2023": 5.87, - "2024": -3.2, - "2025": 43.9, - "2026": 52.22 + "2023": 7.54, + "2024": -1.58, + "2025": 36.75, + "2026": 24.78 } }, "trend50_dd10": { "label": "추세 절반 + 낙폭 제어", "years": 4.79, - "cagr_pct": 15.5, - "vol_pct": 15.16, - "sharpe": 0.86, - "mdd_pct": -16.41, - "calmar": 0.95, + "cagr_pct": 9.9, + "period_return_pct": 57.22, + "vol_pct": 12.84, + "sharpe": 0.59, + "mdd_pct": -13.04, + "calmar": 0.76, "worst_year_pct": -10.02, "losing_years": 2, "total_years": 6, - "negative_months_pct": 36.8, - "avg_exposure_pct": 37.7, - "turnover_per_year_pct": 99.5, - "final_value": 19951442, + "negative_months_pct": 37.9, + "avg_exposure_pct": 37.5, + "turnover_per_year_pct": 95.1, + "final_value": 15721932, "contributed": 10000000, - "profit": 9951442, + "profit": 5721932, "yearly": { - "2021": 0.84, + "2021": 0.82, "2022": -10.02, - "2023": 7.19, - "2024": -5.03, - "2025": 41.68, - "2026": 52.49 + "2023": 6.86, + "2024": -4.26, + "2025": 35.85, + "2026": 24.71 + } + }, + "trend50_dd10_min": { + "label": "추세·낙폭 중 더 낮은 비중", + "years": 4.79, + "cagr_pct": 10.97, + "period_return_pct": 64.69, + "vol_pct": 13.49, + "sharpe": 0.64, + "mdd_pct": -13.84, + "calmar": 0.79, + "worst_year_pct": -10.62, + "losing_years": 2, + "total_years": 6, + "negative_months_pct": 39.7, + "avg_exposure_pct": 43.5, + "turnover_per_year_pct": 74.1, + "final_value": 16469075, + "contributed": 10000000, + "profit": 6469075, + "yearly": { + "2021": 0.82, + "2022": -10.62, + "2023": 7.82, + "2024": -0.66, + "2025": 36.75, + "2026": 24.78 } }, "trend50_vol20": { "label": "추세 절반 + 변동성 20%", "years": 4.79, - "cagr_pct": 10.59, - "vol_pct": 12.88, - "sharpe": 0.64, - "mdd_pct": -16.06, - "calmar": 0.66, - "worst_year_pct": -12.87, + "cagr_pct": 7.48, + "period_return_pct": 41.31, + "vol_pct": 11.76, + "sharpe": 0.44, + "mdd_pct": -16.07, + "calmar": 0.47, + "worst_year_pct": -12.88, "losing_years": 2, "total_years": 6, - "negative_months_pct": 40.4, - "avg_exposure_pct": 44.0, - "turnover_per_year_pct": 138.9, - "final_value": 16195395, + "negative_months_pct": 44.8, + "avg_exposure_pct": 44.3, + "turnover_per_year_pct": 141.6, + "final_value": 14131061, "contributed": 10000000, - "profit": 6195395, + "profit": 4131061, "yearly": { - "2021": 0.84, - "2022": -12.87, - "2023": 10.76, - "2024": -5.67, - "2025": 37.68, - "2026": 28.17 + "2021": 0.82, + "2022": -12.88, + "2023": 10.03, + "2024": -4.8, + "2025": 32.25, + "2026": 16.14 } } } + }, + "exposure_review": { + "start": "2021-12-01", + "symbols": [ + "005930", + "035420", + "005380", + "051910", + "005490", + "055550", + "035720", + "012330", + "105560" + ], + "fractions": { + "60%": { + "label": "고정 비중", + "years": 4.79, + "cagr_pct": 6.07, + "period_return_pct": 32.64, + "vol_pct": 16.47, + "sharpe": 0.45, + "mdd_pct": -19.53, + "calmar": 0.31, + "worst_year_pct": -13.42, + "losing_years": 3, + "total_years": 6, + "negative_months_pct": 46.6, + "avg_exposure_pct": 59.4, + "turnover_per_year_pct": 14.5, + "final_value": 13263508, + "contributed": 10000000, + "profit": 3263508, + "yearly": { + "2021": -0.04, + "2022": -13.42, + "2023": 13.72, + "2024": -4.68, + "2025": 28.48, + "2026": 10.05 + }, + "up_capture": 0.4533433077226763, + "down_capture": 0.48351184271431236, + "up_days": 633, + "down_days": 537 + }, + "70%": { + "label": "고정 비중", + "years": 4.79, + "cagr_pct": 7.02, + "period_return_pct": 38.4, + "vol_pct": 19.1, + "sharpe": 0.46, + "mdd_pct": -22.01, + "calmar": 0.32, + "worst_year_pct": -15.66, + "losing_years": 3, + "total_years": 6, + "negative_months_pct": 46.6, + "avg_exposure_pct": 69.4, + "turnover_per_year_pct": 16.2, + "final_value": 13839548, + "contributed": 10000000, + "profit": 3839548, + "yearly": { + "2021": -0.05, + "2022": -15.66, + "2023": 16.44, + "2024": -5.48, + "2025": 33.59, + "2026": 11.66 + }, + "up_capture": 0.52865273876406, + "down_capture": 0.5638862401315071, + "up_days": 633, + "down_days": 537 + }, + "80%": { + "label": "고정 비중", + "years": 4.79, + "cagr_pct": 8.28, + "period_return_pct": 46.41, + "vol_pct": 21.48, + "sharpe": 0.49, + "mdd_pct": -23.63, + "calmar": 0.35, + "worst_year_pct": -17.9, + "losing_years": 3, + "total_years": 6, + "negative_months_pct": 46.6, + "avg_exposure_pct": 79.3, + "turnover_per_year_pct": 18.6, + "final_value": 14641114, + "contributed": 10000000, + "profit": 4641114, + "yearly": { + "2021": -0.06, + "2022": -17.9, + "2023": 19.3, + "2024": -6.28, + "2025": 38.82, + "2026": 14.95 + }, + "up_capture": 0.6019123990065971, + "down_capture": 0.639747335367282, + "up_days": 633, + "down_days": 537 + } + } } } \ No newline at end of file diff --git a/reports/research/risk_overlay_backtest.md b/reports/research/risk_overlay_backtest.md index c1601664..3d3aa385 100644 --- a/reports/research/risk_overlay_backtest.md +++ b/reports/research/risk_overlay_backtest.md @@ -6,41 +6,68 @@ | 정책 | 연수익률(CAGR) | 최대낙폭 | 샤프 | 칼마 | 최악 연도 | 손실 연도 | 평균 주식 비중 | 연 회전율 | |---|---:|---:|---:|---:|---:|---:|---:|---:| -| 고정 비중(현행) | +7.45% | -28.8% | 0.42 | 0.26 | -18.7% | 7/25 | 51% | 9% | +| 고정 비중 | +7.45% | -28.8% | 0.42 | 0.26 | -18.7% | 7/25 | 51% | 9% | | 추세 필터 · 200일선 아래면 절반 | +7.19% | -21.8% | 0.45 | 0.33 | -12.3% | 7/25 | 41% | 54% | | 추세 필터 · 200일선 아래면 0 | +6.79% | -21.8% | 0.44 | 0.31 | -6.8% | 10/25 | 31% | 102% | | 변동성 목표 15% | +5.79% | -17.8% | 0.38 | 0.33 | -12.3% | 6/25 | 41% | 58% | | 변동성 목표 20% | +6.32% | -20.9% | 0.39 | 0.3 | -14.7% | 6/25 | 46% | 30% | -| 낙폭 제어 · -10%에서 절반 | +6.91% | -23.8% | 0.41 | 0.29 | -14.6% | 7/25 | 48% | 32% | -| 추세 절반 + 낙폭 제어 | +6.66% | -20.3% | 0.43 | 0.33 | -13.1% | 7/25 | 41% | 68% | +| 낙폭 제어 · -10%에서 절반 | +6.06% | -19.4% | 0.37 | 0.31 | -12.4% | 9/25 | 39% | 38% | +| 추세 절반 + 낙폭 제어 | +6.58% | -16.5% | 0.45 | 0.4 | -8.7% | 8/25 | 36% | 62% | +| 추세·낙폭 중 더 낮은 비중 | +6.72% | -19.2% | 0.44 | 0.35 | -12.3% | 7/25 | 38% | 60% | | 추세 절반 + 변동성 20% | +6.22% | -13.8% | 0.44 | 0.45 | -8.9% | 7/25 | 37% | 69% | ### 적립 트랙 (KODEX 200 ETF, 2014~) -- 데이터: 069500 2014-06-30 → 2026-09-17 +- 데이터: 069500 2014-06-30 → 2026-09-16 | 정책 | 연수익률(CAGR) | 최대낙폭 | 샤프 | 칼마 | 최악 연도 | 손실 연도 | 평균 주식 비중 | 연 회전율 | |---|---:|---:|---:|---:|---:|---:|---:|---:| -| 고정 비중(현행) | +9.62% | -21.7% | 0.59 | 0.44 | -11.2% | 4/13 | 51% | 17% | -| 추세 필터 · 200일선 아래면 절반 | +9.32% | -21.7% | 0.62 | 0.43 | -4.6% | 4/13 | 40% | 57% | -| 추세 필터 · 200일선 아래면 0 | +8.86% | -21.7% | 0.60 | 0.41 | -6.1% | 3/13 | 28% | 101% | -| 변동성 목표 15% | +6.59% | -15.7% | 0.48 | 0.42 | -10.0% | 4/13 | 44% | 69% | -| 변동성 목표 20% | +7.44% | -17.0% | 0.52 | 0.44 | -11.0% | 4/13 | 48% | 36% | -| 낙폭 제어 · -10%에서 절반 | +8.84% | -17.3% | 0.59 | 0.51 | -11.2% | 4/13 | 50% | 43% | -| 추세 절반 + 낙폭 제어 | +8.76% | -16.3% | 0.64 | 0.54 | -4.6% | 4/13 | 39% | 83% | -| 추세 절반 + 변동성 20% | +7.45% | -11.9% | 0.61 | 0.63 | -4.3% | 4/13 | 36% | 69% | +| 고정 비중 | +9.59% | -21.7% | 0.59 | 0.44 | -11.2% | 4/13 | 51% | 17% | +| 추세 필터 · 200일선 아래면 절반 | +9.29% | -21.7% | 0.62 | 0.43 | -4.6% | 4/13 | 39% | 58% | +| 추세 필터 · 200일선 아래면 0 | +8.84% | -21.7% | 0.60 | 0.41 | -6.1% | 3/13 | 28% | 101% | +| 변동성 목표 15% | +6.58% | -15.7% | 0.47 | 0.42 | -10.0% | 4/13 | 44% | 69% | +| 변동성 목표 20% | +7.43% | -17.0% | 0.52 | 0.44 | -11.0% | 4/13 | 48% | 36% | +| 낙폭 제어 · -10%에서 절반 | +8.08% | -16.3% | 0.56 | 0.5 | -7.5% | 4/13 | 43% | 55% | +| 추세 절반 + 낙폭 제어 | +8.65% | -16.4% | 0.64 | 0.53 | -4.6% | 4/13 | 38% | 72% | +| 추세·낙폭 중 더 낮은 비중 | +9.08% | -16.4% | 0.68 | 0.55 | -4.6% | 4/13 | 39% | 71% | +| 추세 절반 + 변동성 20% | +7.43% | -11.9% | 0.61 | 0.62 | -4.3% | 4/13 | 36% | 69% | -### 관찰 트랙 (대형주 10종목, 2021-12~) +### 관찰 트랙 (지금 들고 있는 9종목, 이자 없는 현금, 2021-12~) -- 데이터: EW10 2021-12-01 → 2026-09-17 +- 데이터: EW9 2021-12-01 → 2026-09-16 | 정책 | 연수익률(CAGR) | 최대낙폭 | 샤프 | 칼마 | 최악 연도 | 손실 연도 | 평균 주식 비중 | 연 회전율 | |---|---:|---:|---:|---:|---:|---:|---:|---:| -| 고정 비중(현행) | +18.32% | -26.1% | 0.82 | 0.7 | -13.5% | 2/6 | 61% | 22% | -| 추세 필터 · 200일선 아래면 절반 | +16.36% | -24.9% | 0.76 | 0.66 | -12.6% | 2/6 | 53% | 86% | -| 추세 필터 · 200일선 아래면 0 | +13.92% | -24.9% | 0.65 | 0.56 | -11.3% | 2/6 | 45% | 150% | -| 변동성 목표 15% | +9.70% | -15.1% | 0.61 | 0.64 | -11.8% | 2/6 | 41% | 97% | -| 변동성 목표 20% | +12.40% | -18.0% | 0.71 | 0.69 | -14.2% | 2/6 | 51% | 100% | -| 낙폭 제어 · -10%에서 절반 | +15.84% | -16.4% | 0.85 | 0.97 | -10.6% | 2/6 | 43% | 78% | -| 추세 절반 + 낙폭 제어 | +15.50% | -16.4% | 0.86 | 0.95 | -10.0% | 2/6 | 38% | 100% | -| 추세 절반 + 변동성 20% | +10.59% | -16.1% | 0.64 | 0.66 | -12.9% | 2/6 | 44% | 139% | +| 고정 비중 | +6.07% | -19.5% | 0.45 | 0.31 | -13.4% | 3/6 | 59% | 14% | +| 추세 필터 · 200일선 아래면 절반 | +4.58% | -18.1% | 0.39 | 0.25 | -12.6% | 3/6 | 52% | 82% | +| 추세 필터 · 200일선 아래면 0 | +2.44% | -24.9% | 0.25 | 0.1 | -11.6% | 4/6 | 44% | 153% | +| 변동성 목표 15% | +2.62% | -15.8% | 0.30 | 0.17 | -12.1% | 3/6 | 42% | 108% | +| 변동성 목표 20% | +3.87% | -18.5% | 0.37 | 0.21 | -14.1% | 3/6 | 52% | 93% | +| 낙폭 제어 · -10%에서 절반 | +3.10% | -15.0% | 0.32 | 0.21 | -11.1% | 3/6 | 39% | 58% | +| 추세 절반 + 낙폭 제어 | +2.68% | -15.0% | 0.30 | 0.18 | -10.6% | 3/6 | 33% | 80% | +| 추세·낙폭 중 더 낮은 비중 | +3.10% | -15.0% | 0.32 | 0.21 | -11.1% | 3/6 | 39% | 58% | +| 추세 절반 + 변동성 20% | +2.20% | -18.9% | 0.25 | 0.12 | -13.1% | 3/6 | 45% | 141% | + +### 참고 · 예전 표 조건 (000660 포함 10종목, 현금 연 3%) + +- 데이터: EW10 2021-12-01 → 2026-09-16 + +| 정책 | 연수익률(CAGR) | 최대낙폭 | 샤프 | 칼마 | 최악 연도 | 손실 연도 | 평균 주식 비중 | 연 회전율 | +|---|---:|---:|---:|---:|---:|---:|---:|---:| +| 고정 비중 | +13.59% | -17.4% | 0.68 | 0.78 | -13.5% | 2/6 | 61% | 19% | +| 추세 필터 · 200일선 아래면 절반 | +11.32% | -15.8% | 0.59 | 0.72 | -12.6% | 2/6 | 52% | 81% | +| 추세 필터 · 200일선 아래면 0 | +8.90% | -20.1% | 0.46 | 0.44 | -11.3% | 2/6 | 44% | 149% | +| 변동성 목표 15% | +7.78% | -15.1% | 0.49 | 0.51 | -11.8% | 2/6 | 42% | 98% | +| 변동성 목표 20% | +9.45% | -18.0% | 0.55 | 0.53 | -14.2% | 2/6 | 52% | 101% | +| 낙폭 제어 · -10%에서 절반 | +10.70% | -13.8% | 0.62 | 0.77 | -10.6% | 2/6 | 44% | 74% | +| 추세 절반 + 낙폭 제어 | +9.90% | -13.0% | 0.59 | 0.76 | -10.0% | 2/6 | 38% | 95% | +| 추세·낙폭 중 더 낮은 비중 | +10.97% | -13.8% | 0.64 | 0.79 | -10.6% | 2/6 | 44% | 74% | +| 추세 절반 + 변동성 20% | +7.48% | -16.1% | 0.44 | 0.47 | -12.9% | 2/6 | 44% | 142% | + +### 주식 비중별 비교 (지금 들고 있는 종목, 고정 비중, 이자 없는 현금, 2021-12~) + +| 주식 비중 | 연수익률 | 최대낙폭 | 샤프(금리 0%) | 상승 포착 | 하락 포착 | +|---|---:|---:|---:|---:|---:| +| 60% | +6.07% | -19.5% | 0.45 | 45% | 48% | +| 70% | +7.02% | -22.0% | 0.46 | 53% | 56% | +| 80% | +8.28% | -23.6% | 0.49 | 60% | 64% | diff --git a/strategies/breakout_volume.py b/strategies/breakout_volume.py index d1bb2705..91124a55 100644 --- a/strategies/breakout_volume.py +++ b/strategies/breakout_volume.py @@ -12,11 +12,20 @@ → 전일 조건 미충족, 당일 충족 시에만 BUY (edge-trigger) Exit: - 전략 레벨 최소 실패 신호만 사용: close < breakout_ref + 전략 레벨 최소 실패 신호만 사용: close < entry_level + entry_level = 가장 최근 BUY 봉에서 돌파한 breakout_ref (다음 BUY까지 고정). + 종가가 이 레벨 아래에 있는 봉마다 SELL (level-trigger). 최소 보유일 규칙에 막힌 + SELL도 다음 봉에서 다시 나오므로 청산이 사라지지 않는다. 보유하지 않은 종목의 + SELL은 백테스터가 무시한다. 나머지 손절/트레일링은 기존 backtester risk layer (ATR 2.5) 위임. + (예전에는 매 봉의 breakout_ref와 비교했는데, T+1의 breakout_ref는 돌파봉 T의 + 고가까지 포함하므로 "돌파 레벨 재이탈"이 아니라 "close[T+1] < high[T]"를 + 검사하게 되어 진입 다음 봉에 SELL이 나는 1일 왕복 전략이 됐다.) + Look-ahead 방지: breakout_ref, avg_vol_ref 모두 .shift(1) 적용 → 현재 봉의 high/volume 제외. + entry_level은 과거 BUY 봉의 breakout_ref를 앞으로만 채운다(ffill). """ import pandas as pd @@ -79,10 +88,14 @@ def analyze(self, df: pd.DataFrame) -> pd.DataFrame: entry_prev = entry_cond.shift(1, fill_value=False) entry_edge = entry_cond & (~entry_prev) - # ── Exit: 최소 실패 신호 (close < breakout_ref) ── - exit_cond = breakout_ref.notna() & (close < breakout_ref) - exit_prev = exit_cond.shift(1, fill_value=False) - exit_edge = exit_cond & (~exit_prev) + # ── Exit: 최소 실패 신호 (close < 진입 때 돌파한 레벨) ── + # 매 봉의 breakout_ref는 돌파봉 자신의 고가까지 올라가므로 청산 기준으로 쓰면 + # 진입 다음 봉에 대부분 SELL이 났다. 진입 봉의 breakout_ref를 다음 진입까지 고정한다. + entry_level = breakout_ref.where(entry_edge).ffill() + exit_cond = entry_level.notna() & (close < entry_level) + # level-trigger: 레벨 아래에 있는 동안 매 봉 SELL. 최소 보유일에 막힌 청산이 + # 다음 봉에서 다시 나오게 한다 (edge-trigger면 한 번 막히면 영영 사라진다). + exit_trigger = exit_cond # ── 디버그 컬럼 ── volume_surge_ratio = pd.Series(np.nan, index=analyzed.index) @@ -90,12 +103,13 @@ def analyze(self, df: pd.DataFrame) -> pd.DataFrame: volume_surge_ratio[valid_vol] = volume[valid_vol] / avg_vol_ref[valid_vol] analyzed["breakout_ref"] = breakout_ref + analyzed["entry_level"] = entry_level analyzed["avg_vol_ref"] = avg_vol_ref analyzed["volume_surge_ratio"] = volume_surge_ratio analyzed["entry_condition"] = entry_cond analyzed["exit_condition"] = exit_cond analyzed["entry_trigger"] = entry_edge - analyzed["exit_trigger"] = exit_edge + analyzed["exit_trigger"] = exit_trigger # ── strategy_score: 연속 ranking 점수 (동시 BUY 후보 정렬용) ── # 돌파 강도 + 거래량 급증도 + 추세 강도의 도메인 고정 스케일링 가중합. @@ -134,7 +148,7 @@ def analyze(self, df: pd.DataFrame) -> pd.DataFrame: # ── signal 생성 ── analyzed["signal"] = self.HOLD analyzed.loc[entry_edge.fillna(False), "signal"] = self.BUY - analyzed.loc[exit_edge.fillna(False), "signal"] = self.SELL + analyzed.loc[exit_trigger.fillna(False), "signal"] = self.SELL analyzed.loc[analyzed["signal"] == self.SELL, "total_score"] *= -1 analyzed.loc[analyzed["signal"] == self.SELL, "strategy_score"] *= -1 @@ -162,6 +176,7 @@ def generate_signal(self, df: pd.DataFrame, **kwargs) -> dict: "details": { "ADX": round(last.get("adx", 0), 2) if pd.notna(last.get("adx")) else 0, "breakout_ref": round(last.get("breakout_ref", 0), 0) if pd.notna(last.get("breakout_ref")) else 0, + "entry_level": round(last.get("entry_level", 0), 0) if pd.notna(last.get("entry_level")) else 0, "avg_vol_ref": round(last.get("avg_vol_ref", 0), 0) if pd.notna(last.get("avg_vol_ref")) else 0, "volume_surge_ratio": round(last.get("volume_surge_ratio", 0), 2) if pd.notna(last.get("volume_surge_ratio")) else 0, "close": last.get("close", 0), diff --git a/strategies/index_cache.py b/strategies/index_cache.py new file mode 100644 index 00000000..ae7efe06 --- /dev/null +++ b/strategies/index_cache.py @@ -0,0 +1,112 @@ +""" +지수(벤치마크) 종가 캐시 — 전략 인스턴스당 넓은 구간을 한 번 받아 두고 잘라 쓴다. + +strict 백테스트는 analyze()를 봉마다 df.iloc[:i+1]로 부른다. 예전 캐시는 호출의 +(시작, 끝) 날짜를 키로 써서 끝 날짜가 바뀌는 매 봉마다 지수를 새로 내려받았고 +(호출마다 새 DataCollector라 수집기 캐시도 못 탔다), 시장 필터는 반대로 첫 호출 +구간(strict에서는 첫 1일)으로 굳어 버렸다. + +규칙: + - 첫 요청 때 [요청 시작 - warmup_days, max(오늘, 요청 끝)] 구간을 한 번 받는다. + - 이후 요청이 받아 둔 구간 안이면 네트워크 없이 캐시를 쓴다. + - 요청 시작이 더 이르거나(다른 종목) 요청 끝이 받아 둔 끝보다 늦으면(날짜가 넘어간 + 장기 실행 프로세스) 합친 구간으로 다시 받는다. + - 호출하는 쪽은 파생 시리즈(SMA·수익률 등 과거만 보는 계산)를 전체 캐시로 한 번 + 계산해 두고(version이 바뀔 때만 재계산), 매 호출마다 요청 끝 날짜 이하로 잘라 쓴다. + 그래서 요청 시점 이후 봉은 결과에 섞이지 않는다. + - 조회 실패도 같은 구간 동안 기억해 봉마다 재시도하지 않는다. 사유는 last_error. +""" + +from __future__ import annotations + +from datetime import datetime + +import pandas as pd +from loguru import logger + + +class IndexCloseCache: + """한 지수의 일봉 종가를 넓게 캐시하고 요청 끝 날짜 이하로 잘라 준다.""" + + def __init__(self, symbol: str, warmup_days: int, *, label: str = ""): + self.symbol = str(symbol) + self.warmup_days = max(0, int(warmup_days)) + self.label = label or self.symbol + self.fetch_count = 0 # 실제 수집 시도 횟수 (테스트·진단용) + self.version = 0 # 수집할 때마다 증가 — 파생 시리즈 재계산 판단용 + self.last_error: str | None = None + self._closes: pd.Series | None = None + self._span: tuple[pd.Timestamp, pd.Timestamp] | None = None + self._collector = None + + def ensure(self, first, last) -> pd.Series | None: + """[first - warmup, last]를 덮도록 캐시를 채우고 캐시된 종가 전체를 반환. + + first는 계산이 필요한 가장 이른 날짜, last는 요청 끝 날짜다. 반환 시리즈는 + last 이후 봉을 포함할 수 있으므로 호출하는 쪽이 last 이하로 잘라 써야 한다. + 받을 수 없으면 None (사유는 last_error). + """ + first = pd.Timestamp(first).normalize() + last = pd.Timestamp(last).normalize() + if not self._covers(first, last): + self._fetch(first, last) + return self._closes + + def _covers(self, first: pd.Timestamp, last: pd.Timestamp) -> bool: + if self._span is None: + return False + span_first, span_last = self._span + return span_first <= first and last <= span_last + + def _get_collector(self): + if self._collector is None: + from core.data_collector import DataCollector + + self._collector = DataCollector() + self._collector.quiet_ohlcv_log = True + return self._collector + + def _fetch(self, first: pd.Timestamp, last: pd.Timestamp) -> None: + if self._span is not None: + first = min(first, self._span[0]) + last = max(last, self._span[1]) + today = pd.Timestamp(datetime.now().date()) + end = max(last, today) + start = first - pd.Timedelta(days=self.warmup_days) + # 실패해도 같은 구간은 다시 시도하지 않도록 구간을 먼저 기록한다. + self._span = (first, end) + self.fetch_count += 1 + self.version += 1 + try: + df = self._get_collector().fetch_korean_stock( + self.symbol, + start_date=start.strftime("%Y-%m-%d"), + end_date=end.strftime("%Y-%m-%d"), + ) + except Exception as e: # 수집기 예외 종류가 소스별로 달라 넓게 받되, 사유를 남긴다 + self._closes = None + self.last_error = f"{type(e).__name__}: {e}" + logger.warning( + "{}: 지수 {} 조회 실패 ({} ~ {}) — {}", + self.label, self.symbol, start.date(), end.date(), self.last_error, + ) + return + + if df is None or df.empty or "close" not in df.columns: + self._closes = None + self.last_error = "empty" + logger.warning( + "{}: 지수 {} 데이터 없음 ({} ~ {})", + self.label, self.symbol, start.date(), end.date(), + ) + return + + if "date" in df.columns: + df = df.set_index("date") + closes = df["close"].astype(float) + closes.index = pd.to_datetime(closes.index) + if closes.index.tz is not None: + closes.index = closes.index.tz_localize(None) + closes = closes[~closes.index.duplicated(keep="last")].sort_index() + self._closes = closes + self.last_error = None diff --git a/strategies/mean_reversion.py b/strategies/mean_reversion.py index 98ab757a..6479e93b 100644 --- a/strategies/mean_reversion.py +++ b/strategies/mean_reversion.py @@ -45,8 +45,38 @@ def __init__(self, config: Config = None): self.params = self.config.strategies.get("mean_reversion", {}) logger.info("MeanReversionStrategy 초기화 완료") + def unmodelled_backtest_filters(self) -> list[str]: + """설정에서 켜져 있지만 analyze()(백테스트 경로)가 반영하지 않는 필터 이름. + + pykrx·yfinance는 '현재' 재무와 '현재' 코스피200 구성만 주므로, 과거 봉에 + 적용하면 미래 정보와 생존 편향이 섞인다. 이 필터들은 generate_signal() + (paper 판단)에서만 적용된다. + """ + enabled = [] + if self.params.get("restrict_to_kospi200", False): + enabled.append("restrict_to_kospi200") + if (self.params.get("fundamental_filter") or {}).get("enabled", False): + enabled.append("fundamental_filter") + return enabled + def analyze(self, df: pd.DataFrame) -> pd.DataFrame: - """지표 계산 + Z-Score + 전략 signal 컬럼 추가""" + """지표 계산 + Z-Score + 전략 signal 컬럼 추가. + + 52주 고점 급락·52주 저점 근방 매수 제외는 여기서 벡터로 적용한다(롤링 창이라 + 미래 정보 없음). 백테스트와 paper가 같은 매수 규칙을 보게 하려는 것이다. + 펀더멘털 필터와 코스피200 제한은 과거 시점 데이터가 없어 반영하지 않는다 + (unmodelled_backtest_filters 참고). + """ + unmodelled = self.unmodelled_backtest_filters() + if unmodelled and not getattr(self, "_unmodelled_warned", False): + logger.warning( + "평균회귀 analyze(백테스트 경로)는 {} 필터를 반영하지 않습니다 — " + "과거 시점 데이터가 없어 적용하면 미래 정보가 섞입니다. " + "백테스트 결과는 이 필터가 없는 전략의 성과입니다.", + ", ".join(unmodelled), + ) + self._unmodelled_warned = True + analyzed = self.indicator_engine.calculate_all(df.copy()) if analyzed.empty: return analyzed @@ -87,6 +117,23 @@ def analyze(self, df: pd.DataFrame) -> pd.DataFrame: # 거래량 급변 시 평균회귀 매수는 차단 buy_signal = buy_signal & ~((volume_ratio > volume_spike_filter).fillna(False)) + # 52주 필터 (예전에는 generate_signal에서만 적용돼 백테스트가 paper와 다른 + # 전략을 평가했다 — 백테스트 BUY의 약 절반이 paper에서는 걸러지는 매수였다) + # - 52주 고점 대비 max_drawdown_from_52w_high 이상 하락 → 매수 제외 + # - 52주 저점 대비 near_52w_low_pct 이내 → 신저가 근방이라 매수 제외 + exclude_52w = self.params.get("exclude_52w_low_near", True) + max_drawdown_52w = self.params.get("max_drawdown_from_52w_high", 0.30) + near_low_threshold = self.params.get("near_52w_low_pct", 0.05) + veto_drawdown = pd.Series(False, index=analyzed.index) + veto_near_low = pd.Series(False, index=analyzed.index) + if exclude_52w: + raw_buy = buy_signal.fillna(False).astype(bool) + veto_drawdown = raw_buy & (analyzed["drawdown_from_52w_high"] >= max_drawdown_52w) + veto_near_low = raw_buy & (analyzed["pct_above_52w_low"] <= near_low_threshold) + buy_signal = raw_buy & ~veto_drawdown & ~veto_near_low + analyzed["buy_veto_52w_drawdown"] = veto_drawdown + analyzed["buy_veto_52w_near_low"] = veto_near_low + analyzed["signal"] = self.HOLD analyzed.loc[buy_signal.fillna(False), "signal"] = self.BUY analyzed.loc[sell_signal.fillna(False), "signal"] = self.SELL @@ -136,30 +183,26 @@ def generate_signal(self, df: pd.DataFrame, symbol: str = None, **kwargs) -> dic drawdown_52w = last.get("drawdown_from_52w_high") pct_above_52w = last.get("pct_above_52w_low") + # 52주 필터는 analyze()에서 이미 적용됐다(백테스트와 같은 규칙). 여기서는 사유만 남긴다. # 52주 고점 대비 하락률 필터: 고점에서 N% 이상 하락한 종목은 실적 악화·장기 하락 가능성 → 매수 제외 - exclude_52w = self.params.get("exclude_52w_low_near", True) max_drawdown_52w = self.params.get("max_drawdown_from_52w_high", 0.30) - if signal == self.BUY and exclude_52w and drawdown_52w is not None: - if drawdown_52w >= max_drawdown_52w: - signal = self.HOLD - logger.info( - "평균회귀 매수 보류(52주 고점 대비 급락): {} — 52주고점 대비 -{:.1f}% (한도 -{:.0f}%)", - symbol or "?", - float(drawdown_52w) * 100, - max_drawdown_52w * 100, - ) + if bool(last.get("buy_veto_52w_drawdown", False)): + logger.info( + "평균회귀 매수 보류(52주 고점 대비 급락): {} — 52주고점 대비 -{:.1f}% (한도 -{:.0f}%)", + symbol or "?", + float(drawdown_52w) * 100, + max_drawdown_52w * 100, + ) # 52주 저점 근방 필터: 현재가가 저점 대비 N% 이내이면 신저가 구간 → 매수 제외 near_low_threshold = self.params.get("near_52w_low_pct", 0.05) - if signal == self.BUY and exclude_52w and pct_above_52w is not None: - if pct_above_52w <= near_low_threshold: - signal = self.HOLD - logger.info( - "평균회귀 매수 보류(52주 신저가 근방): {} — 52주저점 대비 +{:.1f}% (한도 +{:.0f}%)", - symbol or "?", - float(pct_above_52w) * 100, - near_low_threshold * 100, - ) + if bool(last.get("buy_veto_52w_near_low", False)): + logger.info( + "평균회귀 매수 보류(52주 신저가 근방): {} — 52주저점 대비 +{:.1f}% (한도 +{:.0f}%)", + symbol or "?", + float(pct_above_52w) * 100, + near_low_threshold * 100, + ) details = { "Z-Score": round(z_score, 2), diff --git a/strategies/momentum_factor.py b/strategies/momentum_factor.py index 67a70012..935facd9 100644 --- a/strategies/momentum_factor.py +++ b/strategies/momentum_factor.py @@ -6,9 +6,9 @@ import pandas as pd import numpy as np -from loguru import logger from strategies.base_strategy import BaseStrategy +from strategies.index_cache import IndexCloseCache from config.config_loader import Config @@ -32,7 +32,10 @@ def __init__(self, config: Config = None): ) self.config = config or Config.get() self.params = self.config.strategies.get("momentum_factor", {}) - self._benchmark_return_cache: dict[tuple[str, int, str, str], pd.Series] = {} + # 지수 종가 캐시: (지수, 워밍업 일수) → IndexCloseCache + self._benchmark_index_caches: dict[tuple[str, int], IndexCloseCache] = {} + # 파생 수익률 캐시: (지수, lookback) → (캐시 version, 수익률 시리즈) + self._benchmark_return_cache: dict[tuple[str, int], tuple[int, pd.Series]] = {} def _benchmark_return( self, @@ -40,46 +43,41 @@ def _benchmark_return( lookback: int, benchmark_symbol: str, ) -> pd.Series: - """Return benchmark N-day momentum aligned to the input index.""" + """Return benchmark N-day momentum aligned to the input index. + + 예전에는 (시작, 끝) 날짜를 캐시 키로 써서 strict 백테스트(봉마다 df.iloc[:i+1])가 + 봉마다 지수를 새로 받았다(호출마다 새 DataCollector). 이제 지수를 인스턴스당 + 넓게 한 번 받아 수익률을 한 번 계산하고, 호출의 끝 날짜 이하로 잘라 맞춘다. + 조회 실패는 IndexCloseCache가 사유와 함께 경고하고, 여기서는 NaN(매수 없음)을 준다. + """ if len(index) == 0: return pd.Series(dtype=float, index=index) - try: - from core.data_collector import DataCollector - - dates = pd.to_datetime(index) - margin_days = max(lookback * 3, 120) - start = (dates.min() - pd.Timedelta(days=margin_days)).strftime("%Y-%m-%d") - end = dates.max().strftime("%Y-%m-%d") - cache_key = (benchmark_symbol, lookback, start, end) - if cache_key in self._benchmark_return_cache: - benchmark_return = self._benchmark_return_cache[cache_key] - aligned = benchmark_return.reindex(dates, method="ffill") - return pd.Series(aligned.to_numpy(), index=index) - - collector = DataCollector() - collector.quiet_ohlcv_log = True - benchmark = collector.fetch_korean_stock( - benchmark_symbol, - start_date=start, - end_date=end, + lookback = int(lookback) + dates = pd.to_datetime(index) + margin_days = max(lookback * 3, 120) + cache_key = (benchmark_symbol, margin_days) + cache = self._benchmark_index_caches.get(cache_key) + if cache is None: + cache = IndexCloseCache( + benchmark_symbol, warmup_days=margin_days, label="benchmark-relative momentum", ) - if benchmark is None or benchmark.empty: - logger.warning("benchmark-relative momentum: benchmark data unavailable") - return pd.Series(np.nan, index=index) - - if "date" in benchmark.columns: - benchmark = benchmark.set_index("date") - benchmark.index = pd.to_datetime(benchmark.index) - close = benchmark["close"].astype(float) - benchmark_return = (close / close.shift(lookback) - 1) * 100 - self._benchmark_return_cache[cache_key] = benchmark_return - aligned = benchmark_return.reindex(dates, method="ffill") - return pd.Series(aligned.to_numpy(), index=index) - except Exception as e: - logger.warning("benchmark-relative momentum disabled: {}", e) + self._benchmark_index_caches[cache_key] = cache + + closes = cache.ensure(dates.min(), dates.max()) + if closes is None or closes.empty: return pd.Series(np.nan, index=index) + ret_key = (benchmark_symbol, lookback) + cached = self._benchmark_return_cache.get(ret_key) + if cached is None or cached[0] != cache.version: + benchmark_return = (closes / closes.shift(lookback) - 1) * 100 + cached = (cache.version, benchmark_return) + self._benchmark_return_cache[ret_key] = cached + # 호출 끝 날짜 이후 봉은 잘라 낸다 (strict 백테스트에서 벤치마크 미래 정보 차단) + aligned = cached[1].loc[: dates.max()].reindex(dates, method="ffill") + return pd.Series(aligned.to_numpy(), index=index) + def analyze(self, df: pd.DataFrame) -> pd.DataFrame: """lookback 일 수익률 계산 후 신호 부여""" result = df.copy() diff --git a/strategies/relative_strength_rotation.py b/strategies/relative_strength_rotation.py index 782be650..c5602a1f 100644 --- a/strategies/relative_strength_rotation.py +++ b/strategies/relative_strength_rotation.py @@ -27,6 +27,7 @@ from loguru import logger from strategies.base_strategy import BaseStrategy +from strategies.index_cache import IndexCloseCache from core.indicator_engine import IndicatorEngine from config.config_loader import Config @@ -46,40 +47,65 @@ def __init__(self, config: Config = None): self.config = config or Config.get() self.indicator_engine = IndicatorEngine(self.config) self.params = self.config.strategies.get("relative_strength_rotation", {}) - self._mf_series = None # KS11 > SMA200 market filter cache - self._benchmark_composite_cache = {} + # KS11 > SMA 시장 필터: 날짜별 통과 여부(T-1 기준). NA = 판단 불가(데이터·SMA 없음) + self._mf_series = None + self._mf_cache: IndexCloseCache | None = None + self._mf_cache_period = None + self._mf_series_key = None + # 시장 필터 상태 — 조회 실패 시 ok=False. 연구 리포트가 '필터 미적용' 실행을 걸러낼 수 있게 남긴다. + self.market_filter_status: dict = {"ok": None, "reason": None} + # 벤치마크 지수 캐시: (지수, 워밍업 일수) → IndexCloseCache + self._benchmark_index_caches: dict[tuple[str, int], IndexCloseCache] = {} + # 파생 복합 모멘텀 캐시: (지수, short, long, weight) → (캐시 version, 시리즈) + self._benchmark_composite_cache: dict[tuple, tuple[int, pd.Series]] = {} logger.info("RelativeStrengthRotationStrategy 초기화 완료") def _ensure_market_filter(self, dates_index): - """KS11 > SMA(200) 시장 필터 사전 계산 (lazy cache, 인스턴스당 1회).""" - if self._mf_series is not None: + """KS11 > SMA(N) 시장 필터 계산 (지수는 인스턴스당 넓게 한 번 받아 캐시). + + 예전에는 첫 analyze 호출의 날짜 범위로 한 번만 받아 캐시했다. strict 백테스트의 + 첫 호출은 df.iloc[:1]이라 1일치 상태가 전 기간에 ffill되어 필터가 한 번도 + 작동하지 않았다. 이제 넓은 구간을 받아 두고 필터 시리즈를 그 전체로 한 번 + 계산한 뒤, analyze()가 요청 끝 날짜 이하로 잘라 쓴다. 값은 전일 종가·전일 SMA로만 + 정해지므로(T-1) 넓게 계산해도 각 날짜의 값은 그날 이전 데이터만 반영한다. + """ + if len(dates_index) == 0: return - try: - from core.data_collector import DataCollector - - mf_period = self.params.get("market_filter_ma_period", 200) - collector = DataCollector() - first = dates_index.min() - last = dates_index.max() - margin = mf_period + 100 - start = (first - pd.Timedelta(days=margin)).strftime("%Y-%m-%d") - end = last.strftime("%Y-%m-%d") - ks11 = collector.fetch_korean_stock( - "KS11", start_date=start, end_date=end + mf_period = int(self.params.get("market_filter_ma_period", 200)) + if self._mf_cache is None or self._mf_cache_period != mf_period: + # 거래일 mf_period개를 확보하려면 달력일로 약 1.7배 + 여유가 필요하다. + self._mf_cache = IndexCloseCache( + "KS11", warmup_days=int(mf_period * 1.7) + 30, label="market_filter", ) - if ks11 is None or ks11.empty or len(ks11) < mf_period: - logger.warning( - "market_filter: KS11 데이터 부족({}/{}) — 필터 비활성화 fallback", - len(ks11) if ks11 is not None else 0, - mf_period, - ) - return - close_ks = ks11["close"].astype(float) - sma = close_ks.rolling(mf_period, min_periods=mf_period).mean() - # T-1 기준: 전일 종가 > 전일 SMA200 → 당일 신규 진입 허용 - self._mf_series = (close_ks > sma).shift(1, fill_value=True).astype(bool) - except Exception as e: - logger.warning("market_filter: KS11 로드 실패 — 필터 비활성화: {}", e) + self._mf_cache_period = mf_period + self._mf_series_key = None + + closes = self._mf_cache.ensure(dates_index.min(), dates_index.max()) + key = (mf_period, self._mf_cache.version) + if key == self._mf_series_key: + return + self._mf_series_key = key + + if closes is None or len(closes) < mf_period: + self._mf_series = None + reason = self._mf_cache.last_error or ( + f"KS11 데이터 부족({0 if closes is None else len(closes)}/{mf_period})" + ) + self.market_filter_status = {"ok": False, "reason": reason} + logger.warning( + "market_filter: {} — 필터가 적용되지 않습니다 " + "(market_filter_active=False로 기록, 이 실행은 필터 없는 결과)", + reason, + ) + return + + sma = closes.rolling(mf_period, min_periods=mf_period).mean() + # SMA가 아직 없는 날은 '아래'가 아니라 '판단 불가'(NA)로 둔다. + above = (closes > sma).astype("boolean") + above[sma.isna()] = pd.NA + # T-1 기준: 전일 종가 > 전일 SMA → 당일 신규 진입 허용 + self._mf_series = above.shift(1) + self.market_filter_status = {"ok": True, "reason": None} def _benchmark_composite( self, @@ -89,55 +115,42 @@ def _benchmark_composite( short_w: float, benchmark_symbol: str, ) -> pd.Series: - """Return benchmark composite momentum aligned to the input index.""" + """Return benchmark composite momentum aligned to the input index. + + 예전에는 (시작, 끝) 날짜를 캐시 키로 써서 strict 백테스트가 봉마다 지수를 새로 + 받았다. 이제 지수를 인스턴스당 넓게 한 번 받아 복합 모멘텀을 한 번 계산하고, + 호출의 끝 날짜 이하로 잘라 맞춘다. 조회 실패는 IndexCloseCache가 사유와 함께 + 경고하고, 여기서는 NaN(진입 차단)을 준다. + """ if len(index) == 0: return pd.Series(dtype=float, index=index) - try: - from core.data_collector import DataCollector - - dates = pd.to_datetime(index) - margin_days = max(long_lb * 3, 180) - start = (dates.min() - pd.Timedelta(days=margin_days)).strftime("%Y-%m-%d") - end = dates.max().strftime("%Y-%m-%d") - cache_key = ( - benchmark_symbol, - int(short_lb), - int(long_lb), - float(short_w), - start, - end, - ) - if cache_key in self._benchmark_composite_cache: - cached = self._benchmark_composite_cache[cache_key] - aligned = cached.reindex(dates, method="ffill") - return pd.Series(aligned.to_numpy(), index=index) - - collector = DataCollector() - collector.quiet_ohlcv_log = True - benchmark = collector.fetch_korean_stock( - benchmark_symbol, - start_date=start, - end_date=end, + dates = pd.to_datetime(index) + margin_days = max(int(long_lb) * 3, 180) + index_key = (benchmark_symbol, margin_days) + cache = self._benchmark_index_caches.get(index_key) + if cache is None: + cache = IndexCloseCache( + benchmark_symbol, warmup_days=margin_days, label="benchmark-aware rotation", ) - if benchmark is None or benchmark.empty: - logger.warning("benchmark-aware rotation: benchmark data unavailable") - return pd.Series(np.nan, index=index) - - if "date" in benchmark.columns: - benchmark = benchmark.set_index("date") - benchmark.index = pd.to_datetime(benchmark.index) - close = benchmark["close"].astype(float) - ret_short = close.pct_change(short_lb) - ret_long = close.pct_change(long_lb) - benchmark_composite = short_w * ret_short + (1.0 - short_w) * ret_long - self._benchmark_composite_cache[cache_key] = benchmark_composite - aligned = benchmark_composite.reindex(dates, method="ffill") - return pd.Series(aligned.to_numpy(), index=index) - except Exception as e: - logger.warning("benchmark-aware rotation disabled: {}", e) + self._benchmark_index_caches[index_key] = cache + + closes = cache.ensure(dates.min(), dates.max()) + if closes is None or closes.empty: return pd.Series(np.nan, index=index) + cache_key = (benchmark_symbol, int(short_lb), int(long_lb), float(short_w)) + cached = self._benchmark_composite_cache.get(cache_key) + if cached is None or cached[0] != cache.version: + ret_short = closes.pct_change(short_lb) + ret_long = closes.pct_change(long_lb) + benchmark_composite = short_w * ret_short + (1.0 - short_w) * ret_long + cached = (cache.version, benchmark_composite) + self._benchmark_composite_cache[cache_key] = cached + # 호출 끝 날짜 이후 봉은 잘라 낸다 (strict 백테스트에서 벤치마크 미래 정보 차단) + aligned = cached[1].loc[: dates.max()].reindex(dates, method="ffill") + return pd.Series(aligned.to_numpy(), index=index) + def analyze(self, df: pd.DataFrame) -> pd.DataFrame: """모멘텀 지표 계산 + 월간 리밸런싱 signal 생성.""" analyzed = self.indicator_engine.calculate_all(df.copy()) @@ -236,15 +249,22 @@ def analyze(self, df: pd.DataFrame) -> pd.DataFrame: analyzed["abs_mom_pass"] = abs_pass # ── 시장 필터: KS11 > SMA200 (T-1 기준) ── + # 판단 불가(지수 조회 실패·SMA 미형성) 날은 진입을 막지도, 청산을 강제하지도 않고 + # market_filter_active=False로 기록한다 — 필터가 켜진 설정인데 실제로는 적용되지 + # 않은 실행을 연구 리포트가 걸러낼 수 있게. if self.params.get("market_filter_sma200", False): self._ensure_market_filter(analyzed.index) + market_filter_active = pd.Series(False, index=analyzed.index) if self._mf_series is not None: - mf_aligned = self._mf_series.reindex(analyzed.index, method="ffill") - market_filter_pass = mf_aligned.astype("boolean").fillna(True).astype(bool) + mf = self._mf_series.loc[: analyzed.index.max()] + mf_aligned = mf.reindex(analyzed.index, method="ffill").astype("boolean") + market_filter_active = mf_aligned.notna().astype(bool) + market_filter_pass = mf_aligned.fillna(True).astype(bool) entry_cond = entry_cond & market_filter_pass analyzed["market_filter_pass"] = market_filter_pass else: analyzed["market_filter_pass"] = True + analyzed["market_filter_active"] = market_filter_active market_filter_exit = ( self.params.get("market_filter_sma200", False) and self.params.get("market_filter_exit", False) @@ -340,6 +360,7 @@ def generate_signal(self, df: pd.DataFrame, **kwargs) -> dict: "rebalance_day": bool(last.get("rebalance_day", False)), "above_trend": bool(last.get("above_trend", False)), "market_filter_pass": bool(last.get("market_filter_pass", True)), + "market_filter_active": bool(last.get("market_filter_active", False)), "market_filter_exit": bool(last.get("market_filter_exit", False)), }, "date": last.name if hasattr(last, "name") else None, diff --git a/strategies/trend_following.py b/strategies/trend_following.py index 99509173..24942c4f 100644 --- a/strategies/trend_following.py +++ b/strategies/trend_following.py @@ -22,9 +22,12 @@ class TrendFollowingStrategy(BaseStrategy): 조건: 1. ADX > threshold → 강한 추세 존재 - 2. 가격 > 200일선 → 상승 추세 + 2. 가격 > 추세 이동평균(trend_ma_period, 기본 200일선) → 상승 추세 3. MACD 골든크로스 → 매수 진입 + 손절·트레일링 배수는 이 전략 설정이 아니라 risk_params.yaml(stop_loss/trailing_stop)을 + 따른다. 백테스터와 paper 주문 실행이 모두 그 값을 쓰기 때문이다. + 진입이 늦는 구조이므로 손익비(Profit Factor) ≥ 2.0 달성 여부를 반드시 검증하세요. 한국 시장에서는 추세 지속성이 미국(나스닥)보다 약해 실증 근거가 상대적으로 적으므로, 종목·기간별 검증 권장. """ @@ -43,6 +46,10 @@ def __init__(self, config: Config = None): self.params = self.config.strategies.get("trend_following", {}) logger.info("TrendFollowingStrategy 초기화 완료") + def _trend_ma_period(self) -> int: + """추세 판단 이동평균 기간 (trend_ma_period, 기본 200).""" + return max(2, int(self.params.get("trend_ma_period", 200))) + def analyze(self, df: pd.DataFrame) -> pd.DataFrame: """모든 지표 계산 + 전략 signal 컬럼 추가""" analyzed = self.indicator_engine.calculate_all(df.copy()) @@ -50,16 +57,21 @@ def analyze(self, df: pd.DataFrame) -> pd.DataFrame: return analyzed adx_threshold = self.params.get("adx_threshold", 25) + trend_period = self._trend_ma_period() adx = analyzed.get("adx", pd.Series(np.nan, index=analyzed.index)) close = analyzed.get("close", pd.Series(np.nan, index=analyzed.index)) - sma_200 = analyzed.get("sma_200", pd.Series(np.nan, index=analyzed.index)) + # 추세선은 전략이 직접 계산한다. 예전에는 IndicatorEngine의 sma_200을 고정으로 읽어 + # 최적화기가 탐색하던 trend_ma_period가 아무 효과가 없었다(IndicatorEngine은 + # indicators.moving_average.trend_period 기간만, 그것도 행 수가 충분할 때만 만든다). + trend_ma = close.rolling(window=trend_period, min_periods=trend_period).mean() macd = analyzed.get("macd", pd.Series(np.nan, index=analyzed.index)) macd_signal = analyzed.get("macd_signal", pd.Series(np.nan, index=analyzed.index)) has_trend = adx > adx_threshold - above_200 = sma_200.notna() & (sma_200 > 0) & (close > sma_200) - below_200 = sma_200.notna() & (sma_200 > 0) & (close < sma_200) + above_200 = trend_ma.notna() & (trend_ma > 0) & (close > trend_ma) + below_200 = trend_ma.notna() & (trend_ma > 0) & (close < trend_ma) + analyzed["trend_ma"] = trend_ma macd_golden = ( macd.notna() & macd_signal.notna() @@ -88,16 +100,22 @@ def analyze(self, df: pd.DataFrame) -> pd.DataFrame: def generate_signal(self, df: pd.DataFrame, **kwargs) -> dict: """추세 추종 신호 생성""" analyzed = self.analyze(df) + trend_period = self._trend_ma_period() - if analyzed.empty or len(analyzed) < 200: - return {"signal": self.HOLD, "score": 0, "details": {"이유": "데이터 부족(200일 필요)"}} + if analyzed.empty or len(analyzed) < trend_period: + return { + "signal": self.HOLD, + "score": 0, + "details": {"이유": f"데이터 부족({trend_period}일 필요)"}, + } last = analyzed.iloc[-1] prev = analyzed.iloc[-2] if len(analyzed) >= 2 else last adx = last.get("adx", 0) close = last.get("close", 0) - sma_200 = last.get("sma_200", 0) + trend_ma = last.get("trend_ma", 0) + trend_label = f"{trend_period}일선" macd = last.get("macd", 0) macd_signal = last.get("macd_signal", 0) prev_macd = prev.get("macd", 0) @@ -117,10 +135,10 @@ def generate_signal(self, df: pd.DataFrame, **kwargs) -> dict: if has_trend: reasons.append(f"ADX={adx:.1f} > {adx_threshold}") - # 조건 2: 상승 추세 (200일선 위) - above_200 = pd.notna(sma_200) and sma_200 > 0 and close > sma_200 + # 조건 2: 상승 추세 (추세선 위, 기본 200일선) + above_200 = pd.notna(trend_ma) and trend_ma > 0 and close > trend_ma if above_200: - reasons.append("종가 > 200일선") + reasons.append(f"종가 > {trend_label}") # 조건 3: MACD 골든크로스 macd_golden = ( @@ -130,7 +148,7 @@ def generate_signal(self, df: pd.DataFrame, **kwargs) -> dict: if macd_golden: reasons.append("MACD 골든크로스") - below_200 = pd.notna(sma_200) and sma_200 > 0 and close < sma_200 + below_200 = pd.notna(trend_ma) and trend_ma > 0 and close < trend_ma macd_dead = ( pd.notna(macd) and pd.notna(macd_signal) and macd < macd_signal and prev_macd >= prev_signal @@ -138,7 +156,7 @@ def generate_signal(self, df: pd.DataFrame, **kwargs) -> dict: if macd_dead: reasons.append("MACD 데드크로스") if below_200: - reasons.append("종가 < 200일선") + reasons.append(f"종가 < {trend_label}") return { "signal": signal, @@ -146,7 +164,7 @@ def generate_signal(self, df: pd.DataFrame, **kwargs) -> dict: "details": { "ADX": round(adx, 2) if pd.notna(adx) else 0, "종가": close, - "200일선": round(sma_200, 0) if pd.notna(sma_200) else 0, + trend_label: round(trend_ma, 0) if pd.notna(trend_ma) else 0, "MACD": round(macd, 2) if pd.notna(macd) else 0, "조건": ", ".join(reasons) if reasons else "없음", }, diff --git a/tests/conftest.py b/tests/conftest.py index 71bdbd39..6aee600f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,6 +25,15 @@ def pytest_configure(config): # 운영 바스켓의 '추세 아래/낙폭 발동' 상태를 덮어쓰지 않도록 임시 디렉터리로 격리한다. if not os.environ.get("QUANT_OVERLAY_STATE_DIR"): os.environ["QUANT_OVERLAY_STATE_DIR"] = tempfile.mkdtemp(prefix="quant_test_overlay_") + # 대시보드 런타임 상태·live 런타임 락도 운영 파일이다(2026-09-23 확인: 테스트 실행이 + # data/dashboard_runtime_state.json에 가짜 신호를 남기고 data/.live_runtime.lock을 + # 잡았다 — 같은 시각의 실제 live 실행은 '다른 런타임 실행 중'으로 중단된다). + if not os.environ.get("QUANT_DASHBOARD_STATE_PATH"): + os.environ["QUANT_DASHBOARD_STATE_PATH"] = str( + Path(tempfile.mkdtemp(prefix="quant_test_dash_")) / "dashboard_runtime_state.json" + ) + if not os.environ.get("QUANT_RUNTIME_LOCK_DIR"): + os.environ["QUANT_RUNTIME_LOCK_DIR"] = tempfile.mkdtemp(prefix="quant_test_lock_") # 사용자가 명시적으로 DB 경로를 지정했다면 존중한다. if os.environ.get("QUANT_DB_PATH"): @@ -117,3 +126,37 @@ def _isolate_global_trading_halt(): except Exception: # 스키마 미생성 등으로 정리에 실패해도 테스트 결과를 바꾸지 않는다. pass + + + +# --------------------------------------------------------------------------- +# 연구 증거·런타임 원장 격리 +# +# paper_evidence/paper_runtime/paper_pilot은 reports/ 아래 상대 경로에 원장을 쓴다. +# 개별 테스트가 경로를 바꾸는 걸 잊으면 운영 원장에 테스트 기록이 쌓인다 — +# 2026-09-23 확인: 테스트 실행이 reports/paper_evidence/daily_evidence_scoring.jsonl에 +# execution_backed=True인 '실제 paper' 기록을 남기고 pilot_audit.jsonl을 늘렸다. +# 먼저 임시 경로로 돌려 두고, 경로를 직접 지정하는 테스트는 그 위에 덮어쓴다. +# --------------------------------------------------------------------------- +@pytest.fixture(autouse=True) +def _isolate_research_ledgers(tmp_path_factory, monkeypatch): + base = tmp_path_factory.mktemp("ledgers") + targets = ( + ("core.paper_evidence", "EVIDENCE_DIR", base / "paper_evidence"), + ("core.paper_evidence", "PROMOTION_DIR", base / "promotion"), + ("core.evidence_collector", "EVIDENCE_DIR", base / "paper_evidence"), + ("core.paper_runtime", "RUNTIME_DIR", base / "paper_runtime"), + ("core.paper_runtime", "APPROVED_STRATEGIES_PATH", base / "approved_strategies.json"), + ("core.paper_preflight", "RUNTIME_DIR", base / "paper_runtime"), + ("core.paper_pilot", "RUNTIME_DIR", base / "paper_runtime"), + ("core.paper_pilot", "PILOT_AUTH_FILE", base / "paper_runtime" / "pilot_authorizations.jsonl"), + ("core.paper_pilot", "PILOT_AUDIT_FILE", base / "paper_runtime" / "pilot_audit.jsonl"), + ) + for module_name, attr, path in targets: + try: + module = __import__(module_name, fromlist=[attr]) + except Exception: + continue + if hasattr(module, attr): + monkeypatch.setattr(module, attr, path) + yield diff --git a/tests/js/dashboard.test.cjs b/tests/js/dashboard.test.cjs index 08394b89..1907b3f9 100644 --- a/tests/js/dashboard.test.cjs +++ b/tests/js/dashboard.test.cjs @@ -82,7 +82,7 @@ function dashboard({ now = "2026-10-01T00:15:00+09:00", fetch } = {}) { history: { replaceState() {} }, }); const exports = - "parseDate, fmtLong, fmtDT, calendarAgeDays, rowInstant, buildSeries, monthlyReturns, currentMonthContributionState, renderSignals, refreshCore, refreshSlow, refreshChart, updateChart, renderChartTable, moveHistoryTable, state, chart"; + "parseDate, fmtLong, fmtDT, calendarAgeDays, rowInstant, buildSeries, monthlyReturns, currentMonthContributionState, renderSignals, refreshCore, refreshSlow, refreshChart, updateChart, renderChartTable, moveHistoryTable, renderDecision, renderEvaluations, state, chart"; assert.match(source, /\n main\(\);\s*\}\)\(\);\s*$/); vm.runInContext( source.replace(/\n main\(\);/, `\n globalThis.dashboard = {${exports}};`), @@ -338,3 +338,31 @@ test("운영 상태 새로고침도 진행 중인 조회 결과를 함께 기다 assert.equal(calls.length, 2); assert.ok(calls.every((c) => !c.signal.aborted)); }); + +test("검증 결과를 처음 불러오는 동안에는 실패 안내를 띄우지 않음", () => { + const d = dashboard(); + d.state.runtime = { trading_halt: null, scheduler_stale: false }; + d.state.runtimeStatus = "ready"; + d.state.baskets = [ + { + basket: "kr_pocket", + account_key: "basket_rebalance:kr_pocket", + snapshot: { date: "2026-09-30" }, + missed_trading_days: 0, + contribution_plan: { enabled: false }, + }, + ]; + const title = () => d.element("decisionTitle").textContent; + + d.renderDecision(); + assert.equal(title(), "검증 결과를 확인하고 있습니다"); + + d.renderEvaluations([{ basket: "kr_pocket", issues: [] }]); + d.renderDecision(); + assert.equal(title(), "현재 확인할 항목이 없습니다"); + + d.state.evaluations = null; + d.state.evaluationsStatus = "error"; + d.renderDecision(); + assert.equal(title(), "검증 결과를 불러오지 못했습니다"); +}); diff --git a/tests/test_audit_backtest_metrics.py b/tests/test_audit_backtest_metrics.py new file mode 100644 index 00000000..9f00224f --- /dev/null +++ b/tests/test_audit_backtest_metrics.py @@ -0,0 +1,265 @@ +"""백테스트 성과 지표 감사 회귀 테스트. + +- 보유기간 만료(MAX_HOLD) 청산이 승률·손익비·거래 수·보유 기간에서 빠지지 않는다. +- 칼마 비율은 부호 있는 CAGR / |MDD|. +- 샤프·소르티노의 무위험수익률은 이름 있는 상수 하나(3%)이고 리포트에 표기된다. +""" + +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + + +class _SingleConfig: + """단일 종목 Backtester용 최소 설정 (외부 데이터·비용 없음).""" + + settings = {} + strategies = {} + + def __init__(self, *, max_holding_days=0, min_holding_days=0): + self._trading = {"skip_earnings_days": 0} + self._risk_params = { + "transaction_costs": { + "commission_rate": 0.0, + "tax_rate": 0.0, + "slippage": 0.0, + "slippage_ticks": 0, + "dynamic_slippage": {"enabled": False}, + }, + "stop_loss": {"type": "fixed", "fixed_rate": 0.50}, + "take_profit": {"fixed_rate": 0.50, "partial_exit": False}, + "trailing_stop": {"enabled": False}, + "position_sizing": {"max_risk_per_trade": 0.01, "initial_capital": 1_000_000}, + "diversification": {"max_position_ratio": 0.20, "max_investment_ratio": 0.70}, + "position_limits": { + "min_holding_days": min_holding_days, + "max_holding_days": max_holding_days, + "max_monthly_roundtrips": 0, + }, + "liquidity_filter": {"backtest_max_participation_rate": 1.0}, + "gap_risk": {"enabled": False}, + "blackswan": {"enabled": False}, + "backtest_regime_filter": {"enabled": False}, + } + + @property + def risk_params(self): + return self._risk_params + + @property + def trading(self): + return self._trading + + +def _frame(close, *, open_=None, signals=None, start="2024-01-01"): + dates = pd.bdate_range(start, periods=len(close)) + close = np.asarray(close, dtype=float) + open_ = close if open_ is None else np.asarray(open_, dtype=float) + df = pd.DataFrame( + { + "open": open_, + "high": np.maximum(open_, close), + "low": np.minimum(open_, close), + "close": close, + "volume": [1_000_000] * len(close), + "signal": signals or ["HOLD"] * len(close), + }, + index=dates, + ) + df["_avg_daily_volume"] = df["volume"] + return df + + +def test_max_hold_exit_is_counted_in_trade_metrics(): + """30일 만료로 닫힌 거래가 거래 수·승률·보유 기간에 그대로 잡혀야 한다.""" + from backtest.backtester import Backtester + + bt = Backtester(_SingleConfig(max_holding_days=30)) + n = 30 + close = 100.0 - np.arange(n) * 0.1 # 손절·익절에 걸리지 않는 완만한 하락 + signals = ["BUY"] + ["HOLD"] * (n - 1) + df = _frame(close, signals=signals) + + result = bt._simulate(df, initial_capital=1_000_000.0) + actions = [t["action"] for t in result["trades"]] + assert actions == ["BUY", "MAX_HOLD"] + buy, max_hold = result["trades"] + assert (max_hold["date"] - buy["date"]).days == 30 + assert max_hold["pnl"] < 0 + + metrics = bt._calculate_metrics(result, initial_capital=1_000_000.0) + assert metrics["total_trades"] == 1 + assert metrics["losing_trades"] == 1 + assert metrics["winning_trades"] == 0 + assert metrics["win_rate"] == 0 + assert metrics["avg_holding_days"] == pytest.approx(30.0) + assert metrics["ev_per_trade"] < 0 + assert metrics["max_consecutive_losses"] == 1 + + +def test_partial_exit_keeps_holding_clock_until_full_exit(): + """부분 익절 뒤 MAX_HOLD로 잔량이 닫히면 두 매도 모두 같은 매수일 기준으로 보유 기간을 잰다.""" + from backtest.backtester import Backtester, PNL_EXIT_ACTIONS + + buy_date = pd.Timestamp("2024-01-02") + trades = [ + {"date": buy_date, "action": "BUY", "price": 100.0, "quantity": 10, "pnl": 0}, + { + "date": buy_date + pd.Timedelta(days=5), + "action": "TAKE_PROFIT_PARTIAL", + "price": 105.0, + "quantity": 5, + "pnl": 25.0, + }, + { + "date": buy_date + pd.Timedelta(days=30), + "action": "MAX_HOLD", + "price": 99.0, + "quantity": 5, + "pnl": -5.0, + }, + ] + assert {"MAX_HOLD", "TAKE_PROFIT_PARTIAL"} <= PNL_EXIT_ACTIONS + equity = pd.DataFrame( + { + "date": pd.bdate_range("2024-01-02", periods=22), + "value": [1_000.0] * 22, + } + ) + metrics = Backtester(_SingleConfig())._calculate_metrics( + {"equity_curve": equity, "trades": trades}, initial_capital=1_000.0 + ) + + assert metrics["total_trades"] == 2 + assert metrics["winning_trades"] == 1 + assert metrics["losing_trades"] == 1 + assert metrics["avg_holding_days"] == pytest.approx((5 + 30) / 2) + + +# ─── 칼마 비율: 부호 있는 CAGR / |MDD| ──────────────────────────── + + +def _equity(values, start="2020-01-01"): + values = np.asarray(values, dtype=float) + return pd.DataFrame( + { + "date": pd.bdate_range(start, periods=len(values)), + "value": values, + "n_positions": [1] * len(values), + } + ) + + +def _single_metrics(values, initial_capital=1_000.0): + from backtest.backtester import Backtester + + return Backtester(_SingleConfig())._calculate_metrics( + {"equity_curve": _equity(values), "trades": []}, initial_capital=initial_capital + ) + + +def _portfolio_metrics(values, initial_capital=1_000.0): + from backtest.portfolio_backtester import PortfolioBacktester + + return PortfolioBacktester(_SingleConfig())._calculate_portfolio_metrics( + {"equity_curve": _equity(values), "trades": []}, initial_capital=initial_capital + ) + + +@pytest.mark.parametrize("metrics_fn", [_single_metrics, _portfolio_metrics]) +def test_calmar_is_negative_for_a_losing_strategy(metrics_fn): + """꾸준히 잃는 전략의 칼마는 음수여야 한다 (예전엔 abs()로 양수).""" + metrics = metrics_fn(np.linspace(990.0, 900.0, 252)) + + assert metrics["total_return"] < 0 + assert metrics["max_drawdown"] < 0 + assert metrics["calmar_ratio"] < 0 + # 산술 연간 수익률 필드는 그대로 보고한다. + assert metrics["annual_return"] == pytest.approx(-10.0, abs=0.01) + + +@pytest.mark.parametrize("metrics_fn", [_single_metrics, _portfolio_metrics]) +def test_calmar_uses_cagr_not_arithmetic_annual_return(metrics_fn): + """2년 +21%(CAGR 10%)에 MDD -10%면 칼마는 1.0 (산술 연수익 10.5% 기준이면 1.05).""" + values = np.concatenate(([900.0], np.linspace(900.0, 1_210.0, 503))) + assert len(values) == 504 # 504 / 252 = 2년 + + metrics = metrics_fn(values) + + assert metrics["max_drawdown"] == pytest.approx(-10.0) + assert metrics["cagr"] == pytest.approx(10.0, abs=0.01) + assert metrics["annual_return"] == pytest.approx(10.5, abs=0.01) + assert metrics["calmar_ratio"] == pytest.approx(1.0, abs=0.01) + + +@pytest.mark.parametrize("metrics_fn", [_single_metrics, _portfolio_metrics]) +def test_calmar_is_zero_without_drawdown(metrics_fn): + metrics = metrics_fn(np.linspace(1_000.0, 1_100.0, 252)) + + assert metrics["max_drawdown"] == 0 + assert metrics["calmar_ratio"] == 0.0 + + +# ─── 샤프 무위험수익률: 이름 있는 상수 하나 + 리포트 표기 ─────────────── + + +def test_backtest_risk_free_constant_keeps_three_percent(): + from backtest.backtester import BACKTEST_RISK_FREE_ANNUAL, BACKTEST_RISK_FREE_LABEL + + # 값을 바꾸면 min_sharpe·OOS 게이트 등 이 기준에 맞춘 문턱을 재기준화해야 한다. + assert BACKTEST_RISK_FREE_ANNUAL == 0.03 + assert BACKTEST_RISK_FREE_LABEL == "금리 3%" + + +def test_both_engines_compute_sharpe_with_the_shared_risk_free_rate(): + from backtest.backtester import BACKTEST_RISK_FREE_ANNUAL + + rng = np.random.default_rng(7) + values = 1_000.0 * np.cumprod(1 + rng.normal(0.0008, 0.01, 300)) + single = _single_metrics(values) + portfolio = _portfolio_metrics(values) + + daily = pd.Series(values).pct_change() + daily.iloc[0] = values[0] / 1_000.0 - 1.0 # 두 엔진 모두 첫날을 초기자본 대비로 포함 + expected = (daily.mean() * 252 - BACKTEST_RISK_FREE_ANNUAL) / (daily.std() * np.sqrt(252)) + + assert single["sharpe_ratio"] == pytest.approx(round(expected, 2)) + assert portfolio["sharpe_ratio"] == single["sharpe_ratio"] + assert portfolio["sortino_ratio"] == single["sortino_ratio"] + + +def test_report_labels_show_backtest_risk_free_rate(tmp_path): + from backtest.cost_impact import summarize_cost_impact + from backtest.report_generator import ReportGenerator + from backtest.strategy_validator import StrategyValidator + + metrics = { + "initial_capital": 1_000.0, "final_value": 1_010.0, "total_return": 1.0, + "annual_return": 1.0, "cagr": 1.0, "sharpe_ratio": 0.4, "max_drawdown": -2.0, + "calmar_ratio": 0.5, "total_trades": 0, "win_rate": 0.0, "winning_trades": 0, + "losing_trades": 0, "profit_factor": 0.0, "avg_win": 0.0, "avg_loss": 0.0, + "total_commission": 0.0, "total_tax": 0.0, "total_slippage_cost": 0.0, + "commission_to_profit_ratio": None, "monthly_roundtrips_per_symbol": 0.0, + "annual_roundtrips_total": 0.0, + } + metrics["cost_impact"] = summarize_cost_impact(metrics) + result = { + "strategy": "rf_label", + "period": "2024-01-02 ~ 2024-12-30", + "metrics": metrics, + "trades": [], + "equity_curve": pd.DataFrame(), + } + rg = ReportGenerator(output_dir=str(tmp_path)) + + text = rg.generate_text_report(result) + sharpe_line = next(line for line in text.splitlines() if "샤프 지수" in line) + assert "금리 3%" in sharpe_line + + html = Path(rg.generate_html_report(result, filename="rf_label.html")).read_text(encoding="utf-8") + assert "샤프 지수 (금리 3%)" in html + + section = StrategyValidator._format_section("OUT_OF_SAMPLE", metrics, {"sharpe_ratio": 0.1}) + assert section.count("샤프(금리 3%)") == 2 diff --git a/tests/test_audit_backtest_portfolio.py b/tests/test_audit_backtest_portfolio.py new file mode 100644 index 00000000..dfeeb5ab --- /dev/null +++ b/tests/test_audit_backtest_portfolio.py @@ -0,0 +1,267 @@ +"""PortfolioBacktester 감사 회귀 테스트. + +next_open 체결 순서: (1) 시가 청산(갭다운 → 예약 SELL) → (2) 시가 매수 → (3) 종가 청산. +종가 사건이 먼저 도착한 시가 주문을 밀어내거나, 종가에 판 종목을 같은 날 시가에 다시 사는 +시간 역행이 없어야 한다. +""" + +import numpy as np +import pandas as pd +import pytest + + +class _PortfolioConfig: + settings = {} + strategies = {} + + def __init__(self, *, gap_enabled=False, take_profit=0.08, max_holding_days=0): + self._trading = {"skip_earnings_days": 0} + self._risk_params = { + "transaction_costs": { + "commission_rate": 0.0, + "tax_rate": 0.0, + "slippage": 0.0, + "slippage_ticks": 0, + "dynamic_slippage": {"enabled": False}, + }, + "stop_loss": {"type": "fixed", "fixed_rate": 0.03}, + "take_profit": {"fixed_rate": take_profit}, + "trailing_stop": {"enabled": False}, + "position_sizing": {"max_risk_per_trade": 0.01, "initial_capital": 100_000}, + "diversification": { + "max_positions": 10, + "max_position_ratio": 0.20, + "max_investment_ratio": 0.95, + "min_cash_ratio": 0.0, + }, + "position_limits": {"max_holding_days": max_holding_days}, + "backtest_regime_filter": {"enabled": False}, + "gap_risk": { + "enabled": gap_enabled, + "gap_down_threshold": -0.03, + "gap_up_entry_block": 0.05, + }, + "blackswan": {"enabled": False}, + } + + @property + def risk_params(self): + return self._risk_params + + @property + def trading(self): + return self._trading + + +def _signal_df(close, *, open_=None, signals=None, start="2024-01-01", dates=None): + dates = pd.bdate_range(start, periods=len(close)) if dates is None else pd.DatetimeIndex(dates) + close = np.asarray(close, dtype=float) + open_ = close if open_ is None else np.asarray(open_, dtype=float) + return pd.DataFrame( + { + "open": open_, + "high": np.maximum(open_, close), + "low": np.minimum(open_, close), + "close": close, + "volume": [1_000_000] * len(close), + "signal": signals or ["HOLD"] * len(close), + "strategy_score": [3.0] * len(close), + "atr": close * 0.02, + }, + index=dates, + ) + + +def _run(signals: dict, config, **kwargs): + from backtest.portfolio_backtester import PortfolioBacktester + + all_dates = sorted(set().union(*(df.index for df in signals.values()))) + return PortfolioBacktester(config)._simulate_portfolio( + symbols=list(signals.keys()), + signals=signals, + data={}, + all_dates=all_dates, + initial_capital=100_000.0, + **kwargs, + ) + + +def _actions(result): + return [ + (t["symbol"], t["action"], t["date"].strftime("%Y-%m-%d"), t["price"]) + for t in result["trades"] + ] + + +# ─── (1) 시가 주문이 같은 날 종가 사건보다 먼저 ──────────────────── + + +def test_pending_sell_fills_at_open_before_close_take_profit(): + """전일 SELL 신호는 시가에 체결된다. 그날 종가가 익절선을 넘었어도 TAKE_PROFIT로 바뀌지 않는다.""" + df = _signal_df( + [100.0, 100.0, 109.0], + open_=[100.0, 100.0, 100.0], + signals=["BUY", "SELL", "HOLD"], + ) + + result = _run({"AAA": df}, _PortfolioConfig(take_profit=0.08)) + + assert _actions(result) == [ + ("AAA", "BUY", "2024-01-02", 100.0), + ("AAA", "SELL", "2024-01-03", 100.0), + ] + assert result["trades"][1]["signal_date"] == df.index[1] + assert result["exit_reason_counts"] == {"SELL": 1} + + +def test_gap_down_at_open_takes_precedence_over_pending_sell(): + df = _signal_df( + [100.0, 100.0, 96.0], + open_=[100.0, 100.0, 96.0], + signals=["BUY", "SELL", "HOLD"], + ) + + result = _run({"AAA": df}, _PortfolioConfig(gap_enabled=True)) + + assert _actions(result) == [ + ("AAA", "BUY", "2024-01-02", 100.0), + ("AAA", "GAP_DOWN", "2024-01-03", 96.0), + ] + assert result["exit_reason_counts"] == {"GAP_DOWN": 1} + assert result["executed_sell_count"] == 1 + assert result["gap_down_exits"] == 1 + + +def test_no_rebuy_at_open_of_the_day_a_position_is_stopped_at_the_close(): + """보유 중 BUY 신호가 이어져도, 그날 종가 손절 뒤 같은 날 시가 매수는 생기지 않는다.""" + df = _signal_df( + [100.0, 100.0, 96.0], + open_=[100.0, 100.0, 100.0], + signals=["BUY", "BUY", "HOLD"], + ) + + result = _run({"AAA": df}, _PortfolioConfig()) + + assert _actions(result) == [ + ("AAA", "BUY", "2024-01-02", 100.0), + ("AAA", "STOP_LOSS", "2024-01-03", 96.0), + ] + + +def test_position_bought_at_open_is_still_subject_to_close_stop(): + """시가에 산 종목도 그날 종가 손절선 아래면 종가에 손절된다 (시간 순서상 정상).""" + df = _signal_df([100.0, 96.0], open_=[100.0, 100.0], signals=["BUY", "HOLD"]) + + result = _run({"AAA": df}, _PortfolioConfig()) + + assert _actions(result) == [ + ("AAA", "BUY", "2024-01-02", 100.0), + ("AAA", "STOP_LOSS", "2024-01-02", 96.0), + ] + + +def test_pending_buy_on_gap_down_open_is_not_sold_at_the_same_open(): + df = _signal_df([100.0, 96.0, 96.0], open_=[100.0, 96.0, 96.0], signals=["BUY", "HOLD", "HOLD"]) + + result = _run({"AAA": df}, _PortfolioConfig(gap_enabled=True)) + + assert _actions(result) == [("AAA", "BUY", "2024-01-02", 96.0)] + assert result["gap_down_exits"] == 0 + + +def test_symbol_sold_at_open_is_not_rebought_at_the_same_open(): + """시가에 예약 SELL로 판 종목은 같은 날 시가 BUY 후보에서 빠진다.""" + df = _signal_df( + [100.0, 100.0, 100.0], + open_=[100.0, 100.0, 100.0], + signals=["BUY", "SELL", "HOLD"], + ) + # 두 번째 종목이 1/2 BUY 신호를 내 1/3에도 매수 후보 평가가 일어나게 한다. + other = _signal_df([100.0] * 3, signals=["HOLD", "BUY", "HOLD"]) + + result = _run({"AAA": df, "BBB": other}, _PortfolioConfig()) + + aaa = [a for a in _actions(result) if a[0] == "AAA"] + assert aaa == [("AAA", "BUY", "2024-01-02", 100.0), ("AAA", "SELL", "2024-01-03", 100.0)] + + +# ─── (2) 시가 주문 수량은 시가 시점 평가로 ───────────────────────── + + +def test_open_orders_are_sized_with_open_marks_not_same_day_close(): + """AAA가 그날 종가 +50%여도, 시가에 체결되는 BBB 수량은 시가 평가 자산으로 정한다.""" + aaa = _signal_df( + [100.0, 100.0, 150.0, 150.0], + open_=[100.0, 100.0, 100.0, 150.0], + signals=["BUY", "HOLD", "HOLD", "HOLD"], + ) + bbb = _signal_df([100.0] * 4, signals=["HOLD", "BUY", "HOLD", "HOLD"]) + + result = _run({"AAA": aaa, "BBB": bbb}, _PortfolioConfig(take_profit=0.90)) + + buys = {t["symbol"]: t for t in result["trades"] if t["action"] == "BUY"} + assert buys["AAA"]["quantity"] == 200 # 100,000 × 20% / 100 + # 시가 평가 자산 = 현금 80,000 + AAA 200주 × 시가 100 = 100,000 → 200주 + # (당일 종가 150으로 평가하면 110,000 → 220주가 된다) + assert buys["BBB"]["date"] == pd.Timestamp("2024-01-03") + assert buys["BBB"]["quantity"] == 200 + + +# ─── 당일 행이 없는 보유 종목 평가와 데이터 종료 청산 ─────────────── + + +def _equity_by_date(result): + eq = result["equity_curve"] + return dict(zip(eq["date"].dt.strftime("%Y-%m-%d"), eq["value"])) + + +def test_symbol_whose_data_ends_is_valued_at_last_close_then_force_exited(): + """BBB 데이터가 끝난 다음 날 평균단가(100)로 되돌아가는 가짜 수익 없이 마지막 종가(98)로 청산.""" + from backtest.backtester import PNL_EXIT_ACTIONS + from backtest.portfolio_backtester import PortfolioBacktester + + aaa = _signal_df([100.0] * 10) # 달력을 이어 가는 종목 (거래 없음) + bbb = _signal_df([100.0, 100.0, 99.0, 99.0, 98.0, 98.0], signals=["BUY"] + ["HOLD"] * 5) + + result = _run({"AAA": aaa, "BBB": bbb}, _PortfolioConfig()) + + assert _actions(result) == [ + ("BBB", "BUY", "2024-01-02", 100.0), + ("BBB", "DATA_END", "2024-01-09", 98.0), + ] + assert result["data_end_exits"] == 1 + assert result["exit_reason_counts"] == {"DATA_END": 1} + + equity = _equity_by_date(result) + # 매수 200주, 현금 80,000. 마지막 종가 98 → 99,600에서 멈춰야 한다 (예전엔 100,000으로 복귀). + assert equity["2024-01-08"] == pytest.approx(99_600.0) + assert equity["2024-01-09"] == pytest.approx(99_600.0) + assert equity["2024-01-12"] == pytest.approx(99_600.0) + + assert "DATA_END" in PNL_EXIT_ACTIONS + metrics = PortfolioBacktester(_PortfolioConfig())._calculate_portfolio_metrics( + result, initial_capital=100_000.0 + ) + assert metrics["total_trades"] == 1 + assert metrics["losing_trades"] == 1 + assert metrics["data_end_exits"] == 1 + + +def test_mid_series_missing_row_is_valued_at_last_close_without_exit(): + """거래정지처럼 중간에 행만 빠진 날은 청산하지 않고 마지막 종가로 평가한다.""" + aaa = _signal_df([100.0] * 8) + dates = pd.bdate_range("2024-01-01", periods=8) + bbb_dates = dates.delete(4) # 2024-01-05 결측 + bbb = _signal_df( + [100.0, 100.0, 99.0, 98.0, 98.0, 98.0, 98.0], + signals=["BUY"] + ["HOLD"] * 6, + dates=bbb_dates, + ) + + result = _run({"AAA": aaa, "BBB": bbb}, _PortfolioConfig()) + + assert _actions(result) == [("BBB", "BUY", "2024-01-02", 100.0)] + assert result["data_end_exits"] == 0 + equity = _equity_by_date(result) + assert equity["2024-01-04"] == pytest.approx(99_600.0) + assert equity["2024-01-05"] == pytest.approx(99_600.0) # 예전엔 평균단가로 100,000 diff --git a/tests/test_audit_backtest_report.py b/tests/test_audit_backtest_report.py new file mode 100644 index 00000000..b0fc9d0e --- /dev/null +++ b/tests/test_audit_backtest_report.py @@ -0,0 +1,266 @@ +"""백테스트 리포트 감사 회귀 테스트. + +- 리포트 거래표가 엔진 지표와 같은 청산 목록(PNL_EXIT_ACTIONS)을 쓴다. +- 실전 vs 백테스트 슬리피지 카드는 transaction_costs.slippage를 읽고 '하한'으로 표기한다. +""" + +import re +from pathlib import Path + +import pandas as pd +import pytest + + +def _report_metrics(): + from backtest.cost_impact import summarize_cost_impact + + metrics = { + "initial_capital": 1_000_000.0, + "final_value": 900_000.0, + "total_return": -10.0, + "annual_return": -10.0, + "cagr": -10.0, + "sharpe_ratio": -0.5, + "max_drawdown": -12.0, + "calmar_ratio": -0.83, + "total_trades": 3, + "win_rate": 0.0, + "winning_trades": 0, + "losing_trades": 3, + "profit_factor": 0.0, + "avg_win": 0.0, + "avg_loss": 30_000.0, + "total_commission": 0.0, + "total_tax": 0.0, + "total_slippage_cost": 0.0, + "commission_to_profit_ratio": None, + "monthly_roundtrips_per_symbol": 1.0, + "annual_roundtrips_total": 12.0, + } + metrics["cost_impact"] = summarize_cost_impact(metrics) + return metrics + + +def _risk_exit_trades(): + trades = [] + for i, action in enumerate(("GAP_DOWN", "BLACKSWAN", "MAX_HOLD")): + day = pd.Timestamp("2024-01-02") + pd.Timedelta(days=7 * i) + trades.append( + {"date": day, "action": "BUY", "price": 100.0, "quantity": 100, "pnl": 0, "pnl_rate": 0} + ) + trades.append( + { + "date": day + pd.Timedelta(days=3), + "action": action, + "price": 97.0, + "quantity": 100, + "pnl": -30_000.0, + "pnl_rate": -3.0, + } + ) + return trades + + +def test_report_uses_engine_exit_action_set(): + import backtest.backtester as backtester_mod + import backtest.report_generator as report_mod + + assert report_mod.PNL_EXIT_ACTIONS is backtester_mod.PNL_EXIT_ACTIONS + assert {"GAP_DOWN", "BLACKSWAN", "MAX_HOLD", "TAKE_PROFIT_PARTIAL"} <= report_mod.PNL_EXIT_ACTIONS + + +def test_text_report_lists_gap_down_and_blackswan_sells(tmp_path): + from backtest.report_generator import ReportGenerator + + result = { + "strategy": "audit_exit_actions", + "period": "2024-01-02 ~ 2024-01-31", + "metrics": _report_metrics(), + "trades": _risk_exit_trades(), + "equity_curve": pd.DataFrame(), + } + + text = ReportGenerator(output_dir=str(tmp_path)).generate_text_report(result) + + recent = text.split("[ 최근 매도 거래 (최대 10건) ]", 1)[1] + for action in ("GAP_DOWN", "BLACKSWAN", "MAX_HOLD"): + assert action in recent + + +def test_html_trades_table_lists_gap_down_and_blackswan_sells(): + from backtest.report_generator import ReportGenerator + + html = ReportGenerator._generate_trades_table(_risk_exit_trades()) + + for action in ("GAP_DOWN", "BLACKSWAN", "MAX_HOLD"): + assert f"{action}" in html + + +def _emitted_exit_actions(path: str) -> set: + src = Path(path).read_text(encoding="utf-8") + patterns = ( + r'"action":\s*"([A-Z_]+)"', + r'_execute_full_exit\(\s*"([A-Z_]+)"', + r'\bsell_reason\s*=\s*"([A-Z_]+)"', + ) + found = set() + for pattern in patterns: + found |= set(re.findall(pattern, src)) + found.discard("BUY") + return found + + +def test_every_exit_action_emitted_by_engines_is_in_pnl_exit_actions(): + """엔진이 새 청산 사유를 기록하면 지표·리포트 공용 목록에도 들어 있어야 한다.""" + import backtest.backtester as backtester_mod + import backtest.portfolio_backtester as portfolio_mod + + single = _emitted_exit_actions(backtester_mod.__file__) + portfolio = _emitted_exit_actions(portfolio_mod.__file__) + + # 패턴이 코드 형태 변화로 아무것도 못 찾는 경우를 막기 위한 하한 확인 + assert { + "SELL", "STOP_LOSS", "TAKE_PROFIT", "TAKE_PROFIT_PARTIAL", + "TRAILING_STOP", "MAX_HOLD", "GAP_DOWN", "BLACKSWAN", + } <= single + assert { + "SELL", "STOP_LOSS", "TAKE_PROFIT", "TRAILING_STOP", + "MAX_HOLD", "GAP_DOWN", "BLACKSWAN", + } <= portfolio + assert (single | portfolio) <= backtester_mod.PNL_EXIT_ACTIONS + + +# ─── 실전 vs 백테스트 슬리피지 카드 ───────────────────────────── + + +def test_backtest_slippage_reads_transaction_costs_key(monkeypatch): + import backtest.report_generator as report_mod + from config.config_loader import Config + + class _Cfg: + # 최상위 slippage는 RiskManager가 읽지 않는 키 — 리포트도 무시해야 한다. + risk_params = {"slippage": 0.009, "transaction_costs": {"slippage": 0.001}} + + monkeypatch.setattr(Config, "get", classmethod(lambda cls: _Cfg())) + + assert report_mod._default_backtest_slippage_pct() == pytest.approx(0.1) + + +def test_live_slippage_card_labels_backtest_value_as_fixed_rate_floor(): + import backtest.report_generator as report_mod + + summary = { + "n": 3, + "mean_pct": 0.12, + "median_pct": 0.10, + "max_abs_pct": 0.30, + "backtest_assumed_pct": 0.05, + } + + text = "\n".join(report_mod._format_live_slippage_text_table(summary)) + assert "transaction_costs.slippage" in text + assert "하한" in text + assert "risk_params.slippage" not in text + + html = report_mod._format_live_slippage_html_card(summary) + assert "고정 비율 하한" in html + + +# ─── 시장 국면별 성과: 월수익률·국면 MDD 공식 ────────────────────── + + +def _q1_2024_dates(): + return pd.bdate_range("2024-01-01", "2024-03-29") + + +def _step_series(dates, jan, feb_mar): + """1월은 jan, 2월 첫 거래일부터는 feb_mar (2월 첫날 갭 하락 후 보합).""" + return [jan if d.month == 1 else feb_mar for d in dates] + + +def test_strategy_monthly_return_keeps_month_first_day_move(): + from backtest.report_generator import _strategy_monthly_returns + + dates = _q1_2024_dates() + equity = pd.DataFrame({"date": dates, "value": _step_series(dates, 100.0, 90.0)}) + + rets = _strategy_monthly_returns(equity, initial_capital=100.0) + + assert rets[pd.Period("2024-01", "M")] == pytest.approx(0.0) + assert rets[pd.Period("2024-02", "M")] == pytest.approx(-0.10) + assert rets[pd.Period("2024-03", "M")] == pytest.approx(0.0) + + +def test_strategy_first_month_is_measured_from_initial_capital(): + from backtest.report_generator import _strategy_monthly_returns + + dates = pd.bdate_range("2024-01-01", "2024-01-31") + equity = pd.DataFrame({"date": dates, "value": [95.0] * len(dates)}) + + assert _strategy_monthly_returns(equity, initial_capital=100.0).iloc[0] == pytest.approx(-0.05) + # 초기자본을 모르면 첫 관측값 기준 (기존 동작과 같음) + assert _strategy_monthly_returns(equity).iloc[0] == pytest.approx(0.0) + + +def test_kospi_first_month_uses_close_before_start(): + from backtest.report_generator import _kospi_monthly_returns_from_ohlc + + dates = pd.bdate_range("2023-12-20", "2024-01-31") + close = [100.0 if d.year == 2023 else 105.0 for d in dates] # 1월 첫 거래일 +5% 갭 + ks11 = pd.DataFrame({"close": close}, index=dates) + + rets = _kospi_monthly_returns_from_ohlc(ks11, start=pd.Timestamp("2024-01-01")) + + assert list(rets.index) == [pd.Period("2024-01", "M")] + assert rets.iloc[0] == pytest.approx(0.05) + + +def test_regime_mdd_only_compounds_that_regimes_months(): + from backtest.report_generator import _mdd_from_monthly_returns + + # {1월, 3월}이 보합이면 사이의 2월 손실과 무관하게 MDD 0 + assert _mdd_from_monthly_returns([0.0, 0.0]) == pytest.approx(0.0) + # 한 달만 있어도 그 달의 손실은 낙폭이다. + assert _mdd_from_monthly_returns([-0.10]) == pytest.approx(-10.0) + assert _mdd_from_monthly_returns([0.10, -0.20, 0.05]) == pytest.approx(-20.0) + assert _mdd_from_monthly_returns([]) == 0.0 + + +def test_market_regime_breakdown_classifies_month_opening_gap(monkeypatch): + """2월 첫 거래일 -10% 갭은 2월을 하락장으로 분류하고, 보합인 1·3월 MDD에 섞이지 않는다.""" + from backtest.report_generator import ( + REGIME_BEAR, + REGIME_SIDEWAYS, + compute_market_regime_breakdown, + ) + + ks_dates = pd.bdate_range("2023-12-15", "2024-03-29") + ks11 = pd.DataFrame( + {"close": [2_500.0 if d < pd.Timestamp("2024-02-01") else 2_250.0 for d in ks_dates]}, + index=ks_dates, + ) + + class FakeCollector: + def fetch_korean_stock(self, symbol, start_date=None, end_date=None): + assert symbol == "KS11" + return ks11.loc[pd.Timestamp(start_date): pd.Timestamp(end_date)].copy() + + monkeypatch.setattr("core.data_collector.DataCollector", FakeCollector) + + dates = _q1_2024_dates() + equity = pd.DataFrame({"date": dates, "value": _step_series(dates, 100.0, 90.0)}) + breakdown = compute_market_regime_breakdown( + {"equity_curve": equity, "initial_capital": 100.0}, + warn_bear_underperformance=False, + ) + + bear = breakdown[REGIME_BEAR] + assert bear["n_months"] == 1 + assert bear["avg_strat_pct"] == pytest.approx(-10.0) + assert bear["avg_kospi_pct"] == pytest.approx(-10.0) + assert bear["excess_pct"] == pytest.approx(0.0) + assert bear["mdd_pct"] == pytest.approx(-10.0) + + sideways = breakdown[REGIME_SIDEWAYS] + assert sideways["n_months"] == 2 + assert sideways["mdd_pct"] == pytest.approx(0.0) diff --git a/tests/test_audit_backtest_single.py b/tests/test_audit_backtest_single.py new file mode 100644 index 00000000..b2221e00 --- /dev/null +++ b/tests/test_audit_backtest_single.py @@ -0,0 +1,223 @@ +"""단일 종목 Backtester 체결 순서·보유 규칙 감사 회귀 테스트. + +- next_open에서 시가에 산 주식을 같은 시가에 갭다운으로 되팔지 않는다. +- 최소 보유 기간에는 실전처럼 손실 방어 청산(손절·트레일링·갭다운·블랙스완)만 허용한다. +""" + +import numpy as np +import pandas as pd +import pytest + + +class _GuardConfig: + settings = {} + strategies = {} + + def __init__( + self, + *, + gap_enabled=True, + min_holding_days=0, + max_holding_days=0, + partial_exit=False, + take_profit=0.50, + stop_loss=0.03, + trailing=False, + ): + self._trading = {"skip_earnings_days": 0} + self._risk_params = { + "transaction_costs": { + "commission_rate": 0.0, + "tax_rate": 0.0, + "slippage": 0.0, + "slippage_ticks": 0, + "dynamic_slippage": {"enabled": False}, + }, + "stop_loss": {"type": "fixed", "fixed_rate": stop_loss}, + "take_profit": { + "fixed_rate": take_profit, + "partial_exit": partial_exit, + "partial_ratio": 0.5, + "partial_target": 0.04, + }, + "trailing_stop": {"enabled": trailing, "type": "fixed", "fixed_rate": 0.05}, + "position_sizing": {"max_risk_per_trade": 0.01, "initial_capital": 100_000}, + "diversification": {"max_position_ratio": 0.20, "max_investment_ratio": 0.70}, + "position_limits": { + "min_holding_days": min_holding_days, + "max_holding_days": max_holding_days, + "max_monthly_roundtrips": 0, + }, + "liquidity_filter": {"backtest_max_participation_rate": 1.0}, + "gap_risk": { + "enabled": gap_enabled, + "gap_down_threshold": -0.03, + "gap_up_entry_block": 0.05, + }, + "blackswan": {"enabled": False}, + "backtest_regime_filter": {"enabled": False}, + } + + @property + def risk_params(self): + return self._risk_params + + @property + def trading(self): + return self._trading + + +def _frame(close, *, open_=None, signals=None, start="2024-01-01"): + dates = pd.bdate_range(start, periods=len(close)) + close = np.asarray(close, dtype=float) + open_ = close if open_ is None else np.asarray(open_, dtype=float) + df = pd.DataFrame( + { + "open": open_, + "high": np.maximum(open_, close), + "low": np.minimum(open_, close), + "close": close, + "volume": [1_000_000] * len(close), + "signal": signals or ["HOLD"] * len(close), + }, + index=dates, + ) + df["_avg_daily_volume"] = df["volume"] + return df + + +# ─── 갭다운 청산은 전일부터 보유한 포지션에만 ────────────────────── + + +def test_pending_buy_on_gap_down_open_is_not_sold_at_the_same_open(): + from backtest.backtester import Backtester + + bt = Backtester(_GuardConfig(gap_enabled=True)) + df = _frame( + [100.0, 96.0, 96.0], + open_=[100.0, 96.0, 96.0], # 1일차 시가 -4% 갭다운 (임계 -3%) + signals=["BUY", "HOLD", "HOLD"], + ) + + result = bt._simulate(df, initial_capital=100_000.0) + + assert [t["action"] for t in result["trades"]] == ["BUY"] + buy = result["trades"][0] + assert buy["date"] == df.index[1] + assert buy["price"] == pytest.approx(96.0) + assert result["gap_down_exits"] == 0 + # 매수일 장 마감 시점에도 포지션이 남아 있어야 한다. + day1 = result["equity_curve"].iloc[1] + assert day1["position_value"] == pytest.approx(buy["quantity"] * 96.0) + + +def test_gap_down_still_exits_a_position_carried_overnight(): + from backtest.backtester import Backtester + + bt = Backtester(_GuardConfig(gap_enabled=True)) + df = _frame( + [100.0, 100.0, 96.0], + open_=[100.0, 100.0, 96.0], + signals=["BUY", "HOLD", "HOLD"], + ) + + result = bt._simulate(df, initial_capital=100_000.0) + + assert [t["action"] for t in result["trades"]] == ["BUY", "GAP_DOWN"] + gap = result["trades"][1] + assert gap["date"] == df.index[2] + assert gap["price"] == pytest.approx(96.0) + assert result["gap_down_exits"] == 1 + + +# ─── 최소 보유 기간: 실전처럼 손실 방어 청산만 허용 ─────────────────── + + +def _actions_and_dates(result): + return [(t["action"], t["date"].strftime("%Y-%m-%d")) for t in result["trades"]] + + +def test_min_hold_blocks_take_profit_and_partial_until_day_five(): + """매수 다음날 +9%여도 5일 미만이면 부분·전량 익절하지 않는다 (실전 order_executor와 동일).""" + from backtest.backtester import Backtester + + bt = Backtester( + _GuardConfig(gap_enabled=False, min_holding_days=5, partial_exit=True, take_profit=0.08) + ) + # 2024-01-01(월) 신호 → 01-02 시가 매수. 01-03~01-05는 보유 1~3일, 01-08은 6일. + df = _frame( + [100.0, 100.0, 109.0, 109.0, 109.0, 109.0, 109.0], + signals=["BUY"] + ["HOLD"] * 6, + ) + + result = bt._simulate(df, initial_capital=100_000.0) + + assert _actions_and_dates(result) == [ + ("BUY", "2024-01-02"), + ("TAKE_PROFIT_PARTIAL", "2024-01-08"), + ("TAKE_PROFIT", "2024-01-09"), + ] + + +def test_min_hold_blocks_max_hold_shorter_than_min_hold(): + from backtest.backtester import Backtester + + bt = Backtester(_GuardConfig(gap_enabled=False, min_holding_days=5, max_holding_days=3)) + df = _frame([100.0] * 8, signals=["BUY"] + ["HOLD"] * 7) + + result = bt._simulate(df, initial_capital=100_000.0) + + # 01-05(보유 3일)는 최소 보유 기간 안이라 만료 매도가 막히고 01-08(6일)에 청산된다. + assert _actions_and_dates(result) == [("BUY", "2024-01-02"), ("MAX_HOLD", "2024-01-08")] + + +def test_min_hold_still_allows_stop_loss(): + from backtest.backtester import Backtester + + bt = Backtester(_GuardConfig(gap_enabled=False, min_holding_days=5)) + df = _frame( + [100.0, 100.0, 96.0], + open_=[100.0, 100.0, 100.0], + signals=["BUY", "HOLD", "HOLD"], + ) + + result = bt._simulate(df, initial_capital=100_000.0) + + assert _actions_and_dates(result) == [("BUY", "2024-01-02"), ("STOP_LOSS", "2024-01-03")] + + +def test_min_hold_still_allows_trailing_stop(): + from backtest.backtester import Backtester + + bt = Backtester(_GuardConfig(gap_enabled=False, min_holding_days=5, trailing=True)) + # 고점 104 대비 5% 하락선 98.8 이탈(98.5), 손절선 97은 미도달 + df = _frame( + [100.0, 100.0, 104.0, 98.5], + open_=[100.0, 100.0, 104.0, 104.0], + signals=["BUY", "HOLD", "HOLD", "HOLD"], + ) + + result = bt._simulate(df, initial_capital=100_000.0) + + assert _actions_and_dates(result) == [("BUY", "2024-01-02"), ("TRAILING_STOP", "2024-01-04")] + + +def test_legacy_same_close_keeps_its_original_take_profit_rule(): + """legacy_same_close는 과거 결과 재현 경로라 최소 보유 기간 익절 차단을 적용하지 않는다.""" + from backtest.backtester import Backtester + + bt = Backtester( + _GuardConfig(gap_enabled=False, min_holding_days=5, partial_exit=True, take_profit=0.08) + ) + df = _frame( + [100.0, 100.0, 109.0, 109.0, 109.0, 109.0, 109.0], + signals=["BUY"] + ["HOLD"] * 6, + ) + + result = bt._simulate(df, initial_capital=100_000.0, execution_model="legacy_same_close") + + assert _actions_and_dates(result) == [ + ("BUY", "2024-01-01"), + ("TAKE_PROFIT_PARTIAL", "2024-01-03"), + ("TAKE_PROFIT", "2024-01-04"), + ] diff --git a/tests/test_audit_backtest_validator.py b/tests/test_audit_backtest_validator.py new file mode 100644 index 00000000..42aac006 --- /dev/null +++ b/tests/test_audit_backtest_validator.py @@ -0,0 +1,142 @@ +"""StrategyValidator 감사 회귀 테스트. + +- 코스피 상위 N 동일비중 벤치마크: 늦게 상장한 종목 하나 때문에 벤치마크 기간 전체가 잘리지 않는다 + (outer 패널 + 첫 가격일 편입 매수·보유). +""" + +import numpy as np +import pandas as pd +import pytest + + +def _dates(n=320): + return pd.bdate_range("2023-01-02", periods=n) + + +def _close_frame(dates, close): + close = np.asarray(close, dtype=float) + return pd.DataFrame( + { + "open": close, + "high": close * 1.01, + "low": close * 0.99, + "close": close, + "volume": np.full(len(close), 1_000_000.0), + }, + index=dates, + ) + + +class _PanelCollector: + def __init__(self, frames: dict): + self.frames = frames + + def fetch_korean_stock(self, symbol, start_date=None, end_date=None): + frame = self.frames[symbol] + start = pd.Timestamp(start_date) if start_date else frame.index.min() + end = pd.Timestamp(end_date) if end_date else frame.index.max() + return frame.loc[start:end].copy() + + +def _top50_frames(dates, late_row=100): + rising = np.concatenate((np.linspace(100.0, 110.0, late_row), np.full(len(dates) - late_row, 110.0))) + return { + "AAA": _close_frame(dates, rising), + "BBB": _close_frame(dates, rising), + # 늦게 상장한 종목 (late_row부터 가격 존재) + "CCC": _close_frame(dates[late_row:], np.full(len(dates) - late_row, 50.0)), + } + + +def test_equal_weight_panel_keeps_requested_start_and_marks_late_listing(): + from backtest.strategy_validator import _build_equal_weight_panel + + dates = _dates(20) + frames = { + "AAA": _close_frame(dates, np.full(20, 100.0)), + "LATE": _close_frame(dates[10:], np.full(10, 50.0)), + # 중간 결측(거래정지)과 조기 종료가 섞인 종목 + "GAPPY": _close_frame(dates[:15].delete(5), np.arange(14, dtype=float) + 1.0), + } + + panel = _build_equal_weight_panel( + _PanelCollector(frames), list(frames), str(dates[0].date()), str(dates[-1].date()) + ) + + assert panel.index[0] == dates[0] + assert panel.index[-1] == dates[-1] + assert panel["LATE"].iloc[:10].isna().all() + assert panel["LATE"].iloc[10:].notna().all() + # 중간 결측은 직전 종가로 채우고, 데이터 종료 이후는 NaN으로 둔다. + assert panel["GAPPY"].iloc[5] == panel["GAPPY"].iloc[4] + assert panel["GAPPY"].iloc[15:].isna().all() + + +def test_staggered_equal_weight_equity_enters_late_name_at_its_first_price(): + from backtest.strategy_validator import _staggered_equal_weight_equity + + dates = _dates(6) + panel = pd.DataFrame( + { + "A": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0], + "B": [100.0, 200.0, 200.0, 200.0, 200.0, 200.0], + "C": [np.nan, np.nan, np.nan, 50.0, 50.0, 100.0], + }, + index=dates, + ) + + equity = _staggered_equal_weight_equity(panel, 1_000.0) + + assert equity.index[0] == dates[0] + # A·B 반반 매수 → B 두 배: 500 + 1,000 = 1,500 + assert equity.iloc[1] == pytest.approx(1_500.0) + # C 편입일: 평가액 1,500의 1/3(500)을 C에 배정, A·B는 2/3로 축소 → 평가액 그대로 + assert equity.iloc[3] == pytest.approx(1_500.0) + # C 두 배: 1,000(A·B) + 1,000(C) = 2,000 — 동일 비중 재조정 없이 매수·보유 + assert equity.iloc[5] == pytest.approx(2_000.0) + + +def test_equal_weight_metrics_cover_whole_period_despite_late_listing(): + from backtest.strategy_validator import _equal_weight_buy_and_hold_metrics + + dates = _dates(320) + frames = _top50_frames(dates) + + metrics = _equal_weight_buy_and_hold_metrics( + _PanelCollector(frames), list(frames), str(dates[0].date()), str(dates[-1].date()), 1_000_000.0 + ) + + # 예전 inner 조인은 CCC 상장일부터만 재서 AAA·BBB의 초기 +10%를 놓쳤다(0%). + assert metrics["total_return"] == pytest.approx(10.0, abs=0.01) + + +def test_validator_top50_benchmark_spans_strategy_period(monkeypatch, tmp_path): + import backtest.strategy_validator as sv + + dates = _dates(320) + rng = np.random.default_rng(12) + strategy_close = 50_000 * np.cumprod(1 + rng.normal(0.0003, 0.015, len(dates))) + frames = { + "005930": _close_frame(dates, strategy_close), + "KS11": _close_frame(dates, strategy_close * 0.05), + **_top50_frames(dates), + } + monkeypatch.setattr(sv, "DataCollector", lambda: _PanelCollector(frames)) + monkeypatch.setattr(sv, "_get_kospi_top_n_symbols", lambda *a, **k: ["AAA", "BBB", "CCC"]) + + validator = sv.StrategyValidator(output_dir=str(tmp_path)) + result = validator.run( + symbol="005930", + strategy_name="scoring", + start_date=str(dates[0].date()), + end_date=str(dates[-1].date()), + ) + + top50 = result["benchmark_top50"] + assert top50["coverage"]["at_start"] == 2 + assert top50["coverage"]["late_entries"] == {"CCC": str(dates[100].date())} + assert top50["full"]["total_return"] == pytest.approx(10.0, abs=0.01) + # 인샘플(0~223행)도 CCC 상장일로 잘리지 않고 처음부터 잰다. + assert top50["in_sample"]["total_return"] == pytest.approx(10.0, abs=0.01) + assert top50["out_sample"]["total_return"] == pytest.approx(0.0, abs=0.01) + assert "편입 범위" in validator.render_text_report(result) diff --git a/tests/test_audit_backtest_warmup.py b/tests/test_audit_backtest_warmup.py new file mode 100644 index 00000000..8822e13e --- /dev/null +++ b/tests/test_audit_backtest_warmup.py @@ -0,0 +1,213 @@ +"""워크포워드·OOS 지표 워밍업 감사 회귀 테스트. + +Backtester.run(trade_start_date=...)는 PortfolioBacktester와 같은 규약으로 앞 구간을 지표 +워밍업으로만 쓰고, 거래·자본 곡선·지표는 trade_start_date부터 잰다. 검증기 OOS·워크포워드와 +최적화기 OOS가 이 경로를 쓴다. +""" + +import numpy as np +import pandas as pd +import pytest + + +class _WarmupConfig: + settings = {} + strategies = {} + + def __init__(self, *, dynamic_slippage=False): + self._trading = {"skip_earnings_days": 0} + self._risk_params = { + "transaction_costs": { + "commission_rate": 0.00015, + "tax_rate": 0.002, + "slippage": 0.001, + "slippage_ticks": 0, + "dynamic_slippage": { + "enabled": dynamic_slippage, + "warn_at_volume_ratio": 0.01, + "warn_slippage_multiplier": 2.0, + "critical_at_volume_ratio": 0.03, + "critical_slippage_multiplier": 4.0, + }, + }, + "stop_loss": {"type": "fixed", "fixed_rate": 0.50}, + "take_profit": {"fixed_rate": 0.50, "partial_exit": False}, + "trailing_stop": {"enabled": False}, + "position_sizing": {"max_risk_per_trade": 0.01, "initial_capital": 100_000}, + "diversification": {"max_position_ratio": 0.20, "max_investment_ratio": 0.70}, + "position_limits": {"min_holding_days": 0, "max_holding_days": 0, "max_monthly_roundtrips": 0}, + "liquidity_filter": {"backtest_max_participation_rate": 1.0}, + "gap_risk": {"enabled": False}, + "blackswan": {"enabled": False}, + "backtest_regime_filter": {"enabled": False}, + } + + @property + def risk_params(self): + return self._risk_params + + @property + def trading(self): + return self._trading + + +class _SmaCrossStrategy: + """종가가 N일 단순이동평균 위면 BUY, 아래면 SELL (과거 데이터만 쓰는 인과적 신호).""" + + def __init__(self, window: int, sell_below: bool = False): + self.window = window + self.sell_below = sell_below + + def analyze(self, df): + out = df.copy() + sma = out["close"].rolling(self.window, min_periods=self.window).mean() + out["signal"] = "HOLD" + out.loc[out["close"] > sma, "signal"] = "BUY" + if self.sell_below: + out.loc[out["close"] < sma, "signal"] = "SELL" + return out + + +def _ohlcv(close, volume=None, start="2022-01-03"): + close = np.asarray(close, dtype=float) + dates = pd.bdate_range(start, periods=len(close)) + volume = np.full(len(close), 1_000_000.0) if volume is None else np.asarray(volume, dtype=float) + return pd.DataFrame( + { + "open": close, + "high": close * 1.01, + "low": close * 0.99, + "close": close, + "volume": volume, + }, + index=dates, + ) + + +def _backtester(strategy, **config_kwargs): + from backtest.backtester import Backtester + + bt = Backtester(_WarmupConfig(**config_kwargs)) + bt._get_strategy = lambda _name: strategy + return bt + + +def test_trade_start_date_warms_indicators_before_the_test_window(): + """60일 이동평균 전략: 테스트 구간만 넣으면 60행 뒤에야 첫 매수, 워밍업을 넣으면 첫날 매수.""" + df = _ohlcv(100.0 + np.arange(200, dtype=float)) # 꾸준한 상승 → 워밍업만 되면 항상 BUY + test_start = df.index[120] + strategy = _SmaCrossStrategy(window=60) + + cold = _backtester(strategy).run(df.iloc[120:].copy(), strategy_name="sma", strict_lookahead=True) + warm = _backtester(strategy).run( + df.copy(), strategy_name="sma", strict_lookahead=True, trade_start_date=test_start + ) + + cold_first_buy = next(t for t in cold["trades"] if t["action"] == "BUY") + warm_first_buy = next(t for t in warm["trades"] if t["action"] == "BUY") + assert cold_first_buy["date"] == df.index[180] # 창 60번째 행 신호 → 다음 날 시가 + assert warm_first_buy["date"] == test_start # 워밍업 마지막 날 신호 → 첫날 시가 + assert warm_first_buy["signal_date"] == df.index[119] + + # 자본 곡선·기간·지표는 평가 구간만 + equity = warm["equity_curve"] + assert equity["date"].iloc[0] == test_start + assert len(equity) == 80 + assert warm["warmup_rows"] == 120 + assert warm["period"].startswith(str(test_start)) + assert all(t["date"] >= test_start for t in warm["trades"]) + + +def test_skipped_warmup_rows_do_not_change_strict_results(): + """strict 모드에서 분석을 건너뛴 워밍업 행도 원본 값(거래량 등)을 유지해 relaxed 실행과 같다.""" + # 130행까지 상승 후 하락 → 첫 거래일 매수, 하락 구간에서 매도 + close = np.concatenate((100.0 + np.arange(130) * 0.5, 164.5 - np.arange(1, 31) * 2.0)) + # 워밍업 거래량은 크고 평가 구간은 작다. 워밍업 행의 거래량이 비면 20일 평균 거래량이 + # 급감해 동적 슬리피지 배수가 달라지고 체결가가 relaxed 실행과 어긋난다. + # (1% 손실 규칙으로 주문은 13주 안팎 → 거래량 400주면 참여율 3%를 넘는다) + volume = np.where(np.arange(160) < 99, 1_000_000.0, 400.0) + df = _ohlcv(close, volume) + strategy = _SmaCrossStrategy(window=20, sell_below=True) + kwargs = dict(strategy_name="sma", trade_start_date=df.index[100]) + + strict = _backtester(strategy, dynamic_slippage=True).run(df.copy(), strict_lookahead=True, **kwargs) + relaxed = _backtester(strategy, dynamic_slippage=True).run(df.copy(), strict_lookahead=False, **kwargs) + + assert [t["action"] for t in strict["trades"]][:2] == ["BUY", "SELL"] + assert strict["trades"][0]["date"] == df.index[100] + assert strict["trades"] == relaxed["trades"] + pd.testing.assert_frame_equal(strict["equity_curve"], relaxed["equity_curve"]) + + +def test_trade_start_date_after_last_row_is_an_error(): + df = _ohlcv(np.full(30, 100.0)) + bt = _backtester(_SmaCrossStrategy(window=5)) + + with pytest.raises(ValueError): + bt.run(df, strategy_name="sma", trade_start_date=df.index[-1] + pd.Timedelta(days=30)) + + +# ─── 검증기 OOS ──────────────────────────────────────────────── + + +def test_validator_out_of_sample_run_uses_in_sample_as_warmup(monkeypatch, tmp_path): + import backtest.strategy_validator as sv + + rng = np.random.default_rng(5) + df = _ohlcv(50_000 * np.cumprod(1 + rng.normal(0.0003, 0.015, 160)), rng.integers(500_000, 2_000_000, 160)) + + class FakeCollector: + def fetch_korean_stock(self, symbol, start_date=None, end_date=None): + return df.copy() + + monkeypatch.setattr(sv, "DataCollector", FakeCollector) + validator = sv.StrategyValidator(output_dir=str(tmp_path)) + calls = [] + original_run = validator.backtester.run + + def spy_run(frame, **kwargs): + calls.append((len(frame), kwargs.get("trade_start_date"))) + return original_run(frame, **kwargs) + + monkeypatch.setattr(validator.backtester, "run", spy_run) + + result = validator.run(symbol="005930", strategy_name="scoring", use_benchmark_top50=False) + + split_idx = 112 # max(60, int(160 × 0.7)) + assert (160, df.index[split_idx]) in calls # OOS: 전체를 넣고 split부터 거래 + assert (split_idx, None) in calls # 인샘플은 그대로 + oos_equity = result["out_sample"]["equity_curve"] + assert oos_equity["date"].iloc[0] == df.index[split_idx] + assert len(oos_equity) == 160 - split_idx + + +# ─── 최적화기 OOS ────────────────────────────────────────────── + + +def test_optimizer_out_of_sample_runs_use_train_span_as_warmup(monkeypatch): + import backtest.param_optimizer as po + + df = _ohlcv(100.0 + np.arange(200, dtype=float)) + calls = [] + + def fake_run_single(frame, strategy_name, params, config, strict, capital, trade_start_date=None): + calls.append((len(frame), trade_start_date)) + return {"sharpe_ratio": 2.0, "total_trades": 5, "total_return": 1.0, "max_drawdown": -1.0} + + monkeypatch.setattr(po, "_run_single", fake_run_single) + config = _WarmupConfig() + train_end = 140 # int(200 × 0.7) + + po.grid_search(df, "scoring", search_space={"buy_threshold": [3]}, config=config, initial_capital=100_000) + assert calls == [(train_end, None), (200, df.index[train_end])] + + calls.clear() + result = po.grid_search_scoring_weights( + df, + weight_search_space={"w_macd": [1], "w_bollinger": [1], "w_volume": [1]}, + threshold_pairs=[(3, -3)], + config=config, + initial_capital=100_000, + ) + assert result is not None + assert calls == [(train_end, None), (200, df.index[train_end])] diff --git a/tests/test_audit_cycle.py b/tests/test_audit_cycle.py new file mode 100644 index 00000000..fc06b4e4 --- /dev/null +++ b/tests/test_audit_cycle.py @@ -0,0 +1,366 @@ +"""일일 바스켓 사이클 회귀 테스트 (2026-09-23 점검). + +- 휴장일 실행은 매매 없이 스냅샷만 남긴다(추석 평일에도 일일 태스크는 돈다). +- 결측 보충은 실제로 호출돼야 한다 — 8/27 도입 이래 정의되지 않은 이름(baskets_cfg) + 때문에 매 사이클 실패했지만 경고 로그로만 남아 한 달간 아무도 몰랐다. 함수가 아니라 + 연결을 테스트한다. +- 보충은 계정이 생기기 전 날을 채우지 않고, 입금 경계를 스냅샷을 실제로 찍은 시각으로 잡는다. +""" + +from datetime import date, datetime, time +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + + +def _args(dry_run=False, force_rebalance=False): + return SimpleNamespace( + basket="kr_test", dry_run=dry_run, confirm_live=False, + force_rebalance=force_rebalance, + ) + + +@pytest.fixture +def cycle(monkeypatch): + """사이클 협력자를 모킹하고 (fake_rebalancer, 호출 순서 기록)을 돌려준다.""" + from config.config_loader import Config + + Config._instance = None + order = [] + fake_rb = MagicMock() + fake_rb.portfolio_mgr.initial_capital = 300_000 + fake_rb.get_status_report.side_effect = lambda: order.append("status") or "status" + fake_rb.should_rebalance.return_value = (True, "드리프트") + fake_rb.plan_rebalance.return_value = [] + fake_rb.plan_risk_exits.return_value = [] + monkeypatch.setattr( + "core.basket_rebalancer.BasketRebalancer", MagicMock(return_value=fake_rb) + ) + monkeypatch.setattr("core.notifier.Notifier", MagicMock()) + monkeypatch.setattr("database.repositories.get_trade_history", lambda **kw: []) + calls = {} + + def _fake_backfill(config, account_key, initial_capital, since, until, mode="paper"): + order.append("backfill") + calls["backfill"] = dict( + account_key=account_key, initial_capital=initial_capital, + since=since, until=until, mode=mode, + ) + return [] + + monkeypatch.setattr("core.snapshot_backfill.backfill_account", _fake_backfill) + return fake_rb, order, calls + + +# ------------------------------------------------------------------ 휴장일 + +def test_holiday_run_skips_trading_but_saves_snapshot(cycle, monkeypatch): + import main + + fake_rb, _, _ = cycle + monkeypatch.setattr(main, "_market_closed_today", lambda config, now: True) + + main.run_rebalance(_args()) + + fake_rb.plan_risk_exits.assert_not_called() + fake_rb.should_rebalance.assert_not_called() + fake_rb.plan_rebalance.assert_not_called() + fake_rb.execute.assert_not_called() + fake_rb.save_daily_nav_snapshot.assert_called_once() + + +def test_holiday_run_can_be_forced(cycle, monkeypatch): + import main + + fake_rb, _, _ = cycle + monkeypatch.setattr(main, "_market_closed_today", lambda config, now: True) + + main.run_rebalance(_args(force_rebalance=True)) + + fake_rb.plan_risk_exits.assert_called_once() + fake_rb.plan_rebalance.assert_called_once() + + +def test_trading_day_runs_normal_path(cycle, monkeypatch): + import main + + fake_rb, _, _ = cycle + monkeypatch.setattr(main, "_market_closed_today", lambda config, now: False) + + main.run_rebalance(_args()) + + fake_rb.plan_risk_exits.assert_called_once() + fake_rb.plan_rebalance.assert_called_once() + + +def test_market_closed_helper_uses_calendar(): + import main + from config.config_loader import Config + + Config._instance = None + cfg = Config.get() + assert main._market_closed_today(cfg, datetime(2026, 9, 24, 10, 7)) is True # 추석 + assert main._market_closed_today(cfg, datetime(2026, 9, 28, 10, 7)) is False # 대체 아님 + assert main._market_closed_today(cfg, datetime(2026, 9, 26, 10, 7)) is True # 토요일 + + +# ------------------------------------------------------------------ 결측 보충 연결 + +def test_backfill_is_wired_with_resolved_capital_before_status(cycle, monkeypatch): + import main + + _, order, calls = cycle + monkeypatch.setattr(main, "_market_closed_today", lambda config, now: False) + + main.run_rebalance(_args()) + + from datetime import timedelta + from zoneinfo import ZoneInfo + + assert "backfill" in calls, "결측 보충이 호출되지 않았다" + assert calls["backfill"]["initial_capital"] == 300_000.0 + assert calls["backfill"]["account_key"] == "basket_rebalance:kr_test" + kst_today = datetime.now(ZoneInfo("Asia/Seoul")).date() + assert calls["backfill"]["until"] == kst_today - timedelta(days=1) + # 오버레이 판단(상태 보고에서 계산·캐시)보다 먼저 보충돼야 한다 + assert order.index("backfill") < order.index("status") + + +def test_backfill_failure_is_recorded_as_event(cycle, monkeypatch): + import main + import core.cycle_observability as co + + monkeypatch.setattr(main, "_market_closed_today", lambda config, now: False) + + def _boom(*a, **k): + raise RuntimeError("시세 조회 실패") + + monkeypatch.setattr("core.snapshot_backfill.backfill_account", _boom) + recorded = [] + monkeypatch.setattr( + co, "record_event_once_per_day", + lambda event_type, message, **kw: recorded.append((event_type, kw)) or True, + ) + + main.run_rebalance(_args()) + + assert [e for e, _ in recorded] == ["SNAPSHOT_BACKFILL_FAILED"] + assert recorded[0][1]["strategy"] == "basket_rebalance:kr_test" + + +def test_backfill_not_called_on_dry_run(cycle, monkeypatch): + import main + + _, _, calls = cycle + monkeypatch.setattr(main, "_market_closed_today", lambda config, now: False) + main.run_rebalance(_args(dry_run=True)) + assert "backfill" not in calls + + +def test_backfill_not_called_in_live_mode(cycle, monkeypatch): + """live 기록은 증권사 잔고를 따라야 한다 — 로컬 원장·중간값 추정으로 채우지 않는다.""" + import main + from config.config_loader import Config + + fake_rb, _, calls = cycle + fake_rb.portfolio_mgr.sync_with_broker.return_value = {"ok": True} + config = Config.get() + monkeypatch.setitem(config.trading, "mode", "live") + monkeypatch.setattr(main, "_market_closed_today", lambda config, now: False) + monkeypatch.setattr(main, "_require_live_operator_confirmation", lambda *a, **k: None) + monkeypatch.setattr(main, "_check_live_readiness_gate", lambda *a, **k: []) + monkeypatch.setattr( + "core.basket_rebalancer.check_basket_account_isolation", lambda *a, **k: [], + ) + + main.run_rebalance(_args()) + + assert "backfill" not in calls + + +# ------------------------------------------------------------------ 보충 범위·입금 경계 + +def _add_trade(key, day, symbol="069500", qty=1, price=100_000.0): + from database.models import TradeHistory, get_session, init_database + + init_database() + s = get_session() + try: + s.add(TradeHistory( + account_key=key, symbol=symbol, action="BUY", price=price, quantity=qty, + total_amount=price * qty, mode="paper", + executed_at=datetime.combine(day, time(10, 7)), + )) + s.commit() + finally: + s.close() + + +def test_backfill_does_not_fill_days_before_account_existed(monkeypatch): + """계정 첫 활동일(7/10) 이전의 평일은 '결측'이 아니다.""" + import core.snapshot_backfill as sb + from database.repositories import save_portfolio_snapshot + + key = "basket_rebalance:t_preinception" + _add_trade(key, date(2026, 7, 10)) + for d in (date(2026, 7, 10), date(2026, 7, 13), date(2026, 7, 15)): + save_portfolio_snapshot( + total_value=300_000, cash=200_000, invested=100_000, + account_key=key, snapshot_date=datetime.combine(d, time()), mode="paper", + ) + monkeypatch.setattr(sb, "historical_mark", lambda symbol, day: 100_000.0) + + filled = sb.backfill_account( + None, key, 300_000, date(2026, 7, 1), date(2026, 7, 15), dry_run=True, + ) + + assert [f["date"] for f in filled] == [date(2026, 7, 14)] + + +def test_backfill_returns_nothing_for_account_without_activity(): + import core.snapshot_backfill as sb + + assert sb.backfill_account( + None, "basket_rebalance:t_no_activity", 300_000, + date(2026, 9, 1), date(2026, 9, 22), dry_run=True, + ) == [] + + +def test_deposit_after_previous_snapshot_is_not_booked_as_return(): + """금 10:07 스냅샷 이후 17:19 입금 → 월요일 복원분의 수익률은 0이어야 한다.""" + import core.snapshot_backfill as sb + from database.repositories import record_cash_flow, save_portfolio_snapshot + + key = "basket_rebalance:t_flow_after" + fri, mon = date(2026, 9, 18), date(2026, 9, 21) + save_portfolio_snapshot( + total_value=300_000, cash=300_000, invested=0, cumulative_return=0.0, + account_key=key, snapshot_date=datetime.combine(fri, time()), mode="paper", + measured_at=datetime.combine(fri, time(10, 7)), + ) + record_cash_flow(100_000, account_key=key, occurred_at=datetime.combine(fri, time(17, 19))) + + snap = sb.reconstruct_snapshot(None, key, mon, 300_000) + + assert snap["total_value"] == 400_000 + assert snap["cumulative_return"] == pytest.approx(0.0, abs=1e-9) + assert snap["daily_return"] == pytest.approx(0.0, abs=1e-9) + assert snap["mdd"] == pytest.approx(0.0, abs=1e-9) + + +def test_reconstructed_row_is_measured_at_end_of_its_day(): + """복원 행을 찍은 시각을 저장한 시각(다음 날)으로 두면, 다음 날 아침 입금이 빠진다.""" + import core.snapshot_backfill as sb + from database.models import PortfolioSnapshot, get_session + from database.repositories import ( + get_cash_flow_total_between, + record_cash_flow, + save_portfolio_snapshot, + ) + + key = "basket_rebalance:t_flow_before_cycle" + fri, mon, tue = date(2026, 9, 18), date(2026, 9, 21), date(2026, 9, 22) + save_portfolio_snapshot( + total_value=300_000, cash=300_000, invested=0, cumulative_return=0.0, + account_key=key, snapshot_date=datetime.combine(fri, time()), mode="paper", + measured_at=datetime.combine(fri, time(10, 7)), + ) + record_cash_flow(100_000, account_key=key, occurred_at=datetime.combine(tue, time(8, 0))) + + filled = sb.backfill_account(None, key, 300_000, mon, mon) + assert [f["date"] for f in filled] == [mon] + + s = get_session() + try: + row = s.query(PortfolioSnapshot).filter( + PortfolioSnapshot.account_key == key, + PortfolioSnapshot.date == datetime.combine(mon, time()), + ).one() + measured = row.created_at + assert row.reconstructed is True + assert row.cumulative_return == pytest.approx(0.0, abs=1e-9) + finally: + s.close() + assert measured.date() == mon + # 화요일 실행의 입금 구간(월 복원 행 시각, 화 10:07]에 화 08:00 입금이 들어가야 한다 + assert get_cash_flow_total_between( + key, measured, datetime.combine(tue, time(10, 7)), + ) == pytest.approx(100_000) + + +# ------------------------------------------------------------------ 중복 억제 이벤트 + +def test_event_once_per_day_dedupes_same_cause(): + from core.cycle_observability import record_event_once_per_day + + kw = dict(strategy="basket_rebalance:t_dedupe", mode="paper") + assert record_event_once_per_day("ORDER_REJECTED", "거부 A", symbol="005930", + dedupe_key="사유 A", **kw) is True + assert record_event_once_per_day("ORDER_REJECTED", "거부 A", symbol="005930", + dedupe_key="사유 A", **kw) is False + # 원인이 다르면 따로 남는다 + assert record_event_once_per_day("ORDER_REJECTED", "거부 B", symbol="005930", + dedupe_key="사유 B", **kw) is True + assert record_event_once_per_day("ORDER_REJECTED", "거부 A", symbol="035720", + dedupe_key="사유 A", **kw) is True + + +# ------------------------------------------------------------------ 커버리지 게이트 + +def test_coverage_gate_uses_measured_days_not_reconstructed(): + """보충이 매번 100%로 메우면 게이트가 '사이클이 실제로 돌았는가'를 못 본다.""" + from core.basket_evaluation import evaluate_basket_paper_operation + + r = evaluate_basket_paper_operation( + operation_start=date(2026, 6, 10), today=date(2026, 9, 10), + trading_days_total=60, snapshot_days=60, reconstructed_days=6, + pending_failed_orders=0, total_costs=0, initial_capital=10_000_000, + ) + assert r["snapshot_coverage"] == pytest.approx(1.0) # 표시용 전체 커버리지는 그대로 + assert r["measured_coverage"] == pytest.approx(0.9) + assert r["verdict"] == "FAIL_REVIEW" + assert any("제때 남긴 스냅샷" in i and "나중에 채운 6일 제외" in i for i in r["issues"]) + + +def test_coverage_gate_passes_with_few_reconstructed_days(): + from core.basket_evaluation import evaluate_basket_paper_operation + + r = evaluate_basket_paper_operation( + operation_start=date(2026, 6, 10), today=date(2026, 9, 10), + trading_days_total=60, snapshot_days=60, reconstructed_days=2, + pending_failed_orders=0, total_costs=0, initial_capital=10_000_000, + ) + assert r["verdict"] == "PASS_CANDIDATE" + + +# ------------------------------------------------------------------ 추정 기록과 고점 + +def test_reconstructed_day_does_not_set_the_peak(): + """나중에 채운 추정치(시가·종가 중간값)가 고점이 되면 이후 낙폭이 계속 부풀어 보인다.""" + from database.models import init_database + from database.repositories import get_max_cumulative_return, save_portfolio_snapshot + + init_database() + key = "basket_rebalance:peak_recon" + for day, cum, recon in ((date(2026, 9, 1), 2.0, False), + (date(2026, 9, 2), 5.0, True), # 추정치가 가장 높다 + (date(2026, 9, 3), 3.0, False)): + save_portfolio_snapshot( + total_value=100.0 + cum, cash=0.0, invested=100.0, cumulative_return=cum, + mdd=0.0, position_count=1, account_key=key, mode="paper", + snapshot_date=datetime(day.year, day.month, day.day), reconstructed=recon, + ) + + assert get_max_cumulative_return(account_key=key, mode="paper") == pytest.approx(3.0) + + +def test_overlay_drawdown_skips_reconstructed_peaks(): + from core.risk_overlays import drawdown_from_cumulative_returns + + series = [0.0, 10.0, 4.5] + assert drawdown_from_cumulative_returns(series) == pytest.approx(1.045 / 1.10 - 1) + # 10%가 추정 기록이면 고점은 시작점(1.0)이고 지금은 고점 위라 낙폭 0 + assert drawdown_from_cumulative_returns(series, [True, False, True]) == pytest.approx(0.0) + # 마지막 날이 추정 기록이어도 현재 수준으로는 쓴다 + assert drawdown_from_cumulative_returns([0.0, 10.0, -2.0], [True, True, False]) == pytest.approx(0.98 / 1.10 - 1) diff --git a/tests/test_audit_dashboard.py b/tests/test_audit_dashboard.py new file mode 100644 index 00000000..5666a4dc --- /dev/null +++ b/tests/test_audit_dashboard.py @@ -0,0 +1,233 @@ +"""대시보드 회귀 테스트 (2026-09-23 점검). + +- 기록 공백은 KRX 거래일로 잰다(달력 날짜·스케줄러 루프 나이 규칙은 밤·주말·휴장일마다 + 거짓 경보를 내고, 평일 이틀 공백은 '정상'으로 보였다). +- 원금 대비 손익은 스냅샷 시점 원금으로 잰다(적립 직후 가짜 손실 방지). +- '이전 기본 계좌'는 기본 계정만 DB에서 읽는다(전 계정 합산 가짜 계좌 금지). +- 적립 기록은 로컬 대시보드에서, 운용 중인 계좌에만. +""" + +from datetime import date, datetime +from unittest.mock import patch + +import pytest + +from database.models import init_database + + +# ------------------------------------------------------------ 기록 공백 + +def test_freshness_counts_trading_days_only(): + from monitoring.web_dashboard import _snapshot_freshness + + # 추석 연휴 뒤 첫 거래일(9/28 월) 11:00 — 마지막 기록 9/23이면 9/28 하루만 빠진 것 + out = _snapshot_freshness(date(2026, 9, 23), now=datetime(2026, 9, 28, 11, 0)) + assert out == {"expected_snapshot_date": "2026-09-28", "missed_trading_days": 1} + + +def test_freshness_does_not_expect_today_before_cycle_time(): + from monitoring.web_dashboard import _snapshot_freshness + + out = _snapshot_freshness(date(2026, 9, 23), now=datetime(2026, 9, 28, 9, 0)) + assert out["expected_snapshot_date"] == "2026-09-23" + assert out["missed_trading_days"] == 0 + + +def test_freshness_quiet_over_weekend_and_holidays(): + from monitoring.web_dashboard import _snapshot_freshness + + for now in (datetime(2026, 9, 24, 12, 0), datetime(2026, 9, 26, 12, 0), + datetime(2026, 9, 27, 23, 0)): + assert _snapshot_freshness(date(2026, 9, 23), now=now)["missed_trading_days"] == 0 + + +def test_freshness_flags_two_dead_weekdays(): + from monitoring.web_dashboard import _snapshot_freshness + + out = _snapshot_freshness(date(2026, 9, 16), now=datetime(2026, 9, 18, 11, 0)) + assert out["missed_trading_days"] == 2 + + +# ------------------------------------------------------------ 스케줄러 감시 + +def test_unused_scheduler_is_never_stale(): + from monitoring.web_dashboard import _scheduler_freshness + + out = _scheduler_freshness({}, now=datetime(2026, 9, 22, 11, 0)) + assert out == {"scheduler_in_use": False, "scheduler_stale": False} + + +def test_scheduler_stale_only_during_market_hours(): + from monitoring.web_dashboard import _scheduler_freshness + + runtime = {"loop_metrics": {"last_success": "2026-09-22T09:10:00"}} + assert _scheduler_freshness(runtime, now=datetime(2026, 9, 22, 11, 0))["scheduler_stale"] is True + assert _scheduler_freshness(runtime, now=datetime(2026, 9, 22, 20, 0))["scheduler_stale"] is False + + +# ------------------------------------------------------------ 시장 국면 표시 + +def test_disabled_regime_filter_is_not_shown_as_bullish(monkeypatch): + from monitoring import web_dashboard as wd + + monkeypatch.setattr("core.market_regime.resolve_market_regime_config", + lambda config, **kw: {"enabled": False}) + out = wd.get_runtime_json() + assert out["market_regime"]["regime"] == "disabled" + + +# ------------------------------------------------------------ 이전 기본 계좌 + +def test_legacy_panel_is_empty_when_default_account_has_no_records(): + from monitoring import web_dashboard as wd + + init_database() + with patch("database.repositories.get_all_positions", return_value=[]), \ + patch("database.repositories.get_trade_history", return_value=[]): + out = wd.get_portfolio_json() + assert out["empty"] is True + + +# ------------------------------------------------------------ 스냅샷 시점 원금 + +def test_principal_is_measured_at_snapshot_time(): + """스냅샷(9/22 10:07) 뒤 적립 10만 원은 아직 평가액에 없다 — 손익 비교에서 뺀다.""" + from monitoring import web_dashboard as wd + from database.repositories import record_cash_flow, save_portfolio_snapshot + + init_database() + name = "kr_pocket_pending" + key = f"basket_rebalance:{name}" + cfg = {name: {"enabled": True, "initial_capital": 300_000, + "holdings": {"069500": 0.5, "357870": 0.5}, + "target_stock_weight": 0.95}} + save_portfolio_snapshot( + total_value=390_000, cash=100_000, invested=290_000, account_key=key, + snapshot_date=datetime(2026, 9, 22), mode="paper", + measured_at=datetime(2026, 9, 22, 10, 7), + ) + record_cash_flow(100_000, account_key=key, occurred_at=datetime(2026, 9, 22, 18, 0)) + with patch("core.basket_rebalancer.BasketRebalancer._load_baskets_config", return_value=cfg): + out = wd.get_baskets_json() + b = next(x for x in out["baskets"] if x["basket"] == name) + assert b["principal"] == pytest.approx(400_000) + assert b["principal_at_snapshot"] == pytest.approx(300_000) + assert b["pending_deposits"] == pytest.approx(100_000) + assert b["profit_vs_principal"] == pytest.approx(90_000) # -1만이 아니라 +9만 + + +# ------------------------------------------------------------ 적립 기록 보호 + +def _deposit(host=None, origin=None, basket="kr_pocket_host", enabled=True): + import asyncio + from aiohttp.test_utils import TestClient, TestServer + from monitoring import web_dashboard as wd + + init_database() + cfg = {basket: {"enabled": enabled, "initial_capital": 300_000, + "holdings": {"069500": 1.0}}} + result = {} + + async def run(): + with patch("core.basket_rebalancer.BasketRebalancer._load_baskets_config", + return_value=cfg): + client = TestClient(TestServer(wd.create_app())) + await client.start_server() + try: + headers = {"X-Requested-With": "quant-dashboard", + "Idempotency-Key": f"test-host-check-{basket}-0001"} + if host: + headers["Host"] = host + if origin: + headers["Origin"] = origin + res = await client.post("/api/deposit", json={"basket": basket, "amount": 1000}, + headers=headers) + result["status"] = res.status + finally: + await client.close() + + asyncio.run(run()) + return result["status"] + + +def test_deposit_rejects_non_loopback_host(): + """DNS 리바인딩: 외부 도메인이 127.0.0.1을 가리켜도 Host 헤더는 그 도메인이다.""" + assert _deposit(host="evil.example:8080") == 403 + + +def test_deposit_rejects_cross_origin(): + assert _deposit(origin="https://evil.example") == 403 + + +def test_deposit_rejects_disabled_basket(): + assert _deposit(basket="kr_pocket_off", enabled=False) == 400 + + +def test_deposit_accepts_local_request(): + assert _deposit(origin="http://127.0.0.1:8080", basket="kr_pocket_ok") == 200 + + +# ------------------------------------------------------------ 경보 채널 + +class _Resp: + def __init__(self, status, body=None, text=""): + self.status_code = status + self._body = body or {} + self.text = text + + def json(self): + return self._body + + +def _bot(): + from monitoring.discord_bot import DiscordBot + + bot = DiscordBot.__new__(DiscordBot) + bot.enabled = True + bot.webhook_url = "https://discord.example/api/webhooks/1/SECRET" + bot.username = "t" + bot.avatar_url = "" + return bot + + +def test_discord_rejection_is_logged_without_webhook_secret(monkeypatch): + import monitoring.discord_bot as db + from loguru import logger + + lines = [] + sink = logger.add(lambda m: lines.append(str(m)), level="ERROR") + try: + monkeypatch.setattr(db.req, "post", lambda *a, **k: _Resp(404, text="Unknown Webhook")) + assert _bot().send_message("hi") is False + finally: + logger.remove(sink) + joined = "".join(lines) + assert "ALERT_FAILED" in joined and "404" in joined + assert "SECRET" not in joined + + +def test_discord_429_retries_once(monkeypatch): + import monitoring.discord_bot as db + + calls = [] + + def _post(*a, **k): + calls.append(1) + return _Resp(429, {"retry_after": 0}) if len(calls) == 1 else _Resp(204) + + monkeypatch.setattr(db.req, "post", _post) + assert _bot().send_message("hi") is True + assert len(calls) == 2 + + +def test_lost_alert_is_reported_when_every_channel_fails(monkeypatch): + from core.notifier import Notifier + + n = Notifier.__new__(Notifier) + lost = [] + monkeypatch.setattr(n, "_discord_send_message", lambda text: False, raising=False) + monkeypatch.setattr(n, "_send_email_tracked", lambda *a, **k: False, raising=False) + monkeypatch.setattr(n, "_discord_deliverable", lambda: True, raising=False) + monkeypatch.setattr(n, "_report_lost_alert", lambda t, x: lost.append(t), raising=False) + n.send_message("경보", critical=True) + assert lost == ["알림"] diff --git a/tests/test_audit_data_freshness.py b/tests/test_audit_data_freshness.py new file mode 100644 index 00000000..9789c351 --- /dev/null +++ b/tests/test_audit_data_freshness.py @@ -0,0 +1,242 @@ +"""지수 자료가 최신인지 확인하는 회귀 테스트 (2026-09-23 점검). + +FDR의 KS200·KS11 자료가 2026-09-17에서 멈췄다. 비어 있지 않은 표는 성공으로 보던 +탓에 폴백이 한 번도 시도되지 않았고, kr_pocket 추세 필터는 '직전 상태 유지'로 동결, +평가의 'NAV vs KOSPI'는 며칠 전 종가로 계산됐다 — 전부 경고 로그만 남았다. +""" + +from datetime import date, datetime, timedelta +from unittest.mock import MagicMock + +import pandas as pd +import pytest + +import core.data_collector as dc_mod +from core.data_collector import DataCollector + + +def _frame(days, start=100.0): + idx = pd.DatetimeIndex([pd.Timestamp(d) for d in days], name="date") + closes = [start + i for i in range(len(days))] + return pd.DataFrame( + {"open": closes, "high": closes, "low": closes, "close": closes, "volume": 1000}, + index=idx, + ) + + +@pytest.fixture +def fixed_target(monkeypatch): + monkeypatch.setattr(dc_mod, "_freshness_target", lambda end: date(2026, 9, 22)) + + +def test_stale_fdr_tail_is_filled_from_yfinance(monkeypatch, fixed_target): + dc = DataCollector() + fdr_df = _frame(["2026-09-15", "2026-09-16", "2026-09-17"]) + yf_df = _frame(["2026-09-16", "2026-09-17", "2026-09-18", "2026-09-21", "2026-09-22"], 200) + monkeypatch.setattr(dc, "_fetch_korean_stock_via_yfinance", lambda s, a, b: yf_df) + + merged, source = dc._fill_stale_tail("KS200", fdr_df, "2026-09-23") + + assert source == "FinanceDataReader+yfinance" + assert merged.index.max().date() == date(2026, 9, 22) + # 기존 FDR 봉은 바꾸지 않는다 + assert merged.loc["2026-09-17", "close"] == fdr_df.loc["2026-09-17", "close"] + assert list(merged.index.strftime("%m-%d")) == ["09-15", "09-16", "09-17", "09-18", "09-21", "09-22"] + + +def test_fresh_fdr_is_left_alone(monkeypatch, fixed_target): + dc = DataCollector() + fdr_df = _frame(["2026-09-21", "2026-09-22"]) + called = MagicMock() + monkeypatch.setattr(dc, "_fetch_korean_stock_via_yfinance", called) + + out, source = dc._fill_stale_tail("069500", fdr_df, "2026-09-23") + + assert source == "FinanceDataReader" and out is fdr_df + called.assert_not_called() + + +def test_old_requests_are_not_checked(monkeypatch): + """백테스트처럼 과거 구간 요청은 최신 여부를 확인하지 않는다(네트워크를 안 쓴다).""" + assert dc_mod._freshness_target("2020-01-31") is None + + +def test_freshness_target_skips_holidays_and_today(): + from core.trading_hours import _now_kst + + target = dc_mod._freshness_target(_now_kst().date().isoformat()) + assert target is not None and target < _now_kst().date() + + +def test_benchmark_return_refuses_stale_last_bar(monkeypatch, fixed_target): + """마지막 봉이 기준 거래일보다 오래됐으면 그 값으로 수익률을 내지 않는다.""" + import FinanceDataReader as fdr + + stale = _frame(["2026-06-10", "2026-09-17"]).rename(columns={"close": "Close"}) + monkeypatch.setattr(fdr, "DataReader", lambda *a, **k: stale) + monkeypatch.setattr(dc_mod, "HAS_YF", False) + + assert DataCollector.fetch_benchmark_return("2026-06-10", "2026-09-22", "KS11") is None + + +def test_benchmark_return_uses_fresh_fallback(monkeypatch, fixed_target): + import FinanceDataReader as fdr + + stale = _frame(["2026-06-10", "2026-09-17"]).rename(columns={"close": "Close"}) + fresh = pd.DataFrame({"Close": [100.0, 90.0]}, + index=pd.DatetimeIndex([pd.Timestamp("2026-06-10"), pd.Timestamp("2026-09-22")])) + monkeypatch.setattr(fdr, "DataReader", lambda *a, **k: stale) + monkeypatch.setattr(dc_mod, "HAS_YF", True) + monkeypatch.setattr(dc_mod, "_yf_download_flat", lambda t, s, e: fresh) + + assert DataCollector.fetch_benchmark_return("2026-06-10", "2026-09-22", "KS11") == pytest.approx(-10.0) + + +def test_yfinance_fallback_maps_index_and_includes_end_day(monkeypatch): + dc = DataCollector() + seen = [] + + def _fake(ticker, start, end): + seen.append((ticker, end)) + return pd.DataFrame( + {"Open": [1.0, 2.0], "High": [1.0, 2.0], "Low": [1.0, 2.0], + "Close": [1.0, 2.0], "Volume": [1, 1]}, + index=pd.DatetimeIndex([pd.Timestamp("2026-09-21"), pd.Timestamp("2026-09-22")], name="Date"), + ) + + monkeypatch.setattr(dc_mod, "_yf_download_flat", _fake) + out = dc._fetch_korean_stock_via_yfinance("KS200", "2026-09-01", "2026-09-22") + + assert seen[0] == ("^KS200", "2026-09-23") # end는 배타적 — 하루 뒤로 + assert not out.empty and out.index.max().date() == date(2026, 9, 22) + + +def test_yfinance_fallback_tries_kosdaq_suffix(monkeypatch): + dc = DataCollector() + tried = [] + + def _fake(ticker, start, end): + tried.append(ticker) + if ticker.endswith(".KS"): + return pd.DataFrame() + return pd.DataFrame( + {"Open": [1.0, 2.0], "High": [1.0, 2.0], "Low": [1.0, 2.0], + "Close": [1.0, 2.0], "Volume": [1, 1]}, + index=pd.DatetimeIndex([pd.Timestamp("2026-09-21"), pd.Timestamp("2026-09-22")], name="Date"), + ) + + monkeypatch.setattr(dc_mod, "_yf_download_flat", _fake) + out = dc._fetch_korean_stock_via_yfinance("091990", "2026-09-01", "2026-09-22") + + assert tried == ["091990.KS", "091990.KQ"] + assert not out.empty + + +def test_flat_download_flattens_multiindex(monkeypatch): + cols = pd.MultiIndex.from_tuples([("Close", "^KS200"), ("Open", "^KS200")]) + df = pd.DataFrame([[1.0, 1.0]], columns=cols) + monkeypatch.setattr(dc_mod.yf, "download", lambda *a, **k: df.copy()) + out = dc_mod._yf_download_flat("^KS200", "2026-09-01", "2026-09-23") + assert list(out.columns) == ["Close", "Open"] + + +# ------------------------------------------------------------ 추세 필터를 ETF로 대신 판단 + +def _rebalancer_for_proxy(monkeypatch, index_df, proxy_df): + from core.basket_rebalancer import BasketRebalancer + + rb = BasketRebalancer.__new__(BasketRebalancer) + rb.basket_name = "kr_pocket" + rb.config = MagicMock() + rb.data_collector = MagicMock() + rb.data_collector.fetch_korean_stock.side_effect = ( + lambda sym, s, e: index_df if sym == "KS200" else proxy_df + ) + rb._overlay_input_issues = [] + rb._overlay_source_dates = {} + monkeypatch.setattr(rb, "_overlay_previous_session", lambda: date(2026, 9, 22)) + return rb + + +def test_trend_filter_falls_back_to_tracking_etf_when_index_is_stale(monkeypatch): + days = pd.bdate_range("2025-06-01", "2026-09-22") + stale_index = _frame([d for d in days if d <= pd.Timestamp("2026-09-17")]) + proxy = _frame(list(days), 50_000) + rb = _rebalancer_for_proxy(monkeypatch, stale_index, proxy) + + closes = rb._fetch_index_closes("KS200", 200) + + assert closes is not None and len(closes) >= 200 + assert closes[-1] == pytest.approx(proxy["close"].iloc[-1]) + assert any("069500" in i for i in rb._overlay_input_issues) + # ETF로 판단했으니 '비중 확대 보류' 문구는 남기지 않는다 + assert not any(i.startswith("지수 종가:") for i in rb._overlay_input_issues) + assert rb._overlay_source_dates.get("069500 종가(지수 대신)") == "2026-09-22" + + +def test_trend_filter_keeps_issue_when_proxy_also_stale(monkeypatch): + days = pd.bdate_range("2025-06-01", "2026-09-17") + stale = _frame(list(days)) + rb = _rebalancer_for_proxy(monkeypatch, stale, stale) + + assert rb._fetch_index_closes("KS200", 200) is None + assert any(i.startswith("지수 종가:") for i in rb._overlay_input_issues) + + +def test_overlay_message_names_the_real_cause(): + from core.risk_overlays import compute_decision, parse_overlay_config + + cfg = parse_overlay_config({"overlays": {"trend_filter": {"enabled": True, "ma_days": 200}}}) + empty = compute_decision(cfg, index_closes=None, prev_state={"trend_below": False}) + short = compute_decision(cfg, index_closes=[1.0] * 50, prev_state={"trend_below": False}) + assert any("쓸 수 없음" in i for i in empty.data_issues) + assert any("200일치 부족" in i for i in short.data_issues) + + +def test_proxy_note_does_not_claim_the_weight_was_held_back(): + """069500으로 대신 판단해 비중을 제대로 정했는데 화면에 '비중 확대 보류'라고 쓰면 + 실제와 반대로 알린다(리뷰 지적). 참고 사항과 실제 보류를 구분한다.""" + from core.risk_overlays import compute_decision, describe_decision, parse_overlay_config + + cfg = parse_overlay_config({"overlays": {"trend_filter": {"enabled": True, "ma_days": 200}}}) + ok = compute_decision(cfg, index_closes=[100.0] * 260, prev_state={"trend_below": False}) + ok.data_issues.append("KS200 지수 자료가 늦어 069500 종가로 추세를 봤음") + assert ok.held_back is False + text = describe_decision(ok) + assert "보류" not in text and "069500" in text + + missing = compute_decision(cfg, index_closes=None, prev_state={"trend_below": False}) + assert missing.held_back is True + assert "자료 확인 전 비중 확대 보류" in describe_decision(missing) + + # held_back이 없는 예전 상태 파일은 자료 문제가 있으면 보류로 읽는다 + legacy = {"scale": 1.0, "reasons": [], "data_issues": ["지수 종가: 최근 기록 없음"]} + assert "자료 확인 전 비중 확대 보류" in describe_decision(legacy) + + +def test_health_lists_overlay_data_issues(): + from core.operator_health import summarize_basket_operation + + out = summarize_basket_operation( + ["kr_pocket"], date(2026, 9, 23), 2, date(2026, 9, 23), + data_notes=["바스켓 'kr_pocket' 위험 관리에 쓸 자료 문제: 지수 종가: 최근 기록 2026-09-17"], + ) + assert out["verdict"] == "ATTENTION" + assert any("위험 관리에 쓸 자료 문제" in n for n in out["notes"]) + + +def test_benchmark_return_ignores_empty_close_rows(monkeypatch, fixed_target): + """yfinance가 값이 빈 봉을 끼워 줘도 NaN 수익률을 내지 않는다(실측: 9/22 빈 봉).""" + import FinanceDataReader as fdr + + monkeypatch.setattr(fdr, "DataReader", lambda *a, **k: pd.DataFrame()) + gappy = pd.DataFrame( + {"Close": [100.0, 95.0, float("nan")]}, + index=pd.DatetimeIndex([pd.Timestamp("2026-06-10"), pd.Timestamp("2026-09-21"), + pd.Timestamp("2026-09-22")]), + ) + monkeypatch.setattr(dc_mod, "HAS_YF", True) + monkeypatch.setattr(dc_mod, "_yf_download_flat", lambda t, s, e: gappy) + + # 마지막 유효 봉(9/21)이 기준(9/22)보다 오래됐으니 계산하지 않는다 — NaN도 아니다 + assert DataCollector.fetch_benchmark_return("2026-06-10", "2026-09-22", "KS11") is None diff --git a/tests/test_audit_evaluation.py b/tests/test_audit_evaluation.py new file mode 100644 index 00000000..77fbcbe3 --- /dev/null +++ b/tests/test_audit_evaluation.py @@ -0,0 +1,156 @@ +"""평가 창 회귀 테스트 (2026-09-23 점검). + +- 규칙을 바꾼 트랙은 새 규칙 적용일 이후를 따로 센다(rules_window). 예전 규칙의 + 운영 일수로 진행률이 100%가 되면 새 규칙을 검토할 근거가 없다. +- 설계를 바꾼 트랙의 실행·구성 격차는 설계를 바꾼 날부터 잰다(design_effective_from). +""" + +from datetime import date, datetime +from unittest.mock import patch + +import pytest + +from database.models import init_database + + +def _seed(key, days, *, cum=None, trade_day=None): + from database.models import TradeHistory, get_session + from database.repositories import save_portfolio_snapshot + + init_database() + for i, d in enumerate(days): + save_portfolio_snapshot( + total_value=1_000_000, cash=400_000, invested=600_000, + cumulative_return=(cum[i] if cum else 0.0), + account_key=key, snapshot_date=datetime.combine(d, datetime.min.time()), + mode="paper", + ) + if trade_day: + s = get_session() + try: + s.add(TradeHistory( + account_key=key, strategy=key, symbol="069500", action="BUY", + price=100_000, quantity=1, total_amount=100_000, commission=15, + mode="paper", executed_at=datetime.combine(trade_day, datetime.min.time()), + )) + s.commit() + finally: + s.close() + + +def _collect(name, cfg, **kw): + from core.basket_evaluation import collect_basket_paper_evaluation + + with patch("core.basket_rebalancer.BasketRebalancer._load_baskets_config", + return_value={name: cfg}): + result, _ = collect_basket_paper_evaluation( + basket_name=name, include_benchmark=False, **kw, + ) + return result + + +def test_rules_window_counts_only_days_under_new_rules(): + name = "t_rules_window" + days = [date(2026, 9, 14), date(2026, 9, 15), date(2026, 9, 16), + date(2026, 9, 17), date(2026, 9, 18), date(2026, 9, 21)] + _seed(f"basket_rebalance:{name}", days, trade_day=date(2026, 9, 14)) + cfg = {"enabled": True, "initial_capital": 1_000_000, "holdings": {"069500": 1.0}, + "promotion": {"paper_only": True, "rules_effective_from": "2026-09-17"}} + + r = _collect(name, cfg) + + rw = r["rules_window"] + assert rw["since"] == "2026-09-17" + # 9/17, 9/18, 9/21 + (오늘까지 스냅샷 없는 거래일은 제외되지 않고 결측으로 센다) + assert rw["trading_days"] >= 3 + assert r["progress_days"] >= rw["trading_days"] + 3 # 전체 운영 일수는 그대로 + assert "rules_window" not in _collect(name, {**cfg, "promotion": {"paper_only": True}}) + + +def test_daily_card_shows_new_rules_progress(): + from core.basket_evaluation import build_daily_report_extras + + extras = build_daily_report_extras(eval_result={ + "progress_days": 52, "min_trading_days": 60, "snapshot_days": 52, + "snapshot_coverage": 1.0, "progress_pct": 0.87, "paper_only": True, + "rules_window": {"trading_days": 5, "min_trading_days": 60}, + "metrics": {}, + }) + assert "새 규칙 5/60일" in extras["progress"] + + +def test_attribution_window_starts_at_design_date(): + name = "t_design_window" + days = [date(2026, 8, 5), date(2026, 8, 6), date(2026, 8, 7), date(2026, 8, 10)] + # 8/06까지 누적 +10%, 8/10 누적 +12.2% → 설계 적용 이후 구간은 +2% + _seed(f"basket_rebalance:{name}", days, cum=[5.0, 10.0, 11.0, 12.2], + trade_day=date(2026, 8, 5)) + cfg = {"enabled": True, "initial_capital": 1_000_000, + "holdings": {"005930": 1.0}, "target_stock_weight": 0.6, + "promotion": {"design_effective_from": "2026-08-07"}} + seen = {} + + def _design(holdings, fraction, start, end): + seen["start"] = start + return 1.0 + + with patch("core.basket_evaluation.compute_design_portfolio_return", _design): + r = _collect(name, cfg, include_attribution=True) + + assert seen["start"] == date(2026, 8, 7) + m = r["metrics"] + assert m["attribution_window"][0] == "2026-08-07" + assert m["attribution_nav_pct"] == pytest.approx(2.0, abs=1e-6) + assert m["execution_gap_pct"] == pytest.approx(1.0, abs=1e-6) + + +# ------------------------------------------------------------ 승격 판정 NaN + +def _metrics(**over): + from core.promotion_engine import StrategyMetrics + + base = dict(name="t", total_return=10.0, profit_factor=1.5, mdd=-10.0, + wf_positive_rate=0.8, wf_sharpe_positive_rate=0.8, wf_windows=5, + wf_total_trades=50, sharpe=1.2) + base.update(over) + return StrategyMetrics(**base) + + +@pytest.mark.parametrize("field", ["total_return", "profit_factor", "wf_positive_rate"]) +def test_nan_core_metric_fails_paper_only(field): + from core.promotion_engine import _check_paper_only + + ok, reason = _check_paper_only(_metrics(**{field: float("nan")})) + assert ok is False and field in reason + + +@pytest.mark.parametrize("field", ["sharpe", "mdd"]) +def test_nan_risk_metric_fails_provisional_without_crash(field): + from core.promotion_engine import _check_provisional_candidate + + ok, reason = _check_provisional_candidate(_metrics(**{field: float("nan")})) + assert ok is False and field in reason + + +def test_missing_metric_fails_closed_instead_of_type_error(): + from core.promotion_engine import _check_paper_only + + ok, _ = _check_paper_only(_metrics(total_return=None)) + assert ok is False + + +def test_paper_order_errors_are_counted_but_not_gating(): + from core.cycle_observability import record_cycle_event + from core.basket_evaluation import format_evaluation_report + + name = "t_paper_errors" + key = f"basket_rebalance:{name}" + _seed(key, [date(2026, 9, 21)], trade_day=date(2026, 9, 21)) + record_cycle_event("ORDER_ERROR", "예외", severity="critical", strategy=key, mode="paper") + cfg = {"enabled": True, "initial_capital": 1_000_000, "holdings": {"069500": 1.0}} + + r = _collect(name, cfg) + + assert r["metrics"]["paper_order_errors"] == 1 + assert not any("오류" in i for i in r["issues"]) # 판정에는 넣지 않는다 + assert "주문·실행 오류 1건" in format_evaluation_report(r, name) diff --git a/tests/test_audit_exec.py b/tests/test_audit_exec.py new file mode 100644 index 00000000..3b45934d --- /dev/null +++ b/tests/test_audit_exec.py @@ -0,0 +1,287 @@ +"""주문 실행 경로 회귀 테스트 (2026-09-23 점검). + +- 노출 상한·낙폭 가드를 계획과 같은 시가로 잰다(원가로 재면 하락장 보충 매수가 거부되고, + 평가익이 쌓이면 가짜 '일일 손실'로 매수가 막힌다). +- 목표 비중 주문은 계좌 낙폭 가드 대신 바스켓 낙폭 규칙을 따른다. +- 매수 거부 사유를 로그·이벤트로 남긴다(요약의 '실패 N건'만으로는 원인을 모른다). +- 손절 청산은 최소 보유 기간보다 우선한다(호출부가 emergency로 명시). +- 재매수 차단 종목의 빈 슬롯이 드리프트 트리거를 매일 켜 두지 않는다. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from config.config_loader import Config + + +@pytest.fixture +def executor(monkeypatch): + Config._instance = None + from database.models import init_database + + init_database() + from core.order_executor import OrderExecutor + + ex = OrderExecutor(account_key="audit_exec_test") + ex.config.risk_params["drawdown"]["max_portfolio_mdd"] = 0.15 + ex.config.risk_params["drawdown"]["max_daily_loss"] = 0.03 + return ex + + +def _fake_pm(summary, seen=None): + class FakePortfolioManager: + def __init__(self, config=None, account_key=""): + pass + + def get_portfolio_summary(self, current_prices=None): + if seen is not None: + seen.append(current_prices) + return dict(summary) + + return FakePortfolioManager + + +# ------------------------------------------------------------ 목표 비중 주문과 낙폭 가드 + +def test_mdd_breach_blocks_discretionary_buy(executor, monkeypatch): + monkeypatch.setattr("core.portfolio_manager.PortfolioManager", + _fake_pm({"total_value": 8_500_000, "mdd": 16.0})) + monkeypatch.setattr(executor, "_daily_loss_baseline", lambda: None) + r = executor._drawdown_pre_order_check("BUY") + assert r["allowed"] is False and r["drawdown_guard_type"] == "mdd" + + +def test_mdd_breach_is_delegated_for_weight_policy_orders(executor, monkeypatch): + seen = [] + monkeypatch.setattr("core.portfolio_manager.PortfolioManager", + _fake_pm({"total_value": 8_500_000, "mdd": 16.0}, seen)) + monkeypatch.setattr(executor, "_daily_loss_baseline", lambda: None) + + r = executor._drawdown_pre_order_check( + "BUY", mark_prices={"005930": 70_000.0}, delegated=True, + ) + + assert r["allowed"] is True + assert "MDD" in r["drawdown_guard_delegated"] + assert seen == [{"005930": 70_000.0}] # 판정 자체는 시가로 계산 + + +def test_basket_without_drawdown_rule_keeps_account_guard(executor, monkeypatch): + """낙폭 규칙을 꺼 둔 바스켓(kr_diversified_hold)의 목표 비중 주문은 계좌 가드를 그대로 + 받는다. 넘기면 계좌 MDD 가드를 아무도 적용하지 않는다(리뷰 지적: live에서 사라짐).""" + monkeypatch.setattr("core.portfolio_manager.PortfolioManager", + _fake_pm({"total_value": 8_500_000, "mdd": 16.0})) + monkeypatch.setattr(executor, "_daily_loss_baseline", lambda: None) + monkeypatch.setattr(executor, "_global_trading_halt_check", lambda *a, **k: {"allowed": True}) + monkeypatch.setattr(executor, "_monthly_buy_cap_check", lambda *a, **k: {"allowed": True}) + + r = executor._pre_order_check( + symbol="005930", action="BUY", mark_prices={"005930": 70_000.0}, + weight_policy_managed=True, drawdown_delegable=False, + ) + + assert r["allowed"] is False and r["drawdown_guard_type"] == "mdd" + + +def test_buy_quantity_delegates_only_with_basket_drawdown_rule(executor, monkeypatch): + seen = [] + + def _capture(**kw): + seen.append(kw.get("drawdown_delegable")) + return {"allowed": False, "reason": "stop-here"} + + monkeypatch.setattr(executor, "_pre_order_check", _capture) + monkeypatch.setattr(executor, "_report_buy_rejection", lambda *a, **k: None) + monkeypatch.setattr(executor, "_should_block_new_buy_volatility_window", lambda: False) + kwargs = dict(symbol="069500", price=40_000, quantity=1, capital=1_000_000, + available_cash=500_000, weight_policy_managed=True) + + executor.execute_buy_quantity(**kwargs) + executor.execute_buy_quantity(**kwargs, basket_drawdown_rule=True) + executor.execute_buy_quantity(**{**kwargs, "weight_policy_managed": False}, + basket_drawdown_rule=True) + + assert seen == [False, True, False] + + +def test_rebalancer_reports_its_drawdown_rule(): + from core.basket_rebalancer import BasketRebalancer + + rb = BasketRebalancer.__new__(BasketRebalancer) + rb._overlay_cfg = None + rb.basket = {"overlays": {"drawdown_guard": {"enabled": True, "trigger": -0.1, + "release": -0.05, "scale": 0.5}}} + assert rb._has_drawdown_rule() is True + + rb._overlay_cfg = None + rb.basket = {"overlays": {"drawdown_guard": {"enabled": False}}} + assert rb._has_drawdown_rule() is False + + rb._overlay_cfg = None + rb.basket = {} + assert rb._has_drawdown_rule() is False + + +def test_infrastructure_failure_is_not_delegated(executor, monkeypatch): + """평가 불가·설정 오류는 넘기지 않는다 — 판단 근거가 없으면 여전히 막는다.""" + executor.config.risk_params["drawdown"]["max_portfolio_mdd"] = "x" + r = executor._drawdown_pre_order_check("BUY", delegated=True) + assert r["allowed"] is False and r["drawdown_guard_type"] == "invalid_config" + + +# ------------------------------------------------------------ 시가 노출 + +def test_marked_position_value_uses_market_price(executor): + pos = SimpleNamespace(symbol="005930", quantity=10, avg_price=90_000, total_invested=900_000) + assert executor._marked_position_value(pos, {"005930": 70_000}) == pytest.approx(700_000) + # 가격이 없으면 기존 기준(투자원금) + assert executor._marked_position_value(pos, {"000660": 1}) == pytest.approx(900_000) + assert executor._marked_position_value(pos, None) == pytest.approx(900_000) + + +def test_exposure_check_sees_market_values(executor, monkeypatch): + """원가 기준이면 거부되는 보충 매수가 시가 기준으로는 설계 범위 안이다. + + 원가: 보유 5,936,700 / 총자산 9,556,410 = 62.1% → +269,000 매수 시 64.9% > 63% + 시가: 보유 4,760,200 / 총자산 8,379,910 = 56.8% → +269,000 매수 시 60.0% ≤ 63% + """ + positions = [SimpleNamespace(symbol="005490", quantity=17, avg_price=349_217.6, + total_invested=5_936_700)] + monkeypatch.setattr("core.order_executor.get_all_positions", lambda **kw: positions) + captured = {} + + def _fake_div(**kw): + captured.update(kw) + return {"can_buy": False, "reason": "stop-here"} + + monkeypatch.setattr(executor.risk_manager, "check_diversification", _fake_div) + monkeypatch.setattr(executor, "_pre_order_check", lambda **kw: {"allowed": True}) + monkeypatch.setattr(executor, "_report_buy_rejection", lambda *a, **k: None) + # 장 초반·마감 진입 차단 시간대 판정은 실제 시계를 본다 — 실행 시각에 따라 노출 + # 판정까지 가지 못하므로 고정한다. + monkeypatch.setattr(executor, "_should_block_new_buy_volatility_window", lambda: False) + + executor.execute_buy_quantity( + symbol="005490", price=269_000, quantity=1, capital=8_379_910, + available_cash=3_619_710, weight_policy_managed=True, + exposure_limits={"max_investment_ratio": 0.63}, + mark_prices={"005490": 280_011.76}, + ) + + assert captured["current_invested"] == pytest.approx(17 * 280_011.76) + assert captured["total_value"] == pytest.approx(8_379_910) + + +# ------------------------------------------------------------ 거부 사유 기록 + +def test_buy_rejection_is_logged_and_recorded_once_per_day(executor, monkeypatch): + import core.cycle_observability as co + + recorded = [] + monkeypatch.setattr( + co, "record_event_once_per_day", + lambda event_type, message, **kw: recorded.append((event_type, kw)) or True, + ) + monkeypatch.setattr( + executor, "_execute_buy_quantity_impl", + lambda **kw: {"success": False, "reason": "전체 투자 비중 63% 초과"}, + ) + + r = executor.execute_buy_quantity( + symbol="005490", price=269_000, quantity=1, capital=1, available_cash=1, + strategy="basket_rebalance:kr_diversified_hold", + ) + + assert r["success"] is False + assert recorded and recorded[0][0] == "ORDER_REJECTED" + assert recorded[0][1]["symbol"] == "005490" + assert recorded[0][1]["dedupe_key"] == "전체 투자 비중 63% 초과" + + +# ------------------------------------------------------------ 손절 청산 우선 + +def test_emergency_flag_reaches_sell_impl(executor, monkeypatch): + seen = {} + + def _impl(*args, emergency=False, **kw): + seen["emergency"] = emergency + return {"success": True} + + monkeypatch.setattr(executor, "_execute_sell_impl", _impl) + executor.execute_sell("005930", 70_000, 1, reason="리밸런싱: RISK_EXIT STOP_LOSS: 손절", + emergency=True) + assert seen["emergency"] is True + + +# ------------------------------------------------------------ 드리프트 트리거 + +def test_cooldown_slot_does_not_arm_drift_trigger(monkeypatch): + from core.basket_rebalancer import BasketRebalancer + + rb = BasketRebalancer.__new__(BasketRebalancer) + rb.basket_name = "t" + rb.basket = {"risk": {"reentry_cooldown_days": 60}} + rb.account_key = "acct" + rb.config = MagicMock() + rb.config.trading = {"mode": "paper"} + rb.rebalance_cfg = {"trigger": "drift", "drift_threshold": 0.08, "deployment_band": 0.03} + # 손절로 비운 012330 슬롯만 크게 벌어져 있고 나머지는 거의 목표대로 + monkeypatch.setattr(rb, "calculate_drift", lambda prices=None: { + "012330": {"drift": 0.111}, "005930": {"drift": -0.01}, "035720": {"drift": 0.02}, + }) + monkeypatch.setattr(rb, "_deployment_gap", lambda prices=None: -0.01) + monkeypatch.setattr( + "core.basket_rebalancer.symbols_in_reentry_cooldown", + lambda *a, **k: {"012330": "손절 후 재매수 차단"}, + ) + + should, reason = rb.should_rebalance({}) + + assert should is False, reason + + +def test_tradable_drift_still_arms_trigger(monkeypatch): + from core.basket_rebalancer import BasketRebalancer + + rb = BasketRebalancer.__new__(BasketRebalancer) + rb.basket_name = "t" + rb.basket = {} + rb.account_key = "acct" + rb.config = MagicMock() + rb.config.trading = {"mode": "paper"} + rb.rebalance_cfg = {"trigger": "drift", "drift_threshold": 0.08} + monkeypatch.setattr(rb, "calculate_drift", lambda prices=None: { + "012330": {"drift": 0.111}, "005930": {"drift": -0.09}, + }) + monkeypatch.setattr( + "core.basket_rebalancer.symbols_in_reentry_cooldown", + lambda *a, **k: {"012330": "손절 후 재매수 차단"}, + ) + + should, _ = rb.should_rebalance({}) + assert should is True + + +# ------------------------------------------------------------ 피크 오염 + +def test_peak_only_advances_on_market_priced_summary(monkeypatch): + """가격 없이(평균단가로) 부른 요약이 피크를 올리면 다음 스냅샷이 그 값을 저장한다.""" + from database.models import init_database + + init_database() + from core.portfolio_manager import PortfolioManager + + pos = SimpleNamespace(symbol="005930", quantity=10, avg_price=100_000.0) + monkeypatch.setattr("core.portfolio_manager.get_all_positions", lambda **kw: [pos]) + pm = PortfolioManager(account_key="audit_peak_test", initial_capital=1_000_000) + start_peak = pm._peak_value + + summary = pm.get_portfolio_summary() # 원가 평가 — 총액 2,000,000 + assert pm._peak_value == pytest.approx(start_peak) + # 피크를 올리지 않아도 낙폭이 음수가 되면 안 된다(주문 가드가 abs()로 받아 100%로 읽는다) + assert summary["mdd"] == pytest.approx(0.0) + + pm.get_portfolio_summary(current_prices={"005930": 90_000.0}) # 시가 1,900,000 + assert pm._peak_value == pytest.approx(1_900_000) diff --git a/tests/test_audit_health.py b/tests/test_audit_health.py new file mode 100644 index 00000000..5f0b3f42 --- /dev/null +++ b/tests/test_audit_health.py @@ -0,0 +1,85 @@ +"""헬스 배치율 감시 회귀 테스트 (2026-09-23 점검). + +8/26에 '복원'한 배치율 5%p 감시가 실제로는 울릴 수 없었다. 허용 하한을 보유 슬롯별 +1주 가격의 합으로 잡아서 9종목 바스켓의 허용이 약 20%p였기 때문이다 — 8/07~8/26 +현금 래칫(61% → 54.9%) 상태를 그대로 넣어도 OK가 나왔다. +""" + +import pytest + +from core.operator_health import ( + structural_deployment_tolerance, + summarize_deployment, + unfixable_deployment_gap, +) +from core.risk_overlays import applied_stock_fraction, invested_fraction + +# 2026-09-23 kr_diversified_hold 실제 평균단가(8종목 보유)와 총자산 +HOLD_PRICES = [395_500, 362_000, 291_667, 229_500, 37_262, 302_167, 99_900, 156_000] +HOLD_TOTAL = 9_240_010 + + +def test_cash_ratchet_state_is_now_attention(): + unit = unfixable_deployment_gap(HOLD_PRICES, HOLD_TOTAL, min_trade=200_000, band=0.03) + tol = structural_deployment_tolerance(unit, HOLD_TOTAL, 0.05) + assert tol == pytest.approx(0.05, abs=0.01) # 설정값(5%p) 근처로 돌아와야 한다 + assert summarize_deployment(0.549, 0.60, tolerance=tol)["verdict"] == "ATTENTION" + + +def test_old_sum_of_slots_rule_would_have_hidden_it(): + """예전 호출부가 넘기던 값(슬롯별 1주 가격 합)으로는 같은 상태가 OK였다.""" + old_tol = structural_deployment_tolerance(sum(HOLD_PRICES), HOLD_TOTAL, 0.05) + assert old_tol > 0.2 + assert summarize_deployment(0.549, 0.60, tolerance=old_tol)["verdict"] == "OK" + + +def test_cheapest_executable_lot_respects_min_trade(): + # 3.7만원짜리는 min_trade 20만원을 넘기려면 6주(22.4만원)를 사야 한다. + # 보충은 묶음이 남은 격차의 2배보다 작을 때만 사므로 못 메우는 격차는 그 절반. + unit = unfixable_deployment_gap([37_262], 1_000_000, min_trade=200_000, band=0.0) + assert unit == pytest.approx(6 * 37_262 / 2) + + +def test_gap_smaller_than_half_a_share_is_tolerated(): + # 잔고 40만, 지수 12.8만 단일 슬롯: 부족분 6만(15%p) < 1주의 절반(6.4만)이면 + # 1주를 사면 오히려 격차가 커진다 — 매수 보류가 맞고 경보도 없어야 한다. + unit = unfixable_deployment_gap([128_000], 400_000, min_trade=50_000, band=0.03) + tol = structural_deployment_tolerance(unit, 400_000, 0.10) + assert tol == pytest.approx(0.16) + assert summarize_deployment(0.35, 0.50, tolerance=tol)["verdict"] == "OK" + + +def test_gap_a_share_could_close_is_attention(): + # 같은 계좌에서 부족분 20%p(8만)는 1주(12.8만)로 줄일 수 있다 — 남아 있으면 이상 + unit = unfixable_deployment_gap([128_000], 400_000, min_trade=50_000, band=0.03) + tol = structural_deployment_tolerance(unit, 400_000, 0.10) + assert summarize_deployment(0.30, 0.50, tolerance=tol)["verdict"] == "ATTENTION" + + +def test_gap_inputs_missing_fall_back_to_configured_floor(): + assert unfixable_deployment_gap([], 1_000_000, min_trade=1, band=0.03) == 0.0 + assert unfixable_deployment_gap([100_000], 0, min_trade=1, band=0.03) == 0.0 + assert unfixable_deployment_gap(["x", None, -5], 1_000_000, min_trade=1) == 0.0 + assert structural_deployment_tolerance(0.0, 1_000_000, 0.05) == pytest.approx(0.05) + + +# ---------------------------------------------------------- 오버레이 적용 목표 + +POCKET = {"069500": 0.5, "357870": 0.5} + + +def test_invested_fraction_counts_defensive_symbol_as_invested(): + """주식을 절반으로 줄여도 줄인 만큼 CD ETF를 사므로 투자 비중은 95% 그대로.""" + state = {"scale": 0.5} + assert invested_fraction(POCKET, 0.95, state, "357870") == pytest.approx(0.95) + # 예전 헬스 기준(설계 × 배수)은 절반으로 내려가 CD ETF 매수 실패를 못 봤다 + assert applied_stock_fraction(0.95, state) == pytest.approx(0.475) + + +def test_invested_fraction_without_defensive_symbol_scales_down(): + assert invested_fraction({"a": 1, "b": 1}, 0.6, {"scale": 0.5}) == pytest.approx(0.3) + assert invested_fraction({"a": 1}, 0.6, None) == pytest.approx(0.6) + + +def test_invested_fraction_ignores_bad_state(): + assert invested_fraction(POCKET, 0.95, {"scale": "x"}, "357870") == pytest.approx(0.95) diff --git a/tests/test_audit_kis_5xx.py b/tests/test_audit_kis_5xx.py new file mode 100644 index 00000000..fb1a12f4 --- /dev/null +++ b/tests/test_audit_kis_5xx.py @@ -0,0 +1,185 @@ +"""KIS 5xx 업무 오류 회귀 테스트 (감사: runtime-kis-5xx-business-errors, 조회 경로만). + +KIS 게이트웨이는 초당 한도 초과(EGW00201)와 토큰 무효·만료(EGW00121/EGW00123)를 +HTTP 500 + JSON 본문으로 돌려줄 수 있다. 예전에는 본문을 보지 않고 전부 서버 +장애로 세어, 조회 두 건의 재시도만으로 서킷이 열리고(60초간 손절 SELL까지 차단) +무효 토큰은 로컬 만료 시각까지 갱신되지 않았다. + +주문(비멱등) POST는 의도적으로 기존 '응답 불명' 처리를 유지한다 — 5xx에서는 +브로커가 주문을 접수했는지 단정할 수 없으므로 재전송 금지(test_kis_order_idempotency). +네트워크는 전부 모킹한다. +""" + +from unittest.mock import patch + +import pytest +import requests + +from api.circuit_breaker import CircuitState, get_breaker +from api.kis_api import KISApi, KISOrderResponseUnknown, reset_shared_token_cache + +VTS_URL = "https://openapivts.koreainvestment.com:29443" +_OK_BODY = {"rt_cd": "0", "output": {"stck_prpr": "70000"}} + + +class _Resp: + def __init__(self, status_code, payload=None, headers=None, json_error=False): + self.status_code = status_code + self._payload = payload if payload is not None else {} + self.headers = headers or {} + self.ok = 200 <= status_code < 400 + self.text = "Internal Server Error" if json_error else "" + self._json_error = json_error + + def json(self): + if self._json_error: + raise ValueError("Expecting value: line 1 column 1 (char 0)") + return self._payload + + def raise_for_status(self): + if self.status_code >= 400: + raise requests.HTTPError(f"HTTP {self.status_code}") + + +def _reset(b): + with b._lock: + b.state = CircuitState.CLOSED + b.failure_count = 0 + b._half_open_probe_in_flight = False + b._half_open_probe_started_at = 0.0 + + +@pytest.fixture(autouse=True) +def _isolate_breaker(): + b = get_breaker() + _reset(b) + yield + _reset(b) + + +def _bare_api(): + api = object.__new__(KISApi) + api.use_mock = True + api.cano = "12345678" + api.acnt_prdt_cd = "01" + api.base_url = "https://example.test" + api._is_configured = lambda: True + api._get_headers = lambda tr_id: {} + api._wait_for_token = lambda: None + api._backoff_with_jitter = lambda *a, **kw: 0.0 + return api + + +def test_get_rate_limit_body_retries_without_breaker_failure(): + """GET 500 + EGW00201은 429처럼 대기 후 재시도하고 서킷 실패로 세지 않는다.""" + api = _bare_api() + responses = [ + _Resp(500, {"rt_cd": "1", "msg_cd": "EGW00201", "msg1": "초당 거래건수를 초과하였습니다."}), + _Resp(200, _OK_BODY), + ] + slept = [] + with patch("api.kis_api.requests.get", side_effect=lambda *a, **kw: responses.pop(0)), \ + patch("api.kis_api.time.sleep", side_effect=lambda s: slept.append(s)): + data = api._request("GET", "/quote", "TR", params={}, max_retries=3) + + assert data == _OK_BODY + assert get_breaker().failure_count == 0 + assert slept == [1] + assert api._total_429s == 1 + + +def test_get_rate_limit_bodies_do_not_open_breaker(): + """EGW00201이 연달아 와도(조회 여러 건) 서킷이 열리지 않는다.""" + api = _bare_api() + busy = _Resp(500, {"rt_cd": "1", "msg_cd": "EGW00201", "msg1": "초당 거래건수 초과"}) + with patch("api.kis_api.requests.get", return_value=busy), \ + patch("api.kis_api.time.sleep", lambda s: None): + for _ in range(3): + assert api._request("GET", "/quote", "TR", params={}, max_retries=3) == {} + + breaker = get_breaker() + assert breaker.state == CircuitState.CLOSED + assert breaker.failure_count == 0 + + +def test_get_plain_5xx_is_still_a_breaker_failure(): + """본문이 JSON이 아닌 진짜 서버 장애는 기존처럼 서킷 실패로 누적하고 재시도한다.""" + api = _bare_api() + calls = {"get": 0} + + def fake_get(*a, **kw): + calls["get"] += 1 + return _Resp(503, json_error=True) + + with patch("api.kis_api.requests.get", side_effect=fake_get), \ + patch("api.kis_api.time.sleep", lambda s: None): + assert api._request("GET", "/quote", "TR", params={}, max_retries=3) == {} + + assert calls["get"] == 3 + assert get_breaker().failure_count == 3 + + +def test_order_post_with_rate_limit_body_keeps_response_unknown(): + """주문 POST는 본문이 EGW00201이어도 재전송하지 않고 '응답 불명'으로 올린다(의도된 설계).""" + api = _bare_api() + calls = {"post": 0} + + def fake_post(*a, **kw): + calls["post"] += 1 + return _Resp(500, {"rt_cd": "1", "msg_cd": "EGW00201", "msg1": "초당 거래건수 초과"}) + + with patch("api.kis_api.requests.post", side_effect=fake_post), \ + patch("api.kis_api.time.sleep", side_effect=AssertionError("order must not retry")): + with pytest.raises(KISOrderResponseUnknown, match="HTTP 500"): + api._request("POST", "/order", "TR", body={"x": 1}, max_retries=3, idempotent=False) + + assert calls["post"] == 1 + + +@pytest.fixture +def kis_env(monkeypatch): + from config.config_loader import Config + + config = Config.get() + orig = dict(config._settings.get("kis_api", {})) + config._settings.setdefault("kis_api", {}) + config._settings["kis_api"].update({ + "app_key": "PS_audit_5xx_key", + "app_secret": "audit_secret", + "account_no": "12345678-01", + "use_mock": True, + "mock_url": VTS_URL, + "max_retry": 3, + }) + reset_shared_token_cache() + monkeypatch.setattr(KISApi, "_wait_for_token", lambda self: None) + monkeypatch.setattr(KISApi, "_notify_auth_failure", lambda self, message: None) + yield config + reset_shared_token_cache() + config._settings["kis_api"].clear() + config._settings["kis_api"].update(orig) + + +@pytest.mark.parametrize("msg_cd", ["EGW00121", "EGW00123"]) +def test_get_token_error_body_refreshes_token_once(kis_env, msg_cd): + """GET 500 + 토큰 무효 코드는 서킷 실패가 아니라 토큰 재발급 후 재시도로 간다.""" + post_calls, auth_headers = [], [] + + def fake_post(url, json=None, timeout=None, **kw): + post_calls.append(url) + return _Resp(200, {"access_token": f"tok-{len(post_calls)}", "expires_in": 86400}) + + def fake_get(url, headers=None, params=None, timeout=None): + auth_headers.append(headers["authorization"]) + if headers["authorization"] == "Bearer tok-1": + return _Resp(500, {"rt_cd": "1", "msg_cd": msg_cd, "msg1": "유효하지 않은 token 입니다."}) + return _Resp(200, _OK_BODY) + + with patch("api.kis_api.requests.post", side_effect=fake_post), \ + patch("api.kis_api.requests.get", side_effect=fake_get): + quote = KISApi().get_current_price("005930") + + assert quote is not None and quote["price"] == 70000.0 + assert post_calls == [f"{VTS_URL}/oauth2/tokenP"] * 2 + assert auth_headers == ["Bearer tok-1", "Bearer tok-2"] + assert get_breaker().failure_count == 0 diff --git a/tests/test_audit_kis_breaker.py b/tests/test_audit_kis_breaker.py new file mode 100644 index 00000000..2bf37773 --- /dev/null +++ b/tests/test_audit_kis_breaker.py @@ -0,0 +1,169 @@ +"""서킷 브레이커 락·probe 회귀 테스트 (감사: runtime-breaker-alert-io-under-lock). + +1) 발동 알림(Discord·SMTP I/O)을 락을 쥔 채 보내면 그동안 모든 KIS 호출이 멈춘다. +2) HALF_OPEN probe가 429/400/401/403 같은 확정 응답을 받으면 성공도 실패도 아니라 + probe 점유가 풀리지 않아 60초 동안 모든 요청이 이유 없이 막혔다. +네트워크는 전부 모킹한다. +""" + +import threading +import time +from unittest.mock import patch + +import pytest + +from api.circuit_breaker import CircuitBreaker, CircuitState, get_breaker +from api.kis_api import KISApi + + +def _reset(b): + with b._lock: + b.state = CircuitState.CLOSED + b.failure_count = 0 + b.last_failure_time = 0.0 + b._half_open_probe_in_flight = False + b._half_open_probe_started_at = 0.0 + + +@pytest.fixture(autouse=True) +def _reset_singleton_breaker(): + b = get_breaker() + _reset(b) + yield + _reset(b) + + +class _BlockingNotifier: + """send_message가 이벤트가 풀릴 때까지 멈추는 알림(느린 Discord 흉내).""" + + entered = None + release = None + + def __init__(self, *args, **kwargs): + pass + + def send_message(self, *args, **kwargs): + type(self).entered.set() + type(self).release.wait(timeout=5) + + +def test_alert_is_sent_after_releasing_the_lock(monkeypatch): + """발동 알림 전송 중에도 다른 스레드의 can_request()는 즉시 반환된다.""" + _BlockingNotifier.entered = threading.Event() + _BlockingNotifier.release = threading.Event() + monkeypatch.setattr("core.notifier.Notifier", _BlockingNotifier) + + breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60.0) + for _ in range(4): + breaker.on_failure() + + tripping = threading.Thread(target=breaker.on_failure, daemon=True) + tripping.start() + try: + assert _BlockingNotifier.entered.wait(timeout=2), "알림 전송이 시작되지 않음" + result = {} + + def probe(): + started = time.monotonic() + result["allowed"] = breaker.can_request() + result["elapsed"] = time.monotonic() - started + + prober = threading.Thread(target=probe, daemon=True) + prober.start() + prober.join(timeout=1.0) + assert not prober.is_alive(), "알림 전송 동안 can_request()가 락에 막힘" + assert result["elapsed"] < 0.5 + assert result["allowed"] is False + assert breaker.state == CircuitState.OPEN + finally: + _BlockingNotifier.release.set() + tripping.join(timeout=5) + + +def test_release_probe_keeps_half_open_and_allows_next_probe(): + """확정 응답 뒤 release_probe()는 닫지 않고(HALF_OPEN 유지) 다음 probe를 곧바로 허용한다.""" + breaker = CircuitBreaker(failure_threshold=1, recovery_timeout=60.0) + breaker.state = CircuitState.OPEN + breaker.last_failure_time = time.monotonic() - 61.0 + + assert breaker.can_request() is True + assert breaker.state == CircuitState.HALF_OPEN + assert breaker.can_request() is False + + breaker.release_probe() + + assert breaker.state == CircuitState.HALF_OPEN + assert breaker.can_request() is True + assert breaker.can_request() is False + + +def test_release_probe_is_noop_when_closed(): + breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60.0) + breaker.on_failure() + breaker.release_probe() + assert breaker.state == CircuitState.CLOSED + assert breaker.failure_count == 1 + + +def _bare_api(): + api = object.__new__(KISApi) + api.use_mock = True + api.cano = "12345678" + api.acnt_prdt_cd = "01" + api.base_url = "https://example.test" + api._is_configured = lambda: True + api._get_headers = lambda tr_id: {} + api._wait_for_token = lambda: None + api._backoff_with_jitter = lambda *a, **kw: 0.0 + return api + + +class _Resp: + def __init__(self, status_code, payload=None, headers=None): + self.status_code = status_code + self._payload = payload or {} + self.headers = headers or {} + + def json(self): + return self._payload + + def raise_for_status(self): + return None + + +def _open_breaker_ready_for_probe(): + b = get_breaker() + with b._lock: + b.state = CircuitState.OPEN + b.last_failure_time = time.monotonic() - b.recovery_timeout - 1.0 + return b + + +@pytest.mark.parametrize("status_code", [400, 403]) +def test_request_releases_probe_on_definitive_client_error(status_code): + """HALF_OPEN probe가 400/403을 받으면 다음 요청이 60초를 기다리지 않는다.""" + breaker = _open_breaker_ready_for_probe() + api = _bare_api() + + with patch("api.kis_api.requests.get", return_value=_Resp(status_code)): + assert api._request("GET", "/quote", "TR", params={}, max_retries=1) == {} + + assert breaker.state == CircuitState.HALF_OPEN + assert breaker.can_request() is True + + +def test_request_retries_after_429_probe_instead_of_blocking(): + """HALF_OPEN probe의 429 뒤 재시도가 '서킷 동작 — 재시도 중단'으로 끝나지 않는다.""" + breaker = _open_breaker_ready_for_probe() + api = _bare_api() + responses = [ + _Resp(429, headers={"Retry-After": "1"}), + _Resp(200, {"rt_cd": "0", "output": {"ok": True}}), + ] + + with patch("api.kis_api.requests.get", side_effect=lambda *a, **kw: responses.pop(0)), \ + patch("api.kis_api.time.sleep", lambda s: None): + data = api._request("GET", "/quote", "TR", params={}, max_retries=2) + + assert data == {"rt_cd": "0", "output": {"ok": True}} + assert breaker.state == CircuitState.CLOSED diff --git a/tests/test_audit_kis_rate_stats.py b/tests/test_audit_kis_rate_stats.py new file mode 100644 index 00000000..9555bde6 --- /dev/null +++ b/tests/test_audit_kis_rate_stats.py @@ -0,0 +1,117 @@ +"""KIS 사용량 통계 프로세스 공유 회귀 테스트 (감사: runtime-rate-limit-stats-per-instance). + +호출 예산(토큰 버킷·분당 윈도우)은 이미 프로세스 공유였지만 총 요청·429·연결 +오류 카운터는 인스턴스 필드였다. 스케줄러는 통계를 찍을 때마다 새 KISApi를 +만들어서 '누적 0건, 429 0회'만 기록했고, 429 폭주가 로그에 드러나지 않았다. +네트워크는 전부 모킹한다. +""" + +import uuid +from unittest.mock import patch + +import pytest +import requests + +from api.circuit_breaker import CircuitState, get_breaker +from api.kis_api import KISApi, reset_shared_token_cache + +VTS_URL = "https://openapivts.koreainvestment.com:29443" + + +class _Resp: + def __init__(self, status_code=200, payload=None, headers=None): + self.status_code = status_code + self._payload = payload if payload is not None else {} + self.headers = headers or {} + self.ok = 200 <= status_code < 400 + self.text = "" + + def json(self): + return self._payload + + def raise_for_status(self): + if self.status_code >= 400: + raise requests.HTTPError(f"HTTP {self.status_code}") + + +def _reset_breaker(): + b = get_breaker() + with b._lock: + b.state = CircuitState.CLOSED + b.failure_count = 0 + b._half_open_probe_in_flight = False + b._half_open_probe_started_at = 0.0 + + +@pytest.fixture +def kis_env(): + from config.config_loader import Config + + config = Config.get() + orig = dict(config._settings.get("kis_api", {})) + config._settings.setdefault("kis_api", {}) + # 호출 예산 레지스트리는 프로세스 전역이라, 다른 테스트의 누적치와 섞이지 않게 + # 매번 새 app_key로 새 항목을 만든다. + config._settings["kis_api"].update({ + "app_key": f"PS_audit_stats_{uuid.uuid4().hex[:8]}", + "app_secret": "audit_secret", + "account_no": "12345678-01", + "use_mock": True, + "mock_url": VTS_URL, + "max_retry": 3, + }) + reset_shared_token_cache() + _reset_breaker() + yield config + reset_shared_token_cache() + _reset_breaker() + config._settings["kis_api"].clear() + config._settings["kis_api"].update(orig) + + +def test_counters_are_shared_across_instances(kis_env): + """인스턴스 A가 겪은 요청·429가 새 인스턴스 B의 통계에 보인다.""" + responses = [ + _Resp(429, headers={"Retry-After": "1"}), + _Resp(200, {"rt_cd": "0", "output": {"stck_prpr": "70000"}}), + ] + + def fake_get(url, headers=None, params=None, timeout=None): + return responses.pop(0) + + token = _Resp(200, {"access_token": "tok-stats", "expires_in": 86400}) + with patch("api.kis_api.requests.post", return_value=token), \ + patch("api.kis_api.requests.get", side_effect=fake_get), \ + patch("api.kis_api.time.sleep", lambda s: None): + a = KISApi() + assert a.get_current_price("005930") is not None + + stats = KISApi().get_rate_limit_stats() + assert stats["total_requests"] == 2 + assert stats["total_429s"] == 1 + assert stats["total_conn_errors"] == 0 + assert stats["requests_last_60s"] == 2 + + +def test_connection_errors_are_counted_process_wide(kis_env): + """연결 오류 누적도 새 인스턴스에서 그대로 보인다.""" + def fake_get(url, headers=None, params=None, timeout=None): + raise requests.exceptions.ConnectionError("RST") + + token = _Resp(200, {"access_token": "tok-stats", "expires_in": 86400}) + with patch("api.kis_api.requests.post", return_value=token), \ + patch("api.kis_api.requests.get", side_effect=fake_get), \ + patch("api.kis_api.time.sleep", lambda s: None): + assert KISApi()._request("GET", "/quote", "TR", params={}, max_retries=2) == {} + + assert KISApi().get_rate_limit_stats()["total_conn_errors"] == 2 + + +def test_bare_instance_counts_on_itself(): + """_rate_state가 없는 테스트 더블은 인스턴스 속성으로 센다(공유 상태 오염 없음).""" + api = object.__new__(KISApi) + api._total_429s = 0 + + assert api._bump_usage_counter("total_429s") == 1 + assert api._bump_usage_counter("total_429s") == 2 + assert api._total_429s == 2 diff --git a/tests/test_audit_kis_token.py b/tests/test_audit_kis_token.py new file mode 100644 index 00000000..6a22e6d1 --- /dev/null +++ b/tests/test_audit_kis_token.py @@ -0,0 +1,254 @@ +"""KIS 접근 토큰 프로세스 공유 회귀 테스트 (감사: runtime-kis-token-not-shared). + +KIS는 토큰 발급을 1분당 1회로 제한한다(EGW00133). 예전에는 KISApi 인스턴스마다 +토큰을 따로 발급해, live 한 사이클(동기화·잔고 요약·매수마다 새 인스턴스)에서 +두 번째 발급부터 거절되고 잔고 확인 실패 → live 매수 전면 보류 → 인증 실패 알림 +폭주로 이어졌다. 이제 (base_url, app_key)별 공유 토큰 하나를 쓰고, 발급 실패는 +공유 60초 쿨다운으로 재발급·알림을 한 번으로 모은다. + +네트워크는 전부 모킹한다 — 실제 KIS 엔드포인트를 부르지 않는다. +""" + +import json +import time +from unittest.mock import patch + +import pytest +import requests + +from api.circuit_breaker import CircuitState, get_breaker +from api.kis_api import KISApi, reset_shared_token_cache + +VTS_URL = "https://openapivts.koreainvestment.com:29443" +REAL_URL = "https://openapi.koreainvestment.com:9443" + +_PRICE_BODY = { + "rt_cd": "0", + "output": { + "stck_prpr": "70000", + "stck_oprc": "69000", + "stck_hgpr": "71000", + "stck_lwpr": "68000", + "acml_vol": "1000", + "prdy_ctrt": "1.0", + "stck_sdpr": "69300", + }, +} + + +class _Resp: + def __init__(self, status_code=200, payload=None, headers=None): + self.status_code = status_code + self._payload = payload if payload is not None else {} + self.headers = headers or {} + self.ok = 200 <= status_code < 400 + self.text = json.dumps(self._payload, ensure_ascii=False) + + def json(self): + return self._payload + + def raise_for_status(self): + if self.status_code >= 400: + raise requests.HTTPError(f"HTTP {self.status_code}") + + +def _reset_breaker(): + b = get_breaker() + with b._lock: + b.state = CircuitState.CLOSED + b.failure_count = 0 + b._half_open_probe_in_flight = False + b._half_open_probe_started_at = 0.0 + + +@pytest.fixture +def kis_env(monkeypatch): + """KIS가 '설정됨'으로 보이게 하고 토큰 캐시·서킷·레이트리밋 대기를 격리한다.""" + from config.config_loader import Config + + config = Config.get() + orig = dict(config._settings.get("kis_api", {})) + config._settings.setdefault("kis_api", {}) + config._settings["kis_api"].update({ + "app_key": "PS_audit_token_key", + "app_secret": "audit_secret", + "account_no": "12345678-01", + "use_mock": True, + "mock_url": VTS_URL, + "base_url": REAL_URL, + "max_retry": 2, + }) + reset_shared_token_cache() + _reset_breaker() + monkeypatch.setattr(KISApi, "_wait_for_token", lambda self: None) + alerts = [] + monkeypatch.setattr( + KISApi, "_notify_auth_failure", lambda self, message: alerts.append(message) + ) + yield {"config": config, "alerts": alerts} + reset_shared_token_cache() + _reset_breaker() + config._settings["kis_api"].clear() + config._settings["kis_api"].update(orig) + + +def _token_post_factory(post_calls, *, status=200): + def fake_post(url, json=None, timeout=None, **kwargs): + post_calls.append(url) + if status != 200: + return _Resp(status, { + "error_code": "EGW00133", + "error_description": "접근토큰 발급 잠시 후 다시 시도하세요(1분당 1회)", + }) + return _Resp(200, {"access_token": f"tok-{len(post_calls)}", "expires_in": 86400}) + + return fake_post + + +def test_instances_share_one_token_issuance(kis_env): + """인스턴스 3개가 각각 시세를 조회해도 토큰 발급은 1번이다.""" + post_calls, auth_headers = [], [] + + def fake_get(url, headers=None, params=None, timeout=None): + auth_headers.append(headers["authorization"]) + return _Resp(200, _PRICE_BODY) + + with patch("api.kis_api.requests.post", side_effect=_token_post_factory(post_calls)), \ + patch("api.kis_api.requests.get", side_effect=fake_get): + for _ in range(3): + quote = KISApi().get_current_price("005930") + assert quote is not None and quote["price"] == 70000.0 + + assert post_calls == [f"{VTS_URL}/oauth2/tokenP"] + assert auth_headers == ["Bearer tok-1"] * 3 + + +def test_explicit_authenticate_reuses_valid_shared_token(kis_env): + """executor 생성·live 시작 사전 발급처럼 authenticate()를 직접 불러도 재발급하지 않는다.""" + post_calls = [] + with patch("api.kis_api.requests.post", side_effect=_token_post_factory(post_calls)): + first = KISApi() + assert first.authenticate() is True + second = KISApi() + # 새 인스턴스는 생성 시점에 이미 공유 토큰을 미러로 들고 있다(헬스체크· + # order_executor가 읽는 _access_token 호환). + assert second._access_token == "tok-1" + assert second.authenticate() is True + + assert len(post_calls) == 1 + assert second._access_token == first._access_token == "tok-1" + + +def test_failed_issuance_sets_shared_cooldown_and_alerts_once(kis_env): + """발급 실패는 공유 쿨다운 — 다른 인스턴스도 재발급·알림·'Bearer None' 요청을 하지 않는다.""" + post_calls, get_calls = [], [] + + def fake_get(url, headers=None, params=None, timeout=None): + get_calls.append(headers.get("authorization")) + return _Resp(200, _PRICE_BODY) + + with patch( + "api.kis_api.requests.post", + side_effect=_token_post_factory(post_calls, status=403), + ), patch("api.kis_api.requests.get", side_effect=fake_get): + a, b = KISApi(), KISApi() + assert a.get_current_price("005930") is None + assert b.get_current_price("005930") is None + assert b.authenticate() is False + + assert len(post_calls) == 1 + assert len(kis_env["alerts"]) == 1 + assert "EGW00133" in kis_env["alerts"][0] or "1분당 1회" in kis_env["alerts"][0] + assert get_calls == [] # 토큰 없이 서버를 때리지 않는다 + status = b.token_status() + assert status["valid"] is False + assert status["cooldown_remaining"] > 0 + assert "HTTP 403" in status["last_error"] + assert a.get_rate_limit_stats()["token_cooldown_active"] is True + + +def test_success_clears_shared_failure_state(kis_env): + """쿨다운이 끝난 뒤 다른 인스턴스가 발급에 성공하면 실패 상태가 즉시 사라진다.""" + post_calls = [] + with patch( + "api.kis_api.requests.post", + side_effect=_token_post_factory(post_calls, status=403), + ): + failing = KISApi() + assert failing.authenticate() is False + + # 쿨다운 만료를 흉내 낸다(공유 상태의 종료 시각을 과거로). + failing._get_token_state()["error_until"] = time.monotonic() - 1.0 + + with patch("api.kis_api.requests.post", side_effect=_token_post_factory(post_calls)): + other = KISApi() + assert other.authenticate() is True + + status = failing.token_status() + assert status["valid"] is True + assert status["cooldown_remaining"] == 0.0 + assert status["last_error"] == "" + assert failing._token_error_until == 0.0 + + +def test_mock_and_real_domains_never_share_a_token(kis_env): + """모의(VTS)와 실전은 base_url이 달라 토큰을 절대 공유하지 않는다.""" + post_calls = [] + config = kis_env["config"] + with patch("api.kis_api.requests.post", side_effect=_token_post_factory(post_calls)): + vts = KISApi() + assert vts.authenticate() is True + config._settings["kis_api"]["use_mock"] = False + real = KISApi() + assert real._access_token is None + assert real.authenticate() is True + + assert post_calls == [f"{VTS_URL}/oauth2/tokenP", f"{REAL_URL}/oauth2/tokenP"] + assert vts._access_token == "tok-1" + assert real._access_token == "tok-2" + + +def test_401_refresh_is_single_flight_across_instances(kis_env): + """401은 거절된 토큰만 폐기하고 한 번 재발급한다. 이미 갱신됐으면 재발급하지 않는다.""" + post_calls, auth_headers = [], [] + + def fake_get(url, headers=None, params=None, timeout=None): + auth_headers.append(headers["authorization"]) + if headers["authorization"] == "Bearer tok-1": + return _Resp(401, {"rt_cd": "1", "msg1": "기간이 만료된 token 입니다."}) + return _Resp(200, _PRICE_BODY) + + with patch("api.kis_api.requests.post", side_effect=_token_post_factory(post_calls)), \ + patch("api.kis_api.requests.get", side_effect=fake_get): + a, b = KISApi(), KISApi() + assert a.authenticate() is True + assert b.authenticate() is True + assert a.get_current_price("005930") is not None + # b는 옛 토큰(tok-1)으로 401을 받은 상황 — 이미 a가 갱신했으므로 발급 없이 채택. + assert b._acquire_token(rejected_token="tok-1") is True + + assert len(post_calls) == 2 + assert auth_headers == ["Bearer tok-1", "Bearer tok-2"] + assert b._access_token == "tok-2" + + +def test_token_status_never_issues_a_token(kis_env): + """헬스체크용 상태 조회는 발급 요청을 만들지 않는다.""" + with patch( + "api.kis_api.requests.post", + side_effect=AssertionError("token_status must not issue a token"), + ): + status = KISApi().token_status() + + assert status["has_token"] is False + assert status["valid"] is False + assert status["cooldown_remaining"] == 0.0 + + +def test_bare_instance_does_not_touch_shared_registry(kis_env): + """__init__을 거치지 않은 테스트 더블은 공유 쿨다운을 오염시키지 않는다.""" + bare = object.__new__(KISApi) + bare._token_error_until = time.monotonic() + 600 + + assert KISApi()._token_error_until == 0.0 + assert bare._token_error_until > time.monotonic() diff --git a/tests/test_audit_reporting.py b/tests/test_audit_reporting.py new file mode 100644 index 00000000..5800ef9a --- /dev/null +++ b/tests/test_audit_reporting.py @@ -0,0 +1,117 @@ +"""리포트 지표 회귀 테스트 (2026-09-23 점검). + +입금을 달력 날짜로 묶으면, 그날 10:07 스냅샷 뒤에 들어온 입금이나 주말 입금이 +엉뚱한 구간에 들어가 그 구간이 +25~36% 수익으로 잡힌다(#461과 같은 증상이 적립 +주기마다 재발). 입금이 처음 반영된 스냅샷 구간으로 묶어야 한다. +""" + +from datetime import date, datetime +from types import SimpleNamespace + +import pytest + + +def _snap(d, created, value): + return SimpleNamespace(date=datetime.combine(d, datetime.min.time()), + created_at=created, total_value=value) + + +def _flows(monkeypatch, rows): + monkeypatch.setattr("database.repositories.get_cash_flows", + lambda account_key, mode="paper": rows) + + +def test_weekend_deposit_is_absorbed_by_next_snapshot(monkeypatch): + import main + from core.performance_lens import daily_returns_from_nav + + snaps = [ + _snap(date(2026, 9, 25), datetime(2026, 9, 25, 10, 7), 300_000), + _snap(date(2026, 9, 28), datetime(2026, 9, 28, 10, 7), 400_800), + ] + _flows(monkeypatch, [(datetime(2026, 9, 26, 11, 0), 100_000.0)]) # 토요일 입금 + + flows = main.account_flows_by_snapshot("acct", snaps) + assert flows == {date(2026, 9, 28): 100_000.0} + rets = daily_returns_from_nav([(s.date, s.total_value) for s in snaps], flows=flows) + assert rets[-1][1] == pytest.approx(0.2, abs=1e-6) # 0.8천 원 수익만 남는다(+0.2%) + + +def test_deposit_after_same_day_snapshot_goes_to_next_interval(monkeypatch): + """8/26 실제 사례: 10:07 스냅샷 뒤 17:19 입금 — 날짜로 묶으면 8/26이 -26%, 8/27이 +36%.""" + import main + from core.performance_lens import daily_returns_from_nav + + snaps = [ + _snap(date(2026, 8, 25), datetime(2026, 8, 25, 10, 7), 280_718), + _snap(date(2026, 8, 26), datetime(2026, 8, 26, 10, 7), 284_499), + _snap(date(2026, 8, 27), datetime(2026, 8, 27, 10, 7), 385_460), + ] + _flows(monkeypatch, [(datetime(2026, 8, 26, 17, 19), 100_000.0)]) + + flows = main.account_flows_by_snapshot("acct", snaps) + assert flows == {date(2026, 8, 27): 100_000.0} + rets = [r for _, r in daily_returns_from_nav([(s.date, s.total_value) for s in snaps], flows=flows)] + assert all(abs(r) < 2.0 for r in rets), rets # 가짜 ±30%대 하루가 없어야 한다 + + +def test_flow_after_last_snapshot_is_not_yet_counted(monkeypatch): + import main + + snaps = [_snap(date(2026, 9, 22), datetime(2026, 9, 22, 10, 7), 300_000)] + _flows(monkeypatch, [(datetime(2026, 9, 22, 18, 0), 100_000.0)]) + assert main.account_flows_by_snapshot("acct", snaps) == {} + + +def test_weekly_summary_does_not_call_reconstructed_week_accident_free(): + from core.weekly_report import build_weekly_summary + + base = dict(basket_name="t", eval_result={"verdict": "WAIT", "metrics": {}}, + missing_days=0, cycle_errors=0) + clean = build_weekly_summary(**base) + restored = build_weekly_summary(**base, reconstructed_days=1) + ev = {f["name"]: f["value"] for f in restored["fields"]}["🛠 주간 이벤트"] + assert "무사고" in {f["name"]: f["value"] for f in clean["fields"]}["🛠 주간 이벤트"] + assert "무사고" not in ev and "나중에 채운 기록 1일" in ev + + +def test_regime_note_is_shown(): + from core.weekly_report import build_weekly_summary + + regime = {"up": {"days": 3, "capture": 0.5, "bench_pct": 2.0, "mine_pct": 1.0}, + "down": {"days": 2, "capture": 0.4, "bench_pct": -2.0, "mine_pct": -0.8}} + out = build_weekly_summary(basket_name="t", eval_result={"verdict": "WAIT", "metrics": {}}, + regime=regime, regime_note="근사 — 지수 시가 기준") + val = {f["name"]: f["value"] for f in out["fields"]}["🌗 국면 분해"] + assert "근사" in val + + +def test_sharpe_label_states_risk_free_rate(): + from core.performance_lens import format_risk_line + + line = format_risk_line({"samples": 30, "vol_annual_pct": 10.0, "sharpe_annual": 0.5, + "down_day_ratio": 0.4, "worst_day_pct": -2.0}) + assert "샤프(금리 0% 기준)" in line + + +def test_daily_card_renders_risk_field(monkeypatch): + from core.notifier import Notifier + + sent = {} + n = Notifier.__new__(Notifier) + monkeypatch.setattr(n, "send_embed", lambda title, desc, **kw: sent.update(kw), raising=False) + n.send_daily_report({"total_value": 1, "risk": "연변동성 10.0% · 샤프(금리 0% 기준) +0.50"}) + names = [f["name"] for f in sent["fields"]] + assert "📉 리스크" in names + + +def test_deploy_cost_estimate_exempts_etf_sell_tax(): + from core.basket_deploy import estimate_order_costs + + orders = [SimpleNamespace(action="SELL", symbol="069500", quantity=1), + SimpleNamespace(action="SELL", symbol="005930", quantity=1)] + prices = {"069500": 100_000, "005930": 100_000} + with_exempt = estimate_order_costs(orders, prices, tax_exempt_symbols=["069500"]) + without = estimate_order_costs(orders, prices) + assert with_exempt["est_tax"] == pytest.approx(200) # 005930만 0.20% + assert without["est_tax"] == pytest.approx(400) diff --git a/tests/test_audit_research.py b/tests/test_audit_research.py new file mode 100644 index 00000000..c449964b --- /dev/null +++ b/tests/test_audit_research.py @@ -0,0 +1,51 @@ +"""연구 도구 회귀 테스트 (2026-09-23). + +관찰 트랙 오버레이 결론은 운영 트랙과 같은 조건(보유 종목·재조정 규칙·현금 이자)으로 +내야 한다. 하이닉스가 든 10종목·현금 3% 표로 9종목·무이자 트랙의 정책을 정했다. +""" + +import pandas as pd +import pytest + +from tools import risk_overlay_backtest as research + + +def test_basket_symbols_come_from_config_not_the_old_list(): + symbols = research.configured_basket_symbols() + assert "000660" not in symbols + assert len(symbols) == 9 + + +def test_sleeve_is_rebalanced_when_a_name_drifts(): + idx = pd.date_range("2024-01-01", periods=4, freq="D") + # A만 두 배가 되면 동일비중 슬리브에서 A 비중이 50%→67%로 벌어진다(>8%p) → 재조정 + panel = pd.DataFrame({"A": [1.0, 2.0, 2.0, 1.0], "B": [1.0, 1.0, 1.0, 1.0]}, index=idx) + held = (panel / panel.iloc[0]).mean(axis=1) # 첫날 비중을 들고만 간 경우 + rebal = research.rebalanced_ew_index(panel, cost_rate=0.0) + # 재조정하면 A가 반토막 날 때 손실이 작다(보유만 하면 A 비중이 커진 채로 맞는다) + assert rebal.iloc[-1] > held.iloc[-1] + + +def test_sharpe_uses_given_risk_free_rate(): + idx = pd.date_range("2024-01-01", periods=300, freq="B") + daily = pd.Series(0.0005, index=idx) + daily.iloc[::7] = -0.001 + twr = (1 + daily).cumprod() + frame = pd.DataFrame({ + "twr": twr, "daily_return": daily, "drawdown": twr / twr.cummax() - 1, + "total": twr * 1e6, "contributed": 1e6, + }) + extra = {"turnover_value": 0.0, "avg_exposure": 0.6} + s0 = research.metrics(frame, extra, rf_annual=0.0)["sharpe"] + s3 = research.metrics(frame, extra)["sharpe"] + assert s0 > s3 + + +def test_capture_uses_daily_average_not_compounded_regime_return(): + """상승일만 몇 년치 복리로 이으면 지수 수익이 폭증해 포착률이 0 쪽으로 쏠린다.""" + from core.performance_lens import split_by_regime + + pairs = [(0.6, 1.0)] * 600 + [(-0.6, -1.0)] * 600 + r = split_by_regime(pairs) + assert r["up"]["capture"] == pytest.approx(0.6, abs=0.01) + assert r["down"]["capture"] == pytest.approx(0.6, abs=0.01) diff --git a/tests/test_audit_runtime_basket_owner.py b/tests/test_audit_runtime_basket_owner.py new file mode 100644 index 00000000..8671e7ee --- /dev/null +++ b/tests/test_audit_runtime_basket_owner.py @@ -0,0 +1,128 @@ +"""스케줄러는 바스켓을 거래하지 않는다 (감사: runtime-scheduler-basket-premarket-divergent). + +예전 스케줄러는 장전(08:50~09:00)에 바스켓 리밸런싱을 실행했다. live 주문은 거래 +시간 가드에 전부 거부되고, paper는 전일 종가로 체결됐으며, CLI 사이클의 손절·하루 +1회 거래 가드·스냅샷 보충이 빠져 있었다. 이제 바스켓 실행 경로는 일일 CLI +(main.py --mode rebalance) 하나뿐이고, 스케줄러는 그 사실을 로그로만 남긴다. +""" + +from datetime import datetime +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock + +import numpy as np +import pandas as pd +import pytest +from loguru import logger + + +def _sample_ohlcv(days=60): + rng = np.random.default_rng(7) + dates = pd.date_range(end=datetime.now(), periods=days, freq="B") + prices = 50000 * np.cumprod(1 + rng.normal(0.0002, 0.01, days)) + return pd.DataFrame({ + "open": prices, "high": prices * 1.01, "low": prices * 0.99, + "close": prices, "volume": rng.integers(100_000, 1_000_000, days), + }, index=dates) + + +@pytest.fixture +def captured_warnings(): + messages = [] + sink_id = logger.add(lambda m: messages.append(m.record["message"]), level="WARNING") + yield messages + logger.remove(sink_id) + + +def test_pre_market_never_builds_or_executes_a_basket(monkeypatch, captured_warnings): + """live 모드 장전 단계에서도 BasketRebalancer를 만들지 않고, 실행 경로를 로그로 알린다.""" + from core.scheduler import Scheduler + + constructed = [] + + class FakeRebalancer: + @staticmethod + def get_enabled_baskets(): + return ["kr_diversified_hold", "kr_pocket"] + + def __init__(self, *args, **kwargs): + constructed.append((args, kwargs)) + + sample = _sample_ohlcv() + + class FakeCollector: + def fetch_stock(self, symbol, start=None, end=None): + return sample.copy() + + def check_source_consistency(self, mode="paper"): + return [] + + def has_kis_fallback_symbols(self): + return [] + + class FakeStrategy: + def generate_signal(self, df, symbol=None): + return {"signal": "HOLD", "score": 0, "close": float(df["close"].iloc[-1])} + + monkeypatch.setattr("core.basket_rebalancer.BasketRebalancer", FakeRebalancer) + monkeypatch.setattr("core.data_collector.DataCollector", FakeCollector) + monkeypatch.setattr( + "core.market_regime.check_market_regime", + lambda config, collector=None: {"allow_buys": True, "position_scale": 1.0}, + ) + monkeypatch.setattr( + "core.scheduler.WatchlistManager", + lambda config: SimpleNamespace(resolve=lambda: ["005930"]), + ) + + scheduler = Scheduler(strategy_name="scoring") + scheduler._mode = "live" + scheduler._live_gate_validated = True + scheduler.discord = MagicMock() + scheduler._get_strategy = lambda: FakeStrategy() + scheduler._maybe_record_dashboard_signal = lambda *a, **kw: None + + scheduler._run_pre_market() + + assert constructed == [] + owner_logs = [m for m in captured_warnings if "--mode rebalance" in m] + assert len(owner_logs) == 1 + assert "kr_diversified_hold" in owner_logs[0] and "kr_pocket" in owner_logs[0] + assert "거래하지 않습니다" in owner_logs[0] + + +def test_no_enabled_baskets_logs_nothing(monkeypatch, captured_warnings): + from core.scheduler import Scheduler + + class FakeRebalancer: + @staticmethod + def get_enabled_baskets(): + return [] + + monkeypatch.setattr("core.basket_rebalancer.BasketRebalancer", FakeRebalancer) + Scheduler.__new__(Scheduler)._log_basket_execution_owner() + + assert not [m for m in captured_warnings if "--mode rebalance" in m] + + +def test_scheduler_has_no_basket_execution_path(): + """스케줄러 소스에 바스켓 주문 실행 경로가 되살아나지 않게 고정한다.""" + from core.scheduler import Scheduler + + assert not hasattr(Scheduler, "_run_basket_rebalance_check") + source = (Path(__file__).resolve().parents[1] / "core" / "scheduler.py").read_text( + encoding="utf-8" + ) + for forbidden in ("plan_rebalance(", ".execute(orders", "save_daily_nav_snapshot("): + assert forbidden not in source + + +def test_baskets_yaml_header_names_the_cli_as_only_path(): + header = (Path(__file__).resolve().parents[1] / "config" / "baskets.yaml").read_text( + encoding="utf-8" + ).split("baskets:", 1)[0] + + assert "스케줄러 장전 단계에서 실행" not in header + assert "python main.py --mode rebalance" in header + assert "바스켓을 거래하지 않습니다" in header diff --git a/tests/test_audit_runtime_healthcheck.py b/tests/test_audit_runtime_healthcheck.py new file mode 100644 index 00000000..935b50f5 --- /dev/null +++ b/tests/test_audit_runtime_healthcheck.py @@ -0,0 +1,118 @@ +"""스케줄러 헬스체크 회귀 테스트 (감사: runtime-healthcheck-always-fails). + +예전 헬스체크는 SQLAlchemy 2.0에서 문자열 SQL + Session.remove()로 정상 DB에서도 +항상 'DB 연결 실패'를, live에서는 새 KISApi의 빈 토큰을 보고 '토큰 없음'을 냈다. +10분마다 critical 알림이 가서 진짜 장애와 구분할 수 없었다. +DB는 conftest가 격리한 임시 DB를 쓰고, KIS 네트워크는 호출하지 않는다. +""" + +import sys +import time +import types +from collections import namedtuple +from datetime import datetime, timedelta +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from api.kis_api import KISApi, reset_shared_token_cache + +VTS_URL = "https://openapivts.koreainvestment.com:29443" +_DiskUsage = namedtuple("_DiskUsage", "total used free") + + +@pytest.fixture +def healthy_host(monkeypatch): + """디스크·메모리 검사가 실행 환경 상태에 좌우되지 않게 고정한다.""" + monkeypatch.setattr( + "core.scheduler.shutil.disk_usage", + lambda path: _DiskUsage(500 * 1024 ** 3, 100 * 1024 ** 3, 400 * 1024 ** 3), + ) + fake_psutil = types.ModuleType("psutil") + fake_psutil.virtual_memory = lambda: SimpleNamespace(percent=10.0) + monkeypatch.setitem(sys.modules, "psutil", fake_psutil) + + +def _scheduler(mode="paper"): + from config.config_loader import Config + from core.scheduler import Scheduler + + scheduler = Scheduler.__new__(Scheduler) + scheduler.strategy_name = "scoring" + scheduler.config = SimpleNamespace( + database=Config.get().database, + trading={"mode": mode}, + ) + return scheduler + + +@pytest.fixture +def kis_env(): + from config.config_loader import Config + + config = Config.get() + orig = dict(config._settings.get("kis_api", {})) + config._settings.setdefault("kis_api", {}) + config._settings["kis_api"].update({ + "app_key": "PS_audit_health_key", + "app_secret": "audit_secret", + "account_no": "12345678-01", + "use_mock": True, + "mock_url": VTS_URL, + }) + reset_shared_token_cache() + yield config + reset_shared_token_cache() + config._settings["kis_api"].clear() + config._settings["kis_api"].update(orig) + + +def test_paper_healthcheck_is_clean_on_healthy_db(healthy_host): + assert _scheduler("paper")._run_healthcheck() == [] + + +def test_db_failure_is_reported_exactly(healthy_host, monkeypatch): + def boom(): + raise RuntimeError("db unreachable") + + monkeypatch.setattr("database.models.get_session", boom) + + assert _scheduler("paper")._run_healthcheck() == ["DB 연결 실패: db unreachable"] + + +def _no_token_issuance(): + return patch( + "api.kis_api.requests.post", + side_effect=AssertionError("healthcheck must not issue a KIS token"), + ) + + +def test_live_healthcheck_accepts_shared_token_without_issuing(healthy_host, kis_env): + state = KISApi()._get_token_state() + with state["lock"]: + state["access_token"] = "tok-shared" + state["expires_at"] = datetime.now() + timedelta(hours=1) + + with _no_token_issuance(): + assert _scheduler("live")._run_healthcheck() == [] + + +def test_live_healthcheck_before_first_request_is_not_an_issue(healthy_host, kis_env): + """첫 KIS 요청 전(토큰 발급 전)은 정상 — 10분마다 '토큰 없음' 오경보를 내지 않는다.""" + with _no_token_issuance(): + assert _scheduler("live")._run_healthcheck() == [] + + +def test_live_healthcheck_reports_failed_issuance(healthy_host, kis_env): + state = KISApi()._get_token_state() + with state["lock"]: + state["error_until"] = time.monotonic() + 30 + state["last_error"] = "HTTP 403 / 접근토큰 발급 잠시 후 다시 시도하세요(1분당 1회)" + + with _no_token_issuance(): + issues = _scheduler("live")._run_healthcheck() + + assert len(issues) == 1 + assert issues[0].startswith("KIS API 토큰 발급 실패 상태 — HTTP 403") + assert "재발급 억제" in issues[0] diff --git a/tests/test_audit_runtime_holidays.py b/tests/test_audit_runtime_holidays.py new file mode 100644 index 00000000..643919aa --- /dev/null +++ b/tests/test_audit_runtime_holidays.py @@ -0,0 +1,165 @@ +"""스케줄러 휴장일 자동 갱신 호출부 회귀 테스트 (감사: runtime-holidays-autoupdate-reverts-verified-calendar). + +스케줄러는 holidays.yaml을 실행 위치(CWD) 기준으로 찾아, 다른 폴더에서 띄우면 파일이 +'없다'고 보고 매일 갱신했다. 그리고 pykrx 휴장일 조회가 안 되는 환경(현재 설치 버전에 +get_market_trading_date_by_date 없음)에서는 갱신기가 대체 목록으로 검증된 달력을 +덮어쓸 수 있었다. 이 테스트는 호출부만 고정한다(갱신기 자체 수정은 별도). +실제 holidays.yaml은 건드리지 않는다 — 갱신 함수는 전부 대역이다. +""" + +import os +import sys +import time +import types +from datetime import datetime +from pathlib import Path + +import pytest +from loguru import logger + +import core.scheduler as scheduler_mod + + +@pytest.fixture +def captured_warnings(): + messages = [] + sink_id = logger.add(lambda m: messages.append(m.record["message"]), level="WARNING") + yield messages + logger.remove(sink_id) + + +@pytest.fixture +def update_calls(monkeypatch): + calls = [] + + def fake_update(path=None, **kwargs): + calls.append(path) + return path + + monkeypatch.setattr("core.holidays_updater.update_holidays_yaml", fake_update) + return calls + + +def _scheduler(monkeypatch): + s = scheduler_mod.Scheduler.__new__(scheduler_mod.Scheduler) + s.config = None + s.rebuilt = [] + monkeypatch.setattr(scheduler_mod, "TradingHours", lambda config: s.rebuilt.append(config) or "th") + s.trading_hours = "old" + return s + + +def _stale_file(tmp_path, days_old=100): + path = tmp_path / "holidays.yaml" + path.write_text("holidays: []\n", encoding="utf-8") + old = time.time() - days_old * 86400 + os.utime(path, (old, old)) + return path + + +def test_holidays_path_is_resolved_from_project_root(): + project_root = Path(scheduler_mod.__file__).resolve().parents[1] + assert scheduler_mod._HOLIDAYS_PATH == project_root / "config" / "holidays.yaml" + assert scheduler_mod._HOLIDAYS_PATH.is_absolute() + + +def test_other_working_directory_does_not_trigger_update( + monkeypatch, tmp_path, update_calls +): + """다른 폴더에서 실행해도 최신 달력 파일을 찾아 갱신하지 않는다.""" + fresh = tmp_path / "project" / "holidays.yaml" + fresh.parent.mkdir() + fresh.write_text("holidays: []\n", encoding="utf-8") + monkeypatch.setattr(scheduler_mod, "_HOLIDAYS_PATH", fresh) + monkeypatch.setattr(scheduler_mod, "_pykrx_holiday_source_available", lambda: (True, "")) + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + monkeypatch.chdir(elsewhere) + + s = _scheduler(monkeypatch) + s._maybe_update_holidays() + + assert update_calls == [] + assert s.trading_hours == "old" + + +def test_stale_calendar_is_not_overwritten_when_pykrx_unavailable( + monkeypatch, tmp_path, update_calls, captured_warnings +): + stale = _stale_file(tmp_path) + monkeypatch.setattr(scheduler_mod, "_HOLIDAYS_PATH", stale) + monkeypatch.setattr( + scheduler_mod, + "_pykrx_holiday_source_available", + lambda: (False, "pykrx.stock.get_market_trading_date_by_date 없음"), + ) + + s = _scheduler(monkeypatch) + s._maybe_update_holidays() + + assert update_calls == [] + assert s.rebuilt == [] + assert any("휴장일 자동 갱신 생략" in m for m in captured_warnings) + + +def test_stale_calendar_updates_project_root_path_when_pykrx_available( + monkeypatch, tmp_path, update_calls +): + stale = _stale_file(tmp_path) + monkeypatch.setattr(scheduler_mod, "_HOLIDAYS_PATH", stale) + monkeypatch.setattr(scheduler_mod, "_pykrx_holiday_source_available", lambda: (True, "")) + + s = _scheduler(monkeypatch) + s._maybe_update_holidays() + + assert update_calls == [stale] + assert s.rebuilt == [None] + assert s.trading_hours == "th" + + +@pytest.mark.parametrize("pykrx_ok, expected_calls", [(False, 0), (True, 1)]) +def test_new_year_trigger_also_respects_pykrx_availability( + monkeypatch, tmp_path, update_calls, pykrx_ok, expected_calls +): + """연초(1/1~1/7) 트리거도 pykrx를 쓸 수 없으면 대체 목록으로 덮어쓰지 않는다.""" + last_year = tmp_path / "holidays.yaml" + last_year.write_text("holidays: []\n", encoding="utf-8") + december = datetime(2026, 12, 30, 12, 0).timestamp() + os.utime(last_year, (december, december)) + monkeypatch.setattr(scheduler_mod, "_HOLIDAYS_PATH", last_year) + monkeypatch.setattr( + scheduler_mod, "_pykrx_holiday_source_available", lambda: (pykrx_ok, "" if pykrx_ok else "없음"), + ) + + class _NewYear(datetime): + @classmethod + def now(cls, tz=None): + return datetime(2027, 1, 2, 8, 0) + + monkeypatch.setattr(scheduler_mod, "datetime", _NewYear) + + _scheduler(monkeypatch)._maybe_update_holidays() + + assert len(update_calls) == expected_calls + + +def _fake_pykrx(monkeypatch, *, with_api): + stock = types.ModuleType("pykrx.stock") + if with_api: + stock.get_market_trading_date_by_date = lambda start, end: None + package = types.ModuleType("pykrx") + package.stock = stock + monkeypatch.setitem(sys.modules, "pykrx", package) + monkeypatch.setitem(sys.modules, "pykrx.stock", stock) + + +def test_pykrx_probe_detects_missing_trading_date_api(monkeypatch): + _fake_pykrx(monkeypatch, with_api=False) + available, reason = scheduler_mod._pykrx_holiday_source_available() + assert available is False + assert "get_market_trading_date_by_date" in reason + + +def test_pykrx_probe_accepts_available_api(monkeypatch): + _fake_pykrx(monkeypatch, with_api=True) + assert scheduler_mod._pykrx_holiday_source_available() == (True, "") diff --git a/tests/test_audit_runtime_live_readiness.py b/tests/test_audit_runtime_live_readiness.py new file mode 100644 index 00000000..decd4df3 --- /dev/null +++ b/tests/test_audit_runtime_live_readiness.py @@ -0,0 +1,163 @@ +"""live 게이트 데이터 소스 신선도 회귀 테스트 (감사: runtime-live-readiness-datasource-check-fixed-window). + +예전 게이트는 005930의 고정 구간(2026-01-01~03-26)만 받아 봐서, 과거 이력은 주지만 +갱신이 멈춘 피드와 바스켓 자신의 종목(069500/357870) 문제를 잡지 못했다. 이제 보유 +종목 전부 + 벤치마크의 최근 봉이 직전 거래일에서 1거래일 이내인지 본다. +네트워크는 호출하지 않는다(가짜 DataCollector). 거래일 달력은 테스트가 고정한다. +""" + +from datetime import date, datetime + +import pandas as pd +import pytest +from loguru import logger + +from config.config_loader import Config +from core.live_readiness import check_data_source_freshness, check_live_readiness_gate + +CHUSEOK_2026_WITH_0928 = { + "2026-09-24", "2026-09-25", "2026-09-26", "2026-09-28", + "2026-10-03", "2026-10-05", "2026-10-09", +} +POCKET = {"kr_pocket": {"enabled": True, "holdings": {"069500": 0.5, "357870": 0.5}}} + + +@pytest.fixture(autouse=True) +def calendar(monkeypatch): + monkeypatch.setattr("core.trading_hours._load_holidays", lambda: set(CHUSEOK_2026_WITH_0928)) + + +@pytest.fixture +def pocket_config(monkeypatch): + monkeypatch.setattr( + "core.basket_rebalancer.BasketRebalancer._load_baskets_config", + staticmethod(lambda: POCKET), + ) + + +class FakeCollector: + """종목별 마지막 봉 날짜를 지정할 수 있는 가짜 수집기.""" + + def __init__(self, last_bar_by_symbol=None, default_last_bar=None, source="FinanceDataReader"): + self.last_bar_by_symbol = last_bar_by_symbol or {} + self.default_last_bar = default_last_bar + self.source = source + self.requested = [] + + def fetch_korean_stock(self, symbol, start_date=None, end_date=None): + self.requested.append((symbol, start_date, end_date)) + last = self.last_bar_by_symbol.get(symbol, self.default_last_bar) + if last is None: + return pd.DataFrame() + index = pd.bdate_range(end=pd.Timestamp(last), periods=5) + return pd.DataFrame({"close": [100.0] * len(index)}, index=index) + + def get_last_source_info(self): + return { + "source": self.source, + "history": {symbol: self.source for symbol, _s, _e in self.requested}, + } + + +def test_stale_fixed_window_feed_is_rejected(pocket_config): + """2026-03-26에서 멈춘 피드는 모든 종목·벤치마크에서 거부된다.""" + collector = FakeCollector(default_last_bar="2026-03-26") + + issues = check_data_source_freshness( + Config.get(), "basket_rebalance:kr_pocket", today=date(2026, 9, 23), collector=collector, + ) + + assert [s for s, _a, _b in collector.requested] == ["069500", "357870", "KS11"] + assert len(issues) == 3 + assert all("갱신 멈춤 의심" in i and "2026-03-26" in i for i in issues) + + +def test_fresh_feed_on_previous_trading_day_passes(pocket_config): + collector = FakeCollector(default_last_bar="2026-09-22") + messages = [] + sink = logger.add(lambda m: messages.append(m.record["message"]), level="INFO") + try: + issues = check_data_source_freshness( + Config.get(), "basket_rebalance:kr_pocket", today=date(2026, 9, 23), collector=collector, + ) + finally: + logger.remove(sink) + + assert issues == [] + # 최근 약 10일 구간만 요청한다(고정 과거 구간이 아님). + assert all(start >= "2026-09-10" and end == "2026-09-23" for _s, start, end in collector.requested) + # 실제 마지막 봉 날짜를 로그로 남긴다. + assert any("069500" in m and "2026-09-22" in m for m in messages) + + +def test_one_trading_day_lag_is_tolerated_but_two_is_not(pocket_config): + collector = FakeCollector( + last_bar_by_symbol={"069500": "2026-09-21", "357870": "2026-09-18"}, + default_last_bar="2026-09-22", + ) + + issues = check_data_source_freshness( + Config.get(), "basket_rebalance:kr_pocket", today=date(2026, 9, 23), collector=collector, + ) + + assert len(issues) == 1 + assert "357870" in issues[0] and "2026-09-18" in issues[0] + + +def test_after_chuseok_previous_trading_day_comes_from_calendar(pocket_config): + """9/29 점검: 달력상 직전 거래일은 9/23(9/24~28 휴장) — 9/23 봉이면 통과.""" + collector = FakeCollector(default_last_bar="2026-09-23") + + issues = check_data_source_freshness( + Config.get(), "basket_rebalance:kr_pocket", today=date(2026, 9, 29), collector=collector, + ) + + assert issues == [] + + +def test_signal_strategy_checks_representative_symbol_and_benchmark(): + collector = FakeCollector(default_last_bar="2026-09-22") + + issues = check_data_source_freshness( + Config.get(), "scoring", today=date(2026, 9, 23), collector=collector, + ) + + assert issues == [] + assert [s for s, _a, _b in collector.requested] == ["005930", "KS11"] + + +def test_kis_unadjusted_source_is_flagged(pocket_config): + collector = FakeCollector(default_last_bar="2026-09-22", source="KIS") + + issues = check_data_source_freshness( + Config.get(), "basket_rebalance:kr_pocket", today=date(2026, 9, 23), collector=collector, + ) + + assert len(issues) == 1 and "KIS(비수정주가)" in issues[0] + + +def test_empty_feed_fails_closed(pocket_config): + issues = check_data_source_freshness( + Config.get(), "basket_rebalance:kr_pocket", today=date(2026, 9, 23), + collector=FakeCollector(default_last_bar=None), + ) + assert len(issues) == 3 and all("최근 데이터 없음" in i for i in issues) + + +def test_gate_wires_freshness_check_with_basket_holdings(pocket_config, monkeypatch): + """배선: 바스켓 게이트 통과 후 게이트가 실제로 보유 종목 신선도 점검을 돈다.""" + today = datetime.now().date() + created = [] + + class _Collector(FakeCollector): + def __init__(self): + super().__init__(default_last_bar=today.isoformat()) + created.append(self) + + monkeypatch.setattr("core.live_readiness.check_basket_live_readiness", lambda cfg, name: []) + monkeypatch.setattr("core.data_collector.DataCollector", _Collector) + + issues = check_live_readiness_gate(Config.get(), "basket_rebalance:kr_pocket") + + assert issues == [] + assert [s for s, _a, _b in created[0].requested] == ["069500", "357870", "KS11"] diff --git a/tests/test_audit_runtime_post_market.py b/tests/test_audit_runtime_post_market.py new file mode 100644 index 00000000..28944a29 --- /dev/null +++ b/tests/test_audit_runtime_post_market.py @@ -0,0 +1,146 @@ +"""스케줄러 장마감 실행 창·일간 수익률 회귀 테스트 (감사: runtime-post-market-window-and-zero-daily-return). + +1) 장마감 분기가 hour==15일 때만 열려, 16시 이후 시작·재시작하면 그날 스냅샷· + 리포트·evidence·DB 백업이 조용히 사라졌다. +2) 스케줄러 일일 리포트의 일간 수익률이 0으로 하드코딩돼 있었다(CLI는 TWR 계산). +DB는 conftest가 격리한 임시 DB를 쓴다. +""" + +from datetime import datetime, timedelta +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + + +class _ClosedHours: + """장전·장중이 아닌 시각(장마감 분기만 가능)으로 고정한 TradingHours 대역.""" + + def is_pre_market(self, dt=None): + return False + + def is_market_open(self, dt=None): + return False + + +def _phase_scheduler(): + from core.scheduler import Scheduler + + s = Scheduler.__new__(Scheduler) + s.trading_hours = _ClosedHours() + s._pre_market_done = True + s._post_market_done = False + s.post_market_runs = [] + s._run_post_market = lambda: s.post_market_runs.append(True) + return s + + +@pytest.mark.parametrize("hour, minute", [(15, 35), (16, 10), (23, 5)]) +def test_post_market_runs_once_at_or_after_1535(hour, minute): + s = _phase_scheduler() + now = datetime(2026, 9, 23, hour, minute) + + s._run_trading_day_phase(now) + s._run_trading_day_phase(now + timedelta(minutes=1)) + + assert s.post_market_runs == [True] + assert s._post_market_done is True + + +def test_post_market_does_not_run_before_1535(): + s = _phase_scheduler() + s._run_trading_day_phase(datetime(2026, 9, 23, 15, 34)) + assert s.post_market_runs == [] + assert s._post_market_done is False + + +def _seed_snapshots(account, prev_total, last_total, deposit): + from database.repositories import record_cash_flow, save_portfolio_snapshot + + today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + save_portfolio_snapshot( + total_value=prev_total, cash=prev_total, invested=0, + account_key=account, snapshot_date=today - timedelta(days=1), mode="paper", + ) + save_portfolio_snapshot( + total_value=last_total, cash=last_total, invested=0, + account_key=account, snapshot_date=today, mode="paper", + ) + if deposit: + # 직전 스냅샷 측정(created_at) 뒤의 입금 — TWR 분모에 더해 중화돼야 한다. + record_cash_flow(deposit, account_key=account, note="audit deposit", mode="paper") + + +def _report_scheduler(account): + from core.scheduler import Scheduler + + s = Scheduler.__new__(Scheduler) + s.strategy_name = account + s._ledger_mode = "paper" + return s + + +def test_report_daily_return_uses_twr_with_deposit_neutralized(): + account = "audit_post_market_twr" + _seed_snapshots(account, 10_000_000, 10_250_000, 100_000) + + expected = (10_250_000 / (10_000_000 + 100_000) - 1) * 100 + assert _report_scheduler(account)._report_daily_return() == pytest.approx(expected) + + +def test_report_daily_return_is_blank_with_single_snapshot(): + from database.repositories import save_portfolio_snapshot + + account = "audit_post_market_single" + save_portfolio_snapshot( + total_value=1_000_000, cash=1_000_000, invested=0, account_key=account, mode="paper", + ) + # 비교할 직전 기록이 없으면 0.00%가 아니라 비워 둔다('—') + assert _report_scheduler(account)._report_daily_return() is None + + +def test_post_market_report_card_carries_the_twr_daily_return(monkeypatch): + """배선 확인: 계산 함수가 있어도 리포트 카드에 실제로 실려야 한다.""" + from core.scheduler import Scheduler + + account = "audit_post_market_card" + _seed_snapshots(account, 20_000_000, 19_800_000, 0) + + s = Scheduler.__new__(Scheduler) + s.strategy_name = account + s._ledger_mode = "paper" + s._mode = "paper" + s.config = SimpleNamespace(trading={"mode": "paper"}, risk_params={}) + s.discord = MagicMock() + s.portfolio = MagicMock() + s.portfolio.save_daily_snapshot.return_value = True + s.portfolio.get_portfolio_summary.return_value = { + "total_value": 19_800_000, "cash": 19_800_000, "realized_pnl": 0, + "unrealized_pnl": 0, "total_return": -1.0, "mdd": -1.0, "position_count": 0, + } + s._collect_snapshot_prices = lambda: {} + s._check_live_readiness = lambda: None + s._collect_post_market_evidence = lambda date: None + s._loop_metrics = SimpleNamespace( + summary=lambda: {"total_loops": 0, "total_skips": 0} + ) + monkeypatch.setattr( + "core.scheduler.get_daily_trade_summary", + lambda mode, account_key: { + "total_trades": 0, "buy_count": 0, "sell_count": 0, "realized_pnl": 0, + "total_commission": 0, "total_tax": 0, "winning_trades": 0, "losing_trades": 0, + }, + ) + monkeypatch.setattr("core.scheduler.diagnose_live_post_market", lambda **kw: []) + monkeypatch.setattr("core.scheduler.save_daily_report", lambda **kw: None) + monkeypatch.setattr("database.backup.run_daily_backup", lambda config: None) + # 금요일에 돌면 주간 리포트 파일을 쓰지 않게 막는다(실행 요일에 따라 결과가 바뀌지 않게). + monkeypatch.setattr( + "monitoring.paper_monitor.WeeklyReportGenerator", + lambda *a, **kw: SimpleNamespace(generate=lambda weeks_back=1: {}, save=lambda report: None), + ) + + s._run_post_market() + + card = s.discord.send_daily_report.call_args.args[0] + assert card["daily_return"] == pytest.approx(-1.0) diff --git a/tests/test_audit_runtime_rescan.py b/tests/test_audit_runtime_rescan.py new file mode 100644 index 00000000..a22f461b --- /dev/null +++ b/tests/test_audit_runtime_rescan.py @@ -0,0 +1,136 @@ +"""장중 재스캔·쿨다운 해제 재스캔 후보의 신선도 회귀 테스트 (감사: runtime-rescan-candidates-never-stale). + +두 재스캔 경로의 진입 후보에 timestamp가 없어 30분 경과 후보 폐기가 적용되지 +않았고(기본값 now), _signal_at이 없어 신호→주문 지연도 기록되지 않았다. 쿨다운 +해제 후보에는 시장 국면 스케일도 없었다. 네트워크는 호출하지 않는다. +""" + +from datetime import datetime, timedelta +from types import SimpleNamespace +from unittest.mock import MagicMock + +import numpy as np +import pandas as pd +import pytest + +T0 = datetime(2026, 9, 23, 10, 0) + + +def _sample_ohlcv(days=60): + rng = np.random.default_rng(11) + dates = pd.date_range(end=T0, periods=days, freq="B") + prices = 50000 * np.cumprod(1 + rng.normal(0.0002, 0.01, days)) + return pd.DataFrame({ + "open": prices, "high": prices * 1.01, "low": prices * 0.99, + "close": prices, "volume": rng.integers(100_000, 1_000_000, days), + }, index=dates) + + +class _BuyStrategy: + def generate_signal(self, df, symbol=None): + return {"signal": "BUY", "close": 50_000, "atr": 1_000, "score": 80} + + +def _freeze(monkeypatch, moment): + class _Frozen(datetime): + @classmethod + def now(cls, tz=None): + return moment + + monkeypatch.setattr("core.scheduler.datetime", _Frozen) + + +@pytest.fixture +def scheduler(monkeypatch): + from core.scheduler import Scheduler + + sample = _sample_ohlcv() + + class FakeCollector: + def fetch_stock(self, symbol, start=None, end=None): + return sample.copy() + + monkeypatch.setattr("core.data_collector.DataCollector", FakeCollector) + monkeypatch.setattr( + "core.market_regime.check_market_regime", + lambda config, collector=None: {"allow_buys": True, "position_scale": 0.7}, + ) + monkeypatch.setattr( + "core.scheduler.WatchlistManager", + lambda cfg: SimpleNamespace(resolve=lambda: ["005930"]), + ) + monkeypatch.setattr( + "core.scheduler.get_position", lambda symbol, account_key="", mode="paper": None, + ) + + s = Scheduler(strategy_name="scoring") + # live 모드로 두어 paper runtime/preflight 가드(별도 테스트 대상)를 건너뛴다. + s._mode = "live" + s._ledger_mode = "live" + s._entry_candidates = [] + s._get_strategy = lambda: _BuyStrategy() + s._maybe_record_dashboard_signal = lambda *a, **kw: None + s.discord = MagicMock() + s.portfolio = SimpleNamespace( + get_portfolio_summary=lambda: { + "total_value": 10_000_000, "cash": 10_000_000, "current_value": 0, + } + ) + s.blackswan = SimpleNamespace(get_recovery_scale=lambda: 1.0) + s.executed = [] + + class _Executor: + def execute_buy(self, **kwargs): + s.executed.append(kwargs) + return {"success": True, "symbol": kwargs["symbol"]} + + s._get_or_create_executor = lambda: _Executor() + return s + + +def test_intraday_rescan_candidate_carries_timestamp_and_signal_time(monkeypatch, scheduler): + _freeze(monkeypatch, T0) + scheduler._rescan_for_new_entries() + + assert len(scheduler._entry_candidates) == 1 + candidate = scheduler._entry_candidates[0] + assert candidate["timestamp"] == T0 + assert candidate["_signal_at"] == T0 + assert candidate["market_regime_scale"] == 0.7 + + +def test_post_cooldown_candidate_carries_timestamp_and_regime_scale(monkeypatch, scheduler): + _freeze(monkeypatch, T0) + scheduler._market_regime_scale = 0.5 + + scheduler._run_post_cooldown_rescan() + + assert len(scheduler._entry_candidates) == 1 + candidate = scheduler._entry_candidates[0] + assert candidate["timestamp"] == T0 + assert candidate["_signal_at"] == T0 + assert candidate["market_regime_scale"] == 0.5 + + +@pytest.mark.parametrize("rescan", ["_rescan_for_new_entries", "_run_post_cooldown_rescan"]) +def test_rescan_candidate_older_than_30_minutes_is_discarded(monkeypatch, scheduler, rescan): + _freeze(monkeypatch, T0) + getattr(scheduler, rescan)() + assert scheduler._entry_candidates + + _freeze(monkeypatch, T0 + timedelta(minutes=31)) + scheduler._execute_entry_candidates() + + assert scheduler.executed == [] + assert scheduler._entry_candidates == [] + + +def test_fresh_rescan_candidate_records_signal_latency(monkeypatch, scheduler): + _freeze(monkeypatch, T0) + scheduler._rescan_for_new_entries() + + _freeze(monkeypatch, T0 + timedelta(minutes=10)) + scheduler._execute_entry_candidates() + + assert len(scheduler.executed) == 1 + assert scheduler.executed[0]["signal_at"] == T0 diff --git a/tests/test_audit_runtime_trading_days.py b/tests/test_audit_runtime_trading_days.py new file mode 100644 index 00000000..b1d20c06 --- /dev/null +++ b/tests/test_audit_runtime_trading_days.py @@ -0,0 +1,202 @@ +"""KRX 거래일 기준 evidence 기록·staleness 회귀 테스트. + +감사 두 건을 함께 고정한다(같이 배포해야 하는 쌍): +- runtime-premarket-finalize-holiday-fabrication: 장전 '전일 finalize'가 주말만 + 건너뛰어 평일 휴장일(추석·대체공휴일)의 가짜 real_paper evidence를 만들고, 실제 + 직전 거래일은 finalize하지 않았다. +- runtime-paper-runtime-weekday-stale: paper_runtime이 평일 수로 staleness를 세서 + 연휴 뒤 첫 거래일에 증거가 밀렸다고 오판해 신규 진입을 막았다. + +거래일 달력은 테스트가 고정한다 — 운영 holidays.yaml이 교정돼도(예: 2026-09-28 +대체공휴일 오표기 의심) 이 테스트의 기대값이 흔들리지 않게. +""" + +from datetime import date, datetime +from types import SimpleNamespace + +import pytest +from loguru import logger + +CHUSEOK_2026_WITH_0928 = { + "2026-09-24", "2026-09-25", "2026-09-26", "2026-09-28", + "2026-10-03", "2026-10-05", "2026-10-09", +} + + +@pytest.fixture +def calendar(monkeypatch): + holder = {"holidays": set(CHUSEOK_2026_WITH_0928)} + monkeypatch.setattr("core.trading_hours._load_holidays", lambda: set(holder["holidays"])) + return holder + + +@pytest.fixture +def captured_warnings(): + messages = [] + sink_id = logger.add(lambda m: messages.append(m.record["message"]), level="WARNING") + yield messages + logger.remove(sink_id) + + +def _scheduler(): + from config.config_loader import Config + from core.scheduler import Scheduler + from core.trading_hours import TradingHours + + s = Scheduler.__new__(Scheduler) + s.strategy_name = "scoring" + s._mode = "paper" + s.config = Config.get() + s.trading_hours = TradingHours(s.config) + s._pilot_session = { + "active": False, "pilot_authorized": False, + "pilot_caps_snapshot": {}, "session_mode": "normal_paper", + "evidence_mode": "real_paper", + } + return s + + +@pytest.fixture +def evidence_calls(monkeypatch): + calls = {"finalize": [], "collect": []} + monkeypatch.setattr( + "core.paper_evidence.finalize_daily_evidence", + lambda **kw: calls["finalize"].append(kw) or None, + ) + monkeypatch.setattr( + "core.paper_evidence.collect_daily_evidence", + lambda **kw: calls["collect"].append(kw) or None, + ) + monkeypatch.setattr( + "core.scheduler.WatchlistManager", + lambda cfg: SimpleNamespace(resolve=lambda: ["005930"]), + ) + return calls + + +def _frozen_datetime(frozen): + class _Frozen(datetime): + @classmethod + def now(cls, tz=None): + return frozen + + return _Frozen + + +def test_premarket_finalize_after_chuseok_targets_last_trading_day( + monkeypatch, calendar, evidence_calls +): + """9/29 장전 finalize는 휴장일 9/28이 아니라 직전 거래일 9/23을 확정한다.""" + monkeypatch.setattr( + "core.scheduler.datetime", _frozen_datetime(datetime(2026, 9, 29, 8, 55)) + ) + + class _StopCollector: + def __init__(self, *a, **kw): + raise RuntimeError("테스트: 장전 분석 단계는 여기서 멈춘다") + + monkeypatch.setattr("core.data_collector.DataCollector", _StopCollector) + + _scheduler()._run_pre_market() + + assert [c["date"].date() for c in evidence_calls["finalize"]] == [date(2026, 9, 23)] + + +@pytest.mark.parametrize( + "now, holidays, expected", + [ + (datetime(2026, 9, 29, 8, 55), CHUSEOK_2026_WITH_0928, date(2026, 9, 23)), + (datetime(2026, 9, 29, 8, 55), CHUSEOK_2026_WITH_0928 - {"2026-09-28"}, date(2026, 9, 28)), + (datetime(2026, 10, 6, 8, 55), CHUSEOK_2026_WITH_0928, date(2026, 10, 2)), + (datetime(2026, 10, 12, 8, 55), CHUSEOK_2026_WITH_0928, date(2026, 10, 8)), + (datetime(2026, 9, 22, 8, 55), CHUSEOK_2026_WITH_0928, date(2026, 9, 21)), + ], +) +def test_previous_trading_day_skips_weekday_holidays(calendar, now, holidays, expected): + calendar["holidays"] = set(holidays) + assert _scheduler()._previous_trading_day(now).date() == expected + + +def test_finalize_refuses_holiday_without_raising(calendar, evidence_calls, captured_warnings): + """비거래일 finalize는 경고만 남기고 None — 가짜 real_paper 기록을 만들지 않는다.""" + assert _scheduler()._finalize_evidence_for(datetime(2026, 9, 28)) is None + assert evidence_calls["finalize"] == [] + assert any("2026-09-28" in m and "KRX 거래일이 아니" in m for m in captured_warnings) + + +def test_post_market_collect_refuses_holiday(calendar, evidence_calls, captured_warnings): + assert _scheduler()._collect_post_market_evidence(datetime(2026, 9, 24, 15, 40)) is None + assert evidence_calls["collect"] == [] + assert any("2026-09-24" in m for m in captured_warnings) + + +def test_post_market_collect_runs_on_trading_day(calendar, evidence_calls): + _scheduler()._collect_post_market_evidence(datetime(2026, 9, 23, 15, 40)) + assert [c["date"].date() for c in evidence_calls["collect"]] == [date(2026, 9, 23)] + assert evidence_calls["collect"][0]["evidence_mode"] == "real_paper" + + +@pytest.mark.parametrize( + "start, end, expected", + [ + ("2026-09-23", "2026-09-29", 1), + ("2026-10-02", "2026-10-06", 1), + ("2026-04-10", "2026-04-15", 3), # 휴장일 없는 구간은 평일 수와 같다 + ("2026-09-23", "2026-09-23", 0), + ], +) +def test_runtime_staleness_counts_krx_trading_days(calendar, start, end, expected): + from core.paper_runtime import _trading_days_between + + assert _trading_days_between(start, end) == expected + + +def test_runtime_state_is_not_stale_after_chuseok(calendar, monkeypatch, tmp_path): + """9/23 증거로 9/29에 평가하면 신선하다(예전엔 평일 4일로 세서 신규 진입 차단).""" + import core.paper_evidence as pe + import core.paper_runtime as pr + from core.paper_evidence import _append_jsonl + + monkeypatch.setattr(pe, "EVIDENCE_DIR", tmp_path / "paper_evidence") + monkeypatch.setattr(pr, "RUNTIME_DIR", tmp_path / "paper_runtime") + assert pr.PAPER_RUNTIME_MAX_EVIDENCE_STALE_TRADING_DAYS == 1 + + strategy = "audit_calendar_s" + path = tmp_path / "paper_evidence" / f"daily_evidence_{strategy}.jsonl" + for day_number, day in enumerate(["2026-09-21", "2026-09-22", "2026-09-23"], start=1): + _append_jsonl(path, { + "date": day, "day_number": day_number, "strategy": strategy, + "total_value": 10_000_000, "cash": 3_000_000, "invested": 7_000_000, + "daily_return": 0.1, "cumulative_return": 0.3, "mdd": -1.0, + "position_count": 2, "total_trades": 1, + "same_universe_excess": 0.05, "exposure_matched_excess": 0.03, + "cash_adjusted_excess": 0.02, "benchmark_status": "final", + "benchmark_meta": {"completeness": 1.0}, "raw_fill_rate": 1.0, + "reject_count": 0, "phantom_position_count": 0, "stale_pending_count": 0, + "duplicate_blocked_count": 0, "restart_recovery_count": 0, + "anomalies": [], "cross_validation_warnings": [], "status": "normal", + "record_version": 2, "schema_version": 2, "diagnostics": [], + }) + + state = pr.get_paper_runtime_state(strategy, as_of_date="2026-09-29") + + assert state.metrics["evidence_stale_trading_days"] == 1 + assert state.metrics["evidence_fresh"] is True + assert not any("stale_evidence" in r for r in state.reasons) + assert state.state == "normal" + assert "entry" in state.allowed_actions + + +def test_pilot_business_days_warns_when_calendar_unavailable(monkeypatch, captured_warnings): + """달력 로드 실패는 조용히 평일 수로 떨어지지 않고 경고를 남긴다.""" + import core.trading_hours as th + from core.paper_pilot import _business_days_between + + class _Broken: + def __init__(self, *a, **kw): + raise RuntimeError("holidays.yaml 손상") + + monkeypatch.setattr(th, "TradingHours", _Broken) + + assert _business_days_between("2026-09-23", "2026-09-29") == 4 + assert any("KRX 거래일 달력 로드 실패" in m for m in captured_warnings) diff --git a/tests/test_audit_runtime_websocket.py b/tests/test_audit_runtime_websocket.py new file mode 100644 index 00000000..a0161082 --- /dev/null +++ b/tests/test_audit_runtime_websocket.py @@ -0,0 +1,107 @@ +"""웹소켓 다건 프레임 파싱·문서 정합성 회귀 테스트 (감사: runtime-websocket-handler-unwired). + +1) KIS는 체결이 몰리면 한 프레임에 여러 건(data_count)을 이어 붙여 보내는데, 파서는 + 첫 레코드만 읽고 나머지를 버렸다. +2) PROJECT_GUIDE는 웹소켓 갭 처리를 완료(✅)로 표기했지만 어떤 런타임도 핸들러를 + 시작하지 않는다. 문서와 실제 연결 상태가 다시 어긋나지 않게 함께 고정한다. +""" + +import asyncio +from pathlib import Path +from types import SimpleNamespace + +import pytest +from loguru import logger + +from api.websocket_handler import WebSocketHandler + +ROOT = Path(__file__).resolve().parents[1] +H0STCNT0_FIELDS = 46 # KIS 국내주식 실시간 체결가 레코드 필드 수 + + +def _record(symbol, price, volume, width=H0STCNT0_FIELDS): + fields = ["0"] * width + fields[0] = symbol + fields[1] = "093001" + fields[2] = str(price) + fields[4] = "100" + fields[5] = "0.15" + fields[12] = str(volume) + fields[13] = str(volume * 10) + return "^".join(fields) + + +def _handler(): + handler = WebSocketHandler( + config=SimpleNamespace(kis_api={"app_key": "", "app_secret": "", "use_mock": True}) + ) + received = [] + handler.on_price_update(received.append) + return handler, received + + +def _feed(handler, frame): + asyncio.run(handler._handle_message(frame)) + + +def test_multi_record_frame_emits_every_record(): + handler, received = _handler() + frame = "0|H0STCNT0|002|" + _record("005930", 70000, 10) + "^" + _record("000660", 150000, 3) + + _feed(handler, frame) + + assert [(d["symbol"], d["price"], d["volume"]) for d in received] == [ + ("005930", 70000.0, 10), + ("000660", 150000.0, 3), + ] + assert set(handler._price_cache) == {"005930", "000660"} + + +def test_single_record_frame_is_unchanged(): + handler, received = _handler() + _feed(handler, "0|H0STCNT0|001|" + _record("005930", 70000, 10)) + assert [(d["symbol"], d["price"]) for d in received] == [("005930", 70000.0)] + + +def test_trailing_separator_does_not_break_split(): + handler, received = _handler() + frame = "0|H0STCNT0|002|" + _record("005930", 70000, 10) + "^" + _record("035420", 200000, 5) + "^" + _feed(handler, frame) + assert [d["symbol"] for d in received] == ["005930", "035420"] + + +def test_uneven_frame_falls_back_to_first_record_with_warning(): + handler, received = _handler() + messages = [] + sink = logger.add(lambda m: messages.append(m.record["message"]), level="WARNING") + try: + frame = "0|H0STCNT0|002|" + _record("005930", 70000, 10) + "^extra" + _feed(handler, frame) + finally: + logger.remove(sink) + + assert [d["symbol"] for d in received] == ["005930"] + assert any("나누어떨어지지 않음" in m for m in messages) + + +def _runtime_sources(): + yield ROOT / "main.py" + for folder in ("core", "monitoring", "tools"): + yield from (ROOT / folder).rglob("*.py") + + +def test_project_guide_matches_websocket_wiring_state(): + """런타임이 핸들러를 시작하지 않는 한 문서는 '미연결'로 표기해야 한다(반대도 마찬가지).""" + wired = any( + "WebSocketHandler(" in path.read_text(encoding="utf-8", errors="ignore") + for path in _runtime_sources() + ) + guide = (ROOT / "docs" / "PROJECT_GUIDE.md").read_text(encoding="utf-8") + + if wired: + pytest.fail( + "WebSocketHandler가 런타임에 연결됐다 — docs/PROJECT_GUIDE.md의 '런타임 미연결' " + "표기를 실제 연결 상태로 갱신하고 이 테스트를 고치세요." + ) + assert "✅ **WebSocket 갭 상태/보충 처리**" not in guide + assert "런타임 미연결" in guide diff --git a/tests/test_audit_schema.py b/tests/test_audit_schema.py new file mode 100644 index 00000000..64c9bc9d --- /dev/null +++ b/tests/test_audit_schema.py @@ -0,0 +1,71 @@ +"""모델 ↔ 실제 스키마 대조 (2026-09-23). + +create_all은 이미 있는 테이블을 고치지 않는다 — 모델의 제약을 바꿔도 예전 DB에는 옛 제약이 +남는다. 7/10 kr_pocket 가짜 낙폭이 이 경우였다(positions의 옛 UNIQUE(symbol)). +""" + +from sqlalchemy import create_engine, text + +from database.models import Base, _repair_schema_drift, check_schema_drift + + +def _engine(tmp_path): + engine = create_engine(f"sqlite:///{tmp_path / 'drift.db'}") + Base.metadata.create_all(engine) + return engine + + +def test_fresh_schema_has_no_drift(tmp_path): + assert check_schema_drift(_engine(tmp_path)) == [] + + +def test_legacy_unique_and_missing_index_are_reported(tmp_path): + engine = _engine(tmp_path) + with engine.connect() as conn: + conn.execute(text("DROP INDEX IF EXISTS ix_trade_history_account_key")) + conn.execute(text('DROP TABLE "daily_reports"')) + conn.execute(text( + 'CREATE TABLE daily_reports (id INTEGER PRIMARY KEY, account_key VARCHAR(64), ' + 'date DATETIME, UNIQUE(date))' + )) + conn.commit() + + issues = check_schema_drift(engine) + + assert any("trade_history" in i and "ix_trade_history_account_key" in i for i in issues) + assert any("daily_reports" in i and "옛 UNIQUE" in i for i in issues) + + +def test_repair_fixes_safe_drift_without_losing_rows(tmp_path): + engine = _engine(tmp_path) + with engine.connect() as conn: + conn.execute(text("DROP INDEX IF EXISTS ix_trade_history_account_key")) + conn.execute(text( + "INSERT INTO operation_events (event_type, severity, message, mode, created_at) " + "VALUES ('X', 'info', 'm', 'paper', '2026-09-23')" + )) + conn.commit() + + _repair_schema_drift(engine) + + assert check_schema_drift(engine) == [] + with engine.connect() as conn: + assert conn.execute(text("SELECT COUNT(*) FROM operation_events")).scalar() == 1 + + +def test_non_empty_legacy_daily_reports_is_left_for_operator(tmp_path): + engine = _engine(tmp_path) + with engine.connect() as conn: + conn.execute(text('DROP TABLE "daily_reports"')) + conn.execute(text( + 'CREATE TABLE daily_reports (id INTEGER PRIMARY KEY, account_key VARCHAR(64), ' + 'date DATETIME, UNIQUE(date))' + )) + conn.execute(text("INSERT INTO daily_reports (account_key, date) VALUES ('', '2026-09-01')")) + conn.commit() + + _repair_schema_drift(engine) + + with engine.connect() as conn: + assert conn.execute(text("SELECT COUNT(*) FROM daily_reports")).scalar() == 1 + assert any("옛 UNIQUE" in i for i in check_schema_drift(engine)) diff --git a/tests/test_audit_strategies_breakout.py b/tests/test_audit_strategies_breakout.py new file mode 100644 index 00000000..b45f02c2 --- /dev/null +++ b/tests/test_audit_strategies_breakout.py @@ -0,0 +1,100 @@ +"""breakout_volume 청산 기준 감사 회귀 테스트. + +예전 청산은 매 봉의 breakout_ref(직전 N봉 고가 최대)와 비교했다. T+1의 breakout_ref는 +돌파봉 T의 고가까지 포함하므로, 돌파 레벨을 다시 깨지 않았는데도 진입 다음 봉에 +SELL이 나는 1일 왕복이 대부분이었다. 이제 진입 봉에서 돌파한 레벨(entry_level)을 고정한다. +""" + +import numpy as np +import pandas as pd + +from config.config_loader import Config + + +def _strategy(*, real_indicators=False): + from strategies.breakout_volume import BreakoutVolumeStrategy + + strategy = BreakoutVolumeStrategy(Config.get()) + strategy.params = {"breakout_period": 10, "surge_ratio": 1.5, "adx_min": 20} + if not real_indicators: + # ADX는 항상 추세 있음(30)으로 두고 돌파·거래량 조건만 본다. + strategy.indicator_engine.calculate_all = lambda df: df.assign(adx=30.0) + return strategy + + +def _scenario(): + """0~14 횡보(고가 101) → 15 돌파(종가 105, 고가 108, 거래량 3배) → 이후 되밀림.""" + closes = [100.0] * 15 + [105.0, 103.0, 100.0, 99.0, 102.0] + highs = [101.0] * 15 + [108.0, 104.0, 101.0, 100.0, 103.0] + volumes = [1000.0] * 15 + [3000.0, 1000.0, 1000.0, 1000.0, 1000.0] + dates = pd.bdate_range("2024-01-01", periods=len(closes)) + return pd.DataFrame( + { + "open": closes, + "high": highs, + "low": [c - 1 for c in closes], + "close": closes, + "volume": volumes, + }, + index=dates, + ) + + +def test_next_bar_above_broken_level_is_not_a_sell(): + df = _scenario() + out = _strategy().analyze(df) + t = 15 + assert out["signal"].iloc[t] == "BUY" + # T+1 종가 103은 돌파봉 고가(108)보다 낮지만 돌파한 레벨(101)보다 위 → 실패 아님 + assert out["breakout_ref"].iloc[t + 1] == 108.0 + assert out["signal"].iloc[t + 1] != "SELL" + assert out["entry_level"].iloc[t + 1] == 101.0 + + +def test_close_below_broken_level_sells_every_bar_until_recovered(): + df = _scenario() + out = _strategy().analyze(df) + # 17·18: 종가가 101 아래 → 매 봉 SELL (최소 보유일에 막혀도 다음 봉에 다시 나온다) + assert out["signal"].iloc[17] == "SELL" + assert out["signal"].iloc[18] == "SELL" + # 19: 102로 레벨 위 회복 → SELL 없음 + assert out["signal"].iloc[19] != "SELL" + # 진입 전에는 청산 기준 레벨이 없다 + assert out["entry_level"].iloc[:15].isna().all() + assert "SELL" not in set(out["signal"].iloc[:15]) + + +def test_strict_prefix_matches_full_analyze(): + df = _scenario() + strategy = _strategy() + full = strategy.analyze(df) + for i in range(len(df)): + prefix = strategy.analyze(df.iloc[: i + 1].copy()) + assert prefix["signal"].iloc[-1] == full["signal"].iloc[i], i + + +def test_random_series_never_sells_next_bar_above_broken_level(): + """실제 지표(ADX 포함)로 돌린 무작위 일봉에서 불변식 확인.""" + strategy = _strategy(real_indicators=True) + buys = 0 + for seed in range(8): + rng = np.random.default_rng(seed) + n = 400 + close = 10_000 * np.exp(np.cumsum(rng.normal(0.0005, 0.02, n))) + high = close * (1 + rng.uniform(0, 0.02, n)) + low = close * (1 - rng.uniform(0, 0.02, n)) + volume = rng.lognormal(12, 0.6, n) + df = pd.DataFrame( + {"open": close, "high": high, "low": low, "close": close, "volume": volume}, + index=pd.bdate_range("2022-01-03", periods=n), + ) + out = strategy.analyze(df) + sig = out["signal"].tolist() + for t in range(len(sig) - 1): + if sig[t] != "BUY": + continue + buys += 1 + broken_level = out["breakout_ref"].iloc[t] + if sig[t + 1] == "SELL": + assert out["close"].iloc[t + 1] < broken_level, (seed, t) + assert buys > 0, "시나리오에 진입이 한 번도 없으면 불변식 검사가 무의미하다" diff --git a/tests/test_audit_strategies_ensemble.py b/tests/test_audit_strategies_ensemble.py new file mode 100644 index 00000000..088573c9 --- /dev/null +++ b/tests/test_audit_strategies_ensemble.py @@ -0,0 +1,159 @@ +"""앙상블 auto_downgrade 감사 회귀 테스트. + +예전에는 첫 analyze() 호출의 신호 전체로 상관을 계산해 인스턴스 모드를 영구히 바꿨다. +포트폴리오 백테스트는 한 인스턴스로 종목마다 전 기간 df를 넘기므로, 첫 종목의 미래 +신호가 모든 날짜·종목의 모드를 정했고 결과가 종목 순서에 따라 달라졌다. +""" + +import numpy as np +import pandas as pd + +from config.config_loader import Config + + +class _FixedSignals: + """미리 정한 신호를 날짜에 맞춰 돌려주는 구성 전략.""" + + def __init__(self, signals: pd.Series): + self.signals = signals + + def analyze(self, df): + out = df.copy() + out["signal"] = self.signals.reindex(out.index).fillna("HOLD") + out["strategy_score"] = 0.0 + return out + + +def _random_signals(index, seed): + rng = np.random.default_rng(seed) + return pd.Series(rng.choice(["BUY", "HOLD", "SELL"], size=len(index)), index=index) + + +def _frame(n=200, start="2024-01-01"): + idx = pd.bdate_range(start, periods=n) + close = np.linspace(100, 120, n) + return pd.DataFrame( + {"open": close, "high": close, "low": close, "close": close, "volume": 1e6}, index=idx + ) + + +def _ensemble(components): + from core.strategy_ensemble import StrategyEnsemble + + ens = StrategyEnsemble(Config.get()) + ens.mode = "majority_vote" + ens.auto_downgrade = True + ens._independence_window = 60 + ens._independence_threshold = 0.6 + ens._strategies = [(name, _FixedSignals(sig), 1.0) for name, sig in components] + return ens + + +def _correlated_symbol(df): + """technical과 momentum이 항상 같은 신호 → 모든 창에서 고상관.""" + tech = _random_signals(df.index, 1) + return [ + ("technical", tech), + ("momentum_factor", tech.copy()), + ("volatility_condition", _random_signals(df.index, 3)), + ] + + +def _independent_symbol(df): + return [ + ("technical", _random_signals(df.index, 11)), + ("momentum_factor", _random_signals(df.index, 12)), + ("volatility_condition", _random_signals(df.index, 13)), + ] + + +def test_result_does_not_depend_on_symbol_order(): + df = _frame() + corr_parts = _correlated_symbol(df) + ind_parts = _independent_symbol(df) + + # 종목 순서 A → B + ens = _ensemble(corr_parts) + a_first = ens.analyze(df) + ens._strategies = [(n, _FixedSignals(s), 1.0) for n, s in ind_parts] + b_second = ens.analyze(df) + + # 종목 순서 B → A (새 인스턴스) + ens2 = _ensemble(ind_parts) + b_first = ens2.analyze(df) + ens2._strategies = [(n, _FixedSignals(s), 1.0) for n, s in corr_parts] + a_second = ens2.analyze(df) + + pd.testing.assert_series_equal(a_first["signal"], a_second["signal"]) + pd.testing.assert_series_equal(b_first["signal"], b_second["signal"]) + # 설정 모드는 바뀌지 않는다 + assert ens.mode == "majority_vote" and ens2.mode == "majority_vote" + + +def test_mode_uses_only_trailing_window(): + """고상관 구간이 뒤에만 있으면 앞 구간은 설정 모드 그대로 (미래 신호 미참조).""" + df = _frame(n=240) + tech = _random_signals(df.index, 21) + mom = _random_signals(df.index, 22) + mom.iloc[120:] = tech.iloc[120:] # 120행부터 technical과 같은 신호 + ens = _ensemble([ + ("technical", tech), + ("momentum_factor", mom), + ("volatility_condition", _random_signals(df.index, 23)), + ]) + out = ens.analyze(df) + assert (out["ensemble_mode"].iloc[:120] == "majority_vote").all() + # 창 전체가 동일 신호 구간(179행 이후)이면 반드시 conservative + assert (out["ensemble_mode"].iloc[180:] == "conservative").all() + + +def test_strict_prefix_matches_full_run(): + df = _frame(n=160) + tech = _random_signals(df.index, 31) + mom = _random_signals(df.index, 32) + mom.iloc[80:] = tech.iloc[80:] + parts = [ + ("technical", tech), + ("momentum_factor", mom), + ("volatility_condition", _random_signals(df.index, 33)), + ] + full = _ensemble(parts).analyze(df) + ens = _ensemble(parts) + for i in range(len(df)): + last = ens.analyze(df.iloc[: i + 1]).iloc[-1] + assert last["signal"] == full["signal"].iloc[i], i + assert last["ensemble_mode"] == full["ensemble_mode"].iloc[i], i + + +def test_constant_component_is_not_treated_as_correlated(): + """창 안에서 신호가 변하지 않는 구성(데이터 없는 펀더멘털 등)은 상관 판정에서 빠진다.""" + df = _frame(n=150) + ens = _ensemble([ + ("technical", _random_signals(df.index, 41)), + ("momentum_factor", _random_signals(df.index, 42)), + ("fundamental_factor", pd.Series("HOLD", index=df.index)), + ]) + out = ens.analyze(df) + assert (out["ensemble_mode"] == "majority_vote").all() + + +def test_check_failure_is_logged_at_warning(monkeypatch): + from loguru import logger + + df = _frame(n=100) + ens = _ensemble(_correlated_symbol(df)) + + def boom(_df): + raise RuntimeError("corr failed") + + monkeypatch.setattr(ens, "_trailing_high_correlation", boom) + messages = [] + sink_id = logger.add(lambda m: messages.append(str(m)), level="WARNING") + try: + out = ens.analyze(df) + ens.analyze(df) + finally: + logger.remove(sink_id) + assert (out["ensemble_mode"] == "majority_vote").all() + hits = [m for m in messages if "앙상블 독립성 검사 실패" in m] + assert len(hits) == 1 and "corr failed" in hits[0] diff --git a/tests/test_audit_strategies_index_cache.py b/tests/test_audit_strategies_index_cache.py new file mode 100644 index 00000000..32c8c8c1 --- /dev/null +++ b/tests/test_audit_strategies_index_cache.py @@ -0,0 +1,248 @@ +"""지수 캐시 감사 회귀 테스트 — RSR 시장 필터. + +예전 시장 필터는 첫 analyze() 호출의 날짜 범위로 KS11을 한 번만 받아 캐시했다. +strict 백테스트의 첫 호출은 df.iloc[:1]이라 1일치 상태가 전 기간에 ffill되어 필터가 +한 번도 작동하지 않았고, 조회 실패 시에는 경고만 남기고 필터 없이 돌았다. +""" + +import numpy as np +import pandas as pd +import pytest + +from config.config_loader import Config + + +def _ks11_series(): + """2018~2021 영업일 KS11: 상승 → 2020년 중반 급락 → 회복 (SMA200 이탈 구간 생성).""" + dates = pd.bdate_range("2018-01-02", "2021-12-30") + n = len(dates) + t = np.arange(n) + level = 2000 + 1.2 * t + crash = (t > 600) & (t < 760) + level = np.where(crash, level - 6.0 * (t - 600), level) + level = np.where(t >= 760, level - 6.0 * 160 + 4.0 * (t - 760), level) + return pd.Series(level.astype(float), index=dates) + + +class _FakeCollector: + """요청 구간만 잘라 주는 가짜 수집기. 호출 횟수를 센다.""" + + calls: list = [] + fail = False + + def __init__(self): + self.quiet_ohlcv_log = False + + def fetch_korean_stock(self, symbol, start_date=None, end_date=None): + type(self).calls.append((symbol, start_date, end_date)) + if type(self).fail: + raise ConnectionError("rate limited") + s = _ks11_series().loc[pd.Timestamp(start_date): pd.Timestamp(end_date)] + return pd.DataFrame( + {"open": s, "high": s, "low": s, "close": s, "volume": 1.0}, index=s.index + ) + + +@pytest.fixture +def fake_collector(monkeypatch): + import core.data_collector as dc + + _FakeCollector.calls = [] + _FakeCollector.fail = False + monkeypatch.setattr(dc, "DataCollector", _FakeCollector) + return _FakeCollector + + +def _stock_frame(start="2019-06-03", n=400): + dates = pd.bdate_range(start, periods=n) + close = np.linspace(100, 160, n) + return pd.DataFrame( + {"open": close, "high": close + 1, "low": close - 1, "close": close, "volume": 1e6}, + index=dates, + ) + + +def _rotation(**params): + from strategies.relative_strength_rotation import RelativeStrengthRotationStrategy + + strategy = RelativeStrengthRotationStrategy(Config.get()) + strategy.params = { + "short_lookback": 2, + "long_lookback": 3, + "sma_period": 2, + "short_weight": 0.6, + "market_filter_sma200": True, + "market_filter_exit": True, + "market_filter_ma_period": 200, + **params, + } + strategy.indicator_engine.calculate_all = lambda df: df + return strategy + + +def test_strict_prefix_market_filter_matches_full_run(fake_collector): + df = _stock_frame() + full = _rotation().analyze(df) + assert (~full["market_filter_pass"]).sum() > 20, "시나리오에 필터 차단 구간이 있어야 한다" + assert full["market_filter_active"].all() + + strategy = _rotation() + fake_collector.calls = [] + for i in range(len(df)): + last = strategy.analyze(df.iloc[: i + 1].copy()).iloc[-1] + assert bool(last["market_filter_pass"]) == bool(full["market_filter_pass"].iloc[i]), i + assert bool(last["market_filter_exit"]) == bool(full["market_filter_exit"].iloc[i]), i + # 봉마다가 아니라 인스턴스당 한 번만 받는다 + assert len(fake_collector.calls) == 1 + + +def test_market_filter_value_uses_only_previous_day(fake_collector): + """날짜 d의 통과 여부는 d-1 종가·SMA로 정해진다 (넓게 받아도 미래 정보 없음).""" + df = _stock_frame() + out = _rotation().analyze(df) + ks = _ks11_series() + sma = ks.rolling(200, min_periods=200).mean() + expected_prev = (ks > sma).shift(1) + for d in df.index[::17]: + assert bool(out.loc[d, "market_filter_pass"]) == bool(expected_prev.loc[d]), d + + +def test_load_failure_is_recorded_and_not_retried_per_bar(fake_collector): + fake_collector.fail = True + df = _stock_frame(n=60) + strategy = _rotation() + for i in range(len(df)): + out = strategy.analyze(df.iloc[: i + 1].copy()) + assert len(fake_collector.calls) == 1 + assert not out["market_filter_active"].any() + assert out["market_filter_pass"].all() + assert strategy.market_filter_status["ok"] is False + assert "rate limited" in strategy.market_filter_status["reason"] + res = strategy.generate_signal(df) + assert res["details"]["market_filter_active"] is False + + +def test_sma_warmup_days_are_unknown_not_bearish(fake_collector): + """SMA가 아직 없는 날은 '아래'(차단·강제청산)가 아니라 판단 불가로 둔다.""" + df = _stock_frame(start="2018-01-02", n=260) + out = _rotation().analyze(df) + head = out.iloc[:150] + assert not head["market_filter_active"].any() + assert head["market_filter_pass"].all() + assert not head["market_filter_exit"].any() + assert out["market_filter_active"].iloc[-1] + + +def test_earlier_symbol_refetches_union_span(fake_collector): + strategy = _rotation() + strategy.analyze(_stock_frame(start="2020-01-02", n=200)) + assert len(fake_collector.calls) == 1 + # 같은 인스턴스로 더 이른 기간의 종목을 분석 → 구간을 합쳐 한 번 더 받는다 + out = strategy.analyze(_stock_frame(start="2019-03-04", n=200)) + assert len(fake_collector.calls) == 2 + assert out["market_filter_active"].all() + + +def test_index_cache_refetches_when_request_passes_cached_end(fake_collector): + from strategies.index_cache import IndexCloseCache + + cache = IndexCloseCache("KS11", warmup_days=10) + cache.ensure("2020-01-02", "2020-06-30") + cache.ensure("2020-02-03", "2020-05-29") # 캐시 안 → 재조회 없음 + assert cache.fetch_count == 1 + future = pd.Timestamp.today().normalize() + pd.Timedelta(days=30) + cache.ensure("2020-01-02", future) # 날짜가 넘어간 장기 실행 → 재조회 + assert cache.fetch_count == 2 + + +# ── 벤치마크 상대 모멘텀·회전: 봉마다 지수를 다시 받지 않는다 ── + + +def _momentum(**params): + from strategies.momentum_factor import MomentumFactorStrategy + + strategy = MomentumFactorStrategy(Config.get()) + strategy.params = { + "benchmark_relative": True, + "benchmark_symbol": "KS11", + "lookback_days": 20, + "buy_threshold_pct": 2.0, + "sell_threshold_pct": -2.0, + **params, + } + return strategy + + +def _wavy_stock(start="2019-06-03", n=260): + dates = pd.bdate_range(start, periods=n) + t = np.arange(n) + close = 100 + 10 * np.sin(t / 15.0) + 0.05 * t + return pd.DataFrame( + {"open": close, "high": close + 1, "low": close - 1, "close": close, "volume": 1e6}, + index=dates, + ) + + +def test_momentum_benchmark_fetched_once_in_strict_loop(fake_collector): + df = _wavy_stock() + full = _momentum().analyze(df) + strategy = _momentum() + fake_collector.calls = [] + # 1행짜리 접두는 analyze가 계산 없이 HOLD로 돌려주므로 2행부터 본다. + for i in range(1, len(df)): + last = strategy.analyze(df.iloc[: i + 1].copy()).iloc[-1] + assert last["signal"] == full["signal"].iloc[i], i + if pd.notna(full["benchmark_return"].iloc[i]): + assert np.isclose(last["benchmark_return"], full["benchmark_return"].iloc[i]), i + assert len(fake_collector.calls) == 1 + # 캐시가 봉마다 늘어나지 않는다 (예전에는 봉마다 시리즈가 하나씩 쌓였다) + assert len(strategy._benchmark_return_cache) == 1 + + +def test_momentum_benchmark_values_are_point_in_time(fake_collector): + df = _wavy_stock() + out = _momentum().analyze(df) + ks = _ks11_series() + expected = (ks / ks.shift(20) - 1) * 100 + for d in df.index[::13]: + assert np.isclose(out.loc[d, "benchmark_return"], expected.loc[d]), d + assert (out["signal"] == "BUY").any() and (out["signal"] == "SELL").any() + + +def test_momentum_benchmark_failure_is_not_retried_per_bar(fake_collector): + fake_collector.fail = True + df = _wavy_stock(n=60) + strategy = _momentum() + for i in range(len(df)): + out = strategy.analyze(df.iloc[: i + 1].copy()) + assert len(fake_collector.calls) == 1 + assert out["benchmark_return"].isna().all() + assert "BUY" not in set(out["signal"]) + + +def test_rotation_benchmark_composite_fetched_once_in_strict_loop(fake_collector): + df = _wavy_stock(n=200) + params = { + "market_filter_sma200": False, + "score_mode": "benchmark_excess", + "rank_entry_mode": "dense_ranked", + "use_positive_momentum_filter": False, + "use_trend_filter": False, + "exit_trend_edge": False, + "exit_rebalance_mode": "none", + } + full = _rotation(**params).analyze(df) + strategy = _rotation(**params) + fake_collector.calls = [] + for i in range(len(df)): + last = strategy.analyze(df.iloc[: i + 1].copy()).iloc[-1] + assert last["signal"] == full["signal"].iloc[i], i + expected = full["benchmark_composite_score"].iloc[i] + if pd.notna(expected): + assert np.isclose(last["benchmark_composite_score"], expected), i + assert len(fake_collector.calls) == 1 + + ks = _ks11_series() + expected_composite = 0.6 * ks.pct_change(2) + 0.4 * ks.pct_change(3) + d = df.index[100] + assert np.isclose(full.loc[d, "benchmark_composite_score"], expected_composite.loc[d]) diff --git a/tests/test_audit_strategies_mean_reversion.py b/tests/test_audit_strategies_mean_reversion.py new file mode 100644 index 00000000..da3b3b67 --- /dev/null +++ b/tests/test_audit_strategies_mean_reversion.py @@ -0,0 +1,154 @@ +"""mean_reversion 백테스트/paper 매수 규칙 일치 감사 회귀 테스트. + +예전에는 52주 필터가 generate_signal()에만 있어 백테스트(analyze만 호출)가 필터 없는 +다른 전략을 평가했다. 이제 두 52주 필터는 analyze()에서 적용된다. 펀더멘털 필터와 +코스피200 제한은 '현재' 데이터만 있어 백테스트에 넣으면 미래 정보가 섞이므로 +analyze()에 넣지 않고 '반영 안 됨'으로 기록한다. +""" + +import numpy as np +import pandas as pd +import pytest + +from config.config_loader import Config + +_BASE_PARAMS = { + "z_score_buy": -2.0, + "z_score_sell": 2.0, + "lookback_period": 20, + "adx_filter": 20, + "volume_spike_filter": 3.0, + "exclude_52w_low_near": True, + "max_drawdown_from_52w_high": 0.30, + "near_52w_low_pct": 0.05, + "window_52w": 252, + "restrict_to_kospi200": False, + "fundamental_filter": {"enabled": False}, +} + + +def _strategy(**overrides): + from strategies.mean_reversion import MeanReversionStrategy + + strategy = MeanReversionStrategy(Config.get()) + strategy.params = {**_BASE_PARAMS, **overrides} + # 지표는 고정: ADX 15(<20), RSI 30(<40), 거래량 정상 → Z-Score와 52주 필터만 신호를 가른다. + strategy.indicator_engine.calculate_all = lambda df: df.assign( + adx=15.0, rsi=30.0, volume_ratio=1.0 + ) + return strategy + + +def _frame(closes): + closes = np.asarray(closes, dtype=float) + return pd.DataFrame( + { + "open": closes, + "high": closes + 1, + "low": closes - 1, + "close": closes, + "volume": [1_000_000.0] * len(closes), + }, + index=pd.bdate_range("2023-01-02", periods=len(closes)), + ) + + +def _near_low_case(): + # 100 부근 횡보 뒤 마지막 봉 90으로 급락: 52주 저점(89) 대비 +1.1% → 신저가 근방 + return _frame([100.0 + (i % 3) * 0.5 for i in range(60)] + [90.0]) + + +def _drawdown_case(): + # 150 → 80 하락 → 105 회복 횡보 → 마지막 100: 52주 고점(151) 대비 -33.8%, 저점 대비 +26% + down = list(np.linspace(150, 80, 40)) + up = list(np.linspace(80, 105, 20)) + flat = [105.0 + (i % 2) * 0.3 for i in range(25)] + return _frame(down + up + flat + [100.0]) + + +def _clean_case(): + # 80 → 110 상승 → 105 횡보 → 마지막 100: 고점 대비 -9.9%, 저점 대비 +26% → 필터 통과 + up = list(np.linspace(80, 110, 40)) + flat = [105.0 + (i % 2) * 0.3 for i in range(25)] + return _frame(up + flat + [100.0]) + + +@pytest.mark.parametrize( + "case, expected, veto_col", + [ + (_near_low_case, "HOLD", "buy_veto_52w_near_low"), + (_drawdown_case, "HOLD", "buy_veto_52w_drawdown"), + (_clean_case, "BUY", None), + ], +) +def test_analyze_matches_generate_signal_on_last_bar(case, expected, veto_col): + strategy = _strategy() + df = case() + analyzed = strategy.analyze(df) + assert analyzed["z_score"].iloc[-1] <= -2.0, "시나리오 전제: 마지막 봉이 Z-Score 매수 영역" + assert analyzed["signal"].iloc[-1] == expected + assert strategy.generate_signal(df)["signal"] == expected + if veto_col: + assert bool(analyzed[veto_col].iloc[-1]) is True + + +def test_52w_filter_off_restores_raw_zscore_buy(): + strategy = _strategy(exclude_52w_low_near=False) + analyzed = strategy.analyze(_near_low_case()) + assert analyzed["signal"].iloc[-1] == "BUY" + assert not analyzed["buy_veto_52w_near_low"].any() + + +def test_prefix_parity_between_backtest_path_and_generate_signal(): + """모든 시점에서 analyze(접두) 마지막 신호 == generate_signal(접두) 신호.""" + rng = np.random.default_rng(11) + closes = 100 * np.exp(np.cumsum(rng.normal(-0.002, 0.03, 200))) + df = _frame(closes) + strategy = _strategy() + full = strategy.analyze(df) + assert (full["signal"] == "BUY").any(), "시나리오에 매수 신호가 있어야 한다" + assert (full["buy_veto_52w_near_low"] | full["buy_veto_52w_drawdown"]).any(), ( + "시나리오에 52주 필터로 걸러진 매수가 있어야 한다" + ) + for i in range(25, len(df)): + prefix = df.iloc[: i + 1] + bt_signal = strategy.analyze(prefix)["signal"].iloc[-1] + assert bt_signal == full["signal"].iloc[i], i + assert strategy.generate_signal(prefix)["signal"] == bt_signal, i + + +def test_backtest_path_never_calls_point_in_time_unsafe_filters(monkeypatch): + """펀더멘털 필터·코스피200 조회는 현재 데이터라 analyze()가 부르면 안 된다.""" + import strategies.mean_reversion as mr + + def _boom(*args, **kwargs): + raise AssertionError("analyze()가 현재 시점 펀더멘털을 조회함 (미래 정보)") + + monkeypatch.setattr(mr, "check_fundamental_filter", _boom) + strategy = _strategy( + restrict_to_kospi200=True, + fundamental_filter={"enabled": True, "per_min": 0, "per_max": 50}, + ) + strategy._is_kospi200 = _boom + analyzed = strategy.analyze(_clean_case()) + assert analyzed["signal"].iloc[-1] == "BUY" + assert strategy.unmodelled_backtest_filters() == [ + "restrict_to_kospi200", + "fundamental_filter", + ] + + +def test_unmodelled_filters_warn_once_per_instance(): + from loguru import logger + + messages = [] + sink_id = logger.add(lambda m: messages.append(str(m)), level="WARNING") + try: + strategy = _strategy(fundamental_filter={"enabled": True, "per_min": 0}) + strategy.analyze(_clean_case()) + strategy.analyze(_clean_case()) + finally: + logger.remove(sink_id) + hits = [m for m in messages if "반영하지 않습니다" in m] + assert len(hits) == 1, messages + assert "fundamental_filter" in hits[0] diff --git a/tests/test_audit_strategies_regime.py b/tests/test_audit_strategies_regime.py new file mode 100644 index 00000000..17dbd521 --- /dev/null +++ b/tests/test_audit_strategies_regime.py @@ -0,0 +1,169 @@ +"""시장 국면 모듈 감사 회귀 테스트.""" + +from types import SimpleNamespace + +import pandas as pd +import pytest + + +# ── regime_adaptive: 계산만 되고 읽히지 않던 buy_threshold_offset 제거 ── + + +def _regime_cfg(regime_adaptive): + return SimpleNamespace( + trading={"market_regime_filter": False}, + risk_params={}, + strategies={"regime_adaptive": regime_adaptive}, + ) + + +@pytest.mark.parametrize( + "regime, sl, tp", + [("bullish", 1.0, 1.0), ("caution", 0.7, 0.8), ("bearish", 0.5, 0.6)], +) +def test_regime_adjusted_params_only_carry_sl_tp_multipliers(monkeypatch, regime, sl, tp): + import core.market_regime as mr + from config.config_loader import load_strategies + + monkeypatch.setattr( + mr, + "check_market_regime", + lambda config, collector=None: { + "regime": regime, + "allow_buys": regime != "bearish", + "position_scale": {"bullish": 1.0, "caution": 0.5, "bearish": 0.0}[regime], + "details": {}, + }, + ) + cfg = _regime_cfg(load_strategies()["regime_adaptive"]) + out = mr.get_regime_adjusted_params(cfg) + assert "buy_threshold_offset" not in out + assert out["stop_loss_multiplier"] == sl + assert out["take_profit_multiplier"] == tp + + +def test_strategies_yaml_has_no_dead_buy_threshold_offset(): + from config.config_loader import load_strategies + + ra = load_strategies()["regime_adaptive"] + for regime in ("bullish", "caution", "bearish"): + assert "buy_threshold_offset" not in ra[regime], regime + + +# ── check_market_regime: 거래일 수를 달력일로 환산, 오늘 봉 제외, 마지막 MA 유한성 ── + + +def _runtime_cfg(ma_days=200): + return SimpleNamespace( + strategies={}, + risk_params={}, + trading={ + "market_regime_filter": True, + "market_regime_index": "KS11", + "market_regime_ma_days": ma_days, + "market_regime_short_momentum_days": 20, + "market_regime_short_momentum_threshold": -5.0, + "market_regime_caution_scale": 0.5, + "market_regime_ma_cross_enabled": False, + }, + ) + + +def _krx_days(start, end): + """요청 구간의 KRX 거래일 (주말·config/holidays.yaml 휴장일 제외).""" + from core.trading_hours import _load_holidays + + holidays = {pd.Timestamp(h) for h in _load_holidays()} + days = pd.bdate_range(pd.Timestamp(start), pd.Timestamp(end)) + return pd.DatetimeIndex([d for d in days if d not in holidays]) + + +def _today(): + from datetime import datetime + from zoneinfo import ZoneInfo + + return pd.Timestamp(datetime.now(ZoneInfo("Asia/Seoul")).date()) + + +class _CalendarCollector: + """요청 구간 안의 실제 거래일만 돌려주는 가짜 수집기 (예전 테스트 스텁은 N행 고정).""" + + def __init__(self, level_fn, *, drop_last_sessions=0, extra_today_close=None): + self.level_fn = level_fn + self.drop_last_sessions = drop_last_sessions + self.extra_today_close = extra_today_close + self.requests = [] + + def fetch_korean_stock(self, symbol, start_date=None, end_date=None): + self.requests.append((start_date, end_date)) + days = _krx_days(start_date, end_date) + days = days[days < _today()] + if self.drop_last_sessions: + days = days[: -self.drop_last_sessions] + closes = [self.level_fn(i) for i in range(len(days))] + if self.extra_today_close is not None: + days = days.append(pd.DatetimeIndex([_today()])) + closes.append(self.extra_today_close) + return pd.DataFrame({"close": closes}, index=days) + + +def test_enabled_regime_gets_enough_bars_for_ma200(): + from core.market_regime import check_market_regime + + rising = _CalendarCollector(lambda i: 1000.0 + i) + result = check_market_regime(_runtime_cfg(), rising) + assert result["regime"] == "bullish", result + assert result["allow_buys"] is True + start, end = rising.requests[0] + assert len(_krx_days(start, end)) >= 250 # MA200 + 여유 + + falling = _CalendarCollector(lambda i: 3000.0 - 5.0 * i) + result = check_market_regime(_runtime_cfg(), falling) + assert result["regime"] == "bearish", result + assert result["details"]["below_ma"] is True + + +def test_intraday_bar_dated_today_is_not_used(): + from core.market_regime import check_market_regime + + # 오늘 날짜 봉이 폭락(1.0)이어도 확정 봉(어제까지)으로만 판정한다. + collector = _CalendarCollector(lambda i: 1000.0 + i, extra_today_close=1.0) + result = check_market_regime(_runtime_cfg(), collector) + assert result["regime"] == "bullish", result + assert result["details"]["last_close"] > 1.0 + assert pd.Timestamp(result["details"]["last_bar_date"]) < _today() + + +def test_non_finite_last_ma_fails_closed(): + from core.market_regime import check_market_regime + + collector = _CalendarCollector(lambda i: float("inf") if i > 150 else 1000.0 + i) + result = check_market_regime(_runtime_cfg(), collector) + assert result["regime"] == "unknown" + assert result["allow_buys"] is False + assert result["details"]["reason"] == "ma_unavailable" + + +def test_stale_index_feed_is_reported_in_details(): + from core.market_regime import check_market_regime + + collector = _CalendarCollector(lambda i: 1000.0 + i, drop_last_sessions=3) + result = check_market_regime(_runtime_cfg(), collector) + assert result["regime"] == "bullish" + assert result["details"]["missing_sessions"] == 3 + + +def test_disabled_filter_keeps_default_without_fetch(): + from core.market_regime import check_market_regime + + cfg = _runtime_cfg() + cfg.trading["market_regime_filter"] = False + collector = _CalendarCollector(lambda i: 1000.0 + i) + result = check_market_regime(cfg, collector) + assert result == { + "regime": "bullish", + "position_scale": 1.0, + "allow_buys": True, + "details": {}, + } + assert collector.requests == [] diff --git a/tests/test_audit_strategies_scoring.py b/tests/test_audit_strategies_scoring.py new file mode 100644 index 00000000..24cb0b50 --- /dev/null +++ b/tests/test_audit_strategies_scoring.py @@ -0,0 +1,239 @@ +"""스코어링 신호 감사 회귀 테스트 — MACD 워밍업 NaN 처리. + +예전 _score_macd는 NaN 비교(False)를 'Signal 아래'로 읽어, 시그널선이 나오기 전 +워밍업 구간 전체를 -1로 채점하고 첫 유효 봉을 가짜 골든크로스(+2)로 만들었다. +""" + +import numpy as np +import pandas as pd + +from core.signal_generator import SignalGenerator + + +_WEIGHTS = { + "rsi_oversold": 2, + "rsi_overbought": -2, + "macd_golden_cross": 2, + "macd_dead_cross": -2, + "bollinger_lower": 1, + "bollinger_upper": -1, + "volume_surge": 1, + "ma_golden_cross": 1, + "ma_dead_cross": -1, +} + + +class _Cfg: + """운영 설정과 같은 모드(representative_only + 히스터리시스)의 최소 설정.""" + + def __init__(self, *, mode="representative_only", indicators=None): + self.strategies = { + "scoring": { + "buy_threshold": 2, + "sell_threshold": -2, + "collinearity_mode": mode, + "hysteresis": { + "enabled": True, + "exit_sell_threshold": -1, + "exit_buy_threshold": 0.5, + }, + "weights": dict(_WEIGHTS), + } + } + self.indicators = indicators or { + "rsi": {"oversold": 30, "overbought": 70}, + "volume": {"surge_ratio": 1.5}, + "moving_average": {"short_period": 5, "mid_period": 20}, + } + + +def _indicator_frame(n=120, seed=7): + """실제 IndicatorEngine(pandas-ta)으로 지표를 붙인 무작위 일봉.""" + from config.config_loader import Config + from core.indicator_engine import IndicatorEngine + + rng = np.random.default_rng(seed) + close = 10_000 * np.exp(np.cumsum(rng.normal(0, 0.02, n))) + dates = pd.bdate_range("2024-01-02", periods=n) + df = pd.DataFrame( + { + "open": close, + "high": close * 1.01, + "low": close * 0.99, + "close": close, + "volume": rng.integers(100_000, 1_000_000, n).astype(float), + }, + index=dates, + ) + return IndicatorEngine(Config.get()).calculate_all(df) + + +class TestMacdWarmup: + def test_warmup_rows_score_zero_and_first_valid_row_is_not_a_cross(self): + gen = SignalGenerator(_Cfg()) + for seed in range(20): + df = _indicator_frame(seed=seed) + first_valid = df["macd_signal"].first_valid_index() + assert first_valid is not None + pos = df.index.get_loc(first_valid) + assert pos > 0, "시그널선 워밍업이 있어야 하는 시나리오" + + scores = gen._score_macd(df) + assert (scores.iloc[:pos] == 0.0).all(), ( + f"seed={seed}: 워밍업 구간에 MACD 점수가 들어감 {scores.iloc[:pos].unique()}" + ) + # 첫 유효 봉은 비교할 전일이 없으므로 크로스(±2)가 아니라 유지 점수(±1)다. + assert abs(scores.iloc[pos]) == 1.0, ( + f"seed={seed}: 첫 유효 봉 점수 {scores.iloc[pos]} (크로스로 잡히면 안 됨)" + ) + + def test_cross_still_detected_between_two_valid_rows(self): + gen = SignalGenerator(_Cfg()) + dates = pd.bdate_range("2024-01-01", periods=6) + df = pd.DataFrame( + { + "macd": [np.nan, np.nan, -1.0, -0.5, 1.0, -1.0], + "macd_signal": [np.nan, np.nan, 0.0, 0.0, 0.0, 0.0], + "close": [100.0] * 6, + }, + index=dates, + ) + scores = gen._score_macd(df) + assert scores.tolist() == [0.0, 0.0, -1.0, -1.0, 2.0, -2.0] + + def test_histogram_bonus_not_applied_on_invalid_rows(self): + gen = SignalGenerator(_Cfg()) + dates = pd.bdate_range("2024-01-01", periods=4) + # 히스토그램만 값이 있고 시그널선이 비어 있는 행은 여전히 0점이어야 한다. + df = pd.DataFrame( + { + "macd": [1.0, 2.0, 3.0, 4.0], + "macd_signal": [np.nan, np.nan, 1.0, 1.0], + "macd_histogram": [0.5, 1.0, 2.0, 3.0], + "close": [100.0] * 4, + }, + index=dates, + ) + scores = gen._score_macd(df) + assert scores.iloc[0] == 0.0 + assert scores.iloc[1] == 0.0 + # 2행: 첫 유효 봉(유지 +1) + 히스토그램 상승 보너스 +0.5 + assert scores.iloc[2] == 1.5 + + def test_warmup_does_not_carry_sell_state_into_first_valid_bar(self): + """워밍업 -1과 거래량 하락일 -1이 합쳐져 SELL 상태가 이월되던 경로.""" + gen = SignalGenerator(_Cfg()) + n = 12 + dates = pd.bdate_range("2024-01-01", periods=n) + macd = [np.nan] * 8 + [1.0, 1.2, 1.3, 1.4] + signal = [np.nan] * 8 + [0.5, 0.5, 0.5, 0.5] + close = [100.0] * n + close[3] = 95.0 # 하락일 + volume_ratio = [1.0] * n + volume_ratio[3] = 3.0 # 하락일 거래량 급증 → score_volume -1 + df = pd.DataFrame( + { + "macd": macd, + "macd_signal": signal, + "close": close, + "volume_ratio": volume_ratio, + }, + index=dates, + ) + out = gen.generate(df) + assert (out["score_macd"].iloc[:8] == 0.0).all() + assert "SELL" not in set(out["signal"].iloc[:8]), out["signal"].tolist() + + +# ── MA 점수: 설정 기간의 SMA 컬럼을 정확히 사용 ── + + +def _ma_frame(short_vals, mid_vals, *, short_col="sma_5", mid_col="sma_20", extra=None): + n = len(short_vals) + dates = pd.bdate_range("2024-01-01", periods=n) + data = {short_col: short_vals, mid_col: mid_vals, "close": [100.0] * n} + data.update(extra or {}) + return pd.DataFrame(data, index=dates) + + +class TestMaColumnPick: + def test_uses_sma_cross_not_ema_or_sma200(self): + """SMA는 크로스하고 EMA·sma_200은 크로스하지 않는 프레임 — SMA 크로스만 채점.""" + gen = SignalGenerator(_Cfg(mode="max_per_direction")) + df = _ma_frame( + [9.0, 9.0, 11.0, 11.0, 9.0], + [10.0, 10.0, 10.0, 10.0, 10.0], + extra={ + # 예전 로직은 마지막 일치 컬럼(ema_5/ema_20)을 썼다. EMA는 계속 위에 있다. + "ema_5": [12.0] * 5, + "ema_20": [10.0] * 5, + "sma_200": [50.0] * 5, + }, + ) + # IndicatorEngine과 같은 컬럼 순서(sma → ema)로 둔다. + df = df[["sma_5", "sma_20", "sma_200", "ema_5", "ema_20", "close"]] + scores = gen._score_ma(df) + assert scores.tolist() == [0.0, 0.0, 1.0, 0.0, -1.0] + + def test_reconfigured_periods_use_matching_columns(self): + cfg = _Cfg( + mode="max_per_direction", + indicators={ + "rsi": {"oversold": 30, "overbought": 70}, + "moving_average": {"short_period": 10, "mid_period": 30}, + }, + ) + gen = SignalGenerator(cfg) + df = _ma_frame( + [9.0, 11.0, 11.0, 9.0], + [10.0, 10.0, 10.0, 10.0], + short_col="sma_10", + mid_col="sma_30", + ) + scores = gen._score_ma(df) + assert scores.tolist() == [0.0, 1.0, 0.0, -1.0] + + def test_warmup_nan_is_not_a_cross(self): + gen = SignalGenerator(_Cfg(mode="max_per_direction")) + df = _ma_frame( + [9.0, 9.0, 11.0, 11.0, 12.0], + [np.nan, np.nan, 10.0, 10.0, 10.0], + ) + scores = gen._score_ma(df) + # 2행이 첫 유효 봉: 전일 비교 대상이 없으므로 골든크로스가 아니다. + assert scores.tolist() == [0.0, 0.0, 0.0, 0.0, 0.0] + + def test_missing_configured_columns_warns_once_and_scores_zero(self): + from loguru import logger + + messages = [] + sink_id = logger.add(lambda m: messages.append(str(m)), level="WARNING") + try: + cfg = _Cfg( + mode="max_per_direction", + indicators={"moving_average": {"short_period": 10, "mid_period": 30}}, + ) + gen = SignalGenerator(cfg) + df = _ma_frame([9.0, 11.0], [10.0, 10.0]) # sma_5/sma_20만 있음 + assert gen._score_ma(df).tolist() == [0.0, 0.0] + assert gen._score_ma(df).tolist() == [0.0, 0.0] + finally: + logger.remove(sink_id) + hits = [m for m in messages if "MA 점수 컬럼" in m] + assert len(hits) == 1, messages + + def test_real_indicator_frame_scores_sma_cross(self): + """IndicatorEngine 출력(sma_200 포함)에서도 SMA5/SMA20 크로스와 일치해야 한다.""" + gen = SignalGenerator(_Cfg(mode="max_per_direction")) + df = _indicator_frame(n=260, seed=3) + assert "sma_200" in df.columns + scores = gen._score_ma(df) + above = df["sma_5"] > df["sma_20"] + valid = df["sma_5"].notna() & df["sma_20"].notna() + prev_valid = valid.shift(1, fill_value=False) + expected_golden = valid & prev_valid & above & ~above.shift(1, fill_value=False) + expected_dead = valid & prev_valid & ~above & above.shift(1, fill_value=False) + assert expected_golden.any() and expected_dead.any() + assert (scores[expected_golden] == 1.0).all() + assert (scores[expected_dead] == -1.0).all() + assert (scores[~(expected_golden | expected_dead)] == 0.0).all() diff --git a/tests/test_audit_strategies_trend.py b/tests/test_audit_strategies_trend.py new file mode 100644 index 00000000..7834f80e --- /dev/null +++ b/tests/test_audit_strategies_trend.py @@ -0,0 +1,95 @@ +"""trend_following 최적화 파라미터 감사 회귀 테스트. + +예전에는 최적화기가 trend_ma_period·atr_stop_multiplier를 탐색했지만 전략은 sma_200을 +고정으로 읽고 두 키를 어디서도 읽지 않아, 48개 조합 중 서로 다른 결과는 4개뿐이었다. +""" + +import inspect + +import numpy as np +import pandas as pd + +from config.config_loader import Config + + +def _trending_frame(n=400, seed=5): + rng = np.random.default_rng(seed) + # 상승 → 하락 → 상승 구간을 섞어 추세선 기간에 따라 위/아래 판정이 갈리게 한다. + drift = np.concatenate([ + np.full(n // 3, 0.003), + np.full(n // 3, -0.003), + np.full(n - 2 * (n // 3), 0.003), + ]) + close = 10_000 * np.exp(np.cumsum(drift + rng.normal(0, 0.012, n))) + return pd.DataFrame( + { + "open": close, + "high": close * 1.01, + "low": close * 0.99, + "close": close, + "volume": rng.integers(100_000, 1_000_000, n).astype(float), + }, + index=pd.bdate_range("2022-01-03", periods=n), + ) + + +def _strategy(**overrides): + from strategies import create_strategy + + config = Config.get() + if overrides: + config = config.with_strategy_overrides("trend_following", overrides) + return create_strategy("trend_following", config) + + +def test_trend_ma_period_changes_signals(): + df = _trending_frame() + fast = _strategy(trend_ma_period=60).analyze(df) + slow = _strategy(trend_ma_period=200).analyze(df) + assert not fast["strategy_score"].equals(slow["strategy_score"]) + assert (fast["signal"] != slow["signal"]).any() + # 60일 추세선은 60봉째부터 값이 있다 + assert fast["trend_ma"].first_valid_index() == df.index[59] + + +def test_default_period_matches_previous_sma200_behaviour(): + """기본값 200은 IndicatorEngine의 sma_200과 같은 선이다 — 기존 결과 불변.""" + df = _trending_frame() + out = _strategy().analyze(df) + assert "sma_200" in out.columns + pd.testing.assert_series_equal( + out["trend_ma"], out["sma_200"], check_names=False + ) + + +def test_generate_signal_length_gate_follows_period(): + df = _trending_frame(n=120) + res = _strategy(trend_ma_period=60).generate_signal(df) + assert "데이터 부족" not in str(res["details"].get("이유", "")) + assert "60일선" in res["details"] + res200 = _strategy(trend_ma_period=200).generate_signal(df) + assert res200["details"]["이유"] == "데이터 부족(200일 필요)" + + +def test_every_trend_following_search_key_changes_analyze_output(): + """최적화 탐색 공간의 모든 키가 실제로 전략 출력에 영향을 줘야 한다.""" + from backtest.param_optimizer import DEFAULT_SEARCH_SPACES + + df = _trending_frame() + space = DEFAULT_SEARCH_SPACES["trend_following"] + for key, values in space.items(): + low = _strategy(**{key: min(values)}).analyze(df) + high = _strategy(**{key: max(values)}).analyze(df) + assert not low["strategy_score"].equals(high["strategy_score"]), ( + f"{key}={min(values)} vs {max(values)} 결과가 같음 — 읽히지 않는 탐색 키" + ) + + +def test_dead_stop_multiplier_keys_removed_from_optimizer(): + import backtest.param_optimizer as opt + + assert "atr_stop_multiplier" not in opt.DEFAULT_SEARCH_SPACES["trend_following"] + # bayesian_optimize 안의 default_bounds(지역 dict)도 같은 키를 탐색하지 않아야 한다. + source = inspect.getsource(opt) + assert '"atr_stop_multiplier"' not in source + assert '"trailing_atr_multiplier"' not in source diff --git a/tests/test_basket_evaluation.py b/tests/test_basket_evaluation.py index fe432d0b..989fce88 100644 --- a/tests/test_basket_evaluation.py +++ b/tests/test_basket_evaluation.py @@ -52,14 +52,14 @@ def test_wait_still_surfaces_integrity_issues(): """기간 전이라도 커버리지 붕괴·실패주문은 이슈로 보여준다(판정은 WAIT 유지).""" out = _eval(trading_days_total=10, snapshot_days=5, pending_failed_orders=2) assert out["verdict"] == "WAIT" - assert any("커버리지" in i for i in out["issues"]) + assert any("제때 남긴 스냅샷" in i for i in out["issues"]) assert any("실패 주문" in i for i in out["issues"]) def test_low_snapshot_coverage_fails_after_period(): out = _eval(snapshot_days=50) # 50/60 = 83% < 95% assert out["verdict"] == "FAIL_REVIEW" - assert any("커버리지" in i for i in out["issues"]) + assert any("제때 남긴 스냅샷" in i for i in out["issues"]) def test_pending_failed_orders_fail_after_period(): diff --git a/tests/test_basket_overlay_integration.py b/tests/test_basket_overlay_integration.py index 59d7bd77..0afc1acc 100644 --- a/tests/test_basket_overlay_integration.py +++ b/tests/test_basket_overlay_integration.py @@ -121,7 +121,9 @@ def test_missing_index_data_keeps_previous_state_and_flags(self): with _patch_snapshots(rb): decision = rb.overlay_decision() assert decision.scale == pytest.approx(0.5) - assert decision.data_issues and "부족" in decision.data_issues[0] + # 자료가 아예 없으면 '기간 부족'이 아니라 '쓸 수 없음'으로 적는다 — 오래된 자료로 + # 입력이 비었는데 '200일치 부족'이라고 쓰면 원인을 엉뚱한 데서 찾게 된다(2026-09-23). + assert decision.data_issues and "쓸 수 없음" in decision.data_issues[0] def test_missing_last_close_cannot_look_like_a_trend_recovery(self): save_overlay_state("t", OverlayDecision(scale=0.5, trend_below=True)) diff --git a/tests/test_basket_rebalancer.py b/tests/test_basket_rebalancer.py index 5b380503..31346fc8 100644 --- a/tests/test_basket_rebalancer.py +++ b/tests/test_basket_rebalancer.py @@ -652,6 +652,7 @@ def test_execute_passes_avg_volume_to_buy(self, rebalancer, monkeypatch): rebalancer.portfolio_mgr = SimpleNamespace( get_available_cash=MagicMock(return_value=5_000_000), get_current_capital=MagicMock(return_value=100_000_000), + get_portfolio_summary=MagicMock(return_value={"total_value": 95_000_000}), ) rebalancer._market_snapshot = { "005930": {"price": 70_000.0, "avg_volume": 123_456.0}, @@ -670,6 +671,80 @@ def test_execute_passes_avg_volume_to_buy(self, rebalancer, monkeypatch): kwargs = fake_executor.execute_buy_quantity.call_args.kwargs assert kwargs["avg_daily_volume"] == pytest.approx(123_456.0) + def test_execute_sizes_exposure_at_market_prices(self, rebalancer, monkeypatch): + """계획은 시가로 하는데 주문 단계가 원가로 재면 하락장 보충 매수가 '투자 비중 + 초과'로 거부된다(2026-09-23 점검). 가격 스냅샷이 있으면 총자산과 보유 노출을 + 같은 시가로 넘긴다.""" + from core.basket_rebalancer import RebalanceOrder + + summary = MagicMock(return_value={"total_value": 95_000_000}) + rebalancer.portfolio_mgr = SimpleNamespace( + get_available_cash=MagicMock(return_value=5_000_000), + get_current_capital=MagicMock(return_value=100_000_000), + get_portfolio_summary=summary, + ) + rebalancer._market_snapshot = {"005930": {"price": 70_000.0, "avg_volume": 1.0}} + fake_executor = MagicMock() + fake_executor.execute_buy_quantity.return_value = {"success": True} + monkeypatch.setattr( + "core.order_executor.OrderExecutor", MagicMock(return_value=fake_executor) + ) + + rebalancer.execute([RebalanceOrder("005930", "BUY", 10, 70_000, "테스트")]) + + kwargs = fake_executor.execute_buy_quantity.call_args.kwargs + assert kwargs["capital"] == 95_000_000 # 원가 총자산(1억)이 아니라 시가 + assert kwargs["mark_prices"] == {"005930": 70_000.0} + assert summary.call_args.kwargs["current_prices"] == {"005930": 70_000.0} + rebalancer.portfolio_mgr.get_current_capital.assert_not_called() + + def test_failed_order_reason_is_logged_and_returned(self, rebalancer, monkeypatch): + from core.basket_rebalancer import RebalanceOrder + + rebalancer.portfolio_mgr = SimpleNamespace( + get_available_cash=MagicMock(return_value=5_000_000), + get_current_capital=MagicMock(return_value=100_000_000), + get_portfolio_summary=MagicMock(return_value={"total_value": 100_000_000}), + ) + rebalancer._market_snapshot = {"005930": {"price": 70_000.0, "avg_volume": 1.0}} + fake_executor = MagicMock() + fake_executor.execute_buy_quantity.return_value = { + "success": False, "reason": "전체 투자 비중 63% 초과", + } + monkeypatch.setattr( + "core.order_executor.OrderExecutor", MagicMock(return_value=fake_executor) + ) + + result = rebalancer.execute([RebalanceOrder("005930", "BUY", 10, 70_000, "테스트")]) + + assert result["failed"] == 1 + assert result["details"][0]["reason"] == "전체 투자 비중 63% 초과" + + def test_stop_loss_exit_is_sent_as_emergency_but_take_profit_is_not( + self, rebalancer, monkeypatch, + ): + from core.basket_rebalancer import RebalanceOrder + + rebalancer.portfolio_mgr = SimpleNamespace( + get_available_cash=MagicMock(return_value=0), + get_current_capital=MagicMock(return_value=100_000_000), + ) + rebalancer._market_snapshot = {} + fake_executor = MagicMock() + fake_executor.execute_sell.return_value = {"success": True} + monkeypatch.setattr( + "core.order_executor.OrderExecutor", MagicMock(return_value=fake_executor) + ) + + rebalancer.execute([ + RebalanceOrder("005930", "SELL", 1, 70_000, "RISK_EXIT STOP_LOSS: 손절"), + RebalanceOrder("000660", "SELL", 1, 70_000, "RISK_EXIT TAKE_PROFIT: 익절"), + RebalanceOrder("035720", "SELL", 1, 70_000, "비중 초과 (14.9% → 11.1%)"), + ]) + + flags = [c.kwargs["emergency"] for c in fake_executor.execute_sell.call_args_list] + assert flags == [True, False, False] + class TestBasketAccountIsolation: """여러 enabled 바스켓의 계좌(자본 풀) 공유 차단 — 과배분·트랙레코드 오염 방지.""" diff --git a/tests/test_calendar_audit.py b/tests/test_calendar_audit.py new file mode 100644 index 00000000..53c157c1 --- /dev/null +++ b/tests/test_calendar_audit.py @@ -0,0 +1,130 @@ +"""휴장일 달력 회귀 테스트 (2026-09-23 점검). + +달력 오류는 양방향으로 조용히 해롭다. +- 휴장일 누락 → 커버리지 분모가 부풀고 가짜 결측 경보가 난다(2026-07-17 사례). +- 거래일을 휴장으로 → 그날 스냅샷이 직전 거래일 행을 덮어써 결측이 숨는다 + (2026-09-28 사례 — 미래 날짜라 시장 데이터 대조 도구로는 잡을 수 없었다). +그래서 연도별 '평일 휴장' 집합을 손으로 검증한 값으로 고정한다. 달력을 고치려면 +이 목록도 함께 고쳐야 CI를 통과한다. +""" + +from datetime import date, datetime +from pathlib import Path + +import yaml + +from core import holidays_updater as hu + +_YAML = Path(__file__).resolve().parent.parent / "config" / "holidays.yaml" + +# 월력요항 기준으로 손 검증한 평일 휴장일(주말에 걸린 공휴일은 판정에 영향이 없어 뺐다) +VERIFIED_WEEKDAY_CLOSURES = { + 2026: { + "2026-01-01", "2026-02-16", "2026-02-17", "2026-02-18", "2026-03-02", + "2026-05-01", "2026-05-05", "2026-05-25", "2026-06-03", "2026-07-17", + "2026-08-17", "2026-09-24", "2026-09-25", "2026-10-05", "2026-10-09", + "2026-12-25", "2026-12-31", + }, + 2027: { + "2027-01-01", "2027-02-08", "2027-02-09", "2027-03-01", "2027-05-05", + "2027-05-13", "2027-08-16", "2027-09-14", "2027-09-15", "2027-09-16", + "2027-10-04", "2027-10-11", "2027-12-27", "2027-12-31", + }, +} + + +def _weekday_set(dates, year): + out = set() + for d in dates: + s = str(d)[:10] + if int(s[:4]) == year and date.fromisoformat(s).weekday() < 5: + out.add(s) + return out + + +def _yaml_dates(): + with open(_YAML, encoding="utf-8") as f: + return (yaml.safe_load(f) or {}).get("holidays", []) + + +def test_yaml_weekday_closures_match_verified_list(): + dates = _yaml_dates() + for year, expected in VERIFIED_WEEKDAY_CLOSURES.items(): + assert _weekday_set(dates, year) == expected, f"{year}년 평일 휴장 집합이 검증 목록과 다름" + + +def test_fallback_weekday_closures_match_yaml(): + """fallback 표가 고치기 전 값으로 남아 있으면 자동 갱신이 고친 달력을 되돌린다.""" + dates = _yaml_dates() + for year in VERIFIED_WEEKDAY_CLOSURES: + assert _weekday_set(hu.FALLBACK_BY_YEAR[year], year) == _weekday_set(dates, year) + + +def test_chuseok_2026_saturday_overlap_has_no_substitute(): + from core.trading_hours import TradingHours + + th = TradingHours() + assert th.is_trading_day(datetime(2026, 9, 24)) is False + assert th.is_trading_day(datetime(2026, 9, 25)) is False + assert th.is_trading_day(datetime(2026, 9, 28)) is True + + +# --------------------------------------------------------------- 자동 갱신 안전성 + +def _write(path, dates, header="# 교정 이력 주석\n# 두 번째 줄\n"): + path.write_text(header + "\n" + yaml.safe_dump({"holidays": sorted(dates)}), encoding="utf-8") + + +def test_fallback_update_never_removes_existing_entries(tmp_path, monkeypatch): + """pykrx가 실패하면 fallback이 사람이 고친 파일을 덮어쓰지 못해야 한다.""" + path = tmp_path / "holidays.yaml" + _write(path, {"2026-07-17", "2026-09-24"}) + monkeypatch.setattr(hu, "_fetch_from_pykrx", lambda a, b: (set(), None)) + monkeypatch.setattr(hu, "FALLBACK_BY_YEAR", {2026: {"2026-01-01"}}) + + hu.update_holidays_yaml(path=path, year_from=2026, year_to=2026) + + saved = set(str(d) for d in yaml.safe_load(path.read_text(encoding="utf-8"))["holidays"]) + assert {"2026-07-17", "2026-09-24", "2026-01-01"} <= saved + + +def test_update_preserves_header_comments(tmp_path, monkeypatch): + path = tmp_path / "holidays.yaml" + _write(path, {"2026-09-24"}) + monkeypatch.setattr(hu, "_fetch_from_pykrx", lambda a, b: (set(), None)) + + hu.update_holidays_yaml(path=path, year_from=2026, year_to=2026) + + text = path.read_text(encoding="utf-8") + assert text.startswith("# 교정 이력 주석\n# 두 번째 줄\n") + + +def test_pykrx_verified_range_is_authoritative_but_future_is_kept(tmp_path, monkeypatch): + """시장 데이터로 확인된 구간은 바로잡고, 확인되지 않은 미래 구간은 건드리지 않는다.""" + path = tmp_path / "holidays.yaml" + # 01-27은 실제로는 개장한 날(틀린 항목), 09-24는 아직 확인 불가한 미래 + _write(path, {"2026-01-27", "2026-09-24", "2026-03-01"}) + monkeypatch.setattr( + hu, "_fetch_from_pykrx", lambda a, b: ({"2026-02-16"}, date(2026, 9, 22)), + ) + monkeypatch.setattr(hu, "FALLBACK_BY_YEAR", {2026: {"2026-09-25"}}) + + hu.update_holidays_yaml(path=path, year_from=2026, year_to=2026) + + saved = set(str(d) for d in yaml.safe_load(path.read_text(encoding="utf-8"))["holidays"]) + assert "2026-01-27" not in saved # 확인 구간의 틀린 평일 항목은 바로잡힌다 + assert "2026-02-16" in saved # 확인 구간의 실제 휴장 + assert "2026-03-01" in saved # 주말 항목은 그대로 + assert {"2026-09-24", "2026-09-25"} <= saved # 미래 구간은 기존 ∪ fallback + + +def test_pykrx_derivation_stops_at_last_returned_session(monkeypatch): + """pykrx에는 미래 영업일이 없다 — 마지막 거래일 뒤 평일을 휴장으로 찍으면 안 된다.""" + trading = {date(2026, 9, 21), date(2026, 9, 22), date(2026, 9, 23)} + monkeypatch.setattr(hu, "_pykrx_trading_dates", lambda s, e: trading) + + holidays, verified = hu._fetch_from_pykrx(2026, 2026) + + assert verified == date(2026, 9, 23) + assert all(d <= "2026-09-23" for d in holidays) + assert "2026-09-24" not in holidays and "2026-12-01" not in holidays diff --git a/tests/test_critical_fixes.py b/tests/test_critical_fixes.py index bbb0a507..b5228f82 100644 --- a/tests/test_critical_fixes.py +++ b/tests/test_critical_fixes.py @@ -190,6 +190,8 @@ def save_daily_nav_snapshot(self): notifier = SimpleNamespace(send_message=MagicMock()) monkeypatch.setattr(main_mod.Config, "get", lambda: config) monkeypatch.setattr(main_mod, "_check_live_readiness_gate", gate) + # 휴장일에 테스트가 돌아도 매매 경로를 타도록 거래일로 고정 + monkeypatch.setattr(main_mod, "_market_closed_today", lambda config, now: False) monkeypatch.setattr("core.basket_rebalancer.BasketRebalancer", FakeRebalancer) monkeypatch.setattr("core.notifier.Notifier", lambda cfg: notifier) monkeypatch.setenv("ENABLE_LIVE_TRADING", "true") @@ -1264,12 +1266,17 @@ def fake_fetch(self, symbol, start_date=None, end_date=None): frame["close"] = frame["close"] * 0.95 return frame - def fake_run(df, *, strategy_name, strict_lookahead=True): + def fake_run(df, *, strategy_name, strict_lookahead=True, trade_start_date=None): run_calls.append( { "strategy_name": strategy_name, "strict_lookahead": strict_lookahead, "rows": len(df), + "warmup_rows": ( + int(df.index.searchsorted(pd.Timestamp(trade_start_date))) + if trade_start_date is not None + else 0 + ), } ) return { @@ -1314,7 +1321,11 @@ def test_wf_produces_nonzero_windows(self, monkeypatch, tmp_path): ] assert len(run_calls) == n_total assert all(call["strict_lookahead"] is True for call in run_calls) - assert all(call["rows"] == 252 for call in run_calls) + # 감사 수정(워크포워드 콜드 스타트): 예전엔 테스트 252행만 넘겨 60~200일 지표가 창 + # 앞부분 내내 꺼져 있었다. 이제 각 창은 직전 train_days(504행)를 지표 워밍업으로 함께 + # 넘기고 trade_start_date(테스트 시작일)부터 거래·평가한다. + assert all(call["rows"] == 504 + 252 for call in run_calls) + assert all(call["warmup_rows"] == 504 for call in run_calls) assert str(result["report_path"]).startswith(str(tmp_path)) def test_wf_result_has_flat_keys(self, monkeypatch, tmp_path): diff --git a/tests/test_deployment_ratchet.py b/tests/test_deployment_ratchet.py index b0f7dde6..19ed709a 100644 --- a/tests/test_deployment_ratchet.py +++ b/tests/test_deployment_ratchet.py @@ -413,3 +413,62 @@ def test_deployment_monitoring_is_not_disabled(): assert float(tol) < 1.0, ( "%s: 배치율 감시가 사실상 꺼져 있다(tolerance=%s)" % (name, tol) ) + + +# ------------------------------------------------------------- 방어 자산 보충 (2026-09-23) + +def _pocket(wired, positions, total_value, cash): + holdings = {"069500": 0.5, "357870": 0.5} + rb = _rebalancer(holdings=holdings, target_stock_weight=0.95, min_trade=50_000, + positions=positions, turnover=0.6) + rb.basket["overlays"] = {"defensive_symbol": "357870"} + rb._risk_params = {"diversification": {"min_cash_ratio": 0.05}} + rb.basket["min_cash_ratio"] = 0.05 + wired(rb, total_value) + rb.portfolio_mgr.get_portfolio_summary.return_value = { + "total_value": total_value, "cash": cash, + } + return rb + + +def test_idle_cash_goes_to_defensive_parking_etf(wired): + """kr_pocket 9/23 실제 상태: 지수 1주·파킹 3주·현금 10.4만 원 — 두 종목 다 상한에 막혀 + 한 달째 현금이 놀았다. 파킹 ETF 1주는 다음 사이클 매도 규칙이 되팔지 않는 범위다.""" + prices = {"069500": 113_800, "357870": 57_900} + positions = [_pos("069500", 122_345, 1), _pos("357870", 57_913, 3)] + total = 113_800 + 3 * 57_900 + 103_870 + rb = _pocket(wired, positions, total, 103_870) + + orders = rb.plan_rebalance(prices) + + buys = [o for o in orders if o.action == "BUY"] + assert [(o.symbol, o.quantity) for o in buys] == [("357870", 1)] + assert "배치율 보충" in buys[0].reason + + +def test_defensive_exemption_never_applies_to_equity(wired): + """주식 종목은 여전히 목표+허용을 넘겨 사지 않는다 — 주식 비중이 설계를 넘는다.""" + prices = {"069500": 113_800, "357870": 57_900} + # 파킹 ETF가 이미 목표 이상이라 보충할 곳은 지수뿐인 상태 + positions = [_pos("069500", 113_800, 1), _pos("357870", 57_900, 4)] + total = 113_800 + 4 * 57_900 + 120_000 + rb = _pocket(wired, positions, total, 120_000) + + for o in rb.plan_rebalance(prices): + if o.action == "BUY" and o.symbol == "069500": + investable = total * 0.95 + projected = (113_800 + o.quantity * o.price) / investable + assert projected <= 0.5 + 0.08 + 1e-9 + + +def test_defensive_topup_stays_below_sell_back_threshold(wired): + """다음 사이클에 되팔릴 만큼(초과분 ≥ max(min_trade, 1주)) 사지는 않는다.""" + prices = {"069500": 113_800, "357870": 57_900} + positions = [_pos("069500", 113_800, 1), _pos("357870", 57_900, 3)] + total = 113_800 + 3 * 57_900 + 103_870 + rb = _pocket(wired, positions, total, 103_870) + investable = total * 0.95 + for o in rb.plan_rebalance(prices): + if o.action == "BUY" and o.symbol == "357870": + over = (3 + o.quantity) * 57_900 - investable * 0.5 + assert over < max(50_000, 57_900) diff --git a/tests/test_integration_smoke.py b/tests/test_integration_smoke.py index 7ab8de80..99fd24eb 100644 --- a/tests/test_integration_smoke.py +++ b/tests/test_integration_smoke.py @@ -111,12 +111,14 @@ def test_backtester_all_strategies(self): assert key in m assert "overtrading_warnings" in result - def test_report_generator(self): + def test_report_generator(self, tmp_path): from backtest.backtester import Backtester from backtest.report_generator import ReportGenerator df = _sample_ohlcv(320) result = Backtester().run(df, strategy_name="scoring") - rg = ReportGenerator() + # 기본 출력 경로(reports/)에 쓰면 실행할 때마다 연구 리포트 폴더에 가짜 + # backtest_scoring_*.html/txt가 쌓인다 — 임시 폴더로 돌린다. + rg = ReportGenerator(output_dir=str(tmp_path)) assert rg.generate_text_report(result) is not None assert rg.generate_html_report(result) is not None diff --git a/tests/test_kis_websocket_e2e.py b/tests/test_kis_websocket_e2e.py index d859fd2b..6828390a 100644 --- a/tests/test_kis_websocket_e2e.py +++ b/tests/test_kis_websocket_e2e.py @@ -34,7 +34,14 @@ def _kis_config(self): orig = dict(config._settings.get("kis_api", {})) config._settings.setdefault("kis_api", {}) config._settings["kis_api"].update(mock_kis) + # 접근 토큰은 이제 (base_url, app_key) 단위로 프로세스 전역 공유된다 + # (인스턴스별 발급이 1분 1회 발급 한도를 넘겨 live 매수를 막던 결함 수정). + # 이 클래스의 테스트들은 같은 키를 쓰므로, 앞 테스트가 발급한 토큰이나 + # 발급 실패 쿨다운이 다음 테스트로 새지 않게 매 테스트 전후로 비운다. + from api.kis_api import reset_shared_token_cache + reset_shared_token_cache() yield + reset_shared_token_cache() config._settings["kis_api"].clear() config._settings["kis_api"].update(orig) diff --git a/tests/test_notifier.py b/tests/test_notifier.py index 86a4ecef..190e8dd4 100644 --- a/tests/test_notifier.py +++ b/tests/test_notifier.py @@ -155,6 +155,18 @@ def test_daily_report_omits_absent_v2_fields(monkeypatch): assert "⚠️ 미체결 슬롯" not in names +def test_daily_report_shows_dash_when_daily_return_unknown(monkeypatch): + """계산하지 못한 일간 수익률(None)은 가짜 0.00%가 아니라 '—'로 나온다.""" + notifier, _ = _notifier_with_failing_discord(monkeypatch) + notifier.send_daily_report({ + "total_value": 10_000_000, "cash": 2_000_000, + "daily_return": None, "cumulative_return": 1.2, "mdd": -3.0, + "position_count": 2, "total_trades": 0, + }) + field = next(f for f in notifier.discord.embeds[0]["fields"] if f["name"] == "📈 일일 수익률") + assert field["value"] == "—" + + def test_signal_alert_uses_fallback_and_hold_is_silent(monkeypatch): notifier, emails = _notifier_with_failing_discord(monkeypatch) diff --git a/tests/test_operator_health.py b/tests/test_operator_health.py index bb3007e9..3c0f914e 100644 --- a/tests/test_operator_health.py +++ b/tests/test_operator_health.py @@ -308,9 +308,16 @@ def test_shortfall_beyond_tolerance_is_attention(self): def test_within_tolerance_ok(self): assert self._f(0.76, 0.80)["verdict"] == "OK" - def test_overdeployment_is_ok(self): - # 초과 배치는 리밸런서가 자연 교정 — 감시 대상 아님 - assert self._f(0.90, 0.80)["verdict"] == "OK" + def test_overdeployment_beyond_tolerance_is_attention(self): + # 예전엔 '초과 배치는 리밸런서가 자연 교정'한다고 보고 감시하지 않았다. 그런데 + # 전 종목이 같이 오르면 종목별 비중은 그대로라 아무것도 팔리지 않는다 — + # 60/40이 조용히 70/30이 된다. 현금 래칫의 반대 방향이라 같이 본다(2026-09-23). + out = self._f(0.90, 0.80) + assert out["verdict"] == "ATTENTION" + assert "설계보다 높다" in out["note"] + + def test_small_overdeployment_is_ok(self): + assert self._f(0.83, 0.80)["verdict"] == "OK" def test_none_inputs_ok(self): assert self._f(None, 0.80)["verdict"] == "OK" diff --git a/tests/test_paper_runtime.py b/tests/test_paper_runtime.py index 907e26df..f293d658 100644 --- a/tests/test_paper_runtime.py +++ b/tests/test_paper_runtime.py @@ -243,7 +243,8 @@ def test_research_disabled(self, evidence_dir, runtime_dir, tmp_path): """approved_strategies.json에서 disabled → research_disabled.""" from core.paper_runtime import get_paper_runtime_state - approved = Path("reports/approved_strategies.json") + import core.paper_runtime as _paper_runtime + approved = _paper_runtime.APPROVED_STRATEGIES_PATH original = None if approved.exists(): original = approved.read_text(encoding="utf-8") @@ -283,7 +284,8 @@ def test_corrupt_approved_strategies_fail_closed(self, evidence_dir, registry_pa _seed_evidence(evidence_dir, strategy, [ {"date": "2026-03-24", "status": "normal", "benchmark_status": "final"}, ]) - approved = Path("reports/approved_strategies.json") + import core.paper_runtime as _paper_runtime + approved = _paper_runtime.APPROVED_STRATEGIES_PATH original = None if approved.exists(): original = approved.read_text(encoding="utf-8") @@ -711,7 +713,8 @@ def test_research_disabled_allows_cleanup(self, evidence_dir, runtime_dir, tmp_p """research_disabled + outstanding position → cleanup O.""" from core.paper_runtime import get_paper_runtime_state, is_paper_trade_allowed - approved = Path("reports/approved_strategies.json") + import core.paper_runtime as _paper_runtime + approved = _paper_runtime.APPROVED_STRATEGIES_PATH original = None if approved.exists(): original = approved.read_text(encoding="utf-8") diff --git a/tests/test_policy_exposure_limits.py b/tests/test_policy_exposure_limits.py index 98b30319..61dbcc7d 100644 --- a/tests/test_policy_exposure_limits.py +++ b/tests/test_policy_exposure_limits.py @@ -241,3 +241,22 @@ def test_shipped_etf_baskets_are_actually_buyable(): f"{symbol}이 instrument_classes.non_company_symbols에 없다 — " "업종·실적 필터에 fail-closed로 막힌다" ) + + +def test_defensive_parking_etf_is_capped_by_investment_ratio_only(): + """방어 자산은 사실상 현금이다 — 계획이 놀고 있는 현금을 파킹 ETF로 옮길 때 주문 단계가 + 종목 상한(목표+드리프트)으로 막으면 계획과 실행이 어긋난다(2026-09-23).""" + basket = { + "target_stock_weight": 0.95, + "min_cash_ratio": 0.05, + "holdings": {"069500": 0.5, "357870": 0.5}, + "overlays": {"defensive_symbol": "357870"}, + "rebalance": {"drift_threshold": 0.08, "deployment_band": 0.03}, + } + rb = _rebalancer(basket) + rb._overlay_scale = lambda: 1.0 + parking = rb._policy_exposure_limits("357870") + equity = rb._policy_exposure_limits("069500") + assert parking["max_position_ratio"] == pytest.approx(0.95) + assert equity["max_position_ratio"] == pytest.approx((0.5 + 0.08) * 0.95) + assert parking["max_investment_ratio"] == pytest.approx(0.98) diff --git a/tests/test_rebalance_snapshot.py b/tests/test_rebalance_snapshot.py index 9a341b71..a9d31c0d 100644 --- a/tests/test_rebalance_snapshot.py +++ b/tests/test_rebalance_snapshot.py @@ -38,6 +38,9 @@ def patched_rebalance(monkeypatch): "core.basket_rebalancer.BasketRebalancer", MagicMock(return_value=fake_rb) ) monkeypatch.setattr("core.notifier.Notifier", MagicMock()) + # 휴장일에는 사이클이 매매를 건너뛴다 — 테스트가 실행되는 날(주말·공휴일)에 + # 결과가 달라지지 않게 거래일로 고정한다. 휴장일 경로는 test_audit_cycle.py. + monkeypatch.setattr("main._market_closed_today", lambda config, now: False) return fake_rb diff --git a/tests/test_runtime_lock.py b/tests/test_runtime_lock.py index 9ef14b66..0730e7a6 100644 --- a/tests/test_runtime_lock.py +++ b/tests/test_runtime_lock.py @@ -65,9 +65,12 @@ def test_process_runtime_lock_fails_closed_when_lock_file_unavailable(tmp_path): assert acquired is False -def test_live_runtime_lock_uses_one_global_path(tmp_path): +def test_live_runtime_lock_uses_one_global_path(tmp_path, monkeypatch): from core.runtime_lock import LIVE_RUNTIME_LOCK_FILENAME, live_runtime_lock + # conftest가 테스트 전체의 락 위치를 임시 폴더로 돌린다 — 이 테스트는 기본 경로 규칙을 본다 + monkeypatch.delenv("QUANT_RUNTIME_LOCK_DIR", raising=False) + with live_runtime_lock(tmp_path) as first: assert first is True assert (tmp_path / "data" / LIVE_RUNTIME_LOCK_FILENAME).exists() diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 6c891794..67c65dd1 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -820,160 +820,13 @@ def __init__(self, config=None, account_key="", *, live_gate_validated=False): } -def test_live_scheduler_basket_rebalance_uses_basket_gate_and_scope(monkeypatch): - """live 장전 바스켓 리밸런싱은 바스켓별 승인 단위로 gate/account/order tag를 맞춘다.""" - from core.scheduler import Scheduler - - calls = {} - - class FakeRebalancer: - @staticmethod - def get_enabled_baskets(): - return ["test_basket"] - - def __init__(self, basket_name, config, account_key="", execution_strategy=""): - calls["basket_name"] = basket_name - calls["config"] = config - calls["account_key"] = account_key - calls["execution_strategy"] = execution_strategy - self.portfolio_mgr = SimpleNamespace( - sync_with_broker=MagicMock(return_value={"ok": True, "message": "일치"}) - ) - calls["sync_mock"] = self.portfolio_mgr.sync_with_broker - - def should_rebalance(self): - calls["should_rebalance"] = True - return True, "드리프트" - - def plan_rebalance(self): - calls["plan_rebalance"] = True - return ["order"] - - def execute(self, orders, dry_run=False, live_confirmed=False): - calls["execute"] = { - "orders": orders, - "dry_run": dry_run, - "live_confirmed": live_confirmed, - } - return {"executed": 1, "skipped": 0, "failed": 0} - - gate = MagicMock(return_value=[]) - monkeypatch.setattr("core.basket_rebalancer.BasketRebalancer", FakeRebalancer) - monkeypatch.setattr("core.scheduler.check_live_readiness_gate", gate) - - scheduler = Scheduler(strategy_name="scoring") - old_mode = scheduler.config.trading.get("mode") - try: - scheduler.config.trading["mode"] = "live" - scheduler._mode = "live" - scheduler._live_gate_validated = True - scheduler.discord = MagicMock() - - scheduler._run_basket_rebalance_check() - - gate.assert_called_once_with(scheduler.config, "basket_rebalance:test_basket") - assert calls["basket_name"] == "test_basket" - assert calls["account_key"] == "basket_rebalance:test_basket" - assert calls["execution_strategy"] == "basket_rebalance:test_basket" - calls["sync_mock"].assert_called_once_with() - assert calls["should_rebalance"] is True - assert calls["plan_rebalance"] is True - assert calls["execute"] == { - "orders": ["order"], - "dry_run": False, - "live_confirmed": True, - } - scheduler.discord.send_message.assert_called_once() - finally: - scheduler.config.trading["mode"] = old_mode - - -def test_live_scheduler_basket_rebalance_gate_failure_blocks_before_plan(monkeypatch): - """live 바스켓 전용 gate가 실패하면 리밸런서 생성과 주문 계획까지 가지 않는다.""" - from core.scheduler import Scheduler - - class FakeRebalancer: - @staticmethod - def get_enabled_baskets(): - return ["test_basket"] - - def __init__(self, *args, **kwargs): - pytest.fail("live basket gate failure must block before rebalancer init") - - gate = MagicMock(return_value=["promotion evidence 부족"]) - monkeypatch.setattr("core.basket_rebalancer.BasketRebalancer", FakeRebalancer) - monkeypatch.setattr("core.scheduler.check_live_readiness_gate", gate) - - scheduler = Scheduler(strategy_name="scoring") - old_mode = scheduler.config.trading.get("mode") - try: - scheduler.config.trading["mode"] = "live" - scheduler._mode = "live" - scheduler._live_gate_validated = True - scheduler.discord = MagicMock() - - scheduler._run_basket_rebalance_check() - - gate.assert_called_once_with(scheduler.config, "basket_rebalance:test_basket") - scheduler.discord.send_message.assert_called_once() - assert scheduler.discord.send_message.call_args.kwargs["critical"] is True - assert "promotion evidence 부족" in scheduler.discord.send_message.call_args.args[0] - finally: - scheduler.config.trading["mode"] = old_mode - - -def test_live_scheduler_basket_rebalance_sync_failure_blocks_orders(monkeypatch): - """live 바스켓 리밸런싱 전 브로커 동기화가 실패하면 주문 판단과 실행을 생략한다.""" - from core.scheduler import Scheduler - - calls = {} - - class FakeRebalancer: - @staticmethod - def get_enabled_baskets(): - return ["test_basket"] - - def __init__(self, basket_name, config, account_key="", execution_strategy=""): - calls["account_key"] = account_key - calls["execution_strategy"] = execution_strategy - self.portfolio_mgr = SimpleNamespace( - sync_with_broker=MagicMock( - return_value={"ok": False, "message": "position mismatch"} - ) - ) - - def should_rebalance(self): - pytest.fail("sync failure must block before should_rebalance") - - def plan_rebalance(self): - pytest.fail("sync failure must block before plan_rebalance") - - def execute(self, *args, **kwargs): - pytest.fail("sync failure must block before execute") - - monkeypatch.setattr("core.basket_rebalancer.BasketRebalancer", FakeRebalancer) - monkeypatch.setattr( - "core.scheduler.check_live_readiness_gate", - MagicMock(return_value=[]), - ) - - scheduler = Scheduler(strategy_name="scoring") - old_mode = scheduler.config.trading.get("mode") - try: - scheduler.config.trading["mode"] = "live" - scheduler._mode = "live" - scheduler._live_gate_validated = True - scheduler.discord = MagicMock() - - scheduler._run_basket_rebalance_check() - - assert calls["account_key"] == "basket_rebalance:test_basket" - assert calls["execution_strategy"] == "basket_rebalance:test_basket" - scheduler.discord.send_message.assert_called_once() - assert scheduler.discord.send_message.call_args.kwargs["critical"] is True - assert "position mismatch" in scheduler.discord.send_message.call_args.args[0] - finally: - scheduler.config.trading["mode"] = old_mode +# 스케줄러 장전 바스켓 리밸런싱(_run_basket_rebalance_check)의 gate/계정 범위/동기화 +# 실패 차단을 고정하던 테스트 3건은 삭제했다. 그 경로 자체가 결함이었다(감사 +# runtime-scheduler-basket-premarket-divergent): 08:50~09:00에는 live 주문이 거래 시간 +# 가드에 전부 거부되고 paper는 전일 종가로 체결됐으며, CLI 사이클의 손절·하루 1회 +# 거래 가드·스냅샷 보충이 빠져 있었다. 이제 바스켓은 일일 CLI(main.py --mode rebalance)만 +# 거래하고, 스케줄러가 바스켓을 거래하지 않음은 tests/test_audit_runtime_basket_owner.py가 +# 고정한다. 바스켓 live 게이트·계정 범위는 CLI 경로 테스트(test_basket_live_gate 등)가 다룬다. def test_scheduler_skips_next_cycle_after_overrun(monkeypatch): diff --git a/tests/test_snapshot_backfill.py b/tests/test_snapshot_backfill.py index 3d625b01..73c4bdd3 100644 --- a/tests/test_snapshot_backfill.py +++ b/tests/test_snapshot_backfill.py @@ -145,7 +145,7 @@ def test_report_text_shows_the_split(self): pending_failed_orders=0, total_costs=0, initial_capital=1_000_000, ) text = format_evaluation_report(r, "t") - assert "사후 복원" in text and "실측" in text + assert "나중에 채운" in text and "제때 기록" in text def test_measured_row_wins_over_reconstructed_on_upsert(self): """나중에 실측이 들어오면 보정 표시를 걷어낸다.""" diff --git a/tests/test_trading_hours.py b/tests/test_trading_hours.py index c4167bbb..d1ab7de0 100644 --- a/tests/test_trading_hours.py +++ b/tests/test_trading_hours.py @@ -40,19 +40,24 @@ def test_2026_substitute_holidays_are_non_trading_days(): 트랙레코드 커버리지 분모가 부풀고 스냅샷 귀속(가격 기준일)이 어긋난다. 2026: 3·1절(일)→3/2, 부처님오신날(일)→5/25, 광복절(토)→8/17, - 추석 토요일 겹침→9/28, 개천절(토)→10/5, KRX 연말 휴장 12/31. + 개천절(토)→10/5, KRX 연말 휴장 12/31. (현충일 6/6(토)은 대체 미적용 — 6/8은 거래일이어야 함) + (추석 9/24~26은 토요일과 겹칠 뿐이라 대체 없음 — 9/28은 거래일이어야 함. + 설·추석 대체공휴일은 일요일·다른 공휴일 겹침에만 생긴다. 예전에는 이 테스트가 + 9/28을 휴장으로 고정하고 있었다 — 2026-09-23 수정) """ from datetime import datetime from core.trading_hours import TradingHours th = TradingHours() for d in ("2026-03-02", "2026-05-25", "2026-08-17", - "2026-09-28", "2026-10-05", "2026-12-31"): + "2026-10-05", "2026-12-31"): y, m, dd = map(int, d.split("-")) assert th.is_trading_day(datetime(y, m, dd)) is False, f"{d}는 휴장일이어야 함" # 현충일은 대체공휴일 미적용 — 다음 월요일은 정상 거래일 assert th.is_trading_day(datetime(2026, 6, 8)) is True + # 추석 연휴의 토요일 겹침은 대체공휴일이 아니다 + assert th.is_trading_day(datetime(2026, 9, 28)) is True def test_fallback_sets_are_single_source(): diff --git a/tools/basket_track_record.py b/tools/basket_track_record.py index 7267d258..46e1f4c5 100644 --- a/tools/basket_track_record.py +++ b/tools/basket_track_record.py @@ -40,7 +40,10 @@ def build_track_record(basket_name, start, end, capital=10_000_000): # 이걸 무시하면 '주식50/현금50' 바스켓이 주식 100%로 시뮬레이션돼 # 수익·MDD가 과대 보고된다(트랙레코드는 운영과 같은 조건이어야 정직하다). rb = BasketRebalancer(basket_name=basket_name) - stock_fraction = rb._stock_fraction() + # 설계 비중(오버레이 적용 전)을 쓴다. _stock_fraction은 오늘의 오버레이 판단을 + # 계산해 운영 상태 파일에 저장하므로, 연구용 과거 시뮬레이션이 운영 상태를 바꾸고 + # 오늘의 배수를 과거 전 구간에 적용하게 된다. + stock_fraction = rb.base_stock_fraction() dc = DataCollector() closes = {} diff --git a/tools/risk_overlay_backtest.py b/tools/risk_overlay_backtest.py index 91fc7601..8ec3ab6a 100644 --- a/tools/risk_overlay_backtest.py +++ b/tools/risk_overlay_backtest.py @@ -24,6 +24,8 @@ _ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(_ROOT)) +# 2026-09-17 표를 만든 10종목(000660 포함). 운영 트랙은 8/07부터 000660을 뺀 9종목이다 — +# 1주가 자본 대비 커서 보유가 불가능했기 때문. 비교용으로만 남긴다. BASKET_SYMBOLS = [ "005930", "000660", @@ -36,6 +38,16 @@ "012330", "105560", ] + + +def configured_basket_symbols(name: str = "kr_diversified_hold") -> list[str]: + """baskets.yaml에 실제로 설정된 보유 종목. 연구 결론은 운영 트랙이 들고 있는 + 종목으로 내야 한다 — 하이닉스가 든 10종목 표로 9종목 트랙의 정책을 정하면 안 된다.""" + import yaml + + with open(_ROOT / "config" / "baskets.yaml", encoding="utf-8") as f: + cfg = (yaml.safe_load(f) or {}).get("baskets", {}).get(name) or {} + return [str(s) for s in (cfg.get("holdings") or {})] COMMISSION = 0.00015 SLIPPAGE = 0.0005 STOCK_TAX = 0.0020 @@ -325,14 +337,16 @@ def simulate( } -def metrics(frame, extra, years_hint=None): +def metrics(frame, extra, years_hint=None, rf_annual=RF_ANNUAL): + """rf_annual: 샤프의 무위험수익률. 현금이 이자를 받지 않는 트랙(바스켓 paper)은 0으로 + 잰다 — 시뮬레이션은 현금 0%인데 샤프만 3%를 빼면 같은 흐름이 부당하게 낮아 보인다.""" twr = frame["twr"] daily = frame["daily_return"] n_years = (frame.index[-1] - frame.index[0]).days / 365.25 cagr = twr.iloc[-1] ** (1 / n_years) - 1 if n_years > 0 else 0.0 vol = daily.std() * math.sqrt(TRADING_DAYS) - sharpe = (daily.mean() * TRADING_DAYS - RF_ANNUAL) / vol if vol > 0 else 0.0 + sharpe = (daily.mean() * TRADING_DAYS - rf_annual) / vol if vol > 0 else 0.0 mdd = frame["drawdown"].min() calmar = cagr / abs(mdd) if mdd < 0 else float("nan") yearly = twr.resample("YE").last().pct_change() @@ -395,39 +409,109 @@ def run_pocket(start="2002-01-01", use_etf=False): }, frames -def run_basket(start="2021-12-01"): - """트랙 2: 대형주 10종목 동일비중을 하나의 위험자산(EW 지수)으로 묶고, 주식 비중 60%.""" +def rebalanced_ew_index(panel, drift_threshold=0.08, cost_rate=None): + """동일비중 주식 부분의 지수. 어느 종목이든 목표 비중에서 drift_threshold를 넘게 벗어나면 + 그날 종가로 동일비중으로 되돌리고, 그 회전에 거래비용을 뺀다(운영 트랙과 같은 규칙). + + 첫날 매수 후 계속 들고만 가면 오른 종목(2023~25의 하이닉스 같은)이 슬리브를 점점 + 차지해서, 운영 트랙(8%p 벗어나면 되돌림)과 다른 포트폴리오를 재게 된다. + """ + import numpy as np + import pandas as pd + + if cost_rate is None: + cost_rate = COMMISSION + SLIPPAGE + STOCK_TAX / 2 # 회전의 절반이 매도 + rets = panel.pct_change().fillna(0.0).to_numpy() + n = panel.shape[1] + target = np.full(n, 1.0 / n) + weights = target.copy() + value = 1.0 + out = [value] + for r in rets[1:]: + grown = weights * (1 + r) + step = grown.sum() + value *= step + weights = grown / step + if np.abs(weights - target).max() > drift_threshold: + turnover = np.abs(weights - target).sum() + value *= 1 - turnover * cost_rate + weights = target.copy() + out.append(value) + return pd.Series(out, index=panel.index) + + +def run_basket(start="2021-12-01", symbols=None, rf_annual=0.0, target_stock=0.6, + policies=None, label="EW"): + """트랙 2: 대형주 동일비중(8%p 벗어나면 되돌림) + 이자 없는 현금, 주식 비중 60%. + + symbols 미지정 시 baskets.yaml의 kr_diversified_hold 보유 종목(현재 9종목)을 쓴다. + rf_annual=0: 바스켓 paper 계좌의 현금은 이자를 받지 않는다. + """ import pandas as pd - panel = pd.DataFrame({s: _fdr(s, start) for s in BASKET_SYMBOLS}).dropna(how="any") - ew = (panel / panel.iloc[0]).mean( - axis=1 - ) # 동일비중 지수 (일별 리밸런싱 근사 없음: 첫날 동일비중 보유) - # 첫날 동일비중 매수 후 보유한 가치가 정확히 위 식이다 (수량 고정, 가격만 변동). + symbols = list(symbols or configured_basket_symbols()) + panel = pd.DataFrame({s: _fdr(s, start) for s in symbols}).dropna(how="any") + ew = rebalanced_ew_index(panel) index = _fdr("KS200", start) results = {} frames = {} - for policy in POLICIES: + for policy in (policies or POLICIES): frame, extra = simulate( ew, policy, index_closes=index, - target_stock=0.6, + target_stock=target_stock, initial=10_000_000, monthly=0.0, tax_rate=STOCK_TAX, + rf_annual=rf_annual, ) - results[policy.name] = {"label": policy.label, **metrics(frame, extra)} + results[policy.name] = { + "label": policy.label, **metrics(frame, extra, rf_annual=rf_annual), + } frames[policy.name] = frame return { - "symbol": "EW10", - "symbols": BASKET_SYMBOLS, + "symbol": f"{label}{len(symbols)}", + "symbols": symbols, + "rf_annual": rf_annual, "start": str(ew.index[0].date()), "end": str(ew.index[-1].date()), "results": results, }, frames +def run_exposure_review(start="2021-12-01", fractions=(0.6, 0.7, 0.8)): + """P3 주식 비중 비교: 같은 9종목을 주식 60/70/80%로 들 때 오를 때·내릴 때 얼마나 따라가는지. + + 국면 분해는 주간 리포트와 같은 정의(core.performance_lens.split_by_regime — + KS200 일간 수익률의 부호로 상승일·하락일을 나눔)를 쓴다. + """ + from core.performance_lens import split_by_regime + + static = [p for p in POLICIES if p.name == "static"] + index = _fdr("KS200", start) + bench_daily = index.pct_change() * 100 + rows = {} + for frac in fractions: + summary, frames = run_basket(start, target_stock=frac, policies=static) + frame = frames["static"] + mine = frame["daily_return"] * 100 + pairs = [ + (float(mine.loc[d]), float(bench_daily.loc[d])) + for d in mine.index + if d in bench_daily.index and bench_daily.loc[d] == bench_daily.loc[d] + ] + regime = split_by_regime(pairs) + rows[f"{int(frac * 100)}%"] = { + **summary["results"]["static"], + "up_capture": regime["up"].get("capture"), + "down_capture": regime["down"].get("capture"), + "up_days": regime["up"].get("days"), + "down_days": regime["down"].get("days"), + } + return {"start": start, "symbols": configured_basket_symbols(), "fractions": rows} + + def _plot(track_name, summary, frames, out_png, title): import matplotlib @@ -521,7 +605,9 @@ def main(): "--as-of", default=AS_OF, help="이 날짜 미만의 확정 일봉만 사용 (YYYY-MM-DD)" ) parser.add_argument( - "--track", choices=["all", "pocket", "pocket_etf", "basket"], default="all" + "--track", + choices=["all", "pocket", "pocket_etf", "basket", "exposure"], + default="all", ) parser.add_argument("--out-dir", default=str(_ROOT / "reports" / "research")) parser.add_argument("--image-dir", default=str(_ROOT / "docs" / "images")) @@ -560,14 +646,35 @@ def main(): if args.track in ("all", "basket"): summary, frames = run_basket("2021-12-01") report["basket"] = summary + n = len(summary["symbols"]) _plot( "basket", summary, frames, image_dir / "overlay-basket.png", - "관찰 트랙 · 대형주 10종목 동일비중 60% + 현금 40% · 2022년 약세장 포함", + f"관찰 트랙 · 대형주 {n}종목 동일비중 60% + 현금 40%(이자 없음) · 2022년 약세장 포함", ) - md += [_markdown("관찰 트랙 (대형주 10종목, 2021-12~)", summary), ""] + md += [_markdown(f"관찰 트랙 (지금 들고 있는 {n}종목, 이자 없는 현금, 2021-12~)", summary), ""] + # 비교: 2026-09-17 표의 조건(000660 포함 10종목, 현금 연 3%) + legacy, _ = run_basket("2021-12-01", symbols=BASKET_SYMBOLS, rf_annual=RF_ANNUAL) + report["basket_legacy10"] = legacy + md += [_markdown("참고 · 예전 표 조건 (000660 포함 10종목, 현금 연 3%)", legacy), ""] + if args.track in ("all", "exposure"): + exposure = run_exposure_review("2021-12-01") + report["exposure_review"] = exposure + md += [ + "### 주식 비중별 비교 (지금 들고 있는 종목, 고정 비중, 이자 없는 현금, 2021-12~)", + "", + "| 주식 비중 | 연수익률 | 최대낙폭 | 샤프(금리 0%) | 상승 포착 | 하락 포착 |", + "|---|---:|---:|---:|---:|---:|", + ] + for key, r in exposure["fractions"].items(): + up = f"{r['up_capture'] * 100:.0f}%" if r.get("up_capture") is not None else "—" + down = f"{r['down_capture'] * 100:.0f}%" if r.get("down_capture") is not None else "—" + md.append( + f"| {key} | {r['cagr_pct']:+.2f}% | {r['mdd_pct']:.1f}% | {r['sharpe']:.2f} | {up} | {down} |" + ) + md.append("") (out_dir / "risk_overlay_backtest.json").write_text( json.dumps(report, ensure_ascii=False, indent=1), encoding="utf-8" )