- 스윙 체결가 확인 전 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
193 lines
8.0 KiB
Python
193 lines
8.0 KiB
Python
"""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)
|