설계: 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>
126 lines
5.4 KiB
Python
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"}
|