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

@@ -581,6 +581,91 @@ _last_update_ts: float = 0.0
_CACHE_TTL = 120 # 2분
_update_lock = asyncio.Lock()
# [R2 2026-10-09] 갱신 실패가 이어지면 직전 모드(Normal/Aggressive)가 무기한 유지돼
# Crisis 진입 차단이 fail-open 이 되는 결함 대응: 마지막 "성공" 갱신 시각을 추적하고
# 장중 _STALE_LIMIT_SEC 초과 시 Defensive 로 강등 + 텔레그램 경보.
_STALE_LIMIT_SEC = 600.0 # 10분 (갱신 주기 2분 × 5회)
_STALE_ALERT_INTERVAL_SEC = 1800.0
_last_success_ts: float = time.monotonic() # 기동 시각을 기준선으로(기동 후 계속 실패해도 감지)
_last_stale_alert_ts: float = 0.0
_bg_tasks: set = set() # fire-and-forget 태스크 참조 보관(GC 방지)
def _market_open_or_true_v2() -> bool:
"""장중 여부. 판정 실패 시 True(강등 쪽이 안전)."""
try:
from app.core.market_schedule import is_market_open
return bool(is_market_open())
except Exception as e:
warn_throttled(logger, "adaptive_strategy:stale_mkt", "[Adaptive] 장 상태 판정 실패 — 장중으로 간주: %s", e)
return True
def _spawn_bg_v2(coro) -> None:
try:
task = asyncio.ensure_future(coro)
except Exception:
coro.close()
raise
_bg_tasks.add(task)
task.add_done_callback(_bg_tasks.discard)
def get_last_success_age_sec_v2() -> float:
"""마지막 성공 갱신 이후 경과(초). /health 노출용."""
return time.monotonic() - _last_success_ts
def enforce_staleness_v2() -> Optional["AdaptiveParams"]:
"""갱신이 _STALE_LIMIT_SEC 넘게 실패 중이면(장중) Defensive 로 강등하고 경보한다.
이미 Defensive/Crisis 면 모드는 건드리지 않는다(더 보수적인 쪽 유지).
현재 파라미터를 반환한다.
"""
global _current_params, _last_stale_alert_ts, _last_update_ts
age = get_last_success_age_sec_v2()
if age <= _STALE_LIMIT_SEC or not _market_open_or_true_v2():
return _current_params
cur = _current_params
if cur is not None and cur.trading_mode in (TradingMode.CRISIS.value, TradingMode.DEFENSIVE.value):
degraded = False
else:
import pytz as _pytz
_current_params = AdaptiveParams(
trading_mode=TradingMode.DEFENSIVE.value,
condition=MarketCondition(trading_mode=TradingMode.DEFENSIVE.value),
mdd_current=cur.mdd_current if cur is not None else 0.0,
updated_at=datetime.now(_pytz.timezone("Asia/Seoul")).strftime("%Y-%m-%d %H:%M:%S"),
fallback_used=False,
**_MODE_PARAMS[TradingMode.DEFENSIVE.value],
)
_last_update_ts = time.monotonic()
degraded = True
now = time.monotonic()
if degraded or now - _last_stale_alert_ts >= _STALE_ALERT_INTERVAL_SEC:
_last_stale_alert_ts = now
logger.error(
"[Adaptive] 🚨 갱신 %.0f초째 실패(상한 %.0fs) — %s",
age, _STALE_LIMIT_SEC,
"Defensive 로 강등" if degraded else "모드 유지(이미 보수적)",
)
try:
from app.services.telegram_notifier import telegram_notifier
_spawn_bg_v2(telegram_notifier.notify_system_warning(
category="adaptive_stale",
message=(f"[적응형 전략] 모드 갱신 {age / 60:.0f}분째 실패 "
f"(상한 {_STALE_LIMIT_SEC / 60:.0f}분). "
f"{'Defensive 로 강등' if degraded else '현재 모드 유지'}. 시세/KIS 점검 필요"),
))
except RuntimeError:
pass # 실행 중인 루프 없음(동기 컨텍스트) — 로그 error 로 충분
except Exception as e:
logger.warning("[Adaptive] 경보 전송 실패: %s", e)
return _current_params
async def _save_to_redis(condition: MarketCondition, params: AdaptiveParams) -> None:
"""Redis에 현재 상태 저장."""
@@ -606,6 +691,18 @@ async def _save_to_redis(condition: MarketCondition, params: AdaptiveParams) ->
logger.warning("[Adaptive] Redis 저장 실패: %s", e)
async def _save_data_source_v2(src: str, bars: int) -> None:
"""사용한 데이터 소스·봉수를 Redis 에 기록(진단용)."""
try:
from app.core.redis import get_redis
from app.core.json_helpers import _dumps
client = get_redis()
if client:
await client.set("adaptive:data_source", _dumps({"src": src, "bars": bars}), ex=_REDIS_TTL)
except Exception as e:
logger.warning("[Adaptive] 데이터 소스 Redis 기록 실패: %s", e)
async def _record_mode_change(prev_mode: str, new_mode: str, condition: MarketCondition) -> None:
"""모드 변경 이력을 Redis 리스트에 기록 + Telegram 알림.
@@ -667,6 +764,41 @@ async def _record_mode_change(prev_mode: str, new_mode: str, condition: MarketCo
)
_MIN_BARS_FOR_ATR_PCT = 120 # ATR 백분위 산출 최소 봉수(미달 시 갱신 실패)
async def _fetch_kodex_chart_v2():
"""KODEX200 일봉(newest-first)과 소스명을 반환. 네이버 → KIS 날짜범위 순.
각 소스가 _MIN_BARS_FOR_ATR_PCT 미만이면 다음 소스로 넘어간다. 예외는 warning.
"""
best, best_src = None, "none"
try:
from app.services.naver_finance import get_naver_daily_chart
naver_data = await get_naver_daily_chart("069500", days=400)
if naver_data and len(naver_data) >= _MIN_BARS_FOR_ATR_PCT:
return list(reversed(naver_data)), "naver"
if naver_data:
best, best_src = list(reversed(naver_data)), "naver"
except Exception as _swallowed:
warn_throttled(logger, "adaptive_strategy:643", "[Adaptive] 네이버 KODEX200 차트 조회 실패(KIS 폴백 진행): %s", _swallowed)
try:
from datetime import timedelta
from app.services.korea_investment_api import (
korea_investment_api, real_market_api as _real_market_api,
)
_api = _real_market_api or korea_investment_api
_end = datetime.now()
rows = await _api.get_daily_ohlcv_range(
"069500", (_end - timedelta(days=400)).strftime("%Y%m%d"), _end.strftime("%Y%m%d"))
if rows and (best is None or len(rows) > len(best)):
best, best_src = list(rows), "kis_range"
except Exception as _kis_err:
warn_throttled(logger, "adaptive_strategy:kis_range", "[Adaptive] KIS KODEX200 범위 조회 실패: %s", _kis_err)
return best, best_src
async def refresh_adaptive_params() -> AdaptiveParams:
"""시장 데이터를 조회하여 적응형 파라미터를 갱신한다.
@@ -677,35 +809,22 @@ async def refresh_adaptive_params() -> AdaptiveParams:
- main.py 스케줄러에서 2분 주기 호출
- API 엔드포인트에서 수동 호출 가능
"""
global _current_params, _current_condition, _last_update_ts
global _current_params, _current_condition, _last_update_ts, _last_success_ts
async with _update_lock:
try:
# KODEX200 일봉 데이터 가져오기 (네이버 우선, KIS 폴백)
chart_data = None
try:
from app.services.naver_finance import get_naver_daily_chart
# ATR 백분위 산출 히스토리 확보 — 캘린더일 기준 400일 요청(≈270거래일, 백테스트와 정합)
naver_data = await get_naver_daily_chart("069500", days=400)
if naver_data and len(naver_data) >= 60:
chart_data = list(reversed(naver_data)) # newest first -> list
except Exception as _swallowed:
warn_throttled(logger, "adaptive_strategy:643", "[Adaptive] 네이버 KODEX200 차트 조회 실패(KIS 폴백 진행): %s", _swallowed)
if chart_data is None:
from app.services.korea_investment_api import (
korea_investment_api, real_market_api as _real_market_api,
# KODEX200 일봉 데이터 가져오기 (네이버 우선, KIS 날짜범위 폴백)
# [R3 2026-10-09] ATR 백분위 최소 봉수(_MIN_BARS_FOR_ATR_PCT) 미달이면 갱신 실패로
# 처리한다(공식이 5일/전체 변동비로 조용히 바뀌어 Crisis 오발동하던 결함).
chart_data, _src = await _fetch_kodex_chart_v2()
if not chart_data or len(chart_data) < _MIN_BARS_FOR_ATR_PCT:
logger.warning(
"[Adaptive] KODEX200 데이터 부족(src=%s, bars=%d < %d) — 갱신 실패 처리, staleness 경로",
_src, len(chart_data or []), _MIN_BARS_FOR_ATR_PCT,
)
_api = _real_market_api or korea_investment_api
kospi_chart = await _api.get_daily_chart("069500", "D", limit=120)
if "output2" in kospi_chart and kospi_chart["output2"]:
chart_data = kospi_chart["output2"]
if not chart_data or len(chart_data) < 20:
logger.warning("[Adaptive] KODEX200 데이터 부족 — fallback 유지")
if _current_params is None:
_current_params = _get_fallback_params()
return _current_params
return enforce_staleness_v2() or _current_params
# 데이터 파싱 (chart_data는 newest-first 형식)
closes = [float(item.get("stck_clpr", 0)) for item in chart_data]
@@ -786,12 +905,15 @@ async def refresh_adaptive_params() -> AdaptiveParams:
_current_params = new_params
_current_condition = condition
_last_update_ts = time.monotonic()
_last_success_ts = _last_update_ts
# Redis 저장
await _save_to_redis(condition, new_params)
await _save_data_source_v2(_src, len(chart_data))
logger.info(
"[Adaptive] 갱신 완료: mode=%s, ATR=%.0f%%, ADX=%.1f, 5d=%+.2f%%",
"[Adaptive] 갱신 완료: src=%s bars=%d mode=%s, ATR=%.0f%%, ADX=%.1f, 5d=%+.2f%%",
_src, len(chart_data),
new_params.trading_mode,
condition.atr_percentile,
condition.adx_value,
@@ -804,7 +926,7 @@ async def refresh_adaptive_params() -> AdaptiveParams:
logger.error("[Adaptive] 갱신 실패: %s", e, exc_info=True)
if _current_params is None:
_current_params = _get_fallback_params()
return _current_params
return enforce_staleness_v2() or _current_params
def get_current_adaptive_params() -> AdaptiveParams:
@@ -831,12 +953,13 @@ def get_current_adaptive_params() -> AdaptiveParams:
# 유발할 수 있으므로 get_running_loop()를 우선 사용하고, 실행 중인 루프가
# 없으면(RuntimeError) 백그라운드 갱신을 조용히 스킵한다.
asyncio.get_running_loop()
asyncio.ensure_future(refresh_adaptive_params())
_spawn_bg_v2(refresh_adaptive_params())
except RuntimeError:
pass # 실행 중인 이벤트 루프 없음 — 동기 컨텍스트에서 호출된 경우
except Exception as _swallowed:
warn_throttled(logger, "adaptive_strategy:767", "[Adaptive] 백그라운드 갱신 예약 실패: %s", _swallowed)
return _current_params
# 낡은 값을 무기한 반환하지 않도록 staleness 강등 적용(try 밖)
return enforce_staleness_v2() or _current_params
# 캐시 없음 — fallback
# [FIX] _last_update_ts를 갱신하지 않으면 다음 호출에서 age가 (monotonic()-0.0)으로