"""추천 종목 수동 매수 엔드포인트 (프론트 "매수" 버튼 — 청산 버튼의 매수 대응). POST /api/v1/portfolio/buy - quantity 생략 시: 공용 매수 가드(services/buy_guards) 예산으로 수량 자동 산출 - quantity 지정 시: 같은 예산 상한으로 클램프(응답 clamped/clamp_reasons) - 리스크 가드(§5): 가격 필터·연속손실 ban·MDD 신규진입 차단·최대 보유수·TRADING_HALTED + buy_guards: 종목당 4% 누적·현금 유보·레버리지 20%·최소주문 실효값·Crisis·섹터/상관 - 주문은 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.background_tasks import spawn_tracked_v2 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.buy_guards import BuyGuardRejected, evaluate_manual_buy_v2 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}) 초과") # ── 사이징·자본 가드 (CLAUDE.md §5.1, 공용 buy_guards) ───────────────── # [H-4 2026-10-02] 종목당 4% 누적·현금 유보·레버리지 20%·최소주문 실효값· # 적응형 Crisis·섹터/상관 필터. 지정 수량은 상한으로 클램프. 판정은 try 밖이며 # 조회 실패는 매수 거부(fail-closed). order_price = float(request.price) if request.price else current_price current_holdings = (await db.execute( select(Portfolio).where(Portfolio.quantity > 0) )).scalars().all() try: guard = await evaluate_manual_buy_v2( kis=korea_investment_api, 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), ) except BuyGuardRejected as rej: logger.warning(f"[MANUAL_BUY] {stock_code} 거부({rej.reason}): {rej.detail}") raise HTTPException(status_code=rej.status_code, detail=rej.detail) quantity = guard.quantity logger.info(f"🟢 수동 매수 요청: {stock_name}({stock_code}) {quantity}주 @ {request.price or '시장가'}") # ── 주문 (세마포어 + 분산락) ───────────────────────────────────────── from app.services.trade_executor import ( safe_place_order, _delayed_sync_portfolio, _is_order_outcome_unknown_v2, ) order_result = await safe_place_order( stock_code=stock_code, order_type="BUY", quantity=quantity, price=request.price, is_domestic=True, ) # [R3 2026-10-09] 전송 후 결과 불명(KIS_TRANSPORT_UNKNOWN·msg_cd 없음·응답 없음)은 실제 # 접수·체결됐을 수 있다. 단순 실패로 처리하면 쿨다운 없이 재클릭 → Portfolio 미동기 상태에서 # 4% 누적 상한이 기존 보유분을 못 봐 이중 매수. 쿨다운을 세우고 PENDING 으로 기록한다. if _is_order_outcome_unknown_v2(order_result): from app.services.db_worker import enqueue_trade_history as _eth _now = _kst_now() _last_done[stock_code] = time.monotonic() logger.error( f"[MANUAL_BUY] {stock_code} 주문 결과 불명(전송 후 응답 유실) — 쿨다운 {int(_DUP_COOLDOWN_SEC)}초, " f"PENDING 기록 후 잔고 동기화. 포트폴리오 확인 전 재시도 금지" ) await _eth({ "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": "수동 매수 결과 불명 (접수 여부 미확인 — 포트폴리오 확인 필요)", "strategy_type": "manual", "created_at": _now, }) spawn_tracked_v2(_delayed_sync_portfolio(3)) 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] 유입 잔고 강등 레이스 방어 — 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() # [R3 2026-10-09] 접수(rt_cd 0)만으로 체결을 단정하지 않는다. 지정가는 미체결일 수 있어 # PENDING·미체결 수량 0 으로 기록하고 Portfolio 선생성을 생략한다(체결 확인/sync 담당). is_limit = bool(request.price) 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, # [2026-10-08] 체결가는 체결 확인(swing/fill_sweep)이 채운다 — 주문 기준가를 체결가로 쓰지 않는다. "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": "수동 매수 (추천 패널)", "strategy_type": "manual", "created_at": now, "executed_at": None if is_limit else now, }) if holding is None and not is_limit: 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", }) # [R7] 접수된 주문(ODNO)은 체결 확인 태스크로 PENDING→COMPLETED/CANCELLED 확정한다. # 없으면 체결된 지정가 수동 매수가 영원히 PENDING·0주로 남아 통계·저널에서 빠진다. # (미체결 잔량이 KIS 에 살아 있으면 PENDING 유지 — 취소는 하지 않는다.) if order_id: from app.services.trade_executor import _verify_and_sync_execution_v3 spawn_tracked_v2(_verify_and_sync_execution_v3( order_id=order_id, stock_code=stock_code, stock_name=stock_name, ordered_quantity=quantity, order_price=float(order_price), order_type="BUY", is_domestic=True, exchange_code="KRX", db_trade_id=0, delay=3, sell_pnl_basis={"strategy_type": "manual"}, )) # 체결 후 KIS 잔고 기준 재동기화 (평균단가·수량 정확화) spawn_tracked_v2(_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, **guard.to_response(), }