배치 실계좌에서 종목조회로 변경
This commit is contained in:
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user