Files
auto-stock/backend/app/api/endpoints/trades_v2.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
2026-10-10 12:17:49 +09:00

132 lines
5.7 KiB
Python

"""사용자 스코프 거래 이력 조회 API (`_v2`, 멀티테넌트 1단계 §5.7).
기존 `trades.py`의 `GET /trades`(파라미터·손익 계산 로직)는 그대로 두고,
`TradeHistory.user_id` 필터만 추가한 신규 라우터. 레거시 호환 규칙은
`portfolio_v2.py`의 `_user_scope_filter`와 동일(`MULTI_TENANT_ENABLED=False`면
`user_id == 대상 id` 또는 `user_id IS NULL` 모두 포함).
"""
import logging
import re
from datetime import datetime, timedelta
from decimal import Decimal
from typing import List, Optional
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.tenant_auth import CurrentUser, get_current_user
from app.models.models import TradeHistory
from app.api.endpoints.portfolio_v2 import _resolve_target_user_id, _user_scope_filter
from app.api.endpoints.trades import TradeHistoryResponse
from app.core.timeutil import now_kst
from app.core.log_throttle import warn_throttled # noqa: E402
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("", response_model=List[TradeHistoryResponse])
async def get_trade_history_v2(
skip: int = 0,
limit: int = 100,
stock_code: Optional[str] = None,
status: Optional[str] = None,
order_type: Optional[str] = None,
days: int = 30,
user_id: Optional[int] = None,
user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""사용자 스코프 거래 이력 조회 (`GET /trades`와 동일 파라미터 + 손익 파싱)."""
target_user_id = await _resolve_target_user_id(user, user_id, db)
query = select(TradeHistory).where(_user_scope_filter(TradeHistory.user_id, target_user_id))
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()).offset(skip).limit(limit)
result = await db.execute(query)
trades = result.scalars().all()
trade_responses = []
for trade in trades:
response = TradeHistoryResponse.model_validate(trade)
if trade.order_type == "SELL" and trade.status in ("EXECUTED", "COMPLETED"):
if hasattr(trade, "net_profit_loss") and trade.net_profit_loss is not None:
response.profit_loss = trade.net_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)
if response.profit_loss is None or response.profit_loss_rate is None:
text_to_parse = trade.exit_reason or trade.message or ""
if response.profit_loss is None:
profit_match = re.search(r"손익:\s*([+-]?[\d,]+)원", text_to_parse)
if profit_match:
try:
response.profit_loss = Decimal(profit_match.group(1).replace(",", ""))
except Exception as e:
warn_throttled(logger, "trades_v2:81", f"[trades_v2] 손익 금액 파싱 실패: {e}")
if response.profit_loss_rate is None:
message_rate_match = re.search(r"손익률\s*([+-]?[\d.]+)%", trade.message or "")
rate_match = message_rate_match or re.search(r"([+-]?[\d.]+)%", text_to_parse)
if rate_match:
try:
response.profit_loss_rate = float(rate_match.group(1))
except Exception as e:
warn_throttled(logger, "trades_v2:89", f"[trades_v2] 손익률 파싱 실패: {e}")
trade_responses.append(response)
return trade_responses
# ── DELETE /trades/v2/pending ────────────────────────────────────────────
@router.delete("/pending")
async def delete_pending_trades_v2(
user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""사용자 스코프 PENDING/FAILED 매수 시도 정리 (M6).
기존 `DELETE /trades/pending`(trades.py)은 `user_id` 조건 없이 전체 삭제라
`MULTI_TENANT_ENABLED=True`에서 일반 사용자가 타 사용자의 이력을 지울 수 있었다.
`_user_scope_filter`와 동일 규칙(MT=False면 본인 id 또는 NULL 행 포함, MT=True면
본인 행만)을 적용해 legacy 컨텍스트(관리자/API 키, MT=False)는 기존 동작과 동일하고,
일반 사용자는 본인 이력만 지운다.
"""
from app.services.trade_helpers import get_daily_cutoff_utc
target_user_id = await _resolve_target_user_id(user, None, db)
query = select(TradeHistory).where(
# [R7] PENDING 은 원장/강등 방어 근거라 보존, 당일 행은 한도 근거라 보존(trades.py 와 동일)
TradeHistory.status == "FAILED",
TradeHistory.created_at < get_daily_cutoff_utc(),
_user_scope_filter(TradeHistory.user_id, target_user_id),
)
result = await db.execute(query)
pending_trades = result.scalars().all()
count = len(pending_trades)
for trade in pending_trades:
await db.delete(trade)
await db.commit()
logger.info(f"✅ [v2] user_id={target_user_id} 정리 완료: {count}개의 PENDING/FAILED 매수 시도 삭제")
return {"deleted_count": count, "message": f"Cleaned up {count} pending/failed trades"}