사용자 요구 "스윙으로 편입하는 건 없어야 해" — 시스템이 매수하지 않은 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>
549 lines
25 KiB
Python
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),
|
|
}
|