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,
}

View File

@@ -212,6 +212,7 @@ def _generate_sample_chart(stock_code: str, period: str, limit: int = 100) -> di
chart_cache = {}
_CHART_CACHE_MAX_SIZE = 100
_CHART_CACHE_TTL = 600 # 10분
_CHART_STALE_DAYS = 7 # DB 마지막 봉이 이보다 오래되면 네이버 차트 폴백
def _aggregate_weekly_monthly(daily_data: list, period: str) -> list:
@@ -328,13 +329,23 @@ async def get_chart_data_cached(stock_code: str, period: str, limit: int) -> lis
db_limit = limit if period == "D" else limit * 7 if period == "W" else limit * 31
db_data = await _fetch_chart_from_db(stock_code, min(db_limit, 1200))
if not db_data:
# DB에 데이터 없으면 네이버 차트 폴백
# DB 데이터가 없거나 오래됐으면(마지막 봉이 _CHART_STALE_DAYS일 이상 전) 네이버 차트 폴백
# — KRX 일괄 수집(DATAGOKR)이 멈춘 종목은 DB가 수개월 전에서 끊겨 차트가 현재가와 어긋난다.
_stale = False
if db_data:
try:
_last = datetime.strptime(str(db_data[-1].get("date", ""))[:8], "%Y%m%d")
_stale = (current_time - _last).days > _CHART_STALE_DAYS
except ValueError:
_stale = False
if not db_data or _stale:
if _stale:
logger.info(f"차트 DB 데이터 오래됨 ({stock_code}, 마지막 {db_data[-1].get('date')}) → 네이버 폴백")
try:
from app.services.naver_finance import get_naver_daily_chart
naver_data = await get_naver_daily_chart(stock_code, days=limit * 2)
if naver_data:
db_data = []
naver_rows = []
for item in naver_data:
o = float(item.get("stck_oprc") or 0)
h = float(item.get("stck_hgpr") or 0)
@@ -343,7 +354,7 @@ async def get_chart_data_cached(stock_code: str, period: str, limit: int) -> lis
d = item.get("stck_bsop_date", "")
if not d or c <= 0 or o <= 0 or h <= 0 or l <= 0:
continue
db_data.append({
naver_rows.append({
"date": d,
"open": o,
"high": h,
@@ -351,7 +362,8 @@ async def get_chart_data_cached(stock_code: str, period: str, limit: int) -> lis
"close": c,
"volume": int(item.get("acml_vol") or 0),
})
db_data = db_data[-limit:]
if naver_rows:
db_data = naver_rows[-limit:]
logger.info(f"차트 네이버 폴백: {stock_code} ({len(db_data)}행)")
except Exception as e:
logger.warning(f"네이버 차트 폴백 실패 ({stock_code}): {e}")

View File

@@ -14,6 +14,7 @@ from typing import Dict, Any, Optional, Tuple
from pydantic import BaseModel
import httpx
import logging
import time
from app.core.config import settings
@@ -66,6 +67,20 @@ def get_http_client() -> httpx.AsyncClient:
_fail_open_count: int = 0
_FAIL_OPEN_WARN_THRESHOLD: int = 5 # 연속 N회 초과 시 경고 로그
# ─── RISK_ASSESSMENT 결과 캐시 (MCP_RISK_CACHE_TTL>0 일 때만) ────────────
# 스케줄러가 매 사이클(60s/30s) 같은 종목셋으로 재요청하는 AI 호출을 줄인다.
_risk_cache: Dict[str, Tuple[float, bool, str]] = {} # key → (expires_at, approve, reason)
def _risk_cache_key(trade_plan: Dict[str, Any]) -> str:
stocks = trade_plan.get("top_stocks") or []
codes = sorted(
str(s.get("stock_code", s) if isinstance(s, dict) else s) for s in stocks
)
if not codes and trade_plan.get("stock_code"):
codes = [str(trade_plan["stock_code"])]
return f"{trade_plan.get('strategy', '')}:{trade_plan.get('action', '')}:{','.join(codes)}"
# ─── 공통 리스크 평가 함수 ────────────────────────────────────────────
async def request_mcp_risk_assessment(
@@ -91,6 +106,13 @@ async def request_mcp_risk_assessment(
if not settings.MCP_VALIDATION_ENABLED:
return True, "mcp_disabled"
_ttl = settings.MCP_RISK_CACHE_TTL
_ckey = _risk_cache_key(trade_plan) if _ttl > 0 else None
if _ckey:
_hit = _risk_cache.get(_ckey)
if _hit and _hit[0] > time.monotonic():
return _hit[1], f"{_hit[2]} (cached)"
try:
client = get_http_client()
except RuntimeError:
@@ -116,7 +138,7 @@ async def request_mcp_risk_assessment(
"signals": signals,
},
},
timeout=15.0,
timeout=settings.MCP_REQUEST_TIMEOUT,
)
if resp.status_code == 200:
result = resp.json()
@@ -124,11 +146,11 @@ async def request_mcp_risk_assessment(
payload = result.get("result", result)
decision = str(payload.get("decision", "approve")).lower()
reason = str(payload.get("reason", ""))
if "deny" in decision or "reject" in decision:
_fail_open_count = 0 # 정상 응답 수신 시 카운터 리셋
return False, reason
_fail_open_count = 0 # 정상 응답 수신 시 카운터 리셋
return True, reason
approve = not ("deny" in decision or "reject" in decision)
if _ckey:
_risk_cache[_ckey] = (time.monotonic() + _ttl, approve, reason)
return approve, reason
else:
_fail_open_count += 1
logger.warning(

View File

@@ -178,7 +178,9 @@ async def get_rule_based_recommendations(
else:
result = await get_rule_based_recommendations_from_db(db)
if auto_trade and result.get("recommendations_by_rule"):
# 대시보드 폴링(GET)이 매매를 트리거하면 스윙 스케줄러와 동시 실행되어 일일 한도가 무력화됨
# (2026-09-17). 기본적으로 조회 전용이며, API_TRIGGERED_AUTO_TRADE=true 일 때만 실행.
if auto_trade and settings.API_TRIGGERED_AUTO_TRADE and result.get("recommendations_by_rule"):
rec_snapshot = result
async def _bg_trade():
try:

View File

@@ -291,7 +291,15 @@ async def check_risk_limits(
session = result.scalar_one_or_none()
if not session:
session = TradingSession(session_date=today, starting_balance=Decimal(str(account_balance)))
session = TradingSession(
session_date=today,
starting_balance=Decimal(str(account_balance)),
consecutive_losses=0,
consecutive_wins=0,
daily_profit_loss=0,
daily_trades_count=0,
is_paused=False
)
db.add(session)
await db.commit()
await db.refresh(session)
@@ -386,9 +394,16 @@ async def update_trading_session(
session = result.scalar_one_or_none()
if not session:
session = TradingSession(session_date=today)
session = TradingSession(
session_date=today,
consecutive_losses=0,
consecutive_wins=0,
daily_profit_loss=0,
daily_trades_count=0,
is_paused=False
)
db.add(session)
# 손익 누적
current_pl = float(session.daily_profit_loss) if session.daily_profit_loss else 0
session.daily_profit_loss = Decimal(str(current_pl + profit_loss))

View File

@@ -128,8 +128,7 @@ async def manual_swing_sell(
stock_code=req.stock_code,
order_type="SELL",
quantity=quantity,
price=0,
order_price_type="01",
price=None, # 시장가 (order_dvsn="01"은 price=None일 때 자동 적용)
)
if not order_resp or order_resp.get("rt_cd") != "0":
err = order_resp.get("msg1", "unknown") if order_resp else "no response"

View File

@@ -69,7 +69,7 @@ async def execute_us_trade(req: UsOrderRequest):
if not req.price:
# Get current price for limit order
price_data = await korea_investment_api.get_overseas_price(req.ticker, exchange)
req.price = float(price_data.get("last", 0))
req.price = float(price_data.get("output", {}).get("last", 0))
if not req.price or req.price <= 0:
raise HTTPException(
status_code=400,
@@ -77,12 +77,12 @@ async def execute_us_trade(req: UsOrderRequest):
)
result = await korea_investment_api.place_overseas_order(
req.ticker, "buy", req.quantity, req.price, exchange
req.ticker, req.order_type.upper(), req.quantity, req.price, exchange
)
elif req.order_type.lower() == "sell":
if not req.price:
price_data = await korea_investment_api.get_overseas_price(req.ticker, exchange)
req.price = float(price_data.get("last", 0))
req.price = float(price_data.get("output", {}).get("last", 0))
if not req.price or req.price <= 0:
raise HTTPException(
status_code=400,
@@ -90,7 +90,7 @@ async def execute_us_trade(req: UsOrderRequest):
)
result = await korea_investment_api.place_overseas_order(
req.ticker, "sell", req.quantity, req.price, exchange
req.ticker, req.order_type.upper(), req.quantity, req.price, exchange
)
else:
raise HTTPException(status_code=400, detail="order_type must be 'buy' or 'sell'")