""" 주문/체결 API 엔드포인트 - GET /: 거래 이력 조회 (손익 포함) - GET /stats/summary: 거래 통계 - DELETE /pending: PENDING/FAILED 거래 정리 """ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from typing import List, Optional from datetime import datetime, timedelta from decimal import Decimal import logging from app.core.database import get_db from app.models.models import TradeHistory from pydantic import BaseModel router = APIRouter() logger = logging.getLogger(__name__) class TradeHistoryResponse(BaseModel): id: int rule_id: Optional[int] stock_code: str stock_name: Optional[str] order_type: str order_price: Optional[Decimal] order_quantity: int executed_price: Optional[Decimal] executed_quantity: Optional[int] total_amount: Optional[Decimal] status: str order_id: Optional[str] message: Optional[str] entry_reason: Optional[str] exit_reason: Optional[str] signals: Optional[List[str]] created_at: datetime executed_at: Optional[datetime] profit_loss: Optional[Decimal] = None profit_loss_rate: Optional[float] = None class Config: from_attributes = True @router.get("", response_model=List[TradeHistoryResponse]) async def get_trade_history( skip: int = 0, limit: int = 100, stock_code: Optional[str] = None, status: Optional[str] = None, order_type: Optional[str] = None, days: int = 30, db: AsyncSession = Depends(get_db) ): """거래 이력 조회 (손익 포함)""" query = select(TradeHistory) # 날짜 필터 since_date = datetime.utcnow() - timedelta(days=days) query = query.where(TradeHistory.created_at >= since_date) # 종목 코드 필터 if stock_code: query = query.where(TradeHistory.stock_code == stock_code) # 상태 필터 if status: query = query.where(TradeHistory.status == status) # 주문 유형 필터 if order_type: query = query.where(TradeHistory.order_type == order_type) query = query.order_by(TradeHistory.created_at.desc()) query = query.offset(skip).limit(limit) result = await db.execute(query) trades = result.scalars().all() # 손익 계산을 위해 매수/매도 매칭 trade_responses = [] buy_trades = {} # stock_code -> list of buy trades for trade in trades: response = TradeHistoryResponse.model_validate(trade) # 매도 시 손익 정보 설정 if trade.order_type == "SELL" and trade.status == "EXECUTED": import re # 1. DB에 저장된 값 먼저 사용 if hasattr(trade, 'net_profit_loss') and trade.net_profit_loss is not None: response.profit_loss = trade.net_profit_loss logger.debug(f"✅ DB에서 손익 금액 로드: {response.profit_loss}") if hasattr(trade, 'profit_loss_rate') and trade.profit_loss_rate is not None: response.profit_loss_rate = float(trade.profit_loss_rate) logger.debug(f"✅ DB에서 손익률 로드: {response.profit_loss_rate}%") # 2. DB 값이 없으면 exit_reason/message에서 파싱 (이전 데이터 호환성) if response.profit_loss is None or response.profit_loss_rate is None: text_to_parse = trade.exit_reason or trade.message or "" logger.debug(f"🔍 손익 파싱 시도 - 종목: {trade.stock_name}, 텍스트: {text_to_parse[:100]}") # 손익 금액 추출 if response.profit_loss is None: profit_match = re.search(r'손익:\s*([+-]?[\d,]+)원', text_to_parse) if profit_match: profit_str = profit_match.group(1).replace(',', '') try: response.profit_loss = Decimal(profit_str) logger.info(f"✅ 파싱으로 손익 금액 추출: {response.profit_loss}") except Exception as e: logger.error(f"❌ 손익 금액 변환 실패: {e}") # 손익률 추출 if response.profit_loss_rate is None: message_rate_match = re.search(r'손익률\s*([+-]?[\d.]+)%', trade.message or "") if message_rate_match: try: response.profit_loss_rate = float(message_rate_match.group(1)) logger.info(f"✅ 파싱으로 손익률 추출 (message): {response.profit_loss_rate}%") except Exception as e: logger.error(f"❌ 손익률 변환 실패: {e}") else: rate_match = re.search(r'([+-]?[\d.]+)%', text_to_parse) if rate_match: try: response.profit_loss_rate = float(rate_match.group(1)) logger.info(f"✅ 파싱으로 손익률 추출 (exit_reason): {response.profit_loss_rate}%") except Exception as e: logger.error(f"❌ 손익률 변환 실패: {e}") trade_responses.append(response) return trade_responses @router.get("/{trade_id}", response_model=TradeHistoryResponse) async def get_trade(trade_id: int, db: AsyncSession = Depends(get_db)): """특정 거래 조회""" result = await db.execute(select(TradeHistory).where(TradeHistory.id == trade_id)) trade = result.scalar_one_or_none() if not trade: raise HTTPException(status_code=404, detail="Trade not found") return trade @router.get("/stats/summary") async def get_trade_stats( days: int = 30, db: AsyncSession = Depends(get_db) ): """거래 통계 조회""" since_date = datetime.utcnow() - timedelta(days=days) result = await db.execute( select(TradeHistory).where(TradeHistory.created_at >= since_date) ) trades = result.scalars().all() total_trades = len(trades) buy_trades = len([t for t in trades if t.order_type == "BUY"]) sell_trades = len([t for t in trades if t.order_type == "SELL"]) executed_trades = len([t for t in trades if t.status == "EXECUTED"]) failed_trades = len([t for t in trades if t.status == "FAILED"]) # 총 거래 금액 계산 total_amount = sum( float(t.executed_price or 0) * (t.executed_quantity or 0) for t in trades if t.status == "EXECUTED" ) return { "period_days": days, "total_trades": total_trades, "buy_trades": buy_trades, "sell_trades": sell_trades, "executed_trades": executed_trades, "failed_trades": failed_trades, "success_rate": executed_trades / total_trades if total_trades > 0 else 0, "total_amount": total_amount } @router.delete("/pending") async def delete_pending_trades(db: AsyncSession = Depends(get_db)): """장 마감 후 PENDING과 FAILED 상태의 매수 시도 내역 정리""" result = await db.execute( select(TradeHistory).where( TradeHistory.status.in_(['PENDING', 'FAILED']) ) ) pending_trades = result.scalars().all() count = len(pending_trades) for trade in pending_trades: await db.delete(trade) await db.commit() logger.info(f"✅ 정리 완료: {count}개의 PENDING/FAILED 매수 시도 삭제") return {"deleted_count": count, "message": f"Cleaned up {count} pending/failed trades"}