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

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

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