버그 및 크리티컬 에러 수정
This commit is contained in:
@@ -4,19 +4,38 @@
|
|||||||
- KST 기준 장 시작/종료 시간 판별
|
- KST 기준 장 시작/종료 시간 판별
|
||||||
- 공휴일, 주말 휴장 체크
|
- 공휴일, 주말 휴장 체크
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, time
|
from datetime import datetime, time, date as date_type
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import pytz
|
import pytz
|
||||||
KST = pytz.timezone('Asia/Seoul')
|
KST = pytz.timezone('Asia/Seoul')
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# pytz가 없으면 기본 timezone 사용 (환경변수에 의존)
|
|
||||||
from datetime import timezone, timedelta
|
from datetime import timezone, timedelta
|
||||||
KST = timezone(timedelta(hours=9))
|
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:
|
def is_market_open() -> bool:
|
||||||
"""
|
"""
|
||||||
현재 한국 증시 정규장 운영 시간인지 확인
|
현재 한국 증시 정규장 운영 시간인지 확인
|
||||||
(평일 09:00 ~ 15:30, 공휴일 제외 로직은 아직 미포함)
|
(평일 09:00 ~ 15:30 KST, 공휴일 제외)
|
||||||
|
|
||||||
|
수정:
|
||||||
|
- market_end 경계: <= → < (15:30 정각은 폐장)
|
||||||
|
- 공휴일 체크 추가 (holidays 패키지)
|
||||||
"""
|
"""
|
||||||
now = datetime.now(KST)
|
now = datetime.now(KST)
|
||||||
|
|
||||||
@@ -24,12 +43,16 @@ def is_market_open() -> bool:
|
|||||||
if now.weekday() >= 5:
|
if now.weekday() >= 5:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 2. 시간 체크 (09:00 ~ 15:30)
|
# 2. 공휴일 체크
|
||||||
|
if _is_holiday(now.date()):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 3. 시간 체크 (09:00 이상 ~ 15:30 미만)
|
||||||
current_time = now.time()
|
current_time = now.time()
|
||||||
market_start = time(9, 0)
|
market_start = time(9, 0)
|
||||||
market_end = time(15, 30)
|
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:
|
def is_opening_range_period() -> bool:
|
||||||
@@ -37,6 +60,8 @@ def is_opening_range_period() -> bool:
|
|||||||
now = datetime.now(KST)
|
now = datetime.now(KST)
|
||||||
if now.weekday() >= 5:
|
if now.weekday() >= 5:
|
||||||
return False
|
return False
|
||||||
|
if _is_holiday(now.date()):
|
||||||
|
return False
|
||||||
t = now.time()
|
t = now.time()
|
||||||
return time(9, 0) <= t < time(9, 30)
|
return time(9, 0) <= t < time(9, 30)
|
||||||
|
|
||||||
@@ -46,14 +71,21 @@ def is_day_trade_entry_window() -> bool:
|
|||||||
now = datetime.now(KST)
|
now = datetime.now(KST)
|
||||||
if now.weekday() >= 5:
|
if now.weekday() >= 5:
|
||||||
return False
|
return False
|
||||||
|
if _is_holiday(now.date()):
|
||||||
|
return False
|
||||||
t = now.time()
|
t = now.time()
|
||||||
return time(9, 30) <= t < time(15, 0)
|
return time(9, 30) <= t < time(15, 0)
|
||||||
|
|
||||||
|
|
||||||
def is_eod_flat_time() -> bool:
|
def is_eod_flat_time() -> bool:
|
||||||
"""EOD 강제 청산 구간 (15:20~15:30)"""
|
"""EOD 강제 청산 구간 (15:20~15:29)
|
||||||
|
|
||||||
|
수정: 상한을 15:30 미만(<)으로 변경 — 15:30은 폐장 후.
|
||||||
|
"""
|
||||||
now = datetime.now(KST)
|
now = datetime.now(KST)
|
||||||
if now.weekday() >= 5:
|
if now.weekday() >= 5:
|
||||||
return False
|
return False
|
||||||
|
if _is_holiday(now.date()):
|
||||||
|
return False
|
||||||
t = now.time()
|
t = now.time()
|
||||||
return time(15, 20) <= t <= time(15, 30)
|
return time(15, 20) <= t < time(15, 30)
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ from datetime import datetime, date, timedelta
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
KST = pytz.timezone('Asia/Seoul')
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
from sqlalchemy import delete, select
|
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 pykrx import stock as pykrx_stock
|
||||||
from app.services.mean_reversion_strategy import detect_market_regime
|
from app.services.mean_reversion_strategy import detect_market_regime
|
||||||
|
|
||||||
today = datetime.now().strftime("%Y%m%d")
|
today = datetime.now(KST).strftime("%Y%m%d")
|
||||||
start = (datetime.now() - timedelta(days=400)).strftime("%Y%m%d")
|
start = (datetime.now(KST) - timedelta(days=400)).strftime("%Y%m%d")
|
||||||
|
|
||||||
# KOSPI 지수 코드 1001
|
# KOSPI 지수 코드 1001
|
||||||
df = pykrx_stock.get_index_ohlcv_by_date(start, today, "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:
|
try:
|
||||||
from pykrx import stock as pykrx_stock
|
from pykrx import stock as pykrx_stock
|
||||||
|
|
||||||
today = datetime.now().strftime("%Y%m%d")
|
today = datetime.now(KST).strftime("%Y%m%d")
|
||||||
|
|
||||||
SKIP_KEYWORDS = [
|
SKIP_KEYWORDS = [
|
||||||
"ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG",
|
"ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG",
|
||||||
@@ -281,6 +284,7 @@ async def run_db_scanner(analysis_date: date) -> set[str]:
|
|||||||
- 종가 ≤ BB 하단 × 1.015 (BB 하단 근처)
|
- 종가 ≤ BB 하단 × 1.015 (BB 하단 근처)
|
||||||
- 시세 ≥ 5,000원
|
- 시세 ≥ 5,000원
|
||||||
- ADX ≤ 30 (지나치게 강한 추세는 평균회귀 부적합)
|
- ADX ≤ 30 (지나치게 강한 추세는 평균회귀 부적합)
|
||||||
|
- MA5 ≥ MA20 × 0.95 (ADX 방향 proxy — 강한 하락 추세 종목 제외)
|
||||||
|
|
||||||
Returns: 신호 종목 코드 set
|
Returns: 신호 종목 코드 set
|
||||||
"""
|
"""
|
||||||
@@ -293,6 +297,8 @@ async def run_db_scanner(analysis_date: date) -> set[str]:
|
|||||||
StockAnalysis.rsi,
|
StockAnalysis.rsi,
|
||||||
StockAnalysis.bollinger_lower,
|
StockAnalysis.bollinger_lower,
|
||||||
StockAnalysis.adx_14,
|
StockAnalysis.adx_14,
|
||||||
|
StockAnalysis.ma5,
|
||||||
|
StockAnalysis.ma20,
|
||||||
).where(
|
).where(
|
||||||
StockAnalysis.analysis_date == analysis_date,
|
StockAnalysis.analysis_date == analysis_date,
|
||||||
StockAnalysis.current_price >= 5000,
|
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:
|
if adx is not None and adx > 30:
|
||||||
continue
|
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)
|
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
|
return signal_tickers
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -348,8 +361,8 @@ async def batch_analyze_all_stocks(limit: int = None):
|
|||||||
- DB 트랜잭션: 1,000회 → 50회 (20× 감소)
|
- DB 트랜잭션: 1,000회 → 50회 (20× 감소)
|
||||||
- 동시 처리: 5 → 10 종목
|
- 동시 처리: 5 → 10 종목
|
||||||
"""
|
"""
|
||||||
start_time = datetime.now()
|
start_time = datetime.now(KST)
|
||||||
analysis_date = date.today()
|
analysis_date = datetime.now(KST).date()
|
||||||
|
|
||||||
logger.info("🚀 Starting daily batch analysis for %s", analysis_date)
|
logger.info("🚀 Starting daily batch analysis for %s", analysis_date)
|
||||||
logger.info("⚙️ 동시 처리: %d종목 | 벌크저장 단위: %d개 | API 세마포어: %dreq",
|
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)
|
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()
|
stats = korea_investment_api.get_monitor_stats()
|
||||||
|
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import sys
|
|||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
KST = pytz.timezone('Asia/Seoul')
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
from sqlalchemy import select, delete, update, func
|
from sqlalchemy import select, delete, update, func
|
||||||
@@ -32,6 +35,7 @@ from app.services.watchlist import (
|
|||||||
WATCHLIST_ENTRY_SCORE,
|
WATCHLIST_ENTRY_SCORE,
|
||||||
WATCHLIST_EXIT_SCORE,
|
WATCHLIST_EXIT_SCORE,
|
||||||
)
|
)
|
||||||
|
from batch_daily_analysis import run_db_scanner
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -111,14 +115,14 @@ async def analyze_update_and_manage(
|
|||||||
)
|
)
|
||||||
.values(
|
.values(
|
||||||
is_active=False,
|
is_active=False,
|
||||||
removed_at=datetime.now(),
|
removed_at=datetime.now(KST),
|
||||||
removed_reason="price_filter",
|
removed_reason="price_filter",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
now = datetime.now()
|
now = datetime.now(KST)
|
||||||
score = result.score
|
score = result.score
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
@@ -336,7 +340,7 @@ async def refresh_watchlist(analysis_date: date, current_codes: set[str]) -> int
|
|||||||
if not candidates:
|
if not candidates:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
now = datetime.now()
|
now = datetime.now(KST)
|
||||||
for row in candidates:
|
for row in candidates:
|
||||||
session.add(WatchlistStock(
|
session.add(WatchlistStock(
|
||||||
stock_code=row.stock_code,
|
stock_code=row.stock_code,
|
||||||
@@ -362,8 +366,8 @@ async def main():
|
|||||||
logger.info("📴 장중이 아닙니다 (09:00~15:30 평일). 종료합니다.")
|
logger.info("📴 장중이 아닙니다 (09:00~15:30 평일). 종료합니다.")
|
||||||
return
|
return
|
||||||
|
|
||||||
start_time = datetime.now()
|
start_time = datetime.now(KST)
|
||||||
analysis_date = date.today()
|
analysis_date = datetime.now(KST).date()
|
||||||
logger.info(
|
logger.info(
|
||||||
f"⚡ [Intraday] {start_time.strftime('%H:%M:%S')} 시작 — "
|
f"⚡ [Intraday] {start_time.strftime('%H:%M:%S')} 시작 — "
|
||||||
f"분석종목 재분석 (진입≥{WATCHLIST_ENTRY_SCORE}, 제거<{WATCHLIST_EXIT_SCORE})"
|
f"분석종목 재분석 (진입≥{WATCHLIST_ENTRY_SCORE}, 제거<{WATCHLIST_EXIT_SCORE})"
|
||||||
@@ -392,28 +396,77 @@ async def main():
|
|||||||
]
|
]
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
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(
|
removed = sum(
|
||||||
1 for r in results
|
1 for r in results
|
||||||
if isinstance(r, tuple) and r[0] is True
|
if isinstance(r, tuple) and r[0] is True
|
||||||
and r[1] is not None and r[1] < WATCHLIST_EXIT_SCORE
|
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))
|
error = sum(1 for r in results if isinstance(r, Exception))
|
||||||
|
|
||||||
# 워치리스트 외부 후보 재분석 — score DB 최신화 (refresh_watchlist 전에 실행)
|
# 워치리스트 외부 후보 재분석 — score DB 최신화 (refresh_watchlist 전에 실행)
|
||||||
await rescore_outside_candidates(analysis_date, current_codes)
|
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)
|
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(
|
logger.info(
|
||||||
f"✅ [Intraday] 완료: {success}/{total} 성공, "
|
f"✅ [Intraday] 완료: {retained}/{total} 유지, "
|
||||||
f"{removed} 제거, +{added} 신규, "
|
f"{removed} 제거, +{added} 신규(score), +{added_scanner} 신규(스캐너), "
|
||||||
f"{fail} 실패, {error} 오류 — {duration:.1f}초"
|
f"{fail} 실패, {error} 오류 — {duration:.1f}초"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ numpy>=1.26.0
|
|||||||
ta==0.11.0
|
ta==0.11.0
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
pytz>=2024.1
|
pytz>=2024.1
|
||||||
|
holidays>=0.45
|
||||||
celery==5.3.4
|
celery==5.3.4
|
||||||
pytest==7.4.3
|
pytest==7.4.3
|
||||||
pytest-asyncio==0.21.1
|
pytest-asyncio==0.21.1
|
||||||
|
|||||||
Reference in New Issue
Block a user