Files
auto-stock/backend/app/api/endpoints/manual_buy.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
2026-10-10 12:17:49 +09:00

254 lines
13 KiB
Python

"""추천 종목 수동 매수 엔드포인트 (프론트 "매수" 버튼 — 청산 버튼의 매수 대응).
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(),
}