feat: 자동매매 신뢰성 개선 — 시세 실서버 라우팅·일일 한도 전역화·수동 매수 API

- korea_investment_api: KIS_QUOTES_VIA_REAL — 모의투자 모드에서 시세/차트/호가 조회를
  실계좌 시세 서버(20/s)로 위임, 주문·잔고는 모의서버 유지 (EGW00201 대응)
- recommendations: execute_auto_trade_background asyncio.Lock 중복 실행 방지 +
  MAX_DAILY_TRADES를 당일 BUY 건수 차감 전역 기준으로 적용 (11종목 동시 매수 재발 방지)
- manual_buy.py 신규: POST /api/v1/portfolio/buy (리스크 가드 + safe_place_order + db_worker)
- mcp-server: claude_code_client(claude -p 헤드리스 모드) 추가, trade_monitor 부분매도 시
  active 유지·remaining_pct 갱신, API 키 헤더·/api/v1 경로 수정, ODNO 주문ID 수정
- config: MCP_REQUEST_TIMEOUT, MCP_RISK_CACHE_TTL, BATCH_ANALYSIS_INTERVAL,
  SWING_INDEX_BREAKER_ENABLED(OFF), API_TRIGGERED_AUTO_TRADE(OFF) 추가
- docker-compose: 포트 충돌 해소 (postgres 15433, frontend 3001)
- frontend: 추천 패널 매수 버튼, 차트/호가/저널 패널 개선
- CLAUDE.md 압축 최적화 (규칙 변경 없음), _sweep_single_axis.py 백테스트 단일축 스윕

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-19 23:37:08 +09:00
parent b1da208fd5
commit 028a1c352b
39 changed files with 1073 additions and 694 deletions

View File

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