- 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>
249 lines
9.9 KiB
Python
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)",
|
|
}
|