fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복 - DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복 - 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정 - KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카 - 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증 - 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정 - 전문가 에이전트 6종 추가(.claude/agents) - 유닛 스위트 2667 passed (소켓 차단 하), tsc OK Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
This commit is contained in:
@@ -118,6 +118,8 @@ class CircuitBreaker:
|
||||
self.failure_count = 0
|
||||
self.last_failure_time: Optional[float] = None
|
||||
self.state = CircuitBreakerState.CLOSED
|
||||
self.open_count = 0 # 누적 OPEN 전이 횟수 (헬스 노출용)
|
||||
self.last_opened_at: Optional[float] = None
|
||||
|
||||
def can_request(self) -> bool:
|
||||
"""요청 가능 여부 반환."""
|
||||
@@ -143,11 +145,69 @@ class CircuitBreaker:
|
||||
self.failure_count += 1
|
||||
self.last_failure_time = time.time()
|
||||
if self.state == CircuitBreakerState.HALF_OPEN or self.failure_count >= self.failure_threshold:
|
||||
_was_open = self.state == CircuitBreakerState.OPEN
|
||||
self.state = CircuitBreakerState.OPEN
|
||||
logger.warning(
|
||||
f"⚡ Circuit breaker OPEN: {self.failure_count}회 연속 실패. "
|
||||
f"{self.recovery_timeout}초 후 재시도"
|
||||
)
|
||||
if not _was_open:
|
||||
self.open_count += 1
|
||||
self.last_opened_at = time.time()
|
||||
_alert_breaker_open_v2(self)
|
||||
|
||||
def health(self) -> Dict[str, Any]:
|
||||
"""/health 노출용 스냅샷 (순수 조회)."""
|
||||
return {
|
||||
"state": getattr(self.state, "value", str(self.state)),
|
||||
"failure_count": self.failure_count,
|
||||
"open_count": self.open_count,
|
||||
"last_opened_at": self.last_opened_at,
|
||||
}
|
||||
|
||||
|
||||
# 경보 태스크 참조 보관 (GC 로 경보가 사라지는 것을 방지)
|
||||
_breaker_alert_tasks: set = set()
|
||||
|
||||
|
||||
def _breaker_alert_done_v2(task: "asyncio.Task") -> None:
|
||||
_breaker_alert_tasks.discard(task)
|
||||
try:
|
||||
if not task.cancelled() and task.exception() is not None:
|
||||
logger.warning(f"[KIS] circuit breaker 경보 태스크 실패: {task.exception()}")
|
||||
except Exception as _e:
|
||||
logger.warning(f"[KIS] circuit breaker 경보 태스크 확인 실패: {_e}")
|
||||
|
||||
|
||||
def _alert_breaker_open_v2(breaker: "CircuitBreaker") -> None:
|
||||
"""OPEN 전이 시 error 로그 + 텔레그램 경보(fire-and-forget). 실패해도 요청 경로를 막지 않는다."""
|
||||
logger.error(
|
||||
f"🚨 [KIS] circuit breaker OPEN — 시세·잔고 조회가 {breaker.recovery_timeout}초간 차단된다 "
|
||||
f"(주문은 차단하지 않음). 누적 {breaker.open_count}회"
|
||||
)
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
from app.services.telegram_notifier import telegram_notifier
|
||||
_task = loop.create_task(telegram_notifier.notify_system_warning(
|
||||
"KIS_CIRCUIT_BREAKER",
|
||||
f"KIS 조회 차단기 OPEN ({breaker.failure_count}회 연속 실패, {breaker.recovery_timeout}초). "
|
||||
f"손절 판정용 시세 조회가 지연될 수 있음",
|
||||
))
|
||||
_breaker_alert_tasks.add(_task)
|
||||
_task.add_done_callback(_breaker_alert_done_v2)
|
||||
except Exception as _alert_err:
|
||||
logger.warning(f"[KIS] circuit breaker 경보 전송 실패: {_alert_err}")
|
||||
|
||||
|
||||
def get_kis_breaker_health_v2() -> Dict[str, Any]:
|
||||
"""모듈 레벨 KIS 인스턴스들의 breaker 상태 (main /health 에서 호출용)."""
|
||||
out: Dict[str, Any] = {}
|
||||
for _name in ("korea_investment_api", "real_market_api"):
|
||||
_inst = globals().get(_name)
|
||||
_cb = getattr(_inst, "circuit_breaker", None)
|
||||
if _cb is not None:
|
||||
out[_name] = _cb.health()
|
||||
return out
|
||||
|
||||
|
||||
class APICallMonitor:
|
||||
@@ -712,7 +772,9 @@ class KoreaInvestmentAPI:
|
||||
"""API 요청 실행 (Circuit Breaker + 세마포어 + 재시도 + 모니터링)"""
|
||||
|
||||
# Circuit Breaker 상태 확인
|
||||
if not self.circuit_breaker.can_request():
|
||||
# [R4] 주문 엔드포인트는 breaker 로 차단하지 않는다 — 시세/잔고 조회 장애(EGW00201·타임아웃)가
|
||||
# 손절·EOD·MDD 매도까지 막으면 §8.1-11(청산 우선) 위반. 주문 실패는 breaker 에 세지도 않는다.
|
||||
if endpoint not in self._NON_IDEMPOTENT_ENDPOINTS and not self.circuit_breaker.can_request():
|
||||
wait_sec = self.circuit_breaker.recovery_timeout
|
||||
logger.warning(f"⚡ Circuit breaker OPEN — {wait_sec}초 후 재시도 가능. 요청 거부: {endpoint}")
|
||||
raise Exception(f"Circuit breaker open: KIS API 일시 차단 ({wait_sec}s)")
|
||||
@@ -752,6 +814,17 @@ class KoreaInvestmentAPI:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _record_breaker_failure_v2(self, endpoint: str) -> None:
|
||||
"""주문 엔드포인트 실패는 조회 breaker 에 세지 않는다(주문·조회 장애 분리)."""
|
||||
if endpoint in self._NON_IDEMPOTENT_ENDPOINTS:
|
||||
return
|
||||
self.circuit_breaker.record_failure()
|
||||
|
||||
def _record_breaker_success_v2(self, endpoint: str) -> None:
|
||||
if endpoint in self._NON_IDEMPOTENT_ENDPOINTS:
|
||||
return
|
||||
self.circuit_breaker.record_success()
|
||||
|
||||
async def _make_request_with_retry(self, method: str, endpoint: str, tr_id: str, extra_headers: Optional[Dict[str, str]] = None, **kwargs) -> (Dict[str, Any], Any):
|
||||
"""재시도 로직을 포함한 실제 요청 실행 (주문 엔드포인트는 재시도 없음)"""
|
||||
start_time = time.time()
|
||||
@@ -815,7 +888,7 @@ class KoreaInvestmentAPI:
|
||||
if response.status_code == 200:
|
||||
duration = time.time() - start_time
|
||||
self.monitor.record_call(success=True, duration=duration, retries=retry_count)
|
||||
self.circuit_breaker.record_success()
|
||||
self._record_breaker_success_v2(endpoint)
|
||||
if self.monitor.should_alert(threshold=settings.API_ERROR_THRESHOLD):
|
||||
logger.warning(f"⚠️ API 오류율 높음: {self.monitor.get_error_rate():.1%}")
|
||||
return response.json(), response.headers
|
||||
@@ -833,7 +906,7 @@ class KoreaInvestmentAPI:
|
||||
_payload = self._safe_json(response)
|
||||
if isinstance(_payload, dict) and ("rt_cd" in _payload or "msg_cd" in _payload):
|
||||
self.monitor.record_call(success=False, duration=time.time() - start_time, retries=retry_count)
|
||||
self.circuit_breaker.record_failure()
|
||||
self._record_breaker_failure_v2(endpoint)
|
||||
logger.warning(
|
||||
f"⚠️ 주문 엔드포인트 HTTP {response.status_code} — KIS 거부 응답 반환 "
|
||||
f"(rt_cd={_payload.get('rt_cd')} msg_cd={_payload.get('msg_cd')} "
|
||||
@@ -846,7 +919,7 @@ class KoreaInvestmentAPI:
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
else:
|
||||
self.circuit_breaker.record_failure()
|
||||
self._record_breaker_failure_v2(endpoint)
|
||||
raise Exception(f"API 최대 재시도 초과: {response.status_code}")
|
||||
|
||||
# 클라이언트 오류 (4xx) — 재시도 불필요
|
||||
@@ -878,7 +951,7 @@ class KoreaInvestmentAPI:
|
||||
else:
|
||||
duration = time.time() - start_time
|
||||
self.monitor.record_call(success=False, duration=duration, retries=retry_count)
|
||||
self.circuit_breaker.record_failure()
|
||||
self._record_breaker_failure_v2(endpoint)
|
||||
logger.error(f"❌ API 타임아웃 최대 재시도 초과")
|
||||
raise
|
||||
|
||||
@@ -886,14 +959,14 @@ class KoreaInvestmentAPI:
|
||||
# 예상치 못한 오류
|
||||
duration = time.time() - start_time
|
||||
self.monitor.record_call(success=False, duration=duration, retries=retry_count)
|
||||
self.circuit_breaker.record_failure()
|
||||
self._record_breaker_failure_v2(endpoint)
|
||||
logger.error(f"❌ API 요청 중 예외 발생: {str(e)}")
|
||||
raise
|
||||
|
||||
# 여기까지 왔다면 모든 재시도 실패
|
||||
duration = time.time() - start_time
|
||||
self.monitor.record_call(success=False, duration=duration, retries=retry_count)
|
||||
self.circuit_breaker.record_failure()
|
||||
self._record_breaker_failure_v2(endpoint)
|
||||
if last_exception:
|
||||
raise last_exception
|
||||
raise Exception("API 요청 실패 (알 수 없는 오류)")
|
||||
|
||||
Reference in New Issue
Block a user