"""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)