Files
auto-stock/backend/app/api/endpoints/trades.py
shuim a45007cbb2 feat: TradingView 스타일 대시보드 + 차트 오버레이 API, Crisis 손절 소급 차단 플래그(기본 OFF)
- frontend: 대시보드를 TradingView 레이아웃으로 재구성(components/tv/*) — 캔들+MA/BB/거래량,
  평단·손절·목표·트레일링 가격선, 체결 마커, RSI 기준선은 실효 설정값, 하단 탭(보유·체결·DT 카드·
  비용 후 손익곡선/낙폭·저널), 디자인 토큰·한국식 색·최소 11px, 모바일 가로 스크롤 제거
- backend(GET 전용, require_api_key): /positions/overlay, /risk/equity-history,
  /strategy-config/display, /trades 응답 필드(side·strategy_type·commission·net_profit_loss),
  /risk/mdd-status thresholds, /market-data/chart 1m|5m(캐시·in-flight 공유)
- mean_reversion_strategy: RSI 청산 임계를 resolve_swing_exit_rsi_v2 로 추출(동작 동일) —
  청산과 화면 표시가 같은 값을 쓴다
- §8.5-39: SWING_STOP_IGNORE_CRISIS_OVERLAY(기본 False) — 반사실 재생에서 Crisis 조기 손절이
  오히려 손실을 줄여 기본 OFF 유지, 정식 백테스트 전 켜지 말 것
- 유닛 스위트 2816 passed / 0 failed, tsc·build 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 18:47:00 +09:00

249 lines
9.9 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, model_validator
from app.core.timeutil import now_kst
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
# TV 재구성 계약(Optional 추가): 전략·수수료·순손익·방향
strategy_type: Optional[str] = None
commission: Optional[float] = None
net_profit_loss: Optional[float] = None
side: Optional[str] = None
@model_validator(mode="after")
def _derive_side(self):
if self.side is None and self.order_type:
ot = str(self.order_type).lower()
if ot in ("buy", "sell"):
self.side = ot
return self
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 = now_kst() - 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 = now_kst() - 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"])
# 총 거래 금액 계산 — [2026-10-08] total_amount(체결 확인 시 체결가×체결수량으로 갱신) 우선.
# executed_price 는 체결 확인 전 NULL 이므로 곱셈만 쓰면 미확인 건이 0 으로 빠진다.
def _amount(t) -> float:
if t.total_amount is not None:
return float(t.total_amount)
price = t.executed_price if t.executed_price is not None else t.order_price
return float(price or 0) * (t.executed_quantity or 0)
total_amount = sum(
_amount(t) 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)):
"""FAILED 상태의 주문 시도 내역 정리 — **전일 이전 행만, PENDING 은 보존**.
[R7] PENDING(지정가 미체결·결과 불명 BUY)은 원장 BUY 합계와 강등 방어의 근거라 지우지 않는다.
쓰기는 FAILED 전용 정리(기존 동작 유지). db_worker 에 delete task 가 없어 직접 삭제를 유지한다
(관리자 전용 등록은 security.ADMIN_ONLY_ROUTES 소관 — 별도 보고).
[L 2026-10-02] 기존에는 기간 무제한이라 장중 호출 시 당일 PENDING/FAILED 행까지
지웠다. 당일 행은 `already_ordered_today`·일일 스윙 매수 한도(`_count_swing_buys_today_v2`
가 PENDING 포함)·체결 확인(`_verify_and_sync_execution` 의 order_id 갱신)의 근거라,
지우면 당일 한도가 풀리고 체결 기록이 유실된다. 기준은 trade_executor 와 같은
`get_daily_cutoff_utc()`(KST 당일 00:00, created_at 과 같은 KST naive 규약).
"""
from app.services.trade_helpers import get_daily_cutoff_utc
cutoff = get_daily_cutoff_utc()
result = await db.execute(
select(TradeHistory).where(
# [R7] PENDING 은 보존한다 — 체결 확인 실패 BUY 가 PENDING 으로 남아
# portfolio 원장 BUY 합계·유입잔고 강등(swing→external) 방어의 근거가 된다(main.py 기동 정리와 동일 정책).
TradeHistory.status == 'FAILED',
TradeHistory.created_at < cutoff,
)
)
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}개의 FAILED 시도 삭제(PENDING 보존) (기준 {cutoff} 이전, 당일 행 보존)")
return {
"deleted_count": count,
"message": f"Cleaned up {count} failed trades (PENDING kept) before {cutoff.isoformat()} (today's rows kept)",
}