"""사용자 스코프 포트폴리오 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.buy_guards import BuyGuardRejected, evaluate_manual_buy_v2 from app.services.manual_sell_service import ( allocate_sell_v2, dt_inmemory_qty_v2, reflect_dt_after_manual_sell_v2, sync_active_trade_after_manual_sell_v2, ) 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 _is_order_outcome_unknown_v2, 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 as _swallowed: logger.warning("[portfolio_v2] portfolio_cache.mark_changed 실패: %s", _swallowed) 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}) 초과") # ── 사이징·자본 가드 (CLAUDE.md §5.1, 공용 buy_guards — manual_buy 와 동일) ── # [H-4 2026-10-02] 종목당 4% 누적·현금 유보·레버리지 20%·최소주문 실효값· # 적응형 Crisis·섹터/상관 필터. 지정 수량은 상한으로 클램프. 판정은 try 밖. order_price = float(request.price) if request.price else current_price current_holdings = (await db.execute( select(Portfolio).where( Portfolio.quantity > 0, _user_scope_filter(Portfolio.user_id, ctx.user_id) ) )).scalars().all() try: guard = await evaluate_manual_buy_v2( kis=ctx.kis, stock_code=stock_code, stock_name=stock_name, current_price=current_price, order_price=order_price, requested_quantity=request.quantity, holding=holding, current_holdings=list(current_holdings), # DT 인메모리 상태는 레거시 계좌 소유 — 사용자 컨텍스트에는 섞지 않는다 include_dt_inmemory=bool(ctx.is_legacy), ) except BuyGuardRejected as rej: logger.warning(f"[MANUAL_BUY v2] user_id={ctx.user_id} {stock_code} 거부({rej.reason}): {rej.detail}") raise HTTPException(status_code=rej.status_code, detail=rej.detail) quantity = guard.quantity 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, ) # [R7 2026-10-10] manual_buy R3 와 동일: 전송 후 결과 불명은 접수됐을 수 있다 — 쿨다운 + PENDING. if _is_order_outcome_unknown_v2(order_result): _last_done_v2[(ctx.user_id, stock_code)] = time.monotonic() logger.error( f"[MANUAL_BUY v2] user_id={ctx.user_id} {stock_code} 주문 결과 불명 — " f"쿨다운 {int(_DUP_COOLDOWN_SEC)}초, PENDING 기록. 포트폴리오 확인 전 재시도 금지" ) _unk = { "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": None, "executed_quantity": 0, "total_amount": float(order_price * quantity), "status": "PENDING", "order_id": None, "message": "수동 매수 결과 불명 (v2, 접수 여부 미확인 — 포트폴리오 확인 필요)", "strategy_type": "manual", "created_at": _kst_now(), } if not ctx.is_legacy: _unk["user_id"] = ctx.user_id await enqueue_trade_history(_unk) raise HTTPException( status_code=504, detail="매수 주문 결과 불명 — 접수·체결됐을 수 있습니다. 포트폴리오 확인 후 재시도하세요", ) 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") # [2026-09-30] 유입 잔고 강등 레이스 방어 (manual_buy.py 와 동일 이유) from app.services.system_buy_registry import note_system_buy note_system_buy(stock_code, quantity) # [R7] 접수(rt_cd 0)≠체결. 지정가는 PENDING·체결 0 으로 기록하고 Portfolio 선생성을 생략한다. is_limit = bool(request.price) 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, # [2026-10-08] 체결가는 체결 확인(swing/fill_sweep)이 채운다 — 주문 기준가를 체결가로 쓰지 않는다. # total_amount 는 확인 전 추정치. "executed_price": None, "executed_quantity": 0 if is_limit else quantity, "total_amount": float(order_price * quantity), "status": "PENDING" if is_limit else "COMPLETED", "order_id": order_id, "message": "수동 매수 (v2, 사용자 스코프)", "strategy_type": "manual", "created_at": now, "executed_at": None if is_limit else 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 and not is_limit: 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, **guard.to_response(), } # ── 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 original_strategy_type = holding.strategy_type or "swing" # [Low 3회차] DT 보유분은 주문 전에 확정(레거시 계좌만). 모르면 503 — /swing/sell·/sell 과 동일. _dt_qty = 0 if ctx.is_legacy: _dt_qty = dt_inmemory_qty_v2(stock_code) if _dt_qty is None: raise HTTPException( status_code=503, detail="데이트레이딩 보유분 확인 실패 — 매도 배분을 확정할 수 없습니다", ) order_result = await safe_place_order_v2( ctx, stock_code=stock_code, order_type="SELL", quantity=quantity, price=price, is_domestic=True, ) # [R7 2026-10-10] 결과 불명 SELL: 체결 여부를 모르므로 원장·보유 상태를 바꾸지 않고 # PENDING 만 남긴다(잘못 차감하면 재매도 과다/추적 상실). 잔고 sync 가 보정한다. if _is_order_outcome_unknown_v2(order_result): logger.error(f"[SELL v2] user_id={ctx.user_id} {stock_code} 주문 결과 불명 — PENDING 기록, 상태 미변경") _unk = { "stock_code": stock_code, "stock_name": stock_name, "order_type": "SELL", "order_price": float(price) if price else None, "order_quantity": quantity, "executed_price": None, "executed_quantity": 0, "total_amount": float((price or float(holding.current_price or 0)) * quantity), "status": "PENDING", "order_id": None, "message": "수동 매도 결과 불명 (v2, 접수 여부 미확인 — 포트폴리오 확인 필요)", "strategy_type": "manual", "created_at": _kst_now(), } if not ctx.is_legacy: _unk["user_id"] = ctx.user_id await enqueue_trade_history(_unk) raise HTTPException( status_code=504, detail="매도 주문 결과 불명 — 접수·체결됐을 수 있습니다. 포트폴리오 확인 후 재시도하세요", ) if not order_result or order_result.get("rt_cd") != "0": error_msg = order_result.get("msg1", "알 수 없는 오류") if order_result else "API 호출 실패" # order_lock_busy = 자동 청산 등 다른 경로가 같은 종목을 주문 중 → 재시도 가능(409) _status = 409 if error_msg == "order_lock_busy" else 500 raise HTTPException(status_code=_status, 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 # [R7] 접수≠체결: 지정가 매도는 PENDING·체결 0, 보유/ActiveTrade/연패 상태를 건드리지 않는다 # (미체결인데 차감하면 손절·청산 추적이 끊긴다). 체결 후 sync 가 반영한다. is_limit_sell = bool(price) 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, # [2026-10-08] 체결가는 체결 확인(swing/fill_sweep)이 채운다. 금액·손익은 확인 전 추정치. "executed_price": None, "executed_quantity": 0 if is_limit_sell else quantity, "total_amount": float(executed_price * quantity), "status": "PENDING" if is_limit_sell else "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": None if is_limit_sell else profit_loss, "profit_loss_rate": None if is_limit_sell else profit_loss_rate, "strategy_type": "manual", "created_at": _kst_now(), "executed_at": None if is_limit_sell else _kst_now(), } if not ctx.is_legacy: sell_history_payload["user_id"] = ctx.user_id await enqueue_trade_history(sell_history_payload) if is_limit_sell: logger.info(f"[SELL v2] user_id={ctx.user_id} {stock_code} 지정가 매도 접수 — 체결 확인 전 상태 미변경") return { "success": True, "message": f"{stock_name} {quantity}주 지정가 매도 주문 접수 (체결 대기)", "order_id": (order_result.get("output") or {}).get("ODNO"), "profit_loss": 0.0, "profit_loss_rate": 0.0, "pending": True, } # [M-5 2026-10-02] ActiveTrade·Portfolio·DT 인메모리 반영. # 기존: (1) 보유 행의 strategy_type 한 가지로만 close/upsert → 합산 행(스윙+DT)에서 # 다른 전략 행이 잔존/오폐기, (2) DT 인메모리 미차감 → DT 엔진이 판 수량을 재매도, # (3) `db.delete/commit` 직접 쓰기(§3.2 위반). # 수정: DT 우선 배분(레거시 /portfolio/sell·MDD 축소와 동일 규칙)으로 전략별 행을 # 전략명 필터와 함께 갱신, DB 쓰기는 db_worker 큐 경유. DT 인메모리는 레거시 계좌 소유라 # legacy 컨텍스트에서만 반영한다. alloc = allocate_sell_v2(quantity, original_holding_qty, _dt_qty, swing_only=False) await sync_active_trade_after_manual_sell_v2( stock_code, alloc, price=float(executed_price), user_id=None if ctx.is_legacy else ctx.user_id, ) _pf_payload = { "stock_code": stock_code, # 0 → 목록에서 제외, 다음 sync 가 KIS 기준으로 정리(행 삭제는 sync 와 충돌) "quantity": max(0, original_holding_qty - quantity), "current_price": float(executed_price), } if not ctx.is_legacy: _pf_payload["user_id"] = ctx.user_id await enqueue_portfolio_upsert(_pf_payload) if ctx.is_legacy: await reflect_dt_after_manual_sell_v2(stock_code, alloc.dt_sold) # LossTracker 연패 기록 — 전량 청산 시에만(분할 매도 이중 카운트 방지). # [2026-09-28] 수동 청산이 빠져 연패가 과소 집계되던 구멍을 막는다. # 조건·순서는 swing/exit_service.py 와 동일하게 record → save. # 유입 격리분(external)은 전략 성과가 아니므로 제외. if quantity >= original_holding_qty and original_strategy_type != "external": try: await ctx.loss_tracker.record_trade_result_safe( stock_code=stock_code, is_loss=profit_loss < 0, ) if ctx.is_legacy: await ctx.loss_tracker.save_to_db(stock_code=stock_code) else: await ctx.loss_tracker.save_to_db_v2(ctx.user_id, stock_code=stock_code) except Exception as _lt_err: logger.warning(f"[SELL v2] LossTracker 기록 실패 {stock_code}: {_lt_err}") 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), }