From 62fccd6d6ff44385f183c1034b62ac30f3b339ad Mon Sep 17 00:00:00 2001 From: YoungWooSeo Date: Fri, 27 Feb 2026 12:28:07 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20batch=5Fdaily=5Fanalysis=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EC=9D=B4=EC=8A=88=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [๐Ÿ”ด] saver_task ์˜ˆ์™ธ ์‹œ deadlock ๋ฐฉ์ง€: asyncio.wait๋กœ saver_task ์˜ˆ์™ธ ๊ฐ์ง€ - [๐Ÿ”ด] logging.basicConfig๋ฅผ __main__ ๋ธ”๋ก์œผ๋กœ ์ด๋™ (์ธํ”„๋กœ์„ธ์Šค ์‹คํ–‰ ์‹œ ์ถฉ๋Œ ๋ฐฉ์ง€) - [๐ŸŸก] get_all_tradable_stocks: ์ตœ์†Œ ์ข…๋ชฉ ์ˆ˜(50๊ฐœ) ๋ฏธ๋งŒ ์‹œ RuntimeError ๋ฐœ์ƒ - [๐ŸŸ ] detect_and_log_market_regime: df=None์ผ ๋•Œ len(df) ํ˜ธ์ถœ ์•ˆ์ „ํ•˜๊ฒŒ ์ˆ˜์ • --- backend/batch_daily_analysis.py | 114 +++++++++++++++++++++++--------- 1 file changed, 83 insertions(+), 31 deletions(-) diff --git a/backend/batch_daily_analysis.py b/backend/batch_daily_analysis.py index 5bee7b9..e9cf7e0 100644 --- a/backend/batch_daily_analysis.py +++ b/backend/batch_daily_analysis.py @@ -36,10 +36,8 @@ from app.services.analysis_db_service import bulk_upsert_analysis, passes_price_ from app.api.endpoints.recommendations import analyze_stock_for_batch, StockRecommendation import logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) +# ๐Ÿ”ง [fix] logging.basicConfig๋Š” __main__ ๋ธ”๋ก์—์„œ๋งŒ ํ˜ธ์ถœ +# ์ธํ”„๋กœ์„ธ์Šค ์Šค์ผ€์ค„๋Ÿฌ(main.py)๋กœ ์‹คํ–‰๋  ๋•Œ ๋กœ๊ทธ ์„ค์ • ์ถฉ๋Œ ๋ฐฉ์ง€ logger = logging.getLogger(__name__) # ------------------------------------------------------------------- @@ -48,6 +46,9 @@ logger = logging.getLogger(__name__) BATCH_CONCURRENCY = 10 # API ์„ธ๋งˆํฌ์–ด ํ—ˆ์šฉ ๋ฒ”์œ„ ๋‚ด ๋™์‹œ ์ฒ˜๋ฆฌ ์ˆ˜ BULK_SAVE_CHUNK = 20 # Streaming ์ €์žฅ ๋‹จ์œ„ (ํ์— 20๊ฐœ ์Œ“์ด๋ฉด ์ฆ‰์‹œ UPSERT) +# ์ข…๋ชฉ ๋ชฉ๋ก ์กฐํšŒ ์‹คํŒจ ์‹œ ๋ฐฐ์น˜๋ฅผ ์ค‘๋‹จํ•  ์ตœ์†Œ ์ข…๋ชฉ ์ˆ˜ +MIN_STOCK_COUNT = 50 + # ------------------------------------------------------------------- # 1. ์‹œ์žฅ ๋ ˆ์ง ํŒ๋ณ„ (KOSPI ๊ธฐ์ค€) @@ -68,8 +69,11 @@ async def detect_and_log_market_regime() -> str: # KOSPI ์ง€์ˆ˜ ์ฝ”๋“œ 1001 df = pykrx_stock.get_index_ohlcv_by_date(start, today, "1001") - if df is None or len(df) < 205: - logger.warning("โš ๏ธ KOSPI ๋ฐ์ดํ„ฐ ๋ถ€์กฑ(ํ–‰ %d) โ€” ๋ ˆ์ง ํŒ๋ณ„ ์Šคํ‚ต", 0 if df is None else len(df)) + + # ๐Ÿ”ง [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" kospi_closes = df["์ข…๊ฐ€"].tolist() @@ -94,6 +98,9 @@ async def get_all_tradable_stocks(kis_api: KoreaInvestmentAPI, limit: int = 500) ETF / ๋ ˆ๋ฒ„๋ฆฌ์ง€ / ์ŠคํŒญ ๋“ฑ ํŠน์ˆ˜์ข…๋ชฉ ์ž๋™ ์ œ์™ธ. KOSPI + KOSDAQ ํ•ฉ์‚ฐ ์‹œ๊ฐ€์ด์•ก ์ƒ์œ„ limit๊ฐœ ๋ฐ˜ํ™˜. + + Raises: + RuntimeError: ์กฐํšŒ ๊ฒฐ๊ณผ๊ฐ€ MIN_STOCK_COUNT(50) ๋ฏธ๋งŒ์ธ ๊ฒฝ์šฐ ๋ฐฐ์น˜ ์ค‘๋‹จ """ logger.info("Fetching top %d stocks by market cap from pykrx...", limit) try: @@ -130,8 +137,18 @@ async def get_all_tradable_stocks(kis_api: KoreaInvestmentAPI, limit: int = 500) 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: + raise RuntimeError( + f"์ข…๋ชฉ ์กฐํšŒ ๊ฒฐ๊ณผ ๋„ˆ๋ฌด ์ ์Œ: {len(top_stocks)}๊ฐœ (์ตœ์†Œ {MIN_STOCK_COUNT}๊ฐœ ํ•„์š”). " + "pykrx API ์˜ค๋ฅ˜ ๋˜๋Š” ์žฅ ํœด์žฅ ์—ฌ๋ถ€๋ฅผ ํ™•์ธํ•˜์„ธ์š”." + ) + return top_stocks + except RuntimeError: + raise # ์ตœ์†Œ ์ข…๋ชฉ ์ˆ˜ ๋ฏธ๋‹ฌ โ€” ์ƒ์œ„๋กœ ์ „ํŒŒํ•˜์—ฌ ๋ฐฐ์น˜ ์ค‘๋‹จ except Exception as exc: logger.error("โŒ ์ข…๋ชฉ ๋ชฉ๋ก ์กฐํšŒ ์‹คํŒจ: %s", exc, exc_info=True) return [] @@ -191,33 +208,44 @@ async def _streaming_saver( buffer: list[tuple[str, str, StockRecommendation]] = [] stats = {"ok": 0, "no_chart": 0, "price_filter": 0, "error": 0, "saved": 0} - while True: - item = await queue.get() - if item is None: # sentinel: ๋ถ„์„ ์™„๋ฃŒ - break + 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 + status, code, name, result = item + stats[status] = stats.get(status, 0) + 1 - if status == "ok" and result: - buffer.append((code, name, result)) + if status == "ok" and result: + buffer.append((code, name, result)) - # ๋ฒ„ํผ๊ฐ€ ์ฒญํฌ ํฌ๊ธฐ์— ๋„๋‹ฌํ•˜๋ฉด ์ฆ‰์‹œ ์ €์žฅ - if len(buffer) >= BULK_SAVE_CHUNK: + # ๋ฒ„ํผ๊ฐ€ ์ฒญํฌ ํฌ๊ธฐ์— ๋„๋‹ฌํ•˜๋ฉด ์ฆ‰์‹œ ์ €์žฅ + 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"]) - buffer.clear() + logger.info("๐Ÿ’พ DB ์ €์žฅ(์ž”์—ฌ): %d๊ฑด ๋ˆ„์ ", stats["saved"]) - # ๋‚˜๋จธ์ง€ ์ž”์—ฌ๋ถ„ ์ €์žฅ - 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 @@ -272,16 +300,34 @@ async def batch_analyze_all_stocks(limit: int = None): logger.error("โŒ analyze_one unexpected error %s: %s", code, exc) await queue.put(("error", code, name, None)) - # ์ €์žฅ๊ธฐ๋ฅผ ๋ฐฑ๊ทธ๋ผ์šด๋“œ๋กœ ๋จผ์ € ์‹œ์ž‘ + # ๐Ÿ”ง [fix] saver_task ์˜ˆ์™ธ ๊ฐ์ง€: asyncio.wait๋กœ saver_task ๋น„์ •์ƒ ์ข…๋ฃŒ ์‹œ ์กฐ๊ธฐ ๊ฐ์ง€ saver_task = asyncio.create_task(_streaming_saver(queue, analysis_date)) # ๋ถ„์„ ํƒœ์Šคํฌ ์ „์ฒด ์‹คํ–‰ - tasks = [analyze_one(i, c, n) for i, (c, n) in enumerate(filtered_stocks, 1)] - await asyncio.gather(*tasks, return_exceptions=True) + analysis_gather = asyncio.gather( + *[analyze_one(i, c, n) for i, (c, n) in enumerate(filtered_stocks, 1)], + return_exceptions=True, + ) - # sentinel ์ „์†ก โ†’ ์ €์žฅ๊ธฐ ์ข…๋ฃŒ - await queue.put(None) - stats = await saver_task + # saver_task๊ฐ€ ์˜ˆ์™ธ๋กœ ์ฃฝ์œผ๋ฉด analysis_gather๋„ ์กฐ๊ธฐ ์ทจ์†Œ + done, pending = await asyncio.wait( + [asyncio.ensure_future(analysis_gather), saver_task], + return_when=asyncio.FIRST_EXCEPTION, + ) + + # saver_task ์˜ˆ์™ธ ๋ฐœ์ƒ ์‹œ ๋ถ„์„ ํƒœ์Šคํฌ ์ทจ์†Œ ์ฒ˜๋ฆฌ + for task in pending: + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + + # sentinel ์ „์†ก โ†’ ์ €์žฅ๊ธฐ ์ข…๋ฃŒ (์ •์ƒ ํ๋ฆ„์—์„œ๋„ ํ•ญ์ƒ ์‹คํ–‰) + if not saver_task.done(): + await queue.put(None) + + stats = await saver_task if not saver_task.cancelled() else {} ok_count = stats.get("ok", 0) no_chart = stats.get("no_chart", 0) @@ -344,4 +390,10 @@ 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' + ) asyncio.run(main())