- 스윙 체결가 확인 전 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
254 lines
13 KiB
Python
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(),
|
|
}
|