"""추천 종목 수동 매수 엔드포인트 (프론트 "매수" 버튼 — 청산 버튼의 매수 대응). POST /api/v1/portfolio/buy - quantity 생략 시: 총자산 × MAX_POSITION_SIZE 예산으로 시장가 수량 자동 산출 - 리스크 가드(§5): 가격 필터·연속손실 ban·MDD 신규진입 차단·최대 보유수·TRADING_HALTED - 주문은 trade_executor.safe_place_order (종목별 세마포어 + Redis 분산락) 경유 - DB 쓰기는 db_worker 큐 경유(insert_trade_history / upsert_portfolio), 이후 KIS 잔고 재동기화 """ import asyncio import time import logging from datetime import datetime from typing import Optional import pytz from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings from app.core.database import get_db from app.core.security import require_api_key from app.models.models import Portfolio from app.services.korea_investment_api import korea_investment_api from app.services.loss_tracker import loss_tracker from app.services.mdd_manager import mdd_manager from app.services.utils import _is_domestic logger = logging.getLogger(__name__) router = APIRouter() _KST = pytz.timezone("Asia/Seoul") class BuyRequest(BaseModel): stock_code: str stock_name: Optional[str] = None quantity: Optional[int] = Field(None, gt=0, description="매수 수량 (None이면 예산 기반 자동 산출)") price: Optional[int] = Field(None, gt=0, description="지정가 (None이면 시장가)") def _kst_now() -> datetime: return datetime.now(_KST).replace(tzinfo=None) # 종목별 수동 매수 중복 방지: 진행 중(in-flight) 또는 최근 완료(쿨다운) 시 거부. # KIS 지연으로 주문이 30~60초 걸릴 때 화면 타임아웃 → 재클릭 → 같은 종목 3회 매수된 사고 방지. _inflight: set[str] = set() _last_done: dict[str, float] = {} _DUP_COOLDOWN_SEC = 120.0 @router.post("/buy", dependencies=[Depends(require_api_key)]) async def buy_stock(request: BuyRequest, db: AsyncSession = Depends(get_db)): """추천 종목 수동 매수 (매수 버튼).""" 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 stock_code in _inflight: raise HTTPException(status_code=409, detail="같은 종목 매수 주문이 아직 처리 중입니다 (KIS 응답 대기). 잠시 후 포트폴리오를 확인하세요") _since = time.monotonic() - _last_done.get(stock_code, -1e9) if _since < _DUP_COOLDOWN_SEC: raise HTTPException( status_code=409, detail=f"{int(_DUP_COOLDOWN_SEC - _since)}초 전 같은 종목을 매수했습니다. 중복 주문 방지 쿨다운 중", ) _inflight.add(stock_code) try: return await _buy_stock_impl(request, stock_code, db) finally: _inflight.discard(stock_code) async def _buy_stock_impl(request: BuyRequest, stock_code: str, db: AsyncSession): # ── 현재가 ────────────────────────────────────────────────────────── try: price_data = await korea_investment_api.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 loss_tracker.is_banned_safe(stock_code): raise HTTPException(status_code=400, detail="연속 손실 진입 금지 종목입니다 (loss_tracker ban)") if not await mdd_manager.can_open_new_position(): raise HTTPException(status_code=400, detail="MDD 신규 진입 중단 상태입니다") held = await db.execute(select(Portfolio).where(Portfolio.stock_code == stock_code)) holding = held.scalar_one_or_none() if holding is None: cnt = (await db.execute( select(func.count()).select_from(Portfolio).where(Portfolio.quantity > 0) )).scalar() or 0 if cnt >= settings.MAX_HOLDINGS_COUNT: raise HTTPException(status_code=400, detail=f"최대 보유 종목 수({settings.MAX_HOLDINGS_COUNT}) 초과") # ── 수량 산출 (예산 = 총자산 × MAX_POSITION_SIZE, 예수금 한도) ──────────── quantity = request.quantity order_price = float(request.price) if request.price else current_price if quantity is None: try: balance = await korea_investment_api.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"🟢 수동 매수 요청: {stock_name}({stock_code}) {quantity}주 @ {request.price or '시장가'}") # ── 주문 (세마포어 + 분산락) ───────────────────────────────────────── from app.services.trade_executor import safe_place_order, _delayed_sync_portfolio order_result = await safe_place_order( 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] 유입 잔고 강등 레이스 방어 — DB(TradeHistory) 반영은 db_worker # 큐를 거쳐 비동기라, 3초 뒤 `_delayed_sync_portfolio` 가 먼저 돌면 근거가 없어 # 보인다. 주문 성공 시점에 인메모리 근거를 즉시 남긴다. from app.services.system_buy_registry import note_system_buy note_system_buy(stock_code, quantity) # ── DB 기록 (db_worker 큐) ──────────────────────────────────────────── from app.services.db_worker import enqueue_trade_history, enqueue_portfolio_upsert now = _kst_now() await enqueue_trade_history({ "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": "수동 매수 (추천 패널)", "strategy_type": "manual", "created_at": now, "executed_at": now, }) if holding is None: await enqueue_portfolio_upsert({ "stock_code": stock_code, "stock_name": stock_name, "quantity": quantity, "average_price": order_price, "current_price": current_price, "market": "KRX", "strategy_type": "manual", "currency_code": "KRW", "exchange_code": "KRX", }) # 체결 후 KIS 잔고 기준 재동기화 (평균단가·수량 정확화) asyncio.create_task(_delayed_sync_portfolio(3)) _last_done[stock_code] = time.monotonic() logger.info(f"✅ 수동 매수 주문 완료: {stock_name} {quantity}주 @ {order_price:,.0f}원 (주문번호 {order_id})") return { "success": True, "message": f"{stock_name} {quantity}주 매수 주문 완료", "order_id": order_id, "quantity": quantity, "price": order_price, }