From a47591e40d7836d41d333d6746354ce79853ef73 Mon Sep 17 00:00:00 2001 From: YoungWooSeo Date: Fri, 27 Feb 2026 12:30:56 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20batch=5Fintraday=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 - [๐Ÿ”ด] logging.basicConfig๋ฅผ __main__ ๋ธ”๋ก์œผ๋กœ ์ด๋™ - [๐ŸŸก] analyze_update_and_manage: get_current_score ์„ธ์…˜ ํ†ตํ•ฉ (์„ธ์…˜ ์ธ์Šคํ„ด์Šค ๊ฐ์†Œ) - [๐ŸŸก] rescore_outside_candidates: bulk_upsert_analysis๋กœ ์ผ๊ด„ ์ €์žฅ - [๐ŸŸ ] retained ํ†ต๊ณ„: AI ์Šคํ‚ต ์ข…๋ชฉ ๋ช…ํ™•ํžˆ ๋ถ„๋ฆฌ --- backend/batch_intraday_analysis.py | 149 ++++++++++++++++------------- 1 file changed, 85 insertions(+), 64 deletions(-) diff --git a/backend/batch_intraday_analysis.py b/backend/batch_intraday_analysis.py index 6a62427..b95c38d 100644 --- a/backend/batch_intraday_analysis.py +++ b/backend/batch_intraday_analysis.py @@ -7,7 +7,7 @@ 3. ๋ถ„์„ ๊ฒฐ๊ณผ๋ฅผ watchlist_history์— ์‹œ๊ณ„์—ด๋กœ ๋ˆ„์  ์ €์žฅ 4. score ๊ธฐ์ค€์œผ๋กœ ๋ถ„์„์ข…๋ชฉ ์ง„์ž…(โ‰ฅ60) / ์ œ๊ฑฐ(<20) ์ž๋™ ๊ด€๋ฆฌ -[์ตœ์ ํ™”] +[์ „๋žต] - MCP AI ๋ถ„์„์€ DB ์ ์ˆ˜๊ฐ€ MCP_ANALYSIS_MIN_SCORE(๊ธฐ๋ณธ 70) ์ด์ƒ์ธ ์ข…๋ชฉ๋งŒ ํ˜ธ์ถœ - ์ €์ ์ˆ˜ ์ข…๋ชฉ์€ ๋ฃจํŠธ๋ฆผ ์ง€ํ‘œ ๊ณ„์‚ฐ๋งŒ ์ˆ˜ํ–‰ํ•˜๊ณ  ์ˆ˜๋™ score ์œ ์ง€ @@ -32,7 +32,12 @@ from app.core.database import AsyncSessionLocal # ๐Ÿ”ง ๊ณตํ†ต DB ๋ชจ๋“ˆ from app.core.market_schedule import is_market_open from app.models.models import StockAnalysis, WatchlistStock, WatchlistHistory from app.api.endpoints.recommendations import analyze_stock_for_batch -from app.services.analysis_db_service import upsert_analysis, passes_price_filter, run_db_scanner # ๐Ÿ”ง ๋‹จ์ผํ™” +from app.services.analysis_db_service import ( + upsert_analysis, + bulk_upsert_analysis, + passes_price_filter, + run_db_scanner, +) # ๐Ÿ”ง ๋‹จ์ผํ™” from app.services.watchlist import ( WATCHLIST_MAX_SIZE, WATCHLIST_ENTRY_SCORE, @@ -40,10 +45,7 @@ from app.services.watchlist import ( ) import logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) +# ๐Ÿ”ง [fix] logging.basicConfig๋Š” __main__ ๋ธ”๋ก์—์„œ๋งŒ ํ˜ธ์ถœ logger = logging.getLogger(__name__) # ๋™์‹œ ์ฒ˜๋ฆฌ ์ข…๋ชฉ ์ˆ˜ (KIS API ์„ธ๋งˆํฌ์–ด๊ฐ€ ์ถ”๊ฐ€๋กœ ์†๋„ ์ œ์–ด) @@ -75,24 +77,6 @@ async def get_active_watchlist() -> list[tuple[str, str]]: return [(row.stock_code, row.stock_name) for row in rows] -async def get_current_score(stock_code: str, analysis_date: date) -> int | None: - """์ตœ์‹  DB score ์กฐํšŒ. - - MCP AI ๋ถ„์„ ์ „ score๋ฅผ ํ™•์ธํ•˜์—ฌ MCP_ANALYSIS_MIN_SCORE ๋ฏธ๋งŒ์€ AI ํ˜ธ์ถœ์„ ์Šคํ‚ตํ•˜๊ธฐ ์œ„ํ•ด ์‚ฌ์šฉ. - """ - async with AsyncSessionLocal() as session: - result = await session.execute( - select(StockAnalysis.score) - .where( - StockAnalysis.stock_code == stock_code, - StockAnalysis.analysis_date == analysis_date, - ) - .limit(1) - ) - row = result.first() - return row.score if row else None - - # ------------------------------------------------------------------- # analyze_update_and_manage ๋ถ„๋ฆฌ: ๋ถ„์„ / ์ €์žฅ / ๊ด€๋ฆฌ # ------------------------------------------------------------------- @@ -164,33 +148,47 @@ async def analyze_update_and_manage( ) -> tuple[bool, int | None, bool]: """์ข…๋ชฉ ์žฌ๋ถ„์„ โ†’ stock_analysis ๊ฐฑ์‹  โ†’ watchlist_history ์ €์žฅ โ†’ watchlist ๊ด€๋ฆฌ. - [์ตœ์ ํ™”] DB score < MCP_ANALYSIS_MIN_SCORE์ด๋ฉด AI ๋ถ„์„ ์Šคํ‚ต. - - AI ํ˜ธ์ถœ ์—†์ด ๊ธฐ์กด score ์œ ์ง€, watchlist ์ ์Šค ์ฒดํฌ๋งŒ ์ˆ˜ํ–‰ + [์ „๋žต] DB score < MCP_ANALYSIS_MIN_SCORE์ด๋ฉด AI ๋ถ„์„ ์Šคํ‚ต. + - AI ํ˜ธ์ถœ ์—†์ด ๊ธฐ์กด score ์œ ์ง€, watchlist ์ง„์ž… ์ฒดํฌ๋งŒ ์ˆ˜ํ–‰ - score ๊ฐœ์„  ์‹œ์ฝ”์–ด๋Š” ๋‹ค์Œ ์ฃผ๊ธฐ์— ๋ฐ˜์˜๋จ + [ํฌ์ธํŠธ] ์„ธ์…˜์„ ํ•œ ๋ฒˆ๋งŒ ์—ด์–ด score ์กฐํšŒ + ์ €์žฅ + ๊ด€๋ฆฌ๋ฅผ ํ•œ ํŠธ๋žœ์žญ์…˜์—์„œ ์ฒ˜๋ฆฌ + Returns: (success, score, was_removed) was_removed: _manage_watchlist_membership์ด ์‹ค์ œ๋กœ ๋น„ํ™œ์„ฑํ™” ์ฒ˜๋ฆฌํ–ˆ์œผ๋ฉด True """ try: - # --- [1๋‹จ๊ณ„] DB์—์„œ ํ˜„์žฌ score ์กฐํšŒ --- - current_score = await get_current_score(stock_code, analysis_date) - - # --- [2๋‹จ๊ณ„] score ๋ฏธ๋‹ฌ ์‹œ AI ๋ถ„์„ ์Šคํ‚ต --- - if current_score is not None and current_score < MCP_ANALYSIS_MIN_SCORE: - logger.debug( - "โญ๏ธ AI ์Šคํ‚ต %s (%s): score=%s < %s", - stock_code, stock_name, current_score, MCP_ANALYSIS_MIN_SCORE, + # ๐Ÿ”ง [fix] score ์กฐํšŒ + ์ €์žฅ + watchlist ๊ด€๋ฆฌ๋ฅผ ๋‹จ์ผ ์„ธ์…˜์œผ๋กœ ํ†ตํ•ฉ + # ๊ธฐ์กด์—๋Š” get_current_score์—์„œ ์„ธ์…˜ 1๋ฒˆ + ์ €์žฅ์—์„œ ์„ธ์…˜ 1๋ฒˆ์œผ๋กœ ์ตœ์†Œ 2๋ฒˆ ์—ด๋ ธ์œผ๋‚˜ + # ์ด์ œ score ์กฐํšŒ๋ถ€ํ„ฐ ์ €์žฅ๊นŒ์ง€ ๋‹จ์ผ ์„ธ์…˜์—์„œ ๋ชจ๋‘ ์ฒ˜๋ฆฌ + now = datetime.now(KST) + async with AsyncSessionLocal() as session: + # --- [1๋‹จ๊ณ„] DB์—์„œ ํ˜„์žฌ score ์กฐํšŒ --- + score_result = await session.execute( + select(StockAnalysis.score) + .where( + StockAnalysis.stock_code == stock_code, + StockAnalysis.analysis_date == analysis_date, + ) + .limit(1) ) - # watchlist ์ œ๊ฑฐ ์—ฌ๋ถ€๋งŒ ์ฒดํฌ (WATCHLIST_EXIT_SCORE ๋ฏธ๋งŒ์ด๋ฉด ์ œ๊ฑฐ) - now = datetime.now(KST) - async with AsyncSessionLocal() as session: + score_row = score_result.first() + current_score = score_row.score if score_row else None + + # --- [2๋‹จ๊ณ„] score ๋ฏธ๋‹ฌ ์‹œ AI ๋ถ„์„ ์Šคํ‚ต --- + if current_score is not None and current_score < MCP_ANALYSIS_MIN_SCORE: + logger.debug( + "โญ๏ธ AI ์Šคํ‚ต %s (%s): score=%s < %s", + stock_code, stock_name, current_score, MCP_ANALYSIS_MIN_SCORE, + ) + # watchlist ์ œ๊ฑฐ ์—ฌ๋ถ€๋งŒ ์ฒดํฌ (WATCHLIST_EXIT_SCORE ๋ฏธ๋งŒ์ด๋ฉด ์ œ๊ฑฐ) was_removed = await _manage_watchlist_membership( session, stock_code, stock_name, current_score, now ) await session.commit() - return True, current_score, was_removed + return True, current_score, was_removed - # --- [3๋‹จ๊ณ„] score ๊ธฐ์ค€ ์ด์ƒ โ†’ AI ๋ถ„์„ ์‹คํ–‰ --- + # --- [3๋‹จ๊ณ„] score ๊ธฐ์ค€ ์ด์ƒ โ†’ AI ๋ถ„์„ ์‹คํ–‰ (์„ธ์…˜ ๋ฐ–์—์„œ ์‹คํ–‰) --- result = await analyze_stock_for_batch(stock_code, stock_name) if not result: logger.info("No result for %s (chart/price issue)", stock_code) @@ -208,14 +206,13 @@ async def analyze_update_and_manage( ) .values( is_active=False, - removed_at=datetime.now(KST), + removed_at=now, removed_reason="price_filter", ) ) await session.commit() return False, None, True # ๊ฐ€๊ฒฉํ•„ํ„ฐ ์ œ๊ฑฐ๋„ was_removed=True - now = datetime.now(KST) score = result.score async with AsyncSessionLocal() as session: @@ -241,7 +238,8 @@ async def rescore_outside_candidates( ) -> int: """์›Œ์น˜๋ฆฌ์ŠคํŠธ ์™ธ๋ถ€ ํ›„๋ณด ์ข…๋ชฉ์˜ score๋ฅผ ์‹ค์‹œ๊ฐ„ ์žฌ๋ถ„์„์œผ๋กœ ๊ฐฑ์‹ . - [์ตœ์ ํ™”] DB score < MCP_ANALYSIS_MIN_SCORE์ธ ์™ธ๋ถ€ ํ›„๋ณด๋Š” AI ํ˜ธ์ถœ ์—†์ด ๊ฑด๋„ˆ๋œ€. + [์ „๋žต] DB score < MCP_ANALYSIS_MIN_SCORE์ธ ์™ธ๋ถ€ ํ›„๋ณด๋Š” AI ํ˜ธ์ถœ ์—†์ด ๊ฑด๋„ˆ๋›ฐ. + [์ตœ์ ํ™”] ๋ถ„์„ ๊ฒฐ๊ณผ๋ฅผ ๋ชจ์•„์„œ bulk_upsert_analysis๋กœ ์ผ๊ด„ ์ €์žฅ (์„ธ์…˜ ์ตœ์†Œํ™”) ์•„์นจ ๋ฐฐ์น˜์—์„œ score ์ƒ์œ„๊ถŒ์ด์—ˆ๋˜ ์ข…๋ชฉ์ด ์žฅ์ค‘ ๊ฑฐ๋ž˜๋Ÿ‰ยท๋‰ด์Šค ๊ธ‰๋“ฑ์œผ๋กœ score๊ฐ€ ์˜ฌ๋ผ๊ฐ€๋„ DB์—๋Š” ์˜ค์ „ ๊ฐ’์ด ๊ทธ๋Œ€๋กœ ๋‚จ์•„ ์žˆ๋‹ค. @@ -266,7 +264,7 @@ async def rescore_outside_candidates( candidates = result.all() if not candidates: - logger.info("์™ธ๋ถ€ ํ›„๋ณด ์ข…๋ชฉ ์—†์Œ โ€” ์žฌ๋ถ„์„ ๊ฑด๋„ˆ๋œ€") + logger.info("์™ธ๋ถ€ ํ›„๋ณด ์ข…๋ชฉ ์—†์Œ โ€” ์žฌ๋ถ„์„ ๊ฑด๋„ˆ๋š") return 0 total = len(candidates) @@ -274,7 +272,14 @@ async def rescore_outside_candidates( semaphore = asyncio.Semaphore(BATCH_CONCURRENCY) - async def rescore_one(i: int, code: str, name: str, db_score: int | None) -> bool: + # ๐Ÿ”ง [fix] ๋ถ„์„ ๊ฒฐ๊ณผ๋ฅผ ์ˆ˜์ง‘ํ•˜์—ฌ bulk_upsert_analysis๋กœ ์ผ๊ด„ ์ €์žฅ + # ๊ธฐ์กด: rescore_one๋งˆ๋‹ค ์„ธ์…˜ 1๊ฐœ open/close (30๊ฐœ ๋Œ€์ƒ ์‹œ 30๋ฒˆ) + # ๊ฐœ์„ : ๋ชจ์•„์„œ ํ•œ ๋ฒˆ์— bulk upsert (1๋ฒˆ) + + async def rescore_one( + i: int, code: str, name: str, db_score: int | None + ) -> tuple[str, str, object] | None: + """Returns (code, name, result) on success, None otherwise.""" async with semaphore: # DB score < MCP_ANALYSIS_MIN_SCORE ์ด๋ฉด AI ํ˜ธ์ถœ ์Šคํ‚ต if db_score is not None and db_score < MCP_ANALYSIS_MIN_SCORE: @@ -282,33 +287,39 @@ async def rescore_outside_candidates( " [%d/%d] โญ๏ธ AI ์Šคํ‚ต %s: score=%s < %s", i, total, code, db_score, MCP_ANALYSIS_MIN_SCORE, ) - return False + return None try: res = await analyze_stock_for_batch(code, name) if not res: - return False - # ์ €๊ฐ€์ฃผ ํ•„ํ„ฐ (CLAUDE.md 5์ ˆ ๊ฐ€๊ฒฉ ํ•„ํ„ฐ) + return None if not passes_price_filter(res): logger.debug("Skip %s: price=%.0f์› < 5,000์›", code, res.current_price) - return False - - async with AsyncSessionLocal() as session: - await upsert_analysis(session, code, name, res, analysis_date) - await session.commit() - + return None logger.debug(" [%d/%d] %s (%s): score=%s", i, total, code, name, res.score) - return True + return (code, name, res) except Exception as e: logger.error("โŒ ์™ธ๋ถ€ ํ›„๋ณด ์žฌ๋ถ„์„ ์‹คํŒจ %s: %s", code, e) - return False + return None tasks = [ rescore_one(i, code, name, score) for i, (code, name, score) in enumerate(candidates, 1) ] - results = await asyncio.gather(*tasks, return_exceptions=True) - success = sum(1 for r in results if r is True) - skipped = sum(1 for r in results if r is False) + raw_results = await asyncio.gather(*tasks, return_exceptions=True) + + # ์œ ํšจํ•œ ๊ฒฐ๊ณผ๋งŒ ํ•„ํ„ฐ๋ง‰ํ•œ ํ›„ ์ผ๊ด„ ์ €์žฅ + valid_items = [ + r for r in raw_results + if isinstance(r, tuple) and len(r) == 3 + ] + success = len(valid_items) + skipped = len(candidates) - success + + if valid_items: + async with AsyncSessionLocal() as session: + await bulk_upsert_analysis(session, valid_items, analysis_date) + await session.commit() + logger.info("โœ… ์™ธ๋ถ€ ํ›„๋ณด ์žฌ๋ถ„์„ ์™„๋ฃŒ: %d/%d๊ฐœ ๊ฐฑ์‹ , %d๊ฐœ AI ์Šคํ‚ต", success, total, skipped) return success @@ -413,18 +424,23 @@ async def main(): ] results = await asyncio.gather(*tasks, return_exceptions=True) - analyzed_ok = sum(1 for r in results if isinstance(r, tuple) and r[0] is True) - removed = sum(1 for r in results if isinstance(r, tuple) and len(r) == 3 and r[2] is True) - retained = analyzed_ok - sum( - 1 for r in results if isinstance(r, tuple) and len(r) == 3 and r[0] is True and r[2] is True + analyzed_ok = sum(1 for r in results if isinstance(r, tuple) and r[0] is True) + removed = sum(1 for r in results if isinstance(r, tuple) and len(r) == 3 and r[2] is True) + + # ๐Ÿ”ง [fix] retained: AI์Šคํ‚ต์ด์ง€๋งŒ ์„ฑ๊ณตํ•œ ์ข…๋ชฉ์€ ์œ ์ง€(์ œ๊ฑฐ X)๋กœ ๊ณ„์‚ฐ + # was_removed=True์ธ ๋„ค ๊ฐ€์ง€ ์ฝ”๋“œ(success=True, removed=True)๋ฅผ ์ œ์™ธ๋งŒ ํ•œ ๊ฒƒ + retained = sum( + 1 for r in results + if isinstance(r, tuple) and len(r) == 3 and r[0] is True and r[2] is False ) - fail = sum(1 for r in results if isinstance(r, tuple) and r[0] is False) - error = sum(1 for r in results if isinstance(r, Exception)) + # AI ์Šคํ‚ต ์ข…๋ชฉ ์ˆ˜: success=True์ด๊ณ  score < MCP_ANALYSIS_MIN_SCORE์ธ ๊ฒƒ ai_skipped = sum( 1 for r in results if isinstance(r, tuple) and r[0] is True and r[1] is not None and r[1] < MCP_ANALYSIS_MIN_SCORE ) + fail = sum(1 for r in results if isinstance(r, tuple) and r[0] is False) + error = sum(1 for r in results if isinstance(r, Exception)) # ์›Œ์น˜๋ฆฌ์ŠคํŠธ ์™ธ๋ถ€ ํ›„๋ณด ์žฌ๋ถ„์„ โ€” score DB ์ตœ์‹ ํ™” (refresh_watchlist ์ „์— ์‹คํ–‰) await rescore_outside_candidates(analysis_date, current_codes) @@ -486,4 +502,9 @@ async def main(): if __name__ == "__main__": + # ๐Ÿ”ง [fix] basicConfig๋ฅผ __main__ ๋ธ”๋ก์œผ๋กœ ์ด๋™ + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) asyncio.run(main())