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

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 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=[
_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__)

View File

@@ -98,6 +98,40 @@ 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

View File

@@ -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,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]]:
"""시가총액 상위 종목 조회 (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
today = datetime.now(KST).strftime("%Y%m%d")
logger.info("Fetching top %d stocks by volume from KIS API...", limit)
SKIP_KEYWORDS = [
"ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG",
"KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX",
"선물", "스팭", "리츠", "SPAC",
"선물", "스팩", "리츠", "SPAC",
]
stocks_with_cap = []
for market in ["KOSPI", "KOSDAQ"]:
# 모의투자 모드에서 실계좌 시세 API를 사용할 수 있으면 준비
real_market_api: Optional[KoreaInvestmentAPI] = None
if settings.KIS_MODE == "virtual" and settings.KIS_REAL_APP_KEY:
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:
real_market_api = KoreaInvestmentAPI.for_real_market_data()
logger.info("📡 KOSDAQ 조회: 실계좌 시세 API 사용 (모의투자 서버 제한 우회)")
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:
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):
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)
top_with_mkt = stocks_with_cap[:limit]
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)
# 🔧 [fix] 최소 종목 수 검증 — 너무 적으면 잘못된 데이터로 배치 실행 방지
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 []
# -------------------------------------------------------------------
# 3. 단일 종목 분석 (순수 분석만 — DB 저장 없음)
@@ -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())

View File

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