Files
auto-stock/backend/app/api/endpoints/trades_v2.py
shuim b4a1fc79ab feat: 멀티테넌트 1단계 — 사용자별 KIS 계좌·포트폴리오·수동매매 (MULTI_TENANT_ENABLED 기본 OFF)
설계: docs/multi_tenant_design.md / 검수: docs/multi_tenant_phase1_review.md (High 0, MT=False 배포 가능)

데이터·인프라
- user_kis_credentials(Fernet 암호화, PBKDF2 키 파생, key_version), stock_bans_v2 신설,
  portfolio/trade_history/active_trades/trade_journals/trading_sessions 등 user_id nullable 추가
- alembic 0009~0011(idempotent, legacy owner 백필). main.py 기동 시 portfolio.user_id 스키마 검사(H1)
- Jenkinsfile: DB Migration 을 Deploy 앞으로, alembic 이력 없으면 stamp 0008 후 upgrade(N1)
- bootstrap: users 비어 있으면 BOOTSTRAP_ADMIN_* 로 관리자 시드 + .env KIS 자격증명 이관 + 백필(SAVEPOINT)

백엔드
- core/crypto.py, core/tenant_auth.py(get_current_user — 비활성 사용자 401, API 키→legacy owner,
  deny_non_owner_session_when_mt), core/dt_utils.py
- services/tenant: context, kis_pool, registry(UserContextRegistry, 락 최소화·복호화 to_thread), risk_factory
- KoreaInvestmentAPI.for_user(사용자별 인스턴스, 토큰캐시 users/{id}/, 시세는 공용 위임)
- trade_executor.safe_place_order_v2(사용자별 락/세마포어, legacy ctx 는 기존 키)
- mdd_manager/loss_tracker _v2 + ban_until tz-aware/naive 경계 정규화(레거시 경로 버그 포함 수정)
- db_worker: payload user_id 옵션 필터, upsert_stock_ban_v2, legacy ActiveTrade 조회 LIMIT 1
- API: /account/credentials(등록·마스킹조회·삭제·연결테스트·자동매매), /auth/admin/tenant/*,
  /portfolio/v2(조회·요약·sync·buy·sell), /trades/v2(+pending). PortfolioResponse Optional 수정

프론트
- /account 내 계좌 설정, useTenantMode(세션=v2 / API키=legacy, 미확정 시 쿼리·버튼 비활성),
  계좌 미등록 인라인 안내, v2 매수/매도/거래이력 분기

테스트: tests/integration(격리 Postgres, KIS 전면 mock) 20 passed / 1 xfailed(0012 이전 제약)
2단계(스케줄러 사용자화·legacy 라우터 격리) 전까지 MULTI_TENANT_ENABLED=True 전환 금지

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 10:26:51 +09:00

126 lines
5.4 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
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 = 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()).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:
logger.debug(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:
logger.debug(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)는 기존 동작과 동일하고,
일반 사용자는 본인 이력만 지운다.
"""
target_user_id = await _resolve_target_user_id(user, None, db)
query = select(TradeHistory).where(
TradeHistory.status.in_(["PENDING", "FAILED"]),
_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"}