"""사용자 스코프 포트폴리오 API (`_v2`, 멀티테넌트 1단계 §5.7). 기존 `portfolio.py`/`manual_buy.py`의 함수·시그니처는 전혀 건드리지 않는다. 이 파일은 `get_current_user`/`get_user_context`로 호출자를 해석해 사용자별 KIS 계좌(`ctx.kis`)·리스크 상태(`ctx.mdd`, `ctx.loss_tracker`)로 조회·매매한다. - `MULTI_TENANT_ENABLED=False`(기본)이면 `CurrentUser.effective_user_id`가 legacy owner id 를 반환하고, `get_user_context`도 legacy 컨텍스트(기존 전역 싱글톤)를 반환하므로 기존 `/portfolio`와 동일하게 동작한다. - Portfolio/TradeHistory/ActiveTrade 는 `user_id` 컬럼이 nullable로 이미 추가돼 있으나 `portfolio.stock_code`는 아직 전역 unique(0012 이전)이므로, 서로 다른 사용자가 동시에 같은 종목을 보유하면 DB unique 제약에 걸릴 수 있다(설계 문서 §3.2, §7 열린 질문 — 0012 마이그레이션 전까지는 알려진 제약). - 레거시 호환 조회 규칙: `MULTI_TENANT_ENABLED=False`이면 `user_id == 대상 id` 또는 `user_id IS NULL`(백필 전 데이터)을 모두 포함해서 조회한다. """ import asyncio import logging import time from datetime import datetime from decimal import Decimal from typing import Optional import pytz from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from sqlalchemy import func, or_, select from sqlalchemy.sql import false as sql_false from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings from app.core.database import get_db from app.core.tenant_auth import CurrentUser, get_current_user from app.models.models import Portfolio from app.services.db_worker import ( enqueue_close_active_trade, enqueue_close_active_trade_v2, enqueue_portfolio_upsert, enqueue_trade_history, ) from app.services.tenant.context import UserContext from app.services.tenant.registry import get_user_context, user_context_registry from app.services.trade_executor import safe_place_order_v2 from app.services.utils import _is_domestic from app.api.endpoints.manual_buy import BuyRequest from app.api.endpoints.portfolio import PortfolioResponse, SellRequest logger = logging.getLogger(__name__) router = APIRouter() _KST = pytz.timezone("Asia/Seoul") def _kst_now() -> datetime: return datetime.now(_KST).replace(tzinfo=None) def _user_scope_filter(column, target_user_id: Optional[int]): """레거시 호환 필터: MULTI_TENANT_ENABLED=False 이면 NULL(백필 전) 행도 포함. M1: `target_user_id=None`(관리자 미존재 등)일 때 `column == None`은 SQLAlchemy가 자동으로 `IS NULL`로 컴파일하므로, MULTI_TENANT_ENABLED=True 에서 그대로 두면 "본인 없음"이 아니라 "NULL 행 전체 노출"이 되어버린다. True 모드에서는 fail-closed로 빈 결과를 반환한다. """ if settings.MULTI_TENANT_ENABLED: if target_user_id is None: return sql_false() return column == target_user_id return or_(column == target_user_id, column.is_(None)) async def _resolve_target_user_id( user: CurrentUser, user_id: Optional[int], db: AsyncSession ) -> int: """`?user_id=` 오버라이드(관리자 전용) 해석. 미지정 시 본인 effective_user_id.""" if user_id is None: target = user.effective_user_id if target is None: raise HTTPException(status_code=400, detail="사용자 컨텍스트를 확인할 수 없습니다") return target if not user.is_admin: raise HTTPException(status_code=403, detail="다른 사용자 조회는 관리자만 가능합니다") return user_id async def _resolve_ctx( user: CurrentUser, user_id: Optional[int], db: AsyncSession ) -> UserContext: """`?user_id=` 오버라이드를 반영한 `UserContext` 해석 (KIS 잔고 조회용).""" if user_id is None: return await user_context_registry.resolve(user, db) if not user.is_admin: raise HTTPException(status_code=403, detail="다른 사용자 조회는 관리자만 가능합니다") legacy_ctx = await user_context_registry.legacy(db) if user_id == legacy_ctx.user_id: return legacy_ctx ctx = await user_context_registry.get(user_id, db) if ctx is None: raise HTTPException(status_code=404, detail=f"user_id={user_id} 의 KIS 계좌가 등록·검증되지 않았습니다") return ctx # ── GET /portfolio/v2 ───────────────────────────────────────────────────── @router.get("", response_model=list[PortfolioResponse]) async def get_portfolio_v2( user_id: Optional[int] = None, user: CurrentUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """사용자 스코프 보유 종목 조회. `?user_id=`는 관리자 전용 타 사용자 열람.""" target_user_id = await _resolve_target_user_id(user, user_id, db) query = select(Portfolio).where( Portfolio.quantity > 0, _user_scope_filter(Portfolio.user_id, target_user_id) ) result = await db.execute(query) return list(result.scalars().all()) # ── GET /portfolio/v2/summary ───────────────────────────────────────────── @router.get("/summary") async def get_portfolio_summary_v2( user_id: Optional[int] = None, user: CurrentUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """사용자 스코프 포트폴리오 요약. 총자산/예수금은 `ctx.kis.get_balance()` 기준.""" target_user_id = await _resolve_target_user_id(user, user_id, db) ctx = await _resolve_ctx(user, user_id, db) query = select(Portfolio).where( Portfolio.quantity > 0, _user_scope_filter(Portfolio.user_id, target_user_id) ) holdings = list((await db.execute(query)).scalars().all()) total_value = sum(float(h.current_price or 0) * h.quantity for h in holdings) total_investment = sum(float(h.average_price or 0) * h.quantity for h in holdings) total_profit_loss = total_value - total_investment total_profit_loss_rate = (total_profit_loss / total_investment * 100) if total_investment else 0.0 available_cash = 0.0 total_assets = 0.0 try: balance = await ctx.kis.get_balance() output2 = balance.get("output2") or [{}] b = output2[0] if isinstance(output2, list) else output2 available_cash = float(b.get("dnca_tot_amt") or 0) total_assets = float(b.get("tot_evlu_amt") or 0) except Exception as e: logger.warning(f"[portfolio_v2] user_id={target_user_id} 잔고 조회 실패: {e}") return { "user_id": target_user_id, "total_stocks": len(holdings), "total_value": total_value, "total_investment": total_investment, "total_profit_loss": total_profit_loss, "total_profit_loss_rate": total_profit_loss_rate, "available_cash": available_cash, "total_assets": total_assets, } # ── POST /portfolio/v2/sync ─────────────────────────────────────────────── _sync_locks: dict[int, asyncio.Lock] = {} def _get_sync_lock(user_id: int) -> asyncio.Lock: lock = _sync_locks.get(user_id) if lock is None: lock = asyncio.Lock() _sync_locks[user_id] = lock return lock @router.post("/sync") async def sync_portfolio_v2( ctx: UserContext = Depends(get_user_context), db: AsyncSession = Depends(get_db), ): """`ctx.kis.get_balance()` 로 KIS 잔고를 조회해 사용자 스코프 Portfolio를 upsert/삭제. `sync_portfolio`(portfolio.py)와 동일하게 단일 트랜잭션 직접 DB 접근이 허용되는 예외 경로다(CLAUDE.md §3.2). 다른 사용자 행은 절대 건드리지 않도록 모든 쿼리에 `Portfolio.user_id == ctx.user_id` 조건을 명시한다. legacy 컨텍스트(멀티테넌트 비활성 또는 API 키 호출)는 `user_id`가 legacy owner id 또는 None이며, 이 경우 기존 `/portfolio/sync`가 이미 전체 동기화를 수행하므로 여기서는 스킵한다. """ if ctx.is_legacy: return {"message": "legacy 컨텍스트는 기존 /portfolio/sync 를 사용하세요", "skipped": True} if ctx.user_id is None: raise HTTPException(status_code=400, detail="사용자 컨텍스트를 확인할 수 없습니다") lock = _get_sync_lock(ctx.user_id) try: await asyncio.wait_for(lock.acquire(), timeout=30) except asyncio.TimeoutError: raise HTTPException(status_code=503, detail="Portfolio sync timeout (30s)") try: balance_data = await ctx.kis.get_balance() if not balance_data or balance_data.get("rt_cd") != "0": error_msg = balance_data.get("msg1", "Unknown error") if balance_data else "No response" raise HTTPException(status_code=502, detail=f"Balance API error: {error_msg}") result = await db.execute(select(Portfolio).where(Portfolio.user_id == ctx.user_id)) existing = {p.stock_code: p for p in result.scalars().all()} processed_codes: set[str] = set() for item in balance_data.get("output1") or []: stock_code = item.get("pdno") quantity = int(item.get("hldg_qty", 0) or 0) if not stock_code or quantity <= 0: continue processed_codes.add(stock_code) average_price = Decimal(str(item.get("pchs_avg_pric", 0))) current_price = Decimal(str(item.get("prpr", 0))) profit_loss = Decimal(str(item.get("evlu_pfls_amt", 0))) profit_loss_rate = Decimal(str(item.get("evlu_pfls_rt", 0))) if stock_code in existing: h = existing[stock_code] h.stock_name = item.get("prdt_name") h.quantity = quantity h.average_price = average_price h.current_price = current_price h.profit_loss = profit_loss h.profit_loss_rate = profit_loss_rate h.market = "KRX" else: db.add(Portfolio( user_id=ctx.user_id, stock_code=stock_code, stock_name=item.get("prdt_name"), quantity=quantity, average_price=average_price, current_price=current_price, profit_loss=profit_loss, profit_loss_rate=profit_loss_rate, market="KRX", )) for code, h in existing.items(): if code not in processed_codes: await db.delete(h) await db.commit() if ctx.portfolio_cache is not None: try: ctx.portfolio_cache.mark_changed() except Exception: pass return {"message": "Portfolio synchronized", "count": len(processed_codes), "user_id": ctx.user_id} except HTTPException: raise except Exception as e: logger.error(f"[portfolio_v2] sync 실패 user_id={ctx.user_id}: {e}", exc_info=True) raise HTTPException(status_code=502, detail=f"Failed to sync portfolio: {e}") finally: lock.release() # ── POST /portfolio/v2/buy ───────────────────────────────────────────────── _inflight_v2: set[tuple] = set() _last_done_v2: dict[tuple, float] = {} _DUP_COOLDOWN_SEC = 120.0 @router.post("/buy") async def buy_stock_v2( request: BuyRequest, ctx: UserContext = Depends(get_user_context), db: AsyncSession = Depends(get_db), ): """사용자 스코프 수동 매수 (`manual_buy._buy_stock_impl`의 `_v2` 버전).""" stock_code = request.stock_code.strip() if not _is_domestic(stock_code): raise HTTPException(status_code=400, detail="국내 종목(6자리 코드)만 수동 매수를 지원합니다") if settings.TRADING_HALTED: raise HTTPException(status_code=423, detail="TRADING_HALTED — 전체 매수 중단 상태") if ctx.user_id is None and not ctx.is_legacy: raise HTTPException(status_code=400, detail="사용자 컨텍스트를 확인할 수 없습니다") # L4: legacy 컨텍스트는 registry.legacy()가 조립 시점에 스냅샷한 필드값(최대 60초 TTL 지연) # 대신 `effective_auto_trade_enabled`로 settings.AUTO_TRADE_ENABLED를 즉시 재조회한다. if not ctx.is_legacy and not ctx.effective_auto_trade_enabled: raise HTTPException(status_code=423, detail="사용자 자동매매가 비활성화 상태입니다 (내 계좌 설정에서 활성화)") dedup_key = (ctx.user_id, stock_code) if dedup_key in _inflight_v2: raise HTTPException(status_code=409, detail="같은 종목 매수 주문이 아직 처리 중입니다") since = time.monotonic() - _last_done_v2.get(dedup_key, -1e9) if since < _DUP_COOLDOWN_SEC: raise HTTPException( status_code=409, detail=f"{int(_DUP_COOLDOWN_SEC - since)}초 전 같은 종목을 매수했습니다. 중복 주문 방지 쿨다운 중", ) _inflight_v2.add(dedup_key) try: return await _buy_stock_impl_v2(request, stock_code, db, ctx) finally: _inflight_v2.discard(dedup_key) async def _buy_stock_impl_v2(request: BuyRequest, stock_code: str, db: AsyncSession, ctx: UserContext): try: price_data = await ctx.kis.get_current_price(stock_code) output = (price_data or {}).get("output") or {} current_price = float(output.get("stck_prpr") or 0) stock_name = request.stock_name or output.get("hts_kor_isnm") or stock_code except Exception as e: raise HTTPException(status_code=502, detail=f"현재가 조회 실패: {e}") if current_price <= 0: raise HTTPException(status_code=404, detail=f"현재가를 가져올 수 없습니다: {stock_code}") # ── 리스크 가드 (CLAUDE.md §5, 사용자 스코프) ───────────────────────── if current_price <= settings.MIN_PRICE_KRW: raise HTTPException(status_code=400, detail=f"가격 필터: {settings.MIN_PRICE_KRW:,}원 이하 종목 매수 금지") if await ctx.loss_tracker.is_banned_safe(stock_code): raise HTTPException(status_code=400, detail="연속 손실 진입 금지 종목입니다 (loss_tracker ban)") if not await ctx.mdd.can_open_new_position(): raise HTTPException(status_code=400, detail="MDD 신규 진입 중단 상태입니다") held = await db.execute( select(Portfolio).where( Portfolio.stock_code == stock_code, _user_scope_filter(Portfolio.user_id, ctx.user_id) ) ) holding = held.scalar_one_or_none() if holding is None: cnt = (await db.execute( select(func.count()).select_from(Portfolio).where( Portfolio.quantity > 0, _user_scope_filter(Portfolio.user_id, ctx.user_id) ) )).scalar() or 0 if cnt >= settings.MAX_HOLDINGS_COUNT: raise HTTPException(status_code=400, detail=f"최대 보유 종목 수({settings.MAX_HOLDINGS_COUNT}) 초과") quantity = request.quantity order_price = float(request.price) if request.price else current_price if quantity is None: try: balance = await ctx.kis.get_balance() output2 = balance.get("output2") or [{}] b = output2[0] if isinstance(output2, list) else output2 available_cash = float(b.get("dnca_tot_amt") or 0) total_assets = float(b.get("tot_evlu_amt") or 0) except Exception as e: raise HTTPException(status_code=502, detail=f"잔고 조회 실패: {e}") budget = min(total_assets * settings.MAX_POSITION_SIZE, available_cash) quantity = int(budget // order_price) if quantity < 1: raise HTTPException( status_code=400, detail=f"예산 부족: 예산 {budget:,.0f}원 < 1주 {order_price:,.0f}원 (예수금 {available_cash:,.0f}원)", ) logger.info(f"🟢 [v2] user_id={ctx.user_id} 수동 매수 요청: {stock_name}({stock_code}) {quantity}주") order_result = await safe_place_order_v2( ctx, stock_code=stock_code, order_type="BUY", quantity=quantity, price=request.price, is_domestic=True, ) if not order_result or order_result.get("rt_cd") != "0": msg = order_result.get("msg1", "알 수 없는 오류") if order_result else "API 호출 실패" raise HTTPException(status_code=500, detail=f"매수 주문 실패: {msg}") order_id = (order_result.get("output") or {}).get("ODNO") now = _kst_now() trade_history_payload = { "stock_code": stock_code, "stock_name": stock_name, "order_type": "BUY", "order_price": float(request.price) if request.price else None, "order_quantity": quantity, "executed_price": float(order_price), "executed_quantity": quantity, "total_amount": float(order_price * quantity), "status": "COMPLETED", "order_id": order_id, "message": "수동 매수 (v2, 사용자 스코프)", "strategy_type": "manual", "created_at": now, "executed_at": now, } # M3: legacy 컨텍스트는 user_id를 payload에 넣지 않는다 — 스윙/데이트레이딩 등 # 기존 레거시 쓰기 경로가 전부 user_id 없이 쓰므로(NULL), 정합을 맞춰야 # `_user_scope_filter`(NULL 매칭)로 같은 행이 조회된다. if not ctx.is_legacy: trade_history_payload["user_id"] = ctx.user_id await enqueue_trade_history(trade_history_payload) if holding is None: portfolio_payload = { "stock_code": stock_code, "stock_name": stock_name, "quantity": quantity, "average_price": order_price, "current_price": current_price, "profit_loss": 0, "profit_loss_rate": 0, "market": "KRX", "strategy_type": "manual", "currency_code": "KRW", "exchange_code": "KRX", } if not ctx.is_legacy: portfolio_payload["user_id"] = ctx.user_id await enqueue_portfolio_upsert(portfolio_payload) _last_done_v2[(ctx.user_id, stock_code)] = time.monotonic() logger.info(f"✅ [v2] user_id={ctx.user_id} 수동 매수 주문 완료: {stock_name} {quantity}주 @ {order_price:,.0f}원") return { "success": True, "message": f"{stock_name} {quantity}주 매수 주문 완료", "order_id": order_id, "quantity": quantity, "price": order_price, } # ── POST /portfolio/v2/sell ─────────────────────────────────────────────── @router.post("/sell") async def sell_stock_v2( request: SellRequest, ctx: UserContext = Depends(get_user_context), db: AsyncSession = Depends(get_db), ): """사용자 스코프 수동 매도. 매도 대상은 본인 보유 종목만 조회(타 사용자 종목은 404).""" if ctx.user_id is None and not ctx.is_legacy: raise HTTPException(status_code=400, detail="사용자 컨텍스트를 확인할 수 없습니다") stock_code = request.stock_code quantity = request.quantity price = request.price result = await db.execute( select(Portfolio).where( Portfolio.stock_code == stock_code, _user_scope_filter(Portfolio.user_id, ctx.user_id) ) ) holding = result.scalar_one_or_none() if not holding: raise HTTPException(status_code=404, detail=f"보유하지 않은 종목입니다: {stock_code}") if holding.quantity < quantity: raise HTTPException( status_code=400, detail=f"보유 수량 부족: {holding.quantity}주 보유 중, {quantity}주 매도 요청", ) stock_name = holding.stock_name original_holding_qty = holding.quantity average_price = holding.average_price order_result = await safe_place_order_v2( ctx, stock_code=stock_code, order_type="SELL", quantity=quantity, price=price, is_domestic=True, ) if not order_result or order_result.get("rt_cd") != "0": error_msg = order_result.get("msg1", "알 수 없는 오류") if order_result else "API 호출 실패" raise HTTPException(status_code=500, detail=f"매도 주문 실패: {error_msg}") executed_price = price if price else float(holding.current_price or 0) profit_loss = (executed_price - float(average_price or 0)) * quantity profit_loss_rate = ((executed_price / float(average_price)) - 1) * 100 if average_price else 0.0 sell_history_payload = { "stock_code": stock_code, "stock_name": stock_name, "order_type": "SELL", "order_price": float(price) if price else None, "order_quantity": quantity, "executed_price": float(executed_price), "executed_quantity": quantity, "total_amount": float(executed_price * quantity), "status": "COMPLETED", "order_id": (order_result.get("output") or {}).get("ODNO"), "message": f"수동 청산 (v2): 손익 {profit_loss:+,.0f}원 ({profit_loss_rate:+.2f}%)", "exit_reason": f"사용자 수동 청산 (손익: {profit_loss:+,.0f}원, 손익률: {profit_loss_rate:+.2f}%)", "net_profit_loss": profit_loss, "profit_loss_rate": profit_loss_rate, "strategy_type": "manual", "created_at": _kst_now(), "executed_at": _kst_now(), } if not ctx.is_legacy: sell_history_payload["user_id"] = ctx.user_id await enqueue_trade_history(sell_history_payload) if quantity >= original_holding_qty: await db.delete(holding) if ctx.is_legacy: # M3: legacy 스케줄러/기존 `/portfolio/sell`과 동일하게 user_id 없는 close 사용 # (ActiveTrade.user_id == owner 필터로 NULL 행을 못 찾아 유령 포지션으로 # 남는 결함 방지). await enqueue_close_active_trade(stock_code, strategy_name=holding.strategy_type) else: await enqueue_close_active_trade_v2(ctx.user_id, stock_code, strategy_name=holding.strategy_type) else: holding.quantity -= quantity holding.current_price = Decimal(str(executed_price)) await db.commit() logger.info(f"✅ [v2] user_id={ctx.user_id} 수동 매도 완료: {stock_name} {quantity}주 @ {executed_price:,.0f}원") return { "success": True, "message": f"{stock_name} {quantity}주 매도 주문 완료", "order_id": (order_result.get("output") or {}).get("ODNO"), "profit_loss": float(profit_loss), "profit_loss_rate": float(profit_loss_rate), }