From d6841a06f7260124a620fbe0148a42edd0bebd75 Mon Sep 17 00:00:00 2001 From: shuim Date: Tue, 3 Mar 2026 13:34:31 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B0=B0=EC=B9=98=20=EC=8B=A4=EA=B3=84?= =?UTF-8?q?=EC=A2=8C=EC=97=90=EC=84=9C=20=EC=A2=85=EB=AA=A9=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/core/logging_config.py | 18 +++ backend/app/main.py | 22 +-- backend/app/services/korea_investment_api.py | 44 +++++- backend/batch_daily_analysis.py | 146 +++++++++++-------- docker-compose.yml | 9 ++ 5 files changed, 163 insertions(+), 76 deletions(-) create mode 100644 backend/app/core/logging_config.py diff --git a/backend/app/core/logging_config.py b/backend/app/core/logging_config.py new file mode 100644 index 0000000..fae277e --- /dev/null +++ b/backend/app/core/logging_config.py @@ -0,0 +1,18 @@ +"""공통 로깅 설정 — KST(Asia/Seoul) 타임존 포매터 제공.""" + +import logging +import datetime +import pytz + +KST = pytz.timezone("Asia/Seoul") + + +class KSTFormatter(logging.Formatter): + """%(asctime)s 를 KST(UTC+9) 기준으로 출력하는 포매터.""" + + def formatTime(self, record: logging.LogRecord, datefmt: str | None = None) -> str: + ct = datetime.datetime.fromtimestamp(record.created, tz=KST) + if datefmt: + return ct.strftime(datefmt) + t = ct.strftime(self.default_time_format) + return self.default_msec_format % (t, record.msecs) diff --git a/backend/app/main.py b/backend/app/main.py index 0d5d493..e555074 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -27,22 +27,24 @@ from app.core.market_schedule import is_market_open # 로깅 설정 import os import logging.handlers +from app.core.logging_config import KSTFormatter _log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO) _log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' _log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "logs") os.makedirs(_log_dir, exist_ok=True) _log_file = os.path.join(_log_dir, "backend.log") -logging.basicConfig( - level=_log_level, - format=_log_format, - handlers=[ - logging.StreamHandler(), - logging.handlers.RotatingFileHandler( - _log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8" - ), - ] -) +_formatter = KSTFormatter(_log_format) +_handlers: list[logging.Handler] = [ + logging.StreamHandler(), + logging.handlers.RotatingFileHandler( + _log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8" + ), +] +for _h in _handlers: + _h.setFormatter(_formatter) + +logging.basicConfig(level=_log_level, handlers=_handlers) logger = logging.getLogger(__name__) diff --git a/backend/app/services/korea_investment_api.py b/backend/app/services/korea_investment_api.py index 5cd368c..7a93f39 100644 --- a/backend/app/services/korea_investment_api.py +++ b/backend/app/services/korea_investment_api.py @@ -97,7 +97,41 @@ class KoreaInvestmentAPI: # 토큰 캐시 파일 경로 — 사용자 홈 디렉토리 하위에 저장 (/tmp는 보안 취약) TOKEN_CACHE_FILE = Path.home() / ".cache" / "auto-stock" / "kis_token_cache.json" - + + @classmethod + def for_real_market_data(cls) -> "KoreaInvestmentAPI": + """실계좌 자격증명으로 시세조회 전용 인스턴스 생성. + + 모의투자 모드에서도 실계좌 API로 시세를 조회할 때 사용한다. + (모의투자 서버는 KOSDAQ 거래량 순위 등 일부 조회 API를 지원하지 않음) + 주문 API는 절대 호출하지 말 것. + """ + if not settings.KIS_REAL_APP_KEY: + raise ValueError("KIS_REAL_APP_KEY가 설정되지 않아 실계좌 시세조회를 사용할 수 없습니다.") + instance = cls.__new__(cls) + instance.base_url = settings.KIS_REAL_URL + instance.app_key = settings.KIS_REAL_APP_KEY + instance.app_secret = settings.KIS_REAL_APP_SECRET + instance.account_number = settings.KIS_REAL_ACCOUNT_NO + instance.access_token = None + instance.token_expires_at = None + instance.last_request_time = None + instance.TOKEN_CACHE_FILE = Path.home() / ".cache" / "auto-stock" / "kis_token_cache_real.json" + instance.min_request_interval = settings.API_MIN_REQUEST_INTERVAL + instance.max_retries = settings.API_MAX_RETRIES + instance.retry_delay = settings.API_RETRY_DELAY + instance.retry_backoff = settings.API_RETRY_BACKOFF + instance.timeout = settings.API_TIMEOUT + instance.semaphore = asyncio.Semaphore(settings.API_MAX_CONCURRENT_REQUESTS) + instance._token_lock = asyncio.Lock() + instance.monitor = APICallMonitor(window_size=settings.API_MONITORING_WINDOW) + logger.info( + "🔧 KIS 실계좌 시세조회 API 초기화: base_url=%s, app_key=%s...", + instance.base_url, instance.app_key[:6] + ) + instance._load_token_from_cache() + return instance + def __init__(self): self.base_url = settings.KOREA_INVESTMENT_API_BASE_URL self.app_key = settings.KOREA_INVESTMENT_APP_KEY @@ -106,20 +140,20 @@ class KoreaInvestmentAPI: self.access_token: Optional[str] = None self.token_expires_at: Optional[datetime] = None self.last_request_time: Optional[float] = None - + # 설정 기반 쓰로틀링 파라미터 self.min_request_interval = settings.API_MIN_REQUEST_INTERVAL # 기본 0.5초 (초당 2회) self.max_retries = settings.API_MAX_RETRIES # 기본 3회 self.retry_delay = settings.API_RETRY_DELAY # 기본 1.5초 self.retry_backoff = settings.API_RETRY_BACKOFF # 기본 2.0배 self.timeout = settings.API_TIMEOUT # 기본 30초 - + # 동시 요청 제한 (세마포어) self.semaphore = asyncio.Semaphore(settings.API_MAX_CONCURRENT_REQUESTS) - + # 토큰 발급 Lock (중복 발급 방지) self._token_lock = asyncio.Lock() - + # 모니터링 self.monitor = APICallMonitor(window_size=settings.API_MONITORING_WINDOW) diff --git a/backend/batch_daily_analysis.py b/backend/batch_daily_analysis.py index e9cf7e0..6c343c8 100644 --- a/backend/batch_daily_analysis.py +++ b/backend/batch_daily_analysis.py @@ -20,7 +20,7 @@ Cron: 0 9 * * 1-5 cd /path/to/auto-stock/backend && python batch_daily_analysis. import asyncio import sys -from datetime import datetime, date, timedelta +from datetime import datetime, date from pathlib import Path from typing import Optional @@ -40,6 +40,7 @@ import logging # 인프로세스 스케줄러(main.py)로 실행될 때 로그 설정 충돌 방지 logger = logging.getLogger(__name__) + # ------------------------------------------------------------------- # 성능 설정 # ------------------------------------------------------------------- @@ -59,29 +60,28 @@ async def detect_and_log_market_regime() -> str: BULL / BEAR / SIDEWAYS 중 하나를 리턴하며 배치 전체에 로깅된다. 실패 시 UNKNOWN을 리턴하여 배치를 차단하지 않는다. + + pykrx 대신 KIS API 사용 — KODEX 200(069500)으로 KOSPI 레짐 판별. + KODEX 200은 KOSPI 200을 추종하므로 지수 대용으로 적합하다. """ try: - from pykrx import stock as pykrx_stock from app.services.mean_reversion_strategy import detect_market_regime - today = datetime.now(KST).strftime("%Y%m%d") - start = (datetime.now(KST) - timedelta(days=400)).strftime("%Y%m%d") + # KODEX 200(069500) 일봉 240개 = 약 1년치 데이터 + data = await korea_investment_api.get_daily_chart("069500", period="D", limit=240) + output = data.get("output2") or [] - # KOSPI 지수 코드 1001 - df = pykrx_stock.get_index_ohlcv_by_date(start, today, "1001") - - # 🔧 [fix] df=None일 때 len(df) 직접 호출 방지 - df_len = len(df) if df is not None else 0 - if df_len < 205: - logger.warning("⚠️ KOSPI 데이터 부족(행 %d) — 레짐 판별 스킵", df_len) + if not isinstance(output, list) or len(output) < 205: + logger.warning("⚠️ KODEX200 데이터 부족(%d행) — 레짐 판별 스킵", len(output) if output else 0) return "UNKNOWN" - kospi_closes = df["종가"].tolist() - regime = detect_market_regime(kospi_closes) + # KIS API는 최신순 반환 → 오래된 순(시계열 순)으로 역정렬 + closes = [float(item["stck_clpr"]) for item in reversed(output) if item.get("stck_clpr")] + regime = detect_market_regime(closes) regime_emoji = {"상승장": "📈 BULL", "하락장": "📉 BEAR", "bull": "📈 BULL", "bear": "📉 BEAR"}.get(regime.value, "➡️ " + regime.value.upper()) - logger.info("🌏 시장 레짐: %s (KOSPI %s일 기준)", regime_emoji, today) + logger.info("🌏 시장 레짐: %s (KODEX200 %d일 기준)", regime_emoji, len(closes)) return regime.value.upper() except Exception as exc: @@ -94,64 +94,87 @@ async def detect_and_log_market_regime() -> str: # ------------------------------------------------------------------- async def get_all_tradable_stocks(kis_api: KoreaInvestmentAPI, limit: int = 500) -> list[tuple[str, str]]: - """시가총액 상위 종목 조회 (pykrx 사용) + """거래량 상위 종목 조회 (KIS API 사용) - ETF / 레버리지 / 스팭 등 특수종목 자동 제외. - KOSPI + KOSDAQ 합산 시가총액 상위 limit개 반환. + pykrx 의존성 제거 — KIS 거래량 순위 API(FHPST01710000) 사용. + 시가총액 기준 → 거래량 기준으로 변경 (유동성 높은 종목 우선). + ETF / 레버리지 / 스팩 등 특수종목 자동 제외. + KOSPI + KOSDAQ 합산 거래량 상위 limit개 반환. Raises: RuntimeError: 조회 결과가 MIN_STOCK_COUNT(50) 미만인 경우 배치 중단 """ - logger.info("Fetching top %d stocks by market cap from pykrx...", limit) - try: - from pykrx import stock as pykrx_stock + logger.info("Fetching top %d stocks by volume from KIS API...", limit) - today = datetime.now(KST).strftime("%Y%m%d") + SKIP_KEYWORDS = [ + "ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG", + "KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX", + "선물", "스팩", "리츠", "SPAC", + ] - SKIP_KEYWORDS = [ - "ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG", - "KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX", - "선물", "스팭", "리츠", "SPAC", - ] + # 모의투자 모드에서 실계좌 시세 API를 사용할 수 있으면 준비 + real_market_api: Optional[KoreaInvestmentAPI] = None + if settings.KIS_MODE == "virtual" and settings.KIS_REAL_APP_KEY: + try: + real_market_api = KoreaInvestmentAPI.for_real_market_data() + logger.info("📡 KOSDAQ 조회: 실계좌 시세 API 사용 (모의투자 서버 제한 우회)") + except Exception as exc: + logger.warning("⚠️ 실계좌 시세 API 초기화 실패, 모의투자 API 사용: %s", exc) - stocks_with_cap = [] - for market in ["KOSPI", "KOSDAQ"]: - try: - cap_df = pykrx_stock.get_market_cap_by_ticker(today, market=market) - logger.info(" %s: %d개 종목 시가총액 조회", market, len(cap_df)) - for ticker in cap_df.index: - try: - name = pykrx_stock.get_market_ticker_name(ticker) - if any(k in name for k in SKIP_KEYWORDS): - continue - stocks_with_cap.append((ticker, name, cap_df.loc[ticker, "시가총액"], market)) - except Exception: - continue - except Exception as exc: - logger.error("❌ %s 종목 조회 실패: %s", market, exc) + stocks_with_vol = [] + for market_code, market_name in [("J", "KOSPI"), ("Q", "KOSDAQ")]: + # KOSDAQ은 실계좌 시세 API 우선 사용 (모의투자 서버는 KOSDAQ 미지원) + api_to_use = real_market_api if (market_code == "Q" and real_market_api) else kis_api + try: + result = await api_to_use.get_volume_rank(market_code) + items = result.get("output") or [] - stocks_with_cap.sort(key=lambda x: x[2], reverse=True) - top_with_mkt = stocks_with_cap[:limit] - top_stocks = [(code, name) for code, name, _, _mkt in top_with_mkt] + if not isinstance(items, list): + logger.warning(" %s: output이 리스트 아님 — 건너뜀", market_name) + continue - kospi_n = sum(1 for *_, mkt in top_with_mkt if mkt == "KOSPI") - kosdaq_n = sum(1 for *_, mkt in top_with_mkt if mkt == "KOSDAQ") - logger.info("✅ 선정 %d개 (KOSPI %d / KOSDAQ %d)", len(top_stocks), kospi_n, kosdaq_n) + count = 0 + for item in items: + ticker = item.get("mksc_shrn_iscd") or item.get("stck_shrn_iscd") or "" + name = item.get("hts_kor_isnm") or item.get("stck_kor_isnm") or "" + volume = int(item.get("acml_vol") or 0) - # 🔧 [fix] 최소 종목 수 검증 — 너무 적으면 잘못된 데이터로 배치 실행 방지 - if len(top_stocks) < MIN_STOCK_COUNT: + if not ticker or not name: + continue + if any(k in name for k in SKIP_KEYWORDS): + continue + + stocks_with_vol.append((ticker, name, volume, market_name)) + count += 1 + + logger.info(" %s: %d개 종목 조회", market_name, count) + + except Exception as exc: + logger.error("❌ %s 종목 조회 실패: %s", market_name, exc) + + stocks_with_vol.sort(key=lambda x: x[2], reverse=True) + top_with_mkt = stocks_with_vol[:limit] + top_stocks = [(code, name) for code, name, _, _mkt in top_with_mkt] + + kospi_n = sum(1 for *_, mkt in top_with_mkt if mkt == "KOSPI") + kosdaq_n = sum(1 for *_, mkt in top_with_mkt if mkt == "KOSDAQ") + logger.info("✅ 선정 %d개 (KOSPI %d / KOSDAQ %d)", len(top_stocks), kospi_n, kosdaq_n) + + if len(top_stocks) < MIN_STOCK_COUNT: + if settings.KIS_MODE == "virtual": + # 모의투자 서버는 KOSDAQ 거래량 순위 미지원 — 조회된 종목으로 진행 + logger.warning( + "⚠️ 모의투자 서버 종목 수 제한: %d개 (기준 %d개). " + "KOSDAQ 거래량 순위 미지원으로 인한 정상 동작 — 배치 계속 진행합니다.", + len(top_stocks), MIN_STOCK_COUNT + ) + else: raise RuntimeError( f"종목 조회 결과 너무 적음: {len(top_stocks)}개 (최소 {MIN_STOCK_COUNT}개 필요). " - "pykrx API 오류 또는 장 휴장 여부를 확인하세요." + "KIS API 오류 또는 장 휴장 여부를 확인하세요." ) - return top_stocks - - except RuntimeError: - raise # 최소 종목 수 미달 — 상위로 전파하여 배치 중단 - except Exception as exc: - logger.error("❌ 종목 목록 조회 실패: %s", exc, exc_info=True) - return [] + return top_stocks # ------------------------------------------------------------------- @@ -392,8 +415,9 @@ async def main(): if __name__ == "__main__": # 🔧 [fix] basicConfig를 __main__ 블록으로 이동 # 인프로세스 스케줄러(main.py lifespan)에서 import될 때 로그 설정 충돌 방지 - logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' - ) + from app.core.logging_config import KSTFormatter + _fmt = KSTFormatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + _handler = logging.StreamHandler() + _handler.setFormatter(_fmt) + logging.basicConfig(level=logging.INFO, handlers=[_handler]) asyncio.run(main()) diff --git a/docker-compose.yml b/docker-compose.yml index 61b4c02..5ed086c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,7 @@ services: image: postgres:15-alpine container_name: autostock-postgres environment: + TZ: Asia/Seoul POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} @@ -24,6 +25,8 @@ services: redis: image: redis:7-alpine container_name: autostock-redis + environment: + TZ: Asia/Seoul ports: - "6379:6379" volumes: @@ -41,6 +44,7 @@ services: image: rabbitmq:3-management container_name: autostock-rabbitmq environment: + TZ: Asia/Seoul RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER} RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS} ports: @@ -64,6 +68,7 @@ services: dockerfile: Dockerfile container_name: autostock-mcp-server environment: + - TZ=Asia/Seoul - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - MCP_SERVER_HOST=${MCP_SERVER_HOST} - MCP_SERVER_PORT=${MCP_SERVER_PORT} @@ -88,6 +93,7 @@ services: dockerfile: Dockerfile container_name: autostock-backend environment: + - TZ=Asia/Seoul - DATABASE_URL=${DATABASE_URL} - REDIS_URL=${REDIS_URL} - RABBITMQ_URL=${RABBITMQ_URL} @@ -131,6 +137,7 @@ services: image: prodrigestivill/postgres-backup-local:15 container_name: autostock-postgres-backup environment: + - TZ=Asia/Seoul - POSTGRES_HOST=postgres - POSTGRES_DB=${POSTGRES_DB} - POSTGRES_USER=${POSTGRES_USER} @@ -158,6 +165,8 @@ services: - VITE_API_URL=${VITE_API_URL} - VITE_WS_URL=${VITE_WS_URL} container_name: autostock-frontend + environment: + - TZ=Asia/Seoul ports: - "3000:80" networks: