""" 연속 손실 추적 - CLAUDE.md 섹션 7.4 동일 종목에서 3회 이상 연속 손실 시 30거래일 동안 신규 진입 금지. 재시작 후에도 ban 상태 유지: DB(stock_bans 테이블) 영속화. """ import logging from datetime import datetime, timedelta from typing import Dict, Optional logger = logging.getLogger(__name__) class ConsecutiveLossTracker: """종목별 연속 손실 추적 및 쿨링오프 관리""" def __init__(self, ban_count: int = 3, ban_calendar_days: int = 42): """ Args: ban_count: 연속 손실 횟수 임계값 (기본 3) ban_calendar_days: 금지 기간 (30거래일 ≈ 42캘린더일) """ self.ban_count = ban_count self.ban_calendar_days = ban_calendar_days # {stock_code: {"consecutive_losses": int, "ban_until": datetime|None}} self._tracker: Dict[str, dict] = {} def record_trade_result(self, stock_code: str, is_loss: bool): """매매 결과 인메모리 기록 (DB 저장은 save_to_db 별도 호출) Args: stock_code: 종목코드 is_loss: 손실 여부 """ if stock_code not in self._tracker: self._tracker[stock_code] = {"consecutive_losses": 0, "ban_until": None} entry = self._tracker[stock_code] if is_loss: entry["consecutive_losses"] += 1 if entry["consecutive_losses"] >= self.ban_count: entry["ban_until"] = datetime.now() + timedelta(days=self.ban_calendar_days) logger.warning( f"{stock_code}: {entry['consecutive_losses']}회 연속 손실 → " f"{entry['ban_until'].strftime('%Y-%m-%d')}까지 진입 금지" ) else: entry["consecutive_losses"] = 0 entry["ban_until"] = None async def save_to_db(self, db, stock_code: str): """인메모리 상태를 DB에 영속화 (record_trade_result 후 호출)""" try: from sqlalchemy import select from app.models.models import StockBan entry = self._tracker.get(stock_code) if entry is None: return result = await db.execute(select(StockBan).where(StockBan.stock_code == stock_code)) record = result.scalar_one_or_none() if record is None: record = StockBan( stock_code=stock_code, consecutive_losses=entry["consecutive_losses"], ban_until=entry["ban_until"], ) db.add(record) else: record.consecutive_losses = entry["consecutive_losses"] record.ban_until = entry["ban_until"] record.updated_at = datetime.utcnow() await db.commit() logger.debug(f"loss_tracker DB 저장 완료: {stock_code}") except Exception as e: logger.error(f"loss_tracker DB 저장 실패 ({stock_code}): {e}") async def load_from_db(self, db): """앱 시작 시 DB에서 ban 상태 복원""" try: from sqlalchemy import select from app.models.models import StockBan result = await db.execute(select(StockBan)) records = result.scalars().all() now = datetime.now() loaded = 0 for rec in records: # 만료된 ban은 초기화 ban_until = rec.ban_until if ban_until and now >= ban_until: ban_until = None consecutive_losses = 0 else: consecutive_losses = rec.consecutive_losses self._tracker[rec.stock_code] = { "consecutive_losses": consecutive_losses, "ban_until": ban_until, } if ban_until: loaded += 1 logger.info( f"ban 복원: {rec.stock_code} → {ban_until.strftime('%Y-%m-%d')}까지 진입 금지" ) logger.info(f"loss_tracker DB 로드 완료: {len(records)}건 중 {loaded}건 활성 ban") except Exception as e: logger.error(f"loss_tracker DB 로드 실패: {e}") def is_banned(self, stock_code: str) -> bool: """해당 종목이 진입 금지 상태인지 확인""" entry = self._tracker.get(stock_code) if not entry or not entry["ban_until"]: return False if datetime.now() >= entry["ban_until"]: # 금지 기간 만료 → 리셋 entry["consecutive_losses"] = 0 entry["ban_until"] = None return False return True def get_ban_info(self, stock_code: str) -> dict: """종목의 연속 손실 및 금지 정보 조회""" entry = self._tracker.get(stock_code, {"consecutive_losses": 0, "ban_until": None}) return { "stock_code": stock_code, "consecutive_losses": entry["consecutive_losses"], "is_banned": self.is_banned(stock_code), "ban_until": entry["ban_until"].isoformat() if entry["ban_until"] else None, } # 싱글톤 인스턴스 — settings에서 ban_count 주입 def _create_loss_tracker() -> ConsecutiveLossTracker: try: from app.core.config import settings return ConsecutiveLossTracker( ban_count=settings.CONSECUTIVE_LOSS_BAN_COUNT, ban_calendar_days=42, # 30거래일 ≈ 42캘린더일 ) except Exception: return ConsecutiveLossTracker() loss_tracker = _create_loss_tracker()