from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from typing import List from decimal import Decimal from app.core.database import get_db from app.models.models import Portfolio from app.services.korea_investment_api import korea_investment_api import asyncio import logging from datetime import datetime, timedelta logger = logging.getLogger(__name__) # 단순 인메모리 캐시 (프로세스 단위). 다중 프로세스 환경이면 Redis 등 필요. class PortfolioCache: def __init__(self): self.holdings = [] # List[Portfolio] self.summary = None # Dict self.last_synced: datetime | None = None self.change_flag = False # 외부 이벤트로 변경 감지 (매수/매도 등) self.sync_lock = asyncio.Lock() self.min_interval = timedelta(seconds=15) # 최소 동기화 간격 self.last_attempt: datetime | None = None self.last_error: str | None = None # 실패 관리 (백오프) self.failure_count: int = 0 self.next_retry_at: datetime | None = None self.last_sync_success: bool = False def mark_changed(self): self.change_flag = True def should_sync(self) -> bool: now = datetime.now() # 최초 또는 변경 플래그 또는 최소간격 경과 후 오류 상태 재시도 if self.last_synced is None: return True if self.change_flag: return True # 오류 발생 시 백오프 기간에는 재시도 금지 if self.last_error: if self.next_retry_at and now < self.next_retry_at: return False # 백오프가 지났으면 재시도 허용 (최소 간격과 무관) return True if (now - self.last_synced) > timedelta(minutes=5): # 주기적 신선도 보장 return True return False def _compute_backoff(self) -> timedelta: # 30s, 60s, 120s, max 5min base = 30 delay = base * (2 ** max(self.failure_count - 1, 0)) delay = min(delay, 300) return timedelta(seconds=delay) def record_failure(self, err: Exception | str): self.last_error = str(err) self.failure_count += 1 self.next_retry_at = datetime.now() + self._compute_backoff() self.last_sync_success = False def record_success(self): self.last_error = None self.failure_count = 0 self.next_retry_at = None self.last_sync_success = True def cache_summary(self): holdings = self.holdings total_stocks = len(holdings) total_value = sum(float(h.current_price or 0) * h.quantity for h in holdings) total_investment = sum(float(h.average_price or 0) * h.quantity for h in holdings) total_profit_loss = sum(float(h.profit_loss or 0) for h in holdings) total_profit_loss_rate = (total_profit_loss / total_investment * 100) if total_investment > 0 else 0 self.summary = { "total_stocks": total_stocks, "total_value": total_value, "total_investment": total_investment, "total_profit_loss": total_profit_loss, "total_profit_loss_rate": total_profit_loss_rate } portfolio_cache = PortfolioCache() from pydantic import BaseModel router = APIRouter() class PortfolioResponse(BaseModel): id: int stock_code: str stock_name: str = None quantity: int average_price: Decimal = None current_price: Decimal = None profit_loss: Decimal = None profit_loss_rate: Decimal = None class Config: from_attributes = True @router.get("", response_model=List[PortfolioResponse]) async def get_portfolio(db: AsyncSession = Depends(get_db)): """포트폴리오 조회 (캐시 활용)""" if portfolio_cache.holdings and not portfolio_cache.should_sync(): logger.debug("Portfolio cache hit (holdings)") return portfolio_cache.holdings # 필요하면 동기화 시도 try: await sync_portfolio(db, force=False) except HTTPException: # 강제 동기화가 아닌 경우에는 오류를 사용자에게 전파하지 않고 캐시/빈 목록 반환 logger.warning("get_portfolio: sync failed, returning cached holdings") return portfolio_cache.holdings @router.get("/sync") async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True): """실제 계좌와 포트폴리오 동기화 (캐시 반영) force=False이면 캐시 정책에 따라 생략될 수 있음. """ if not force and not portfolio_cache.should_sync(): logger.debug("Skip sync: cache fresh & no change flag") return {"message": "Skipped (cache fresh)", "cached": True} async with portfolio_cache.sync_lock: portfolio_cache.last_attempt = datetime.now() try: balance_data = await korea_investment_api.get_balance() portfolio_cache.record_success() # 기존 레코드 로드 (삭제 대신 업데이트) result = await db.execute(select(Portfolio)) existing_all = {p.stock_code: p for p in result.scalars().all()} processed_codes = set() new_holdings = [] if "output1" in balance_data: for item in balance_data["output1"]: stock_code = item.get("pdno") stock_name = item.get("prdt_name") quantity = int(item.get("hldg_qty", 0)) if quantity <= 0: continue average_price = Decimal(item.get("pchs_avg_pric", 0)) current_price = Decimal(item.get("prpr", 0)) profit_loss = Decimal(item.get("evlu_pfls_amt", 0)) profit_loss_rate = Decimal(item.get("evlu_pfls_rt", 0)) processed_codes.add(stock_code) if stock_code in existing_all: h = existing_all[stock_code] h.stock_name = stock_name h.quantity = quantity h.average_price = average_price h.current_price = current_price h.profit_loss = profit_loss h.profit_loss_rate = profit_loss_rate new_holdings.append(h) else: new_holding = Portfolio( stock_code=stock_code, stock_name=stock_name, quantity=quantity, average_price=average_price, current_price=current_price, profit_loss=profit_loss, profit_loss_rate=profit_loss_rate ) db.add(new_holding) new_holdings.append(new_holding) # 제거된 종목 반영 (수량 0 처리) for code, h in existing_all.items(): if code not in processed_codes: h.quantity = 0 await db.commit() portfolio_cache.holdings = [h for h in new_holdings if h.quantity > 0] portfolio_cache.cache_summary() portfolio_cache.last_synced = datetime.now() portfolio_cache.change_flag = False logger.info(f"Portfolio synced: {len(portfolio_cache.holdings)} holdings") return {"message": "Portfolio synchronized", "count": len(portfolio_cache.holdings), "cached": False} except Exception as e: portfolio_cache.record_failure(e) logger.error(f"Portfolio sync failed: {e}") if force: # 명시적 강제 동기화 요청 시에는 오류 전파 raise HTTPException(status_code=502, detail=f"Failed to sync portfolio: {e}") # 비강제(백그라운드) 동기화 실패 시에는 캐시/빈 값 사용 return {"message": "Sync failed (using cache)", "cached": True, "error": str(e)} @router.get("/summary") async def get_portfolio_summary(db: AsyncSession = Depends(get_db)): """포트폴리오 요약 (캐시 활용)""" if portfolio_cache.summary and not portfolio_cache.should_sync(): logger.debug("Portfolio cache hit (summary)") resp = dict(portfolio_cache.summary) resp.update({ "sync_status": "ok" if portfolio_cache.last_error is None else "error", "last_synced": portfolio_cache.last_synced.isoformat() if portfolio_cache.last_synced else None, "last_error": portfolio_cache.last_error, }) return resp try: await sync_portfolio(db, force=False) except HTTPException: logger.warning("get_portfolio_summary: sync failed, returning cached summary") base = portfolio_cache.summary or { "total_stocks": 0, "total_value": 0, "total_investment": 0, "total_profit_loss": 0, "total_profit_loss_rate": 0 } resp = dict(base) resp.update({ "sync_status": "ok" if portfolio_cache.last_error is None else "error", "last_synced": portfolio_cache.last_synced.isoformat() if portfolio_cache.last_synced else None, "last_error": portfolio_cache.last_error, }) return resp @router.post("/refresh") async def refresh_portfolio(db: AsyncSession = Depends(get_db)): """강제 캐시 무효화 및 재동기화""" portfolio_cache.change_flag = True return await sync_portfolio(db, force=True)