Initial commit
This commit is contained in:
228
backend/app/api/endpoints/portfolio.py
Normal file
228
backend/app/api/endpoints/portfolio.py
Normal file
@@ -0,0 +1,228 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user