배치 실계좌에서 종목조회로 변경

This commit is contained in:
2026-03-03 13:34:31 +09:00
parent 0531952edb
commit d6841a06f7
5 changed files with 163 additions and 76 deletions

View File

@@ -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)

View File

@@ -27,22 +27,24 @@ from app.core.market_schedule import is_market_open
# 로깅 설정 # 로깅 설정
import os import os
import logging.handlers import logging.handlers
from app.core.logging_config import KSTFormatter
_log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO) _log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO)
_log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' _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") _log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "logs")
os.makedirs(_log_dir, exist_ok=True) os.makedirs(_log_dir, exist_ok=True)
_log_file = os.path.join(_log_dir, "backend.log") _log_file = os.path.join(_log_dir, "backend.log")
logging.basicConfig( _formatter = KSTFormatter(_log_format)
level=_log_level, _handlers: list[logging.Handler] = [
format=_log_format,
handlers=[
logging.StreamHandler(), logging.StreamHandler(),
logging.handlers.RotatingFileHandler( logging.handlers.RotatingFileHandler(
_log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8" _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__) logger = logging.getLogger(__name__)

View File

@@ -98,6 +98,40 @@ class KoreaInvestmentAPI:
# 토큰 캐시 파일 경로 — 사용자 홈 디렉토리 하위에 저장 (/tmp는 보안 취약) # 토큰 캐시 파일 경로 — 사용자 홈 디렉토리 하위에 저장 (/tmp는 보안 취약)
TOKEN_CACHE_FILE = Path.home() / ".cache" / "auto-stock" / "kis_token_cache.json" 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): def __init__(self):
self.base_url = settings.KOREA_INVESTMENT_API_BASE_URL self.base_url = settings.KOREA_INVESTMENT_API_BASE_URL
self.app_key = settings.KOREA_INVESTMENT_APP_KEY self.app_key = settings.KOREA_INVESTMENT_APP_KEY

View File

@@ -20,7 +20,7 @@ Cron: 0 9 * * 1-5 cd /path/to/auto-stock/backend && python batch_daily_analysis.
import asyncio import asyncio
import sys import sys
from datetime import datetime, date, timedelta from datetime import datetime, date
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -40,6 +40,7 @@ import logging
# 인프로세스 스케줄러(main.py)로 실행될 때 로그 설정 충돌 방지 # 인프로세스 스케줄러(main.py)로 실행될 때 로그 설정 충돌 방지
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ------------------------------------------------------------------- # -------------------------------------------------------------------
# 성능 설정 # 성능 설정
# ------------------------------------------------------------------- # -------------------------------------------------------------------
@@ -59,29 +60,28 @@ async def detect_and_log_market_regime() -> str:
BULL / BEAR / SIDEWAYS 중 하나를 리턴하며 배치 전체에 로깅된다. BULL / BEAR / SIDEWAYS 중 하나를 리턴하며 배치 전체에 로깅된다.
실패 시 UNKNOWN을 리턴하여 배치를 차단하지 않는다. 실패 시 UNKNOWN을 리턴하여 배치를 차단하지 않는다.
pykrx 대신 KIS API 사용 — KODEX 200(069500)으로 KOSPI 레짐 판별.
KODEX 200은 KOSPI 200을 추종하므로 지수 대용으로 적합하다.
""" """
try: try:
from pykrx import stock as pykrx_stock
from app.services.mean_reversion_strategy import detect_market_regime from app.services.mean_reversion_strategy import detect_market_regime
today = datetime.now(KST).strftime("%Y%m%d") # KODEX 200(069500) 일봉 240개 = 약 1년치 데이터
start = (datetime.now(KST) - timedelta(days=400)).strftime("%Y%m%d") data = await korea_investment_api.get_daily_chart("069500", period="D", limit=240)
output = data.get("output2") or []
# KOSPI 지수 코드 1001 if not isinstance(output, list) or len(output) < 205:
df = pykrx_stock.get_index_ohlcv_by_date(start, today, "1001") logger.warning("⚠️ KODEX200 데이터 부족(%d행) — 레짐 판별 스킵", len(output) if output else 0)
# 🔧 [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)
return "UNKNOWN" return "UNKNOWN"
kospi_closes = df["종가"].tolist() # KIS API는 최신순 반환 → 오래된 순(시계열 순)으로 역정렬
regime = detect_market_regime(kospi_closes) closes = [float(item["stck_clpr"]) for item in reversed(output) if item.get("stck_clpr")]
regime = detect_market_regime(closes)
regime_emoji = {"상승장": "📈 BULL", "하락장": "📉 BEAR", regime_emoji = {"상승장": "📈 BULL", "하락장": "📉 BEAR",
"bull": "📈 BULL", "bear": "📉 BEAR"}.get(regime.value, "➡️ " + regime.value.upper()) "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() return regime.value.upper()
except Exception as exc: except Exception as exc:
@@ -94,65 +94,88 @@ async def detect_and_log_market_regime() -> str:
# ------------------------------------------------------------------- # -------------------------------------------------------------------
async def get_all_tradable_stocks(kis_api: KoreaInvestmentAPI, limit: int = 500) -> list[tuple[str, str]]: async def get_all_tradable_stocks(kis_api: KoreaInvestmentAPI, limit: int = 500) -> list[tuple[str, str]]:
"""시가총액 상위 종목 조회 (pykrx 사용) """거래량 상위 종목 조회 (KIS API 사용)
ETF / 레버리지 / 스팭 등 특수종목 자동 제외. pykrx 의존성 제거 — KIS 거래량 순위 API(FHPST01710000) 사용.
KOSPI + KOSDAQ 합산 시가총액 상위 limit개 반환. 시가총액 기준 → 거래량 기준으로 변경 (유동성 높은 종목 우선).
ETF / 레버리지 / 스팩 등 특수종목 자동 제외.
KOSPI + KOSDAQ 합산 거래량 상위 limit개 반환.
Raises: Raises:
RuntimeError: 조회 결과가 MIN_STOCK_COUNT(50) 미만인 경우 배치 중단 RuntimeError: 조회 결과가 MIN_STOCK_COUNT(50) 미만인 경우 배치 중단
""" """
logger.info("Fetching top %d stocks by market cap from pykrx...", limit) logger.info("Fetching top %d stocks by volume from KIS API...", limit)
try:
from pykrx import stock as pykrx_stock
today = datetime.now(KST).strftime("%Y%m%d")
SKIP_KEYWORDS = [ SKIP_KEYWORDS = [
"ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG", "ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG",
"KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX", "KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX",
"선물", "스팭", "리츠", "SPAC", "선물", "스팩", "리츠", "SPAC",
] ]
stocks_with_cap = [] # 모의투자 모드에서 실계좌 시세 API를 사용할 수 있으면 준비
for market in ["KOSPI", "KOSDAQ"]: real_market_api: Optional[KoreaInvestmentAPI] = None
if settings.KIS_MODE == "virtual" and settings.KIS_REAL_APP_KEY:
try: try:
cap_df = pykrx_stock.get_market_cap_by_ticker(today, market=market) real_market_api = KoreaInvestmentAPI.for_real_market_data()
logger.info(" %s: %d개 종목 시가총액 조회", market, len(cap_df)) logger.info("📡 KOSDAQ 조회: 실계좌 시세 API 사용 (모의투자 서버 제한 우회)")
for ticker in cap_df.index: except Exception as exc:
logger.warning("⚠️ 실계좌 시세 API 초기화 실패, 모의투자 API 사용: %s", 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: try:
name = pykrx_stock.get_market_ticker_name(ticker) result = await api_to_use.get_volume_rank(market_code)
items = result.get("output") or []
if not isinstance(items, list):
logger.warning(" %s: output이 리스트 아님 — 건너뜀", market_name)
continue
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)
if not ticker or not name:
continue
if any(k in name for k in SKIP_KEYWORDS): if any(k in name for k in SKIP_KEYWORDS):
continue 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_cap.sort(key=lambda x: x[2], reverse=True) stocks_with_vol.append((ticker, name, volume, market_name))
top_with_mkt = stocks_with_cap[:limit] 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] 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") 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") 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) logger.info("✅ 선정 %d개 (KOSPI %d / KOSDAQ %d)", len(top_stocks), kospi_n, kosdaq_n)
# 🔧 [fix] 최소 종목 수 검증 — 너무 적으면 잘못된 데이터로 배치 실행 방지
if len(top_stocks) < MIN_STOCK_COUNT: 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( raise RuntimeError(
f"종목 조회 결과 너무 적음: {len(top_stocks)}개 (최소 {MIN_STOCK_COUNT}개 필요). " f"종목 조회 결과 너무 적음: {len(top_stocks)}개 (최소 {MIN_STOCK_COUNT}개 필요). "
"pykrx API 오류 또는 장 휴장 여부를 확인하세요." "KIS API 오류 또는 장 휴장 여부를 확인하세요."
) )
return top_stocks return top_stocks
except RuntimeError:
raise # 최소 종목 수 미달 — 상위로 전파하여 배치 중단
except Exception as exc:
logger.error("❌ 종목 목록 조회 실패: %s", exc, exc_info=True)
return []
# ------------------------------------------------------------------- # -------------------------------------------------------------------
# 3. 단일 종목 분석 (순수 분석만 — DB 저장 없음) # 3. 단일 종목 분석 (순수 분석만 — DB 저장 없음)
@@ -392,8 +415,9 @@ async def main():
if __name__ == "__main__": if __name__ == "__main__":
# 🔧 [fix] basicConfig를 __main__ 블록으로 이동 # 🔧 [fix] basicConfig를 __main__ 블록으로 이동
# 인프로세스 스케줄러(main.py lifespan)에서 import될 때 로그 설정 충돌 방지 # 인프로세스 스케줄러(main.py lifespan)에서 import될 때 로그 설정 충돌 방지
logging.basicConfig( from app.core.logging_config import KSTFormatter
level=logging.INFO, _fmt = KSTFormatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' _handler = logging.StreamHandler()
) _handler.setFormatter(_fmt)
logging.basicConfig(level=logging.INFO, handlers=[_handler])
asyncio.run(main()) asyncio.run(main())

View File

@@ -4,6 +4,7 @@ services:
image: postgres:15-alpine image: postgres:15-alpine
container_name: autostock-postgres container_name: autostock-postgres
environment: environment:
TZ: Asia/Seoul
POSTGRES_USER: ${POSTGRES_USER} POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB} POSTGRES_DB: ${POSTGRES_DB}
@@ -24,6 +25,8 @@ services:
redis: redis:
image: redis:7-alpine image: redis:7-alpine
container_name: autostock-redis container_name: autostock-redis
environment:
TZ: Asia/Seoul
ports: ports:
- "6379:6379" - "6379:6379"
volumes: volumes:
@@ -41,6 +44,7 @@ services:
image: rabbitmq:3-management image: rabbitmq:3-management
container_name: autostock-rabbitmq container_name: autostock-rabbitmq
environment: environment:
TZ: Asia/Seoul
RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER} RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS} RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS}
ports: ports:
@@ -64,6 +68,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: autostock-mcp-server container_name: autostock-mcp-server
environment: environment:
- TZ=Asia/Seoul
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- MCP_SERVER_HOST=${MCP_SERVER_HOST} - MCP_SERVER_HOST=${MCP_SERVER_HOST}
- MCP_SERVER_PORT=${MCP_SERVER_PORT} - MCP_SERVER_PORT=${MCP_SERVER_PORT}
@@ -88,6 +93,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: autostock-backend container_name: autostock-backend
environment: environment:
- TZ=Asia/Seoul
- DATABASE_URL=${DATABASE_URL} - DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL} - REDIS_URL=${REDIS_URL}
- RABBITMQ_URL=${RABBITMQ_URL} - RABBITMQ_URL=${RABBITMQ_URL}
@@ -131,6 +137,7 @@ services:
image: prodrigestivill/postgres-backup-local:15 image: prodrigestivill/postgres-backup-local:15
container_name: autostock-postgres-backup container_name: autostock-postgres-backup
environment: environment:
- TZ=Asia/Seoul
- POSTGRES_HOST=postgres - POSTGRES_HOST=postgres
- POSTGRES_DB=${POSTGRES_DB} - POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER} - POSTGRES_USER=${POSTGRES_USER}
@@ -158,6 +165,8 @@ services:
- VITE_API_URL=${VITE_API_URL} - VITE_API_URL=${VITE_API_URL}
- VITE_WS_URL=${VITE_WS_URL} - VITE_WS_URL=${VITE_WS_URL}
container_name: autostock-frontend container_name: autostock-frontend
environment:
- TZ=Asia/Seoul
ports: ports:
- "3000:80" - "3000:80"
networks: networks: