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:
@@ -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)으로
|
||||
|
||||
Reference in New Issue
Block a user