Files
auto-stock/backend/app/services/ml_retrain_pipeline.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
2026-10-10 12:17:49 +09:00

734 lines
26 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)
def _resolve_trade_price_v2(row) -> float:
"""체결가 NULL 규약 대응: executed_price -> total_amount/executed_quantity -> order_price -> 0."""
ep = getattr(row, "executed_price", None)
if ep:
return float(ep)
amt = getattr(row, "total_amount", None)
qty = getattr(row, "executed_quantity", None)
if amt and qty:
return float(amt) / float(qty)
op = getattr(row, "order_price", None)
return float(op) if op else 0.0
# 체결 확정 상태. 운영 trade_history 는 COMPLETED 만 존재(EXECUTED 0건)하므로
# EXECUTED 단독 필터는 성능 저하 감시를 영구히 '데이터 부족'으로 만들었다. EXECUTED 는 레거시 호환.
_FILLED_TRADE_STATUSES = ("COMPLETED", "EXECUTED")
# 성능 저하 재학습을 허용하는 장외 시간대(KST): 주말 전일, 평일 20:00~익일 06:59
_RETRAIN_SAFE_START_HOUR = 20
_RETRAIN_SAFE_END_HOUR = 7
def _is_retrain_safe_window_v2(now: datetime) -> bool:
"""장중이 아닌(주말·야간) 시각이면 True. 인프로세스 재학습 허용 시간대."""
if now.weekday() >= 5:
return True
return now.hour >= _RETRAIN_SAFE_START_HOUR or now.hour < _RETRAIN_SAFE_END_HOUR
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.in_(_FILLED_TRADE_STATUSES),
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": _resolve_trade_price_v2(row),
"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
# ─── 메인 재학습 실행 ──────────────────────────────────────────────────────
def _build_training_lists_impl(
price_df: pd.DataFrame,
) -> Tuple[List[np.ndarray], List[np.ndarray]]:
"""종목별 피처·라벨 구성(동기, CPU 바운드). 반드시 to_thread 로 호출한다."""
all_X_list: List[np.ndarray] = []
all_y_list: List[np.ndarray] = []
for code in price_df["stock_code"].unique():
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
return all_X_list, all_y_list
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. 종목별 피처 + 라벨 생성 (CPU 바운드 — 이벤트 루프 보호를 위해 워커 스레드)
all_X_list, all_y_list = await asyncio.to_thread(
_build_training_lists_impl, price_df
)
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
retrain_pending = False
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. 감지만 하고 재학습은 장외로 미룬다 ──
# (인프로세스 재학습은 이벤트 루프·메모리를 점유해 손절/EOD 루프에 영향)
if (
(now.hour, now.minute) >= (9, 30)
and last_degradation_check != today
):
last_degradation_check = today
try:
degraded, details = await check_performance_degradation()
if degraded:
retrain_pending = True
logger.warning(
f"ML 성능 저하 감지 -> 재학습 필요 플래그 설정 (장외에 실행): {details}"
)
else:
logger.info(f"성능 저하 미감지: {details}")
except Exception as e:
logger.error(f"성능 저하 감지 실패: {e}", exc_info=True)
# ── 보류된 성능 저하 재학습: 주말·야간에만 실행 ──
if retrain_pending and _is_retrain_safe_window_v2(now):
retrain_pending = False
try:
result = await run_retrain(force=True)
logger.info(f"성능 저하 재학습 결과: {result.get('status')}")
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],
}