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

@@ -199,7 +199,8 @@ class KoreaInvestmentAPI:
instance._http_client = None
instance._http_client_lock = asyncio.Lock()
instance.TOKEN_CACHE_FILE = Path.home() / ".cache" / "auto-stock" / "kis_token_cache_real.json"
instance.min_request_interval = settings.API_MIN_REQUEST_INTERVAL
# 실서버는 초당 20건 허용 — 모의서버용 간격(API_MIN_REQUEST_INTERVAL)과 분리
instance.min_request_interval = getattr(settings, "KIS_REAL_MIN_REQUEST_INTERVAL", 0.1)
instance.max_retries = settings.API_MAX_RETRIES
instance.retry_delay = settings.API_RETRY_DELAY
instance.retry_backoff = settings.API_RETRY_BACKOFF
@@ -549,8 +550,24 @@ class KoreaInvestmentAPI:
raise last_exception
raise Exception("API 요청 실패 (알 수 없는 오류)")
# ── 시세 조회 실서버 라우팅 ──────────────────────────────────────────────
# 모의투자 서버(openapivts)는 앱키당 초당 2건 제한 + 응답 지연이 잦아(EGW00201)
# 시세·차트·호가 조회를 감당하지 못한다. KIS_QUOTES_VIA_REAL=True 이고 실계좌
# 시세 인스턴스(real_market_api)가 있으면 조회성 호출만 실서버로 위임한다.
# 주문·잔고는 그대로 모의서버. (real_market_api 자신은 위임하지 않음 → 재귀 방지)
def _quote_delegate(self) -> "KoreaInvestmentAPI | None":
if not getattr(settings, "KIS_QUOTES_VIA_REAL", False):
return None
_real = globals().get("real_market_api")
if _real is None or _real is self:
return None
return _real
async def get_current_price(self, stock_code: str) -> Dict[str, Any]:
"""현재가 조회"""
_d = self._quote_delegate()
if _d is not None:
return await _d.get_current_price(stock_code)
try:
result, _ = await self._make_request(
"GET",
@@ -572,6 +589,9 @@ class KoreaInvestmentAPI:
캐시 키: chart:daily:{stock_code}:{period}:{limit}
TTL: 5분 (장중 업데이트 주기 고려)
"""
_d = self._quote_delegate()
if _d is not None:
return await _d.get_daily_chart(stock_code, period, limit)
cache_key = f"chart:daily:{stock_code}:{period}:{limit}"
# ── 1. 캐시 히트 확인 ───────────────────────────────────────────────
@@ -771,6 +791,9 @@ class KoreaInvestmentAPI:
]
}
"""
_d = self._quote_delegate()
if _d is not None:
return await _d.get_minute_chart(stock_code, ref_time, include_past)
try:
if not ref_time:
from datetime import datetime
@@ -829,6 +852,9 @@ class KoreaInvestmentAPI:
Returns:
List[dict] — output2 레코드 목록 (오래된 것 → 최신 순)
"""
_d = self._quote_delegate()
if _d is not None:
return await _d.get_minute_chart_full_day(stock_code, market_open)
from datetime import datetime
import pytz
kst = pytz.timezone("Asia/Seoul")
@@ -1244,6 +1270,9 @@ class KoreaInvestmentAPI:
async def get_orderbook(self, stock_code: str) -> Dict[str, Any]:
"""호가 정보 조회 (매수/매도 호가 10단계)"""
_d = self._quote_delegate()
if _d is not None:
return await _d.get_orderbook(stock_code)
try:
result, _ = await self._make_request(
"GET",