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

@@ -0,0 +1,192 @@
"""MDD 상태의 Redis 외 보조 영속화 (파일, 2026-10-09 하드닝 R1).
mdd:state 가 Redis 에만 있으면 Redis 초기화/볼륨 교체/연결 실패 때 peak 가 현재 잔고로
재시드되어 낙폭 이력이 소실된다. 이 모듈은 같은 스냅샷을 `~/.cache/auto-stock/` (compose 의
./token_cache 마운트로 유지)에 원자적으로 기록하고, Redis 복원 실패 시 폴백으로 읽는다.
DB 가 아니라 파일이므로 db_worker 규칙(§3)과 무관하다.
"""
import asyncio
import json
import logging
import os
import tempfile
import time
from pathlib import Path
from typing import Optional
from app.core.json_helpers import _dumps
logger = logging.getLogger(__name__)
MDD_BACKUP_INTERVAL_SEC = 60
def _default_path() -> Path:
return Path.home() / ".cache" / "auto-stock" / "mdd_state.json"
def _write_sync(path: Path, payload: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".mdd_state_")
try:
with os.fdopen(fd, "w") as f:
f.write(payload)
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
async def save_mdd_backup_v2(mgr, path: Optional[Path] = None) -> bool:
"""현재 MDD 스냅샷을 파일에 기록. peak<=0(미시드)이면 기존 백업을 덮지 않는다."""
path = path or _default_path()
async with mgr._lock:
snap = mgr._state_snapshot_v2()
if float(snap.get("peak_balance", 0) or 0) <= 0:
return False
try:
snap["saved_at"] = time.time() # epoch (참고용 — Redis 스냅샷엔 없어 비교는 peak/플래그 기준)
await asyncio.to_thread(_write_sync, path, _dumps(snap))
return True
except Exception as e:
logger.warning("[MDD] 파일 백업 저장 실패 (%s): %s", path, e)
return False
async def restore_mdd_from_backup_v2(mgr, path: Optional[Path] = None) -> bool:
"""파일 백업에서 MDD 상태 복원. 성공 시 True."""
path = path or _default_path()
try:
raw = await asyncio.to_thread(lambda: path.read_text() if path.exists() else "")
if not raw:
return False
state = json.loads(raw)
peak = float(state.get("peak_balance", 0.0))
except Exception as e:
logger.warning("[MDD] 파일 백업 복원 실패 (%s): %s", path, e)
return False
if peak <= 0:
return False
async with mgr._lock:
mgr.peak_balance = peak
mgr.is_paused = bool(state.get("is_paused", False))
mgr.is_short_term_strategy_paused = bool(state.get("is_short_term_strategy_paused", False))
mgr.reduction_applied = state.get("reduction_applied")
mgr._reduction_executed_10pct = bool(state.get("_reduction_executed_10pct", False))
mgr._reduction_executed_20pct = bool(state.get("_reduction_executed_20pct", False))
mgr._restore_reduction_progress(state)
return True
async def _read_redis_saved_at_v2() -> Optional[float]:
"""Redis mdd:state 의 saved_at(epoch). 없거나 읽기 실패면 None (구 스냅샷/미기록)."""
try:
from app.core.redis import get_redis
from app.services.mdd_manager import _MDD_REDIS_KEY
r = get_redis()
if not r:
return None
raw = await r.get(_MDD_REDIS_KEY)
if not raw:
return None
v = json.loads(raw).get("saved_at")
return float(v) if v is not None else None
except Exception as e:
logger.warning("[MDD] Redis saved_at 읽기 실패: %s", e)
return None
async def save_mdd_state_everywhere_v2(mgr, path: Optional[Path] = None) -> bool:
"""Redis 와 파일에 **같은 saved_at** 을 찍어 기록. reset/peak 보정 직후(락 밖) 호출용.
peak 가 낮아지는 변경(reset 재기준, 입출금 보정)은 peak 크기 비교로는 최신성을 알 수 없으므로
saved_at 으로 비교한다. peak<=0 이면 아무것도 쓰지 않는다.
"""
path = path or _default_path()
async with mgr._lock:
snap = mgr._state_snapshot_v2()
if float(snap.get("peak_balance", 0) or 0) <= 0:
return False
snap["saved_at"] = time.time()
try:
from app.services.mdd_manager import _MDD_REDIS_KEY
await mgr._write_state_v2(_MDD_REDIS_KEY, snap)
except Exception as e:
logger.warning("[MDD] Redis 동기 기록 실패: %s", e)
try:
await asyncio.to_thread(_write_sync, path, _dumps(snap))
return True
except Exception as e:
logger.warning("[MDD] 파일 백업 저장 실패 (%s): %s", path, e)
return False
def _progress_score_v2(state: dict) -> int:
"""보수성 점수: 켜진 방어 플래그 수 (많을수록 더 진행된/보수적 상태)."""
return sum(1 for k in ("is_paused", "is_short_term_strategy_paused",
"_reduction_executed_10pct", "_reduction_executed_20pct")
if state.get(k)) + (1 if state.get("reduction_applied") else 0)
async def _read_file_state_v2(path: Path) -> Optional[dict]:
try:
raw = await asyncio.to_thread(lambda: path.read_text() if path.exists() else "")
if not raw:
return None
state = json.loads(raw)
if float(state.get("peak_balance", 0.0)) <= 0:
return None
return state
except Exception as e:
logger.warning("[MDD] 파일 백업 비교 읽기 실패 (%s): %s", path, e)
return None
async def restore_mdd_state_v2(mgr, path: Optional[Path] = None) -> str:
"""Redis 와 파일 중 더 최신/보수적인 쪽으로 복원. 반환: 'redis' | 'file' | 'none'.
Redis 는 RDB 전용이라 크래시 후 오래된 스냅샷으로 되살아날 수 있다(스냅샷에 시각 없음).
양쪽에 saved_at 이 있으면 최신성(saved_at)으로 비교한다(리셋/출금 보정으로 peak 가 낮아져도
올바름). 시각이 없는 구 스냅샷끼리는 파일 peak 가 더 크면 파일이 최신이라고 본다. peak 동률이면
방어 플래그가 더 많이 켜진 쪽(보수적)을 쓴다. 'none' 은 호출부가 경보.
"""
path = path or _default_path()
if await mgr.load_from_redis():
redis_state = mgr._state_snapshot_v2()
file_state = await _read_file_state_v2(path)
if file_state is not None:
f_peak = float(file_state.get("peak_balance", 0.0))
r_peak = float(redis_state.get("peak_balance", 0.0))
f_at = file_state.get("saved_at")
r_at = await _read_redis_saved_at_v2()
if f_at is not None and r_at is not None:
# 최신성 기준: peak 가 리셋/보정으로 낮아졌어도 더 늦게 저장된 쪽이 이긴다.
# 동일 시각이면 보수적(방어 플래그 많은) 쪽.
use_file = float(f_at) > float(r_at) or (
float(f_at) == float(r_at)
and _progress_score_v2(file_state) > _progress_score_v2(redis_state))
else:
# 한쪽이라도 시각이 없으면(구 스냅샷/mdd_manager 직접 기록) 기존 휴리스틱
use_file = f_peak > r_peak or (
f_peak == r_peak and _progress_score_v2(file_state) > _progress_score_v2(redis_state))
if use_file:
if await restore_mdd_from_backup_v2(mgr, path):
logger.warning("[MDD] Redis 스냅샷이 파일 백업보다 오래됨(peak %s < %s) — 파일 우선",
f"{r_peak:,.0f}", f"{f_peak:,.0f}")
return "file"
return "redis"
if await restore_mdd_from_backup_v2(mgr, path):
return "file"
return "none"
async def mdd_backup_loop_v2(mgr, interval: float = MDD_BACKUP_INTERVAL_SEC, path: Optional[Path] = None) -> None:
while True:
await asyncio.sleep(interval)
try:
await save_mdd_backup_v2(mgr, path)
except Exception as e:
logger.warning("[MDD] 파일 백업 루프 오류: %s", e)