Files
auto-stock/backend/app/api/endpoints/trades.py
shuim de7921d270 fix: CRITICAL/HIGH 버그 30건 수정 + 전체 코드 주석 추가 + 중복 정리
## CRITICAL 수정 (20건)
- day_trading: global 선언 누락, loss_tracker 연동, ban 체크 추가
- trade_executor: BUY 체결가 order_price로 수정, remaining_pct 누적 계산
- risk_validator: get_current_price dict 파싱, MAX_POSITION_SIZE 동기화, DB 세션 누수
- risk_management/recommendations: date.today() → KST 변환
- mdd_manager: Lock 밖 executed_key 참조 수정
- correlation_filter: output → output2 키 수정
- portfolio: 해외 종목 USD/KRW 환율 적용 분리
- Sharpe Ratio 거래빈도 기반 연간화
- telegram_notifier: send_message() 공개 래퍼 추가
- position_manager: 앱 재시작 시 DB 포지션 복원
- swing exit_service: remaining_pct 조건 범위 수정
- run_backtest: original_qty 기반 remaining_pct 계산
- us_market_schedule: 2027년 미국 공휴일 추가
- Login.tsx: res.ok 검증 추가
- mean_reversion_strategy: RSI 로그 하드코딩 수정

## HIGH 수정 (10건)
- db_worker: DLQ 파일 백업 메커니즘 추가
- korea_investment_api: 만료 토큰 임의 연장 제거
- market_data: chart_cache 최대 100개 + TTL 만료
- main.py: rate_limit_store 1시간 미접근 IP 정리
- recommendations: fire-and-forget try/except 추가
- swing/scheduler: DB 세션 3개 분리
- Dashboard: wsMessages 전체 순회
- Layout: /health 엔드포인트 + 30초 폴링
- ml_ensemble: POST /train require_api_key 추가
- day_trading: record_trade_result_safe() 전환

## 코드 정리
- 프론트엔드: isUsStock, formatTime 공통 유틸 추출
- 백엔드: dead code 제거, 중복 import 통합
- MCP: JSON 파싱 헬퍼 추출 (~60줄 감소)
- 전체 ~100개 파일 한국어 주석 추가
- 테스트 13건 코드 기준 수정 (468 passed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:05:41 +09:00

210 lines
7.6 KiB
Python

"""
주문/체결 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
import re
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 in ("EXECUTED", "COMPLETED"):
# 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 in ("EXECUTED", "COMPLETED")])
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 in ("EXECUTED", "COMPLETED")
)
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"}