Files
auto-stock/backend/app/api/endpoints/trades.py
shuim 9fab4018d4 fix: 프로그램 검증 루프 7회차 — 리스크·주문·청산·인프라 결함 일괄 수정 + 거래 확대 결정 반영
하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소.

주문·리스크
- MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문)
- KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류)
- 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백
- 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락
- 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드)
- 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단
데이트레이딩
- 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한
- 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정
스윙
- 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT)
- 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed
- 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일
- 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON
인프라·보안
- 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹
- KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환
- db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시
- KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패
하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준
CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신

backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:01:31 +09:00

224 lines
8.5 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
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
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"])
# 총 거래 금액 계산
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 상태의 매수 시도 내역 정리 — **전일 이전 행만**.
[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(
TradeHistory.status.in_(['PENDING', '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}개의 PENDING/FAILED 매수 시도 삭제 (기준 {cutoff} 이전, 당일 행 보존)")
return {
"deleted_count": count,
"message": f"Cleaned up {count} pending/failed trades before {cutoff.isoformat()} (today's rows kept)",
}