Files
auto-stock/backend/app/api/endpoints/portfolio_v2.py
shuim 9de0715165 fix: 스윙 자동편입 차단 + DT 처리량 복구 + ATR/양봉 세션 경계 + DLQ 안전
사용자 요구 "스윙으로 편입하는 건 없어야 해" — 시스템이 매수하지 않은 KIS
계좌 잔고가 strategy_type='swing' 이 되는 경로를 전부 닫았다. 2026-09-30
09:26 에 유한양행 8주가 -6.49%/-40,000원 손절된 것이 직접 계기다.

## 스윙 자동편입 차단
- 강등 허용(swing→external). 기존엔 승격만 가능해 유입 잔고가 영구히
  자동 손절 대상이었다. 3중 방어로 정상 포지션 강등을 막는다:
  system_buy_registry(주문 즉시 인메모리) / 연속2회+120초 / db_worker 큐 적체 보류
  ※ 레이스는 이론이 아니다 — _verify_and_sync_execution 이 매수 직후
    _delayed_sync_portfolio(0) 을 지연 0초로 부른다
- 수량 인식 분류(_classify_holding_strategy_type_v2):
  _sys<=0<qty → external / 0<_sys<qty AND _sys==_dt_held → external
  == 을 쓴 이유: 과소·과대 어느 쪽으로 틀려도 swing(관리 유지)으로 실패한다
- _system_qty 를 과대 집계 쪽으로 편향 — 오판이 안전한 방향으로 나게
- external 행에 스윙 추가매수 차단(혼재 발생 자체를 봉쇄, 청산은 통과)
- 근거를 두 벌로 분리: 신규태깅·강등방어는 registry 포함("있다고 보는 게 안전"),
  승격·ActiveTrade 교정은 durable("없다고 보는 게 안전"). 안전 방향이 반대다
- 정합 루프가 external/혼재 행을 교정하지 않게(전제 "active 1행 = 전량 대표"를
  직접 검사). 미교정 시 ActiveTrade 가 부풀어 스윙 청산이 유입분까지 판다(§3.5)
- status 허용목록 → 거부목록(FAILED 만 제외). 진짜 구멍은 FILLED 가 아니라
  PENDING 이었다(체결확인 실패 시 영구 PENDING, 주식은 보유 중)
- SELL 중복제거 키에 nullif+trim — order_id 는 NULL 이 아니라 '' 로 저장된다
  (build_kis_order_id 가 미발견 시 "" 반환). coalesce 만으로는 그룹이 붕괴해
  SELL 이 과소 집계되고 유입 행이 영구히 swing 이 된다

## DT 처리량
- 감시종목 20 → 30 (DAY_TRADE_WATCHLIST_SIZE, 예산 상한 32에서 마진)
- 선조회 동시성 4→8, per-stock 45초, 단계 데드라인 120초 신설
  (120 < 150 _SCAN_TIME_BUDGET < 180 _guard). 오후는 종목당 32초가
  타임아웃 20초를 넘겨 4~7종목만 스캔되던 포화 상태였다 — 약 9배 복구
- 리스크 노출 불변: 동시 5종목·종목당 6%·DT 30% 무변경. 기회만 증가

## 세션 경계 (ATR·양봉)
- _atr() 이 내부에서 세션을 끊는다. 워밍업 시계열의 당일 첫 봉 TR =
  오버나이트 갭이라 갭 3%에서 ATR 1.50배, 10%에서 2.75배 부풀었고
  클램프 없는 target=현재가+1.5×ATR 을 직접 왜곡했다
  → 진입 ATR == 청산 트레일링 ATR 보장
  ※ 부작용 승인됨: 09:30~10:10 은 ATR 부재로 폴백 -3.5% 가 실효 손절폭
- 양봉 판정이 형성 중 버킷을 보던 것 → 마지막 완성봉(거래량 게이트와 같은 봉)
- 1.5R 하한 보장(두 분기). risk=max(1.5×ATR,2%) 라 저변동 종목은
  R:R 0.21 까지 떨어졌다 — §7 "1.5R 분할" 규약 복귀, RSI_BB 와 동일

## DLQ 안전
- replay_dlq 나이 제한: 상태 2시간+같은날짜 / 기록 24시간 / close 는
  충돌판정만(7일). "닫아라"는 지시는 시간이 지나도 거짓이 되지 않는다 —
  격리하면 반대로 유령 active 행이 남는다(§8.4.1 129개와 같은 형태)
- 상태 충돌 확인(유령 생성 위험 시 expired/ 격리, 삭제 안 함)
- DB 장애 시 단락 — 없으면 500파일 × 접속타임아웃만큼 기동이 정지한다

## 진단·테스트
- 진입 reason 에 [진단 ATR=…, 갭=…], 탈락 후보 RSI·갭 집계 로그(하루 ~60줄)
- 잔고조회 실패 debug→warning (available_cash=0 → 모든 진입 조용히 차단)
- 벽시계 의존 테스트 재발 방지: autouse 가드(시각 미고정 시 AssertionError)
  + 호출부 전수 테스트
- 소스 문자열 매칭 단언을 동작 검증으로 전환(값·분기 판정 한정)

테스트 848 → 865 passed / 0 failed. 전략 임계값 변경 0건. alembic 불필요.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-30 16:50:54 +09:00

549 lines
25 KiB
Python

"""사용자 스코프 포트폴리오 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")
# [2026-09-30] 유입 잔고 강등 레이스 방어 (manual_buy.py 와 동일 이유)
from app.services.system_buy_registry import note_system_buy
note_system_buy(stock_code, quantity)
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
original_strategy_type = holding.strategy_type or "swing"
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)
# Portfolio.strategy_type('daytrading'/'us_swing')과 ActiveTrade.strategy_name
# ('day_trade'/'us_mean_reversion')은 값 체계가 달라 그대로 넘기면 매칭에 실패해
# active 행이 영구 잔존한다(2026-09-23).
_AT_STRATEGY = {"daytrading": "day_trade", "us_swing": "us_mean_reversion"}
_at_name = _AT_STRATEGY.get(holding.strategy_type or "swing", holding.strategy_type or "swing")
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=_at_name)
else:
await enqueue_close_active_trade_v2(ctx.user_id, stock_code, strategy_name=_at_name)
else:
holding.quantity -= quantity
holding.current_price = Decimal(str(executed_price))
# 부분매도 시 ActiveTrade 잔량 갱신 — 누락 시 다음 청산이 매도 전 수량으로 판다
from app.services.db_worker import enqueue_active_trade_upsert as _enq_at
_at_payload = {
"stock_code": stock_code,
"strategy_name": _at_name,
"quantity": original_holding_qty - quantity,
"current_price": float(executed_price),
}
if not ctx.is_legacy:
_at_payload["user_id"] = ctx.user_id
await _enq_at(_at_payload)
await db.commit()
# 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),
}