""" 매일 아침 장 시작 전(9시) 실행되는 배치 분석 스크립트 전체 거래 종목을 분석하여 DB에 저장 1. 시장 레짐 판별 (KOSPI200 기준 BULL/BEAR/SIDEWAYS) 2. 차트 데이터 조회 + 기술적 지표 계산 3. 매매 신호 생성 + DB Streaming 저장 (분석 즉시 UPSERT) 4. 배치 완료 후 DB 스캐너 실행 (추가 API 호출 없이) 5. 워치리스트 초기화 — 스캐너 신호 종목 우선 배치 성능 개선 (이전 대비): - BATCH_CONCURRENCY: 5 → 10 (API 세마포어 허용 범위 내) - DB 트랜잭션: 종목당 2개(DELETE+INSERT) → UPSERT (원자적, race-condition 없음) - Streaming: 전체 메모리 적재 → 분석 완료 즉시 BULK_SAVE_CHUNK(20)개 단위 저장 - DB 엔진: 스크립트 자체 생성 → app.core.database 공통 모듈 사용 실행: python batch_daily_analysis.py Cron: 0 9 * * 1-5 cd /path/to/auto-stock/backend && python batch_daily_analysis.py """ import asyncio import sys from datetime import datetime, date, timedelta from pathlib import Path from typing import Optional import pytz KST = pytz.timezone('Asia/Seoul') sys.path.insert(0, str(Path(__file__).parent)) from app.core.config import settings from app.core.database import AsyncSessionLocal # 🔧 공통 DB 모듈 사용 from sqlalchemy import select as sa_select, func as sa_func from app.models.models import StockDailyPrice from app.services.analysis_db_service import bulk_upsert_analysis, passes_price_filter, run_db_scanner # 🔧 단일화 from app.services.market_analyzer import analyze_stock_for_batch_db, StockRecommendation from app.services.market_analyzer import ( reset_ml_skip_stats, log_ml_skip_summary, ) import logging # 🔧 [fix] logging.basicConfig는 __main__ 블록에서만 호출 # 인프로세스 스케줄러(main.py)로 실행될 때 로그 설정 충돌 방지 logger = logging.getLogger(__name__) # ------------------------------------------------------------------- # 성능 설정 # ------------------------------------------------------------------- BATCH_CONCURRENCY = 10 # API 세마포어 허용 범위 내 동시 처리 수 BULK_SAVE_CHUNK = 20 # Streaming 저장 단위 (큐에 20개 쌓이면 즉시 UPSERT) # 종목 목록 조회 실패 시 배치를 중단할 최소 종목 수 MIN_STOCK_COUNT = 50 # ------------------------------------------------------------------- # 1. 시장 레짐 판별 (KOSPI 기준) # ------------------------------------------------------------------- async def _fetch_db_chart(stock_code: str, limit: int = 240) -> list: """stock_daily_prices DB에서 KIS 형식 차트 데이터 조회 (newest first). KIS API 호출 없이 collect_krx_data.py가 사전 수집한 데이터를 사용. """ async with AsyncSessionLocal() as db: result = await db.execute( sa_select(StockDailyPrice) .where(StockDailyPrice.stock_code == stock_code) .order_by(StockDailyPrice.trade_date.desc()) .limit(limit) ) rows = result.scalars().all() return [ { "stck_bsop_date": row.trade_date.strftime("%Y%m%d"), "stck_clpr": str(row.close_price or 0), "stck_oprc": str(row.open_price or 0), "stck_hgpr": str(row.high_price or 0), "stck_lwpr": str(row.low_price or 0), "acml_vol": str(row.volume or 0), "hts_kor_isnm": row.stock_name or "", } for row in rows ] async def detect_and_log_market_regime() -> str: """배치 시작 시 KOSPI200 레짐 판별 — KIS API로 KODEX200(069500) 일봉 조회. BULL / BEAR 중 하나를 리턴하며 배치 전체에 로깅된다. 실패 시 UNKNOWN을 리턴하여 배치를 차단하지 않는다. 1차: KIS API get_daily_ohlcv_range (069500, 320일) 2차 폴백: DB stock_daily_prices """ try: # [M-4 2026-10-02 3회차] 배치는 앱 내부 스케줄러에서 같은 프로세스로 돈다 → # detect_market_regime() 은 실전 히스테리시스 전역(_previous_regime)을 덮어쓴다. # 무상태 v2 를 쓴다(배치 판정은 로깅용). from app.services.mean_reversion_strategy import detect_market_regime_v2 from app.services.korea_investment_api import korea_investment_api closes: list[float] = [] # 1차: KIS API — KODEX200 일봉 320캘린더일 try: end_dt = datetime.now(KST) start_dt = end_dt - timedelta(days=320) chart_data = await korea_investment_api.get_daily_ohlcv_range( "069500", start_dt.strftime("%Y%m%d"), end_dt.strftime("%Y%m%d"), ) if chart_data and len(chart_data) >= 205: # KIS API 결과는 newest first → oldest first 변환 closes = list(reversed([float(item["stck_clpr"]) for item in chart_data])) logger.info("📡 KODEX200 KIS API 조회 성공: %d행", len(closes)) except Exception as api_exc: logger.warning("⚠️ KIS API KODEX200 조회 실패, DB 폴백 시도: %s", api_exc) # 2차 폴백: DB if len(closes) < 205: chart_data = await _fetch_db_chart("069500", limit=240) if chart_data: closes = list(reversed([float(item["stck_clpr"]) for item in chart_data])) logger.info("📂 KODEX200 DB 폴백 조회: %d행", len(closes)) if len(closes) < 205: logger.warning("⚠️ KOSPI200 데이터 부족(%d행) — 레짐 판별 스킵", len(closes)) return "UNKNOWN" regime = detect_market_regime_v2(closes) regime_emoji = {"bull": "📈 BULL", "bear": "📉 BEAR"}.get(regime.value, "➡️ " + regime.value.upper()) logger.info("🌏 시장 레짐: %s (KODEX200 %d일 기준)", regime_emoji, len(closes)) return regime.value.upper() except Exception as exc: logger.error("❌ 레짐 판별 실패: %s", exc, exc_info=True) return "UNKNOWN" # ------------------------------------------------------------------- # 2. 종목 목록 조회 # ------------------------------------------------------------------- async def _get_stocks_from_db(limit: int = 500) -> list[tuple[str, str]]: """stock_daily_prices DB에서 종목 목록 조회 (KIS API 실패 시 폴백). 최근 거래일 기준으로 데이터가 있는 종목을 조회한다. ETF / 레버리지 / 인버스 / 스팩 등 특수종목은 이름 기반으로 제외. """ SKIP_KEYWORDS = [ "ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG", "KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX", "RISE", "선물", "스팩", "리츠", "SPAC", ] async with AsyncSessionLocal() as db: # 최근 거래일 조회 latest_date_result = await db.execute( sa_select(sa_func.max(StockDailyPrice.trade_date)) ) latest_date = latest_date_result.scalar() if not latest_date: logger.warning("⚠️ stock_daily_prices 테이블이 비어 있음") return [] logger.info("📂 DB 폴백: %s 기준 종목 조회", latest_date) # 최근 거래일 종목 전체 조회 (종목코드, 종목명) result = await db.execute( sa_select(StockDailyPrice.stock_code, StockDailyPrice.stock_name) .where(StockDailyPrice.trade_date == latest_date) .where(StockDailyPrice.close_price > 5000) .order_by(StockDailyPrice.volume.desc()) .limit(limit * 3) # 필터 후 충분한 수가 남도록 여유분 조회 ) rows = result.all() stocks = [] for code, name in rows: if not name: continue name_upper = name.upper() if any(kw.upper() in name_upper for kw in SKIP_KEYWORDS): continue stocks.append((code, name)) if len(stocks) >= limit: break logger.info("📂 DB 폴백 결과: %d개 종목 (%s 기준)", len(stocks), latest_date) return stocks async def get_all_tradable_stocks(limit: int = 500) -> list[tuple[str, str]]: """네이버 파이낸스 시가총액 순위로 거래 가능 종목 조회 (KOSPI 60% + KOSDAQ 40%). ETF / 레버리지 / 스팩 등 특수종목 자동 제외. 네이버 파이낸스 결과가 MIN_STOCK_COUNT(50) 미만이면 stock_daily_prices DB 폴백을 사용한다. DB 폴백도 실패하면 RuntimeError를 발생시켜 배치를 중단한다. """ from app.services.naver_finance import get_naver_stock_list logger.info("📡 네이버 파이낸스 종목 조회 (KOSPI+KOSDAQ, limit=%d)", limit) stocks = await get_naver_stock_list( max_pages_per_market=10, limit=limit, kospi_ratio=0.6, # KOSPI 300 + KOSDAQ 200 (limit=500 기준) ) if len(stocks) < MIN_STOCK_COUNT: logger.warning( "⚠️ 네이버 파이낸스 결과 부족 (%d개 < %d개) — DB 폴백 사용", len(stocks), MIN_STOCK_COUNT, ) db_stocks = await _get_stocks_from_db(limit) if len(db_stocks) < MIN_STOCK_COUNT: raise RuntimeError( f"종목 조회 실패: 네이버({len(stocks)}개) 및 DB 폴백({len(db_stocks)}개) 모두 " f"최소 기준({MIN_STOCK_COUNT}개) 미달." ) result = db_stocks[:limit] logger.info("✅ DB 폴백 선정 %d개", len(result)) return result logger.info("✅ 선정 %d개", len(stocks)) return stocks # ------------------------------------------------------------------- # 2.5. 보유종목+분석종목 차트 데이터 KIS → DB 수집 # ------------------------------------------------------------------- CHART_COLLECT_CONCURRENCY = 5 # KIS API 동시 호출 수 (차트 수집용) CHART_COLLECT_DAYS = 400 # 수집할 캘린더일 수 (약 240 거래일) async def _collect_chart_for_stock( stock_code: str, stock_name: str, start_date: str, end_date: str, semaphore: asyncio.Semaphore, ) -> tuple[str, int]: """단일 종목 차트 데이터를 KIS API에서 가져와 DB에 UPSERT. Returns: (stock_code, 저장된 행 수) """ from app.services.korea_investment_api import korea_investment_api from sqlalchemy.dialects.postgresql import insert as pg_insert async with semaphore: try: raw_items = await korea_investment_api.get_daily_ohlcv_range( stock_code, start_date, end_date, ) if not raw_items: return stock_code, 0 records = [] for item in raw_items: try: trade_date_str = item.get("stck_bsop_date", "") if not trade_date_str or len(trade_date_str) != 8: continue td = date( int(trade_date_str[:4]), int(trade_date_str[4:6]), int(trade_date_str[6:8]), ) close_price = int(float(str(item.get("stck_clpr", "0")).replace(",", ""))) if close_price == 0: continue records.append({ "stock_code": stock_code, "stock_name": stock_name, "trade_date": td, "open_price": int(float(str(item.get("stck_oprc", "0")).replace(",", ""))), "high_price": int(float(str(item.get("stck_hgpr", "0")).replace(",", ""))), "low_price": int(float(str(item.get("stck_lwpr", "0")).replace(",", ""))), "close_price": close_price, "volume": int(item.get("acml_vol", 0)), "change_rate": None, "market_cap": None, }) except (ValueError, TypeError): continue if not records: return stock_code, 0 # DB UPSERT (500건 단위) async with AsyncSessionLocal() as session: batch_size = 500 for i in range(0, len(records), batch_size): batch = records[i: i + batch_size] stmt = pg_insert(StockDailyPrice).values(batch) stmt = stmt.on_conflict_do_update( constraint="uq_stock_daily", set_={ "stock_name": stmt.excluded.stock_name, "open_price": stmt.excluded.open_price, "high_price": stmt.excluded.high_price, "low_price": stmt.excluded.low_price, "close_price": stmt.excluded.close_price, "volume": stmt.excluded.volume, }, ) await session.execute(stmt) await session.commit() return stock_code, len(records) except Exception as exc: logger.warning("⚠️ 차트수집 실패 %s: %s", stock_code, exc) return stock_code, 0 async def collect_chart_data_for_targets( analysis_stocks: list[tuple[str, str]], ) -> int: """보유종목 + 분석 대상 종목의 차트 데이터를 KIS API에서 수집하여 DB 저장. - 보유종목: portfolio 테이블에서 조회 - 분석종목: 배치에서 넘겨받은 종목 리스트 - 중복 제거 후 KIS API 호출 Returns: 총 저장된 행 수 """ from app.models.models import Portfolio # 1. 보유종목 조회 portfolio_stocks: list[tuple[str, str]] = [] try: async with AsyncSessionLocal() as session: result = await session.execute( sa_select(Portfolio.stock_code, Portfolio.stock_name) .where(Portfolio.quantity > 0) ) portfolio_stocks = [(row[0], row[1] or "") for row in result.all()] logger.info("📂 보유종목: %d개", len(portfolio_stocks)) except Exception as exc: logger.warning("⚠️ 보유종목 조회 실패: %s", exc) # 2. 중복 제거 (보유종목 우선) seen = set() targets: list[tuple[str, str]] = [] for code, name in portfolio_stocks + analysis_stocks: if code not in seen: seen.add(code) targets.append((code, name)) if not targets: logger.info("📊 차트 수집 대상 없음") return 0 logger.info("📊 차트 데이터 수집 시작: %d종목 (보유 %d + 분석 %d, 중복제거)", len(targets), len(portfolio_stocks), len(analysis_stocks)) # 3. 날짜 범위 end_dt = datetime.now(KST) start_dt = end_dt - timedelta(days=CHART_COLLECT_DAYS) start_date = start_dt.strftime("%Y%m%d") end_date = end_dt.strftime("%Y%m%d") # 4. 병렬 수집 semaphore = asyncio.Semaphore(CHART_COLLECT_CONCURRENCY) results = await asyncio.gather( *[ _collect_chart_for_stock(code, name, start_date, end_date, semaphore) for code, name in targets ], return_exceptions=True, ) total_saved = 0 success_count = 0 for r in results: if isinstance(r, tuple): code, count = r total_saved += count if count > 0: success_count += 1 logger.info("✅ 차트 데이터 수집 완료: %d종목 성공 / %d종목 총 %d행 저장", success_count, len(targets), total_saved) return total_saved # ------------------------------------------------------------------- # 3. 단일 종목 분석 (순수 분석만 — DB 저장 없음) # ------------------------------------------------------------------- async def analyze_one_stock( idx: int, total: int, stock_code: str, stock_name: str, ) -> tuple[str, Optional[StockRecommendation]]: """종목 분석만 수행하고 결과 반환. DB 저장은 Streaming 저장기에서 처리. DB(stock_daily_prices)에서 차트 데이터를 조회 — KIS API 호출 없음. Returns: (status, result) status: "ok" | "no_chart" | "price_filter" | "error" """ try: logger.info("[%d/%d] 분석 중: %s (%s)", idx, total, stock_code, stock_name) chart_data = await _fetch_db_chart(stock_code) if not chart_data: logger.info("샘 차트없음(DB) %s (%s)", stock_code, stock_name) return "no_chart", None result = await analyze_stock_for_batch_db(chart_data, stock_code, stock_name) if not result: logger.info("샘 차트없음 %s (%s)", stock_code, stock_name) return "no_chart", None # 5,000원 이하 저가주 제외 (CLAUDE.md 5절 가격 필터) if not passes_price_filter(result): logger.info("샘 가격필터 %s: %.0f원 < 5,000원", stock_code, result.current_price) return "price_filter", None ml_info = f", ml_score={result.ml_score}" if result.ml_score is not None else "" logger.info("✅ 분석완료 %s (%s): score=%s%s", stock_code, stock_name, result.score, ml_info) return "ok", result except Exception as exc: logger.error("❌ Failed %s: %s", stock_code, exc) return "error", None # ------------------------------------------------------------------- # 4. Streaming 저장기 — 분석 완료 즉시 BULK_SAVE_CHUNK 단위 UPSERT # ------------------------------------------------------------------- async def _streaming_saver( queue: asyncio.Queue, analysis_date: date, ) -> dict: """큐에서 분석 결과를 꺼내 BULK_SAVE_CHUNK(20)개마다 즉시 DB UPSERT. 전체 메모리 적재 없이 분석 완료 즉시 저장하여 메모리 사용 최소화. Returns: stats dict {"ok", "no_chart", "price_filter", "error", "saved"} """ buffer: list[tuple[str, str, StockRecommendation]] = [] stats = {"ok": 0, "no_chart": 0, "price_filter": 0, "error": 0, "saved": 0} try: while True: item = await queue.get() if item is None: # sentinel: 분석 완료 break status, code, name, result = item stats[status] = stats.get(status, 0) + 1 if status == "ok" and result: buffer.append((code, name, result)) # 버퍼가 청크 크기에 도달하면 즉시 저장 if len(buffer) >= BULK_SAVE_CHUNK: async with AsyncSessionLocal() as session: saved = await bulk_upsert_analysis(session, buffer, analysis_date) await session.commit() stats["saved"] += saved logger.info("💾 DB 저장: %d건 누적", stats["saved"]) buffer.clear() # 나머지 잔여분 저장 if buffer: async with AsyncSessionLocal() as session: saved = await bulk_upsert_analysis(session, buffer, analysis_date) await session.commit() stats["saved"] += saved logger.info("💾 DB 저장(잔여): %d건 누적", stats["saved"]) except Exception as exc: # 🔧 [fix] saver_task 예외 시 큐를 드레인하여 분석 태스크 deadlock 방지 logger.error("❌ _streaming_saver 예외 발생 — 큐 드레인 후 종료: %s", exc, exc_info=True) # 큐에 남은 항목 모두 소비하여 analyze_one의 queue.put() 블로킹 해제 while not queue.empty(): try: queue.get_nowait() except asyncio.QueueEmpty: break return stats # ------------------------------------------------------------------- # 6. 메인 배치 # ------------------------------------------------------------------- async def batch_analyze_all_stocks(limit: int = None): """전체 종목 배치 분석 실행 순서: 1. 시장 레짐 판별 (KOSPI 지수 기준) 2. 시가총액 상위 500개 종목 조회 3. 세마포어로 BATCH_CONCURRENCY(10)개씩 병렬 분석 (API 호출만) 4. Streaming: 분석 완료 즉시 BULK_SAVE_CHUNK(20)개 단위 DB UPSERT 5. DB 스캐너 실행 (추가 API 호출 없음) 6. 워치리스트 초기화 — 스캐너 신호 종목 우선 성능 개선 (기존 대비): - DB 트랜잭션: DELETE+INSERT race-condition → 원자적 UPSERT - 메모리: 전체 결과 적재 → Streaming(분석 즉시 저장) - DB 엔진: 스크립트 자체 생성 → 공통 모듈 공유 """ start_time = datetime.now(KST) analysis_date = datetime.now(KST).date() logger.info("🚀 Starting daily batch analysis for %s", analysis_date) logger.info("⚙️ 동시 처리: %d종목 | 벌크저장 단위: %d개 | API 세마포어: %dreq", BATCH_CONCURRENCY, BULK_SAVE_CHUNK, settings.API_MAX_CONCURRENT_REQUESTS) # ML 스킵 집계 초기화 — 사이클 종료 시 사유별 1줄 요약을 남긴다. # 9/23~9/28 닷새간 ml_score 가 200/200 NULL 이었는데 로그가 0줄이었다. (2026-09-28) reset_ml_skip_stats() # Step 0: DB 데이터 사전 확인 (collect_krx_data.py 미실행 감지) async with AsyncSessionLocal() as _db: _db_check = await _db.execute( sa_select(StockDailyPrice.trade_date) .order_by(StockDailyPrice.trade_date.desc()) .limit(1) ) _last_row = _db_check.scalar() if _last_row is None: logger.error( "❌ stock_daily_prices 테이블이 비어있습니다. " "먼저 collect_krx_data.py를 실행하여 데이터를 수집하세요." ) raise RuntimeError("DB에 차트 데이터 없음 — collect_krx_data.py 선행 실행 필요") from datetime import timedelta as _td _days_old = (date.today() - _last_row).days if _days_old > 3: logger.warning( "⚠️ DB 마지막 데이터: %s (%d일 전) — collect_krx_data.py --update 실행 권장", _last_row, _days_old ) else: logger.info("✅ DB 최신 데이터: %s", _last_row) # Step 1: 시장 레짐 판별 regime = await detect_and_log_market_regime() # Step 2: 종목 목록 조회 (KIS 거래량 순위 API) all_stocks = await get_all_tradable_stocks(limit=500) filtered_stocks = all_stocks[:limit] if limit else all_stocks total = len(filtered_stocks) logger.info("📊 분석 대상: %d개 (시장 레짐: %s)", total, regime) # Step 2.5: 보유종목+분석종목 차트 데이터 KIS → DB 수집 try: chart_rows = await collect_chart_data_for_targets(filtered_stocks) logger.info("📈 차트 데이터 DB 저장 완료: %d행", chart_rows) except Exception as exc: logger.warning("⚠️ 차트 데이터 수집 실패 (non-critical, 기존 DB 데이터로 계속): %s", exc) # Step 3 + 4: 병렬 분석 & Streaming 저장 # 분석 태스크 → queue → _streaming_saver (동시 실행) queue: asyncio.Queue = asyncio.Queue(maxsize=BULK_SAVE_CHUNK * 3) semaphore = asyncio.Semaphore(BATCH_CONCURRENCY) # saver_task를 먼저 시작 saver_task = asyncio.create_task(_streaming_saver(queue, analysis_date)) async def analyze_one(i: int, code: str, name: str): try: async with semaphore: status, result = await analyze_one_stock(i, total, code, name) # saver가 이미 종료(예외 등)됐으면 put 블로킹 방지 if not saver_task.done(): await queue.put((status, code, name, result)) else: logger.warning("⚠️ saver 종료됨 — %s 저장 건너뜀", code) except Exception as exc: logger.error("❌ analyze_one unexpected error %s: %s", code, exc) if not saver_task.done(): await queue.put(("error", code, name, None)) # 분석 태스크 전체 실행 (완료까지 대기) await asyncio.gather( *[analyze_one(i, c, n) for i, (c, n) in enumerate(filtered_stocks, 1)], return_exceptions=True, ) # 모든 분석 완료 후 sentinel 전송 → saver 정상 종료 # 기존 코드의 deadlock 원인: sentinel을 asyncio.wait() 이후에 보냈기 때문에 # FIRST_EXCEPTION 모드에서 예외 없으면 ALL_COMPLETED 동작 → saver가 sentinel을 # 기다리며 무한 대기 (deadlock) if not saver_task.done(): await queue.put(None) stats = {} try: stats = await saver_task except Exception as exc: logger.error("❌ saver_task 최종 오류: %s", exc) ok_count = stats.get("ok", 0) no_chart = stats.get("no_chart", 0) price_flt = stats.get("price_filter", 0) err_count = stats.get("error", 0) save_count = stats.get("saved", 0) # Step 5: DB 스캐너 (배치 완료 후 추가 API 호출 없이) scanner_tickers = await run_db_scanner(analysis_date) # Step 6: 워치리스트 초기화 — 스캐너 신호 종목 우선 try: from app.services.watchlist import initialize_watchlist async with AsyncSessionLocal() as session: count = await initialize_watchlist( session, analysis_date, scanner_tickers=scanner_tickers ) logger.info("📋 워치리스트 초기화 완료: %d개 (스캐너 신호 %d개 포함)", count, len(scanner_tickers)) except Exception as exc: logger.warning("⚠️ 워치리스트 초기화 실패 (non-critical): %s", exc) # 결과 요약 duration = (datetime.now(KST) - start_time).total_seconds() logger.info("=" * 60) logger.info("✅ Batch analysis completed!") logger.info("🌏 시장 레짐: %s", regime) logger.info("📊 성공: %d / 전체: %d | DB저장: %d건", ok_count, total, save_count) logger.info(" ├─ 차트없음: %d건", no_chart) logger.info(" ├─ 가격필터: %d건", price_flt) logger.info(" ├─ 오류: %d건", err_count) logger.info("🔍 DB 스캐너 신호: %d개", len(scanner_tickers)) log_ml_skip_summary("일일배치") logger.info("⏱️ %.1f초 (%.1f분)", duration, duration / 60) logger.info("=" * 60) # ------------------------------------------------------------------- # Entry Point # ------------------------------------------------------------------- async def main(): import argparse parser = argparse.ArgumentParser(description='Daily Stock Analysis Batch') parser.add_argument('--limit', type=int, help='Limit number of stocks (for testing)') parser.add_argument('--test', action='store_true', help='Test mode (10 stocks)') args = parser.parse_args() if args.test: logger.info("🧪 TEST MODE: 10개 종목") await batch_analyze_all_stocks(limit=10) elif args.limit: await batch_analyze_all_stocks(limit=args.limit) else: await batch_analyze_all_stocks() if __name__ == "__main__": # 🔧 [fix] basicConfig를 __main__ 블록으로 이동 # 인프로세스 스케줄러(main.py lifespan)에서 import될 때 로그 설정 충돌 방지 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())