Files
auto-stock/backend/app/services/ml_retrain_pipeline.py
shuim 9fab4018d4 fix: 프로그램 검증 루프 7회차 — 리스크·주문·청산·인프라 결함 일괄 수정 + 거래 확대 결정 반영
하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소.

주문·리스크
- MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문)
- KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류)
- 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백
- 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락
- 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드)
- 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단
데이트레이딩
- 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한
- 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정
스윙
- 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT)
- 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed
- 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일
- 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON
인프라·보안
- 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹
- KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환
- db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시
- KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패
하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준
CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신

backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:01:31 +09:00

688 lines
24 KiB
Python

"""
ML 자가 재학습 파이프라인
주기적(매주 일요일 02:00 KST) 또는 성능 저하 감지 시 LightGBM 모델을 자동 재학습합니다.
기능:
1. 재학습 트리거: 주기적(매주 일요일 02:00) + 성능 저하 감지(최근 20건 승률 vs 전체 평균)
2. 학습 데이터: stock_daily_prices + trade_history 기반 피처 엔지니어링
3. 모델: LightGBM (rolling 6개월 윈도우, Train/Val 80/20 split)
4. 버전 관리: ~/.cache/auto-stock/models/ 에 model_YYYYMMDD.pkl 저장, 최근 5개 유지
5. 성능 모니터링: validation accuracy, precision, recall 로깅
6. 스케줄러 통합: main.py 에서 ml_retrain_scheduler() 호출
ARCHITECTURE:
- DB 읽기는 AsyncSessionLocal 직접 사용 (SELECT only)
- DB 쓰기가 필요하면 db_task_queue 경유 (현재는 쓰기 없음)
- 무거운 학습은 asyncio.to_thread 로 워커 스레드 실행
- from app.core.config import settings 싱글톤 사용
"""
import asyncio
import logging
import os
import shutil
from datetime import datetime, date, timedelta
from pathlib import Path
from typing import Dict, Any, Optional, Tuple, List
import numpy as np
import pandas as pd
import joblib
import pytz
from app.core.config import settings
from app.core.database import AsyncSessionLocal
from app.core.timeutil import now_kst
from app.services.feature_engineering import (
extract_features,
db_data_to_dataframe,
FEATURE_COLUMNS,
)
from app.core.log_throttle import warn_throttled # noqa: E402
logger = logging.getLogger(__name__)
# ─── 상수 ─────────────────────────────────────────────────────────────────
KST = pytz.timezone("Asia/Seoul")
# 모델 저장 디렉토리
MODEL_BASE_DIR = Path(os.path.expanduser("~")) / ".cache" / "auto-stock" / "models"
# 재학습 파이프라인 피처 (요구사항 기반 7개 — 기존 feature_engineering 20개와 호환)
RETRAIN_FEATURE_COLUMNS = [
"rsi_14", # RSI(14)
"bb_position", # BB %B(20)
"atr_ratio", # ATR / close
"volume_ratio", # vol / avg20
"ma5_ma20_gap", # MA5/MA20 이격
"trend_strength", # ADX 근사치 (feature_engineering 의 trend_strength)
"macd", # MACD
]
# 라벨링 기준
LABEL_FORECAST_DAYS = 5
LABEL_BUY_THRESHOLD = 0.01 # +1%
LABEL_SELL_THRESHOLD = -0.01 # -1%
# 성능 저하 감지 기준
PERF_DEGRADE_LOOKBACK = 20 # 최근 N건
PERF_DEGRADE_THRESHOLD = 0.15 # 15%p 하락
# Rolling window
ROLLING_WINDOW_MONTHS = 6
# 모델 버전 유지 개수
MAX_MODEL_VERSIONS = 5
# ─── 피처 엔지니어링 (재학습 전용) ─────────────────────────────────────────
def _compute_retrain_features(df: pd.DataFrame) -> pd.DataFrame:
"""stock_daily_prices DataFrame 에서 재학습용 피처를 계산합니다.
기존 feature_engineering.extract_features() 를 기반으로 하되,
추가 피처(atr_ratio, ma5_ma20_gap)를 포함합니다.
Args:
df: columns=[date, open, high, low, close, volume], 시간순 정렬
Returns:
동일 인덱스의 피처 DataFrame (RETRAIN_FEATURE_COLUMNS 컬럼)
"""
# 기존 20개 피처 계산
base_features = extract_features(df)
close = df["close"].values.astype(float)
high = df["high"].values.astype(float)
low = df["low"].values.astype(float)
n = len(close)
# ATR(14) / close 비율
atr_ratio = np.full(n, np.nan, dtype=float)
tr = np.zeros(n, dtype=float)
for i in range(1, n):
tr[i] = max(
high[i] - low[i],
abs(high[i] - close[i - 1]),
abs(low[i] - close[i - 1]),
)
for i in range(14, n):
atr_14 = np.mean(tr[i - 13 : i + 1])
if close[i] > 0:
atr_ratio[i] = atr_14 / close[i]
# MA5 / MA20 이격
ma5_ma20_gap = np.full(n, np.nan, dtype=float)
for i in range(19, n):
ma5 = np.mean(close[i - 4 : i + 1])
ma20 = np.mean(close[i - 19 : i + 1])
if ma20 > 0:
ma5_ma20_gap[i] = (ma5 - ma20) / ma20
result = pd.DataFrame(index=df.index)
result["rsi_14"] = base_features["rsi_14"]
result["bb_position"] = base_features["bb_position"]
result["atr_ratio"] = atr_ratio
result["volume_ratio"] = base_features["volume_ratio"]
result["ma5_ma20_gap"] = ma5_ma20_gap
result["trend_strength"] = base_features["trend_strength"]
result["macd"] = base_features["macd"]
return result
def _prepare_retrain_labels(close: np.ndarray) -> np.ndarray:
"""5일 후 수익률 기반 라벨 생성.
Returns:
labels: (N,) array, 0=SELL, 1=HOLD, 2=BUY, NaN=미확정
"""
n = len(close)
labels = np.full(n, np.nan, dtype=float)
for i in range(n - LABEL_FORECAST_DAYS):
if close[i] > 0:
ret = (close[i + LABEL_FORECAST_DAYS] - close[i]) / close[i]
if ret > LABEL_BUY_THRESHOLD:
labels[i] = 2 # BUY
elif ret < LABEL_SELL_THRESHOLD:
labels[i] = 0 # SELL
else:
labels[i] = 1 # HOLD
return labels
def _build_training_dataset(
all_features: pd.DataFrame,
labels: np.ndarray,
) -> Tuple[np.ndarray, np.ndarray]:
"""NaN 이 아닌 유효 행만 추출하여 (X, y) 반환.
Returns:
X: (N_valid, len(RETRAIN_FEATURE_COLUMNS))
y: (N_valid,)
"""
feat_arr = all_features[RETRAIN_FEATURE_COLUMNS].values
n = len(feat_arr)
valid_mask = np.ones(n, dtype=bool)
valid_mask[np.isnan(labels)] = False
for i in range(n):
if valid_mask[i] and np.any(np.isnan(feat_arr[i])):
valid_mask[i] = False
valid_idx = np.where(valid_mask)[0]
if len(valid_idx) == 0:
return np.array([]), np.array([])
X = feat_arr[valid_idx]
y = labels[valid_idx].astype(int)
return X, y
# ─── 모델 학습 ─────────────────────────────────────────────────────────────
def _train_lightgbm(
X_train: np.ndarray,
y_train: np.ndarray,
X_val: np.ndarray,
y_val: np.ndarray,
) -> Tuple[Any, Dict[str, Any]]:
"""LightGBM 모델 학습 및 검증 메트릭 반환.
Returns:
(model, metrics_dict)
"""
import lightgbm as lgb
from sklearn.metrics import accuracy_score, precision_score, recall_score
model = lgb.LGBMClassifier(
n_estimators=200,
max_depth=6,
learning_rate=0.05,
num_leaves=31,
min_child_samples=10,
subsample=0.8,
colsample_bytree=0.8,
reg_alpha=0.1,
reg_lambda=0.1,
random_state=42,
verbose=-1,
num_class=3,
objective="multiclass",
)
model.fit(
X_train, y_train,
eval_set=[(X_val, y_val)],
)
y_pred = model.predict(X_val)
accuracy = float(accuracy_score(y_val, y_pred))
precision = float(precision_score(y_val, y_pred, average="macro", zero_division=0))
recall = float(recall_score(y_val, y_pred, average="macro", zero_division=0))
# 피처 중요도
importance = model.feature_importances_
top_features = sorted(
zip(RETRAIN_FEATURE_COLUMNS, importance),
key=lambda x: x[1],
reverse=True,
)
class_distribution = {
"train": {int(k): int(v) for k, v in zip(*np.unique(y_train, return_counts=True))},
"val": {int(k): int(v) for k, v in zip(*np.unique(y_val, return_counts=True))},
}
metrics = {
"accuracy": round(accuracy, 4),
"precision": round(precision, 4),
"recall": round(recall, 4),
"val_samples": len(y_val),
"train_samples": len(y_train),
"class_distribution": class_distribution,
"feature_importance": [
{"name": name, "importance": int(imp)} for name, imp in top_features
],
}
return model, metrics
# ─── 모델 버전 관리 ────────────────────────────────────────────────────────
def _save_model_version(
model: Any,
scaler: Any,
metrics: Dict[str, Any],
model_dir: Path,
) -> Path:
"""모델을 날짜 기반 버전으로 저장하고 최근 MAX_MODEL_VERSIONS 개만 유지.
Returns:
저장된 모델 파일 경로
"""
model_dir.mkdir(parents=True, exist_ok=True)
today_str = datetime.now(KST).strftime("%Y%m%d")
version_dir = model_dir / f"v_{today_str}"
version_dir.mkdir(parents=True, exist_ok=True)
model_path = version_dir / f"model_{today_str}.pkl"
scaler_path = version_dir / "scaler.pkl"
metrics_path = version_dir / "metrics.json"
joblib.dump(model, model_path)
if scaler is not None:
joblib.dump(scaler, scaler_path)
from app.core.json_helpers import _dumps
with open(metrics_path, "w") as f:
f.write(_dumps(metrics))
# 최신 심볼릭 (복사) — latest 디렉토리
latest_dir = model_dir / "latest"
if latest_dir.exists():
shutil.rmtree(latest_dir)
shutil.copytree(version_dir, latest_dir)
# 오래된 버전 정리
_cleanup_old_versions(model_dir)
logger.info(f"모델 저장 완료: {version_dir}")
return model_path
def _cleanup_old_versions(model_dir: Path):
"""MAX_MODEL_VERSIONS 개를 초과하는 오래된 버전 디렉토리를 삭제합니다."""
version_dirs = sorted(
[d for d in model_dir.iterdir() if d.is_dir() and d.name.startswith("v_")],
key=lambda d: d.name,
reverse=True,
)
for old_dir in version_dirs[MAX_MODEL_VERSIONS:]:
shutil.rmtree(old_dir, ignore_errors=True)
logger.info(f"오래된 모델 버전 삭제: {old_dir.name}")
def load_latest_model(
model_dir: Optional[Path] = None,
) -> Tuple[Optional[Any], Optional[Any], Optional[Dict[str, Any]]]:
"""최신 모델, scaler, metrics 를 로드합니다.
Returns:
(model, scaler, metrics) 또는 (None, None, None)
"""
if model_dir is None:
model_dir = MODEL_BASE_DIR
latest_dir = model_dir / "latest"
if not latest_dir.exists():
return None, None, None
try:
# 모델 파일 찾기
model_files = list(latest_dir.glob("model_*.pkl"))
if not model_files:
return None, None, None
model = joblib.load(model_files[0])
scaler = None
scaler_path = latest_dir / "scaler.pkl"
if scaler_path.exists():
scaler = joblib.load(scaler_path)
metrics = None
metrics_path = latest_dir / "metrics.json"
if metrics_path.exists():
import json
with open(metrics_path) as f:
metrics = json.load(f)
return model, scaler, metrics
except Exception as e:
logger.error(f"모델 로드 실패: {e}")
return None, None, None
# ─── DB 데이터 로드 (읽기 전용) ────────────────────────────────────────────
async def _load_daily_prices(
months: int = ROLLING_WINDOW_MONTHS,
) -> pd.DataFrame:
"""stock_daily_prices 에서 최근 N개월 데이터를 로드합니다.
Returns:
DataFrame[stock_code, date, open, high, low, close, volume]
"""
from sqlalchemy import select, and_
from app.models.models import StockDailyPrice
cutoff = date.today() - timedelta(days=months * 30)
async with AsyncSessionLocal() as session:
stmt = (
select(StockDailyPrice)
.where(StockDailyPrice.trade_date >= cutoff)
.order_by(StockDailyPrice.stock_code, StockDailyPrice.trade_date)
)
result = await session.execute(stmt)
rows = result.scalars().all()
if not rows:
return pd.DataFrame()
records = []
for row in rows:
records.append({
"stock_code": row.stock_code,
"date": str(row.trade_date),
"open": float(row.open_price) if row.open_price else 0,
"high": float(row.high_price) if row.high_price else 0,
"low": float(row.low_price) if row.low_price else 0,
"close": float(row.close_price) if row.close_price else 0,
"volume": float(row.volume) if row.volume else 0,
})
return pd.DataFrame(records)
async def _load_trade_history(
months: int = ROLLING_WINDOW_MONTHS,
) -> pd.DataFrame:
"""trade_history 에서 최근 N개월 체결 매매 이력을 로드합니다.
Returns:
DataFrame[stock_code, order_type, executed_price, profit_loss_rate, created_at]
"""
from sqlalchemy import select, and_
from app.models.models import TradeHistory
# [2026-09-23] TradeHistory.created_at 은 KST naive (app.core.timeutil.now_kst)
# 저장)이므로, tz-aware인 datetime.now(KST)로 비교하면 asyncpg가
# "can't subtract offset-naive and offset-aware datetimes" DataError를 낸다.
# 컬럼과 동일하게 naive UTC로 맞춘다.
cutoff = now_kst() - timedelta(days=months * 30)
async with AsyncSessionLocal() as session:
stmt = (
select(TradeHistory)
.where(
and_(
TradeHistory.status == "EXECUTED",
TradeHistory.created_at >= cutoff,
)
)
.order_by(TradeHistory.created_at)
)
result = await session.execute(stmt)
rows = result.scalars().all()
if not rows:
return pd.DataFrame()
records = []
for row in rows:
records.append({
"stock_code": row.stock_code,
"order_type": row.order_type,
"executed_price": float(row.executed_price) if row.executed_price else 0,
"profit_loss_rate": float(row.profit_loss_rate) if row.profit_loss_rate else 0,
"created_at": row.created_at,
})
return pd.DataFrame(records)
# ─── 성능 저하 감지 ────────────────────────────────────────────────────────
async def check_performance_degradation() -> Tuple[bool, Dict[str, Any]]:
"""최근 20건 매매의 승률이 전체 평균보다 15%p 이상 하락했는지 확인합니다.
Returns:
(degraded: bool, details: dict)
"""
trade_df = await _load_trade_history(months=12)
if trade_df.empty or len(trade_df) < PERF_DEGRADE_LOOKBACK:
return False, {"reason": "데이터 부족", "total_trades": len(trade_df) if not trade_df.empty else 0}
# 매도 건만 (수익률이 있는 건)
sell_df = trade_df[
(trade_df["order_type"].str.upper() == "SELL")
& (trade_df["profit_loss_rate"] != 0)
].copy()
if len(sell_df) < PERF_DEGRADE_LOOKBACK:
return False, {"reason": f"매도 건수 부족: {len(sell_df)}", "total_sells": len(sell_df)}
# 전체 승률
total_wins = (sell_df["profit_loss_rate"] > 0).sum()
total_win_rate = total_wins / len(sell_df)
# 최근 N건 승률
recent_sells = sell_df.tail(PERF_DEGRADE_LOOKBACK)
recent_wins = (recent_sells["profit_loss_rate"] > 0).sum()
recent_win_rate = recent_wins / len(recent_sells)
degraded = (total_win_rate - recent_win_rate) >= PERF_DEGRADE_THRESHOLD
details = {
"total_win_rate": round(float(total_win_rate), 4),
"recent_win_rate": round(float(recent_win_rate), 4),
"gap": round(float(total_win_rate - recent_win_rate), 4),
"threshold": PERF_DEGRADE_THRESHOLD,
"degraded": degraded,
"total_sells": len(sell_df),
"recent_lookback": PERF_DEGRADE_LOOKBACK,
}
if degraded:
logger.warning(
f"ML 성능 저하 감지: 전체 승률 {total_win_rate:.1%} vs "
f"최근 {PERF_DEGRADE_LOOKBACK}건 승률 {recent_win_rate:.1%} "
f"(차이 {total_win_rate - recent_win_rate:.1%}p >= {PERF_DEGRADE_THRESHOLD:.0%}p)"
)
return degraded, details
# ─── 메인 재학습 실행 ──────────────────────────────────────────────────────
async def run_retrain(
model_dir: Optional[Path] = None,
force: bool = False,
) -> Dict[str, Any]:
"""ML 재학습 파이프라인을 실행합니다.
Args:
model_dir: 모델 저장 디렉토리 (기본: ~/.cache/auto-stock/models/)
force: True 시 성능 저하 여부와 무관하게 강제 재학습
Returns:
재학습 결과 메트릭
"""
if model_dir is None:
model_dir = MODEL_BASE_DIR
start_time = datetime.now(KST)
logger.info("ML 재학습 파이프라인 시작...")
# 1. 일봉 데이터 로드
logger.info(f"최근 {ROLLING_WINDOW_MONTHS}개월 일봉 데이터 로딩...")
price_df = await _load_daily_prices(months=ROLLING_WINDOW_MONTHS)
if price_df.empty:
msg = "재학습 중단: stock_daily_prices 데이터 없음"
logger.warning(msg)
return {"status": "skipped", "reason": msg}
stock_codes = price_df["stock_code"].unique()
logger.info(f"로드된 종목 수: {len(stock_codes)}, 총 행: {len(price_df)}")
# 2. 종목별 피처 + 라벨 생성
all_X_list: List[np.ndarray] = []
all_y_list: List[np.ndarray] = []
for code in stock_codes:
code_df = price_df[price_df["stock_code"] == code].copy()
if len(code_df) < 80: # 최소 80일 필요 (60일 피처 + 20일 여유)
continue
code_df = code_df.reset_index(drop=True)
try:
features = _compute_retrain_features(code_df)
labels = _prepare_retrain_labels(code_df["close"].values.astype(float))
X, y = _build_training_dataset(features, labels)
if len(X) > 0:
all_X_list.append(X)
all_y_list.append(y)
except Exception as e:
warn_throttled(logger, "ml_retrain_pipeline:540", f"종목 {code} 피처 추출 실패: {e}")
continue
if not all_X_list:
msg = "재학습 중단: 유효 학습 데이터 없음"
logger.warning(msg)
return {"status": "skipped", "reason": msg}
X_all = np.vstack(all_X_list)
y_all = np.concatenate(all_y_list)
logger.info(
f"학습 데이터 준비 완료: {len(X_all)} 샘플, "
f"클래스 분포: {dict(zip(*np.unique(y_all, return_counts=True)))}"
)
if len(X_all) < 100:
msg = f"재학습 중단: 학습 데이터 부족 ({len(X_all)}개 < 100개)"
logger.warning(msg)
return {"status": "skipped", "reason": msg}
# 3. Train/Val split (80/20, 시계열이므로 뒤쪽 20%를 검증용)
split_idx = int(len(X_all) * 0.8)
X_train, X_val = X_all[:split_idx], X_all[split_idx:]
y_train, y_val = y_all[:split_idx], y_all[split_idx:]
# 4. 스케일링
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_val_scaled = scaler.transform(X_val)
# 5. 학습 (CPU 바운드이므로 워커 스레드에서 실행)
logger.info("LightGBM 학습 시작 (워커 스레드)...")
model, metrics = await asyncio.to_thread(
_train_lightgbm, X_train_scaled, y_train, X_val_scaled, y_val
)
logger.info(
f"학습 완료 - Accuracy: {metrics['accuracy']:.4f}, "
f"Precision: {metrics['precision']:.4f}, "
f"Recall: {metrics['recall']:.4f}"
)
# 6. 모델 저장 (버전 관리)
saved_path = await asyncio.to_thread(
_save_model_version, model, scaler, metrics, model_dir
)
elapsed = (datetime.now(KST) - start_time).total_seconds()
result = {
"status": "completed",
"model_path": str(saved_path),
"elapsed_seconds": round(elapsed, 1),
"total_samples": len(X_all),
"stocks_used": len(all_X_list),
"metrics": metrics,
"retrained_at": start_time.isoformat(),
}
logger.info(f"ML 재학습 파이프라인 완료 ({elapsed:.1f}초)")
return result
# ─── 스케줄러 ──────────────────────────────────────────────────────────────
async def ml_retrain_scheduler():
"""ML 재학습 스케줄러.
- 매주 일요일 02:00 KST 주기적 재학습
- 매일 09:30 KST 성능 저하 감지 -> 자동 재학습 트리거
- 1시간 주기로 루프 (sleep 3600초)
"""
logger.info("ML 재학습 스케줄러 시작")
await asyncio.sleep(30) # 앱 초기화 대기
last_scheduled_retrain: Optional[date] = None
last_degradation_check: Optional[date] = None
while True:
try:
now = datetime.now(KST)
today = now.date()
# ── 주기적 재학습: 매주 일요일 02:00 KST ──
if (
now.weekday() == 6 # Sunday
and now.hour >= 2
and last_scheduled_retrain != today
):
logger.info("주기적 ML 재학습 트리거 (매주 일요일 02:00)")
last_scheduled_retrain = today
try:
result = await run_retrain(force=True)
logger.info(f"주기적 재학습 결과: {result.get('status')}")
except Exception as e:
logger.error(f"주기적 재학습 실패: {e}", exc_info=True)
# ── 성능 저하 감지: 매일 09:30 KST (장 시작 전) ──
if (
now.hour >= 9
and now.minute >= 30
and last_degradation_check != today
):
last_degradation_check = today
try:
degraded, details = await check_performance_degradation()
if degraded:
logger.warning(f"성능 저하 감지 -> 자동 재학습 트리거: {details}")
result = await run_retrain(force=True)
logger.info(f"성능 저하 재학습 결과: {result.get('status')}")
else:
logger.debug(f"성능 저하 미감지: {details}")
except Exception as e:
logger.error(f"성능 저하 감지/재학습 실패: {e}", exc_info=True)
except asyncio.CancelledError:
logger.info("ML 재학습 스케줄러 종료")
return
except Exception as e:
logger.error(f"ML 재학습 스케줄러 오류: {e}", exc_info=True)
await asyncio.sleep(3600) # 1시간 주기
# ─── 외부 호출용 함수 ──────────────────────────────────────────────────────
async def get_retrain_status() -> Dict[str, Any]:
"""현재 모델 상태와 최근 재학습 정보를 반환합니다."""
model, scaler, metrics = await asyncio.to_thread(load_latest_model)
model_dir = MODEL_BASE_DIR
version_dirs = []
if model_dir.exists():
version_dirs = sorted(
[d.name for d in model_dir.iterdir() if d.is_dir() and d.name.startswith("v_")],
reverse=True,
)
return {
"model_loaded": model is not None,
"scaler_loaded": scaler is not None,
"latest_metrics": metrics,
"model_dir": str(model_dir),
"available_versions": version_dirs[:MAX_MODEL_VERSIONS],
}