Files
auto-stock/backend/app/api/endpoints/portfolio_v2.py
shuim 9fab4018d4 fix: 프로그램 검증 루프 7회차 — 리스크·주문·청산·인프라 결함 일괄 수정 + 거래 확대 결정 반영
하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소.

주문·리스크
- MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문)
- KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류)
- 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백
- 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락
- 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드)
- 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단
데이트레이딩
- 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한
- 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정
스윙
- 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT)
- 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed
- 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일
- 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON
인프라·보안
- 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹
- KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환
- db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시
- KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패
하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준
CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신

backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:01:31 +09:00

570 lines
26 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.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 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,
)
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,
**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,
)
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
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)
# [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),
}