From 1814f5e55e689f917dc0cc1e3fb70bfabdc02c76 Mon Sep 17 00:00:00 2001 From: shuim Date: Mon, 23 Feb 2026 00:30:23 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EB=B0=8F=20=ED=81=AC?= =?UTF-8?q?=EB=A6=AC=ED=8B=B0=EC=BB=AC=20=EC=97=90=EB=9F=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/core/market_schedule.py | 46 +++++++++++++++--- backend/batch_daily_analysis.py | 27 ++++++++--- backend/batch_intraday_analysis.py | 75 ++++++++++++++++++++++++----- backend/requirements.txt | 1 + 4 files changed, 124 insertions(+), 25 deletions(-) diff --git a/backend/app/core/market_schedule.py b/backend/app/core/market_schedule.py index 1e5163d..fe60a2a 100644 --- a/backend/app/core/market_schedule.py +++ b/backend/app/core/market_schedule.py @@ -4,19 +4,38 @@ - KST 기준 장 시작/종료 시간 판별 - 공휴일, 주말 휴장 체크 """ -from datetime import datetime, time +from datetime import datetime, time, date as date_type + try: import pytz KST = pytz.timezone('Asia/Seoul') except ImportError: - # pytz가 없으면 기본 timezone 사용 (환경변수에 의존) from datetime import timezone, timedelta KST = timezone(timedelta(hours=9)) +# 한국 공휴일 (holidays 패키지) +try: + import holidays as _holidays_lib + _kr_holidays = _holidays_lib.KR() +except ImportError: + _kr_holidays = None + + +def _is_holiday(d: date_type) -> bool: + """공휴일 여부 확인. holidays 패키지 없으면 False 반환.""" + if _kr_holidays is None: + return False + return d in _kr_holidays + + def is_market_open() -> bool: """ 현재 한국 증시 정규장 운영 시간인지 확인 - (평일 09:00 ~ 15:30, 공휴일 제외 로직은 아직 미포함) + (평일 09:00 ~ 15:30 KST, 공휴일 제외) + + 수정: + - market_end 경계: <= → < (15:30 정각은 폐장) + - 공휴일 체크 추가 (holidays 패키지) """ now = datetime.now(KST) @@ -24,12 +43,16 @@ def is_market_open() -> bool: if now.weekday() >= 5: return False - # 2. 시간 체크 (09:00 ~ 15:30) + # 2. 공휴일 체크 + if _is_holiday(now.date()): + return False + + # 3. 시간 체크 (09:00 이상 ~ 15:30 미만) current_time = now.time() market_start = time(9, 0) market_end = time(15, 30) - return market_start <= current_time <= market_end + return market_start <= current_time < market_end # 15:30 정각 제외 def is_opening_range_period() -> bool: @@ -37,6 +60,8 @@ def is_opening_range_period() -> bool: now = datetime.now(KST) if now.weekday() >= 5: return False + if _is_holiday(now.date()): + return False t = now.time() return time(9, 0) <= t < time(9, 30) @@ -46,14 +71,21 @@ def is_day_trade_entry_window() -> bool: now = datetime.now(KST) if now.weekday() >= 5: return False + if _is_holiday(now.date()): + return False t = now.time() return time(9, 30) <= t < time(15, 0) def is_eod_flat_time() -> bool: - """EOD 강제 청산 구간 (15:20~15:30)""" + """EOD 강제 청산 구간 (15:20~15:29) + + 수정: 상한을 15:30 미만(<)으로 변경 — 15:30은 폐장 후. + """ now = datetime.now(KST) if now.weekday() >= 5: return False + if _is_holiday(now.date()): + return False t = now.time() - return time(15, 20) <= t <= time(15, 30) + return time(15, 20) <= t < time(15, 30) diff --git a/backend/batch_daily_analysis.py b/backend/batch_daily_analysis.py index e7cdd2f..b1c746b 100644 --- a/backend/batch_daily_analysis.py +++ b/backend/batch_daily_analysis.py @@ -23,6 +23,9 @@ 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 sqlalchemy import delete, select @@ -83,8 +86,8 @@ async def detect_and_log_market_regime() -> str: from pykrx import stock as pykrx_stock from app.services.mean_reversion_strategy import detect_market_regime - today = datetime.now().strftime("%Y%m%d") - start = (datetime.now() - timedelta(days=400)).strftime("%Y%m%d") + today = datetime.now(KST).strftime("%Y%m%d") + start = (datetime.now(KST) - timedelta(days=400)).strftime("%Y%m%d") # KOSPI 지수 코드 1001 df = pykrx_stock.get_index_ohlcv_by_date(start, today, "1001") @@ -119,7 +122,7 @@ async def get_all_tradable_stocks(kis_api: KoreaInvestmentAPI, limit: int = 500) try: from pykrx import stock as pykrx_stock - today = datetime.now().strftime("%Y%m%d") + today = datetime.now(KST).strftime("%Y%m%d") SKIP_KEYWORDS = [ "ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG", @@ -281,6 +284,7 @@ async def run_db_scanner(analysis_date: date) -> set[str]: - 종가 ≤ BB 하단 × 1.015 (BB 하단 근처) - 시세 ≥ 5,000원 - ADX ≤ 30 (지나치게 강한 추세는 평균회귀 부적합) + - MA5 ≥ MA20 × 0.95 (ADX 방향 proxy — 강한 하락 추세 종목 제외) Returns: 신호 종목 코드 set """ @@ -293,6 +297,8 @@ async def run_db_scanner(analysis_date: date) -> set[str]: StockAnalysis.rsi, StockAnalysis.bollinger_lower, StockAnalysis.adx_14, + StockAnalysis.ma5, + StockAnalysis.ma20, ).where( StockAnalysis.analysis_date == analysis_date, StockAnalysis.current_price >= 5000, @@ -319,9 +325,16 @@ async def run_db_scanner(analysis_date: date) -> set[str]: if adx is not None and adx > 30: continue + # ADX 방향 proxy: MA5 < MA20 × 0.95 → 강한 하락 추세 → 평균회귀 부적합 + # DI+/DI- 컬럼이 없으므로 MA 방향으로 추세 하락 여부 판단 + ma5, ma20 = row.ma5, row.ma20 + if ma5 is not None and ma20 is not None and ma20 > 0: + if float(ma5) < float(ma20) * 0.95: + continue + signal_tickers.add(row.stock_code) - logger.info("🔍 DB 스캐너: %d개 신호 종목 (RSI≤30 + BB하단 + ADX≤30)", len(signal_tickers)) + logger.info("🔍 DB 스캐너: %d개 신호 종목 (RSI≤30 + BB하단 + ADX≤30 + MA방향)", len(signal_tickers)) return signal_tickers except Exception as exc: @@ -348,8 +361,8 @@ async def batch_analyze_all_stocks(limit: int = None): - DB 트랜잭션: 1,000회 → 50회 (20× 감소) - 동시 처리: 5 → 10 종목 """ - start_time = datetime.now() - analysis_date = date.today() + 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", @@ -409,7 +422,7 @@ async def batch_analyze_all_stocks(limit: int = None): logger.warning("⚠️ 워치리스트 초기화 실패 (non-critical): %s", exc) # 결과 요약 - duration = (datetime.now() - start_time).total_seconds() + duration = (datetime.now(KST) - start_time).total_seconds() stats = korea_investment_api.get_monitor_stats() logger.info("=" * 60) diff --git a/backend/batch_intraday_analysis.py b/backend/batch_intraday_analysis.py index 1747bd4..4ff1fc7 100644 --- a/backend/batch_intraday_analysis.py +++ b/backend/batch_intraday_analysis.py @@ -16,6 +16,9 @@ import sys from datetime import datetime, date from pathlib import Path +import pytz +KST = pytz.timezone('Asia/Seoul') + sys.path.insert(0, str(Path(__file__).parent)) from sqlalchemy import select, delete, update, func @@ -32,6 +35,7 @@ from app.services.watchlist import ( WATCHLIST_ENTRY_SCORE, WATCHLIST_EXIT_SCORE, ) +from batch_daily_analysis import run_db_scanner logging.basicConfig( level=logging.INFO, @@ -111,14 +115,14 @@ async def analyze_update_and_manage( ) .values( is_active=False, - removed_at=datetime.now(), + removed_at=datetime.now(KST), removed_reason="price_filter", ) ) await session.commit() return False, None - now = datetime.now() + now = datetime.now(KST) score = result.score async with AsyncSessionLocal() as session: @@ -336,7 +340,7 @@ async def refresh_watchlist(analysis_date: date, current_codes: set[str]) -> int if not candidates: return 0 - now = datetime.now() + now = datetime.now(KST) for row in candidates: session.add(WatchlistStock( stock_code=row.stock_code, @@ -362,8 +366,8 @@ async def main(): logger.info("📴 장중이 아닙니다 (09:00~15:30 평일). 종료합니다.") return - start_time = datetime.now() - analysis_date = date.today() + start_time = datetime.now(KST) + analysis_date = datetime.now(KST).date() logger.info( f"⚡ [Intraday] {start_time.strftime('%H:%M:%S')} 시작 — " f"분석종목 재분석 (진입≥{WATCHLIST_ENTRY_SCORE}, 제거<{WATCHLIST_EXIT_SCORE})" @@ -392,28 +396,77 @@ async def main(): ] results = await asyncio.gather(*tasks, return_exceptions=True) - success = sum(1 for r in results if isinstance(r, tuple) and r[0] is True) + # removed 카운터 수정: analyze_update_and_manage는 제거 시에도 True 반환하므로 + # success(=분석 성공)에 removed가 포함됨 → retained = success - removed 로 표시 + 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 r[0] is True and r[1] is not None and r[1] < WATCHLIST_EXIT_SCORE ) - fail = sum(1 for r in results if isinstance(r, tuple) and r[0] is False) + retained = analyzed_ok - removed # 워치리스트에 실제 유지된 종목 수 + 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) - # 고점수 신규 종목 추가 + # 장중 DB 스캐너 실행 — 장중 과매도 신규 종목 포착 (RSI≤30 + BB하단 + ADX≤30 + MA방향) + scanner_tickers = await run_db_scanner(analysis_date) + scanner_new = scanner_tickers - current_codes # 워치리스트 미포함 신규 신호 종목 + added_scanner = 0 + if scanner_new: + now_kst = datetime.now(KST) + async with AsyncSessionLocal() as session: + # 현재 활성 종목 수 확인 + from sqlalchemy import func as sa_func + count_result = await session.execute( + select(sa_func.count()) + .select_from(WatchlistStock) + .where(WatchlistStock.is_active == True) + ) + active_count = count_result.scalar() or 0 + slots = WATCHLIST_MAX_SIZE - active_count + + for code in list(scanner_new)[:slots]: + # stock_analysis에서 종목명 조회 + name_result = await session.execute( + select(StockAnalysis.stock_name) + .where( + StockAnalysis.stock_code == code, + StockAnalysis.analysis_date == analysis_date, + ) + .limit(1) + ) + name_row = name_result.first() + if not name_row: + continue + session.add(WatchlistStock( + stock_code=code, + stock_name=name_row.stock_name, + added_at=now_kst, + added_reason="intraday_scanner", + score_at_add=None, + is_active=True, + )) + added_scanner += 1 + await session.commit() + + if added_scanner: + logger.info(f"📡 장중 스캐너 신규 추가: {added_scanner}개 (RSI≤30+BB하단)") + current_codes |= scanner_new # refresh_watchlist 중복 방지용 갱신 + + # 고점수 신규 종목 추가 (스캐너 추가분 제외) added = await refresh_watchlist(analysis_date, current_codes) - duration = (datetime.now() - start_time).total_seconds() + duration = (datetime.now(KST) - start_time).total_seconds() logger.info( - f"✅ [Intraday] 완료: {success}/{total} 성공, " - f"{removed} 제거, +{added} 신규, " + f"✅ [Intraday] 완료: {retained}/{total} 유지, " + f"{removed} 제거, +{added} 신규(score), +{added_scanner} 신규(스캐너), " f"{fail} 실패, {error} 오류 — {duration:.1f}초" ) + if __name__ == "__main__": asyncio.run(main()) diff --git a/backend/requirements.txt b/backend/requirements.txt index da5cfcf..0b1ec2a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -20,6 +20,7 @@ numpy>=1.26.0 ta==0.11.0 python-dotenv==1.0.0 pytz>=2024.1 +holidays>=0.45 celery==5.3.4 pytest==7.4.3 pytest-asyncio==0.21.1