9/23~9/28 닷새간 stock_analysis.ml_score 가 200/200 NULL 이었고 로그는 0줄이었다.
원인 3건이 겹쳐 있었다.
1. 시계열 역방향 (추론 ≠ 학습)
market_analyzer 는 chart_data 를 oldest-first 로 정규화해 넘기는데
chart_data_to_dataframe() 은 "입력은 최신순" 가정으로 무조건 뒤집는다.
→ ML 피처가 역방향 시계열에서 계산됐다. 학습은 db_data_to_dataframe
(trade_date ASC, 정방향)이므로 추론이 학습과 정반대 방향이었다.
ensemble_score 관측 범위가 18~50 에 갇혀 ML BUY 가 0건이던 것의 직접 원인.
→ chart_data_to_dataframe_v2(): stck_bsop_date 오름차순 정렬로 방향 의존성 제거.
2. LSTM 시퀀스 워밍업 NaN → int(nan) ValueError
탭뷸러는 NaN 검사를 받지만 시퀀스는 검사되지 않았다. 시퀀스 피처
volatility_20d 의 첫 유효 인덱스가 20 이므로 요구 봉수는 60+20=80 인데
게이트가 <61 이어서 61~79 구간이 크래시 밴드였다.
NaN → LSTM 이 [nan,nan,nan] 반환(예외 아님) → 가중평균 전체 NaN
→ ml_ensemble.py int(prob_up*100) 에서 ValueError.
→ MIN_BARS_FOR_PREDICT=80 + 시퀀스 유한성 검사. NaN 을 0 으로 채우지 않고
사유를 담은 error 로 반환한다.
prepare_training_data 도 idx 60~79 샘플 시퀀스에 NaN 을 넣고 있었다
(valid_mask[:60]=False 만 존재) → X_seq.mean 이 NaN → seq_mean/std 오염.
3. except Exception: pass 가 1·2 를 조용히 삼켰다
→ _run_ml_ensemble() 분리 + 사유별 집계(get/reset_ml_skip_stats,
log_ml_skip_summary). 최초 1건 + 이후 50건마다 warning, 사이클 종료 시
사유별 1줄 요약. batch_daily_analysis 에 배선.
부수 수정
- 네이버 ML 차트 80일창(75봉) → 200일창(154봉). 룰 입력은 _trim_to_legacy_window
로 기존 창과 동일 배열로 절단 — adx_wilder 는 Wilder 재귀라 배열 길이에
민감하므로 절단 없이는 SIDEWAYS 히스테리시스 판정이 바뀐다(실측 14.21→14.55).
전 지표 동일성 확인: rsi/ma/bb/atr/adx/stochastic 모두 일치.
- predict() 가 어떤 입력에도 예외를 던지지 않도록 얇은 가드 + _predict_impl 분리.
비유한 확률을 내는 모델은 warning 과 함께 앙상블에서 제외(degraded_models).
- training_metrics 가 항상 {} 였던 원인: self.training_metrics = metrics 가
_save_models() 뒤에 있어 직전 값이 기록됨. 순서 정정 + per-class 지표 추가
("전부 HOLD" 모델을 정확도만으로 판별 못하는 문제 대응).
- endpoints/ml_ensemble.py: get_daily_chart 는 output2 를 채우는데 output 을
읽어 항상 400 이었다. 게이트 61→MIN_BARS_FOR_PREDICT, async 핸들러의
동기 predict() → predict_async().
미변경: 모델 파일, 아키텍처·하이퍼파라미터, 임계값(60/0.3/0.7, 가중치
0.4/0.35/0.25), torch.load(weights_only=True), 지표 계산식, KIS 호출량.
검증: 579 passed / 0 failed (소켓·KIS 차단 하, 기준선 559 + 신규 20).
신규 tests/test_ml_feature_direction.py 는 결함 역주입으로 검출력 증명 —
방향 역전 2건 + 크래시 밴드 61/70/79 3건 + 게이트 1건 = 6건 실패 확인.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
673 lines
27 KiB
Python
673 lines
27 KiB
Python
"""
|
|
매일 아침 장 시작 전(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:
|
|
from app.services.mean_reversion_strategy import detect_market_regime
|
|
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(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())
|