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:
2026-10-10 12:17:49 +09:00
parent 5c4803b339
commit a264561576
230 changed files with 41160 additions and 2334 deletions

View File

@@ -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 요청 실패 (알 수 없는 오류)")