feat(phase-4): LGBM 모델 + 앙상블 + 매칭/재학습 잡

- backend/app/models/lgbm.py: 종목 × horizon 별 LightGBM 회귀(y_ret_h)
  + 다중분류(y_dir_h, 3-class). joblib 으로 backend/data/models/{code}_h{H}_*.pkl
  저장. early_stopping(30). predict_one() 으로 최신 영업일 피처에 추론.
- backend/app/models/weights.py: ensemble_weights 테이블 IO,
  default w_chronos=0.6 / w_lgbm=0.4 (DB 행 없으면 fallback).
- backend/app/models/ensemble.py: Chronos sample 분포 + LGBM regression+cls
  결합. point/q10/q90 + prob_up/flat/down + direction 라벨. 한쪽 모델
  실패 시 다른 쪽 단독 fallback (cold start: chronos 단독).
- backend/app/pipelines/predict_one.py: predict_and_store(). 결과를
  predictions 테이블에 UPSERT, user_triggered 누적 OR. base_date = 마지막
  ohlcv 거래일, target_date = base_date + H 영업일(주말 스킵, 공휴일은
  매칭잡에서 자연 보정).
- backend/app/pipelines/match_outcomes.py: target_date == d 인
  user_triggered=TRUE 예측을 d 의 실제 종가와 매칭 → prediction_outcomes
  적재. direction_hit(±0.3% flat band) + abs_error. 실제 종가 없으면
  자연 skip.
- backend/app/pipelines/retrain_weekly.py: 시드 10종목 × H 재학습 +
  최근 30일 model_performance 적재.
- backend/app/db/migrations/003_ensemble_weights.sql: (code, horizon) →
  (w_chronos, w_lgbm, hit_rate_*, sample_count).
- backend/app/pipelines/scheduler.py:
    daily_batch    : 평일 16:00 KST
    match_outcomes : 평일 16:30 KST   ← 사용자가 확정한 매칭 시점
    retrain_weekly : 일요일 02:00 KST

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
tkrmagid
2026-05-20 16:03:01 +09:00
parent b1ca6ab5d3
commit bf4fb01146
8 changed files with 896 additions and 2 deletions

View File

@@ -0,0 +1,174 @@
"""Chronos + LGBM 앙상블 추론.
final_price[h] = w_c * chronos.median[h-1] + w_l * lgbm.predicted_close
final_q10[h] = w_c * chronos.q10[h-1] + w_l * lgbm.predicted_close * 0.97
final_q90[h] = w_c * chronos.q90[h-1] + w_l * lgbm.predicted_close * 1.03
LGBM 은 단일 horizon 의 다음 종가(point) 만 주므로, 그 자체로는 신뢰구간이 없음.
근사로 ±3% band 를 LGBM 의 q10/q90 자리에 사용. Chronos 의 sample 분포가
주된 신뢰구간 정보 (Chronos 우세하면 ci 가 좁아짐).
direction 확률:
- LGBM 분류기에서 prob_up/flat/down (3-class) 그대로
- Chronos 는 next-day return 부호 비율: samples.shift1 / base_close - 1 의 부호
- 둘을 같은 가중치로 평균
LGBM 모델이 없으면 Chronos 단독으로 진행 (cold start).
Chronos 도 실패하면 LGBM 단독으로 진행.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
import numpy as np
from app.models.chronos import ChronosForecast
from app.models.chronos import forecast as chronos_forecast
from app.models.lgbm import LgbmForecast
from app.models.lgbm import predict_one as lgbm_predict
from app.models.weights import load_weights
logger = logging.getLogger(__name__)
@dataclass
class EnsembleStep:
horizon: int # 1..H 거래일 후
target_idx: int # chronos median 의 0-based 인덱스 (horizon-1)
point_close: float
ci_low: float
ci_high: float
prob_up: float
prob_flat: float
prob_down: float
direction: str # 'up' / 'flat' / 'down'
expected_return: float
@dataclass
class EnsemblePrediction:
code: str
base_close: float
horizons: list[int]
steps: list[EnsembleStep]
sources_used: list[str]
def _chronos_direction(samples: list[list[float]], base_close: float, horizon: int) -> tuple[float, float, float]:
"""Chronos sample 분포에서 (prob_up, prob_flat, prob_down). ±0.3% flat band."""
if not samples:
return 0.33, 0.34, 0.33
arr = np.array(samples)[:, horizon - 1] # 해당 step 의 sample 값
ret = arr / base_close - 1.0
p_up = float((ret > 0.003).mean())
p_dn = float((ret < -0.003).mean())
p_fl = 1.0 - p_up - p_dn
return p_up, p_fl, p_dn
def predict(code: str, *, horizons: tuple[int, ...] = (1, 3, 5)) -> EnsemblePrediction:
"""한 종목에 대해 horizons 별 앙상블 예측. on-demand 추론용."""
max_h = max(horizons)
# Chronos: 종가 시계열 가져와서 max_h 까지 예측.
from app.models.features import build_features # local import
ff = build_features(code, lookback_days=400, horizons=horizons, with_targets=False)
df = ff.df
if df.empty:
raise RuntimeError(f"no OHLCV data for {code}")
closes = df["close"].astype(float).tolist()
base_close = float(closes[-1])
sources_used: list[str] = []
cf: ChronosForecast | None = None
try:
cf = chronos_forecast(closes, horizon=max_h, num_samples=30)
sources_used.append("chronos")
except Exception as exc: # noqa: BLE001
logger.warning("chronos forecast failed for %s: %s", code, exc)
steps: list[EnsembleStep] = []
for h in horizons:
lf: LgbmForecast | None = None
try:
lf = lgbm_predict(code, h)
if lf is not None:
sources_used.append(f"lgbm_h{h}")
except Exception as exc: # noqa: BLE001
logger.warning("lgbm predict failed for %s h=%d: %s", code, h, exc)
# 가중치 (DB 없으면 default 0.6/0.4).
w = load_weights(code, h)
wc, wl = w.w_chronos, w.w_lgbm
# 한쪽이 없으면 다른 쪽 전부.
if cf is None and lf is None:
raise RuntimeError(f"both chronos & lgbm failed for {code} h={h}")
if cf is None:
wc, wl = 0.0, 1.0
if lf is None:
wc, wl = 1.0, 0.0
if cf is not None:
c_med = cf.median[h - 1]
c_q10 = cf.q10[h - 1]
c_q90 = cf.q90[h - 1]
else:
c_med = c_q10 = c_q90 = base_close # not used (wc=0)
if lf is not None:
l_close = lf.predicted_close
l_lo = l_close * 0.97
l_hi = l_close * 1.03
l_pu, l_pf, l_pd = lf.prob_up, lf.prob_flat, lf.prob_down
else:
l_close = l_lo = l_hi = base_close
l_pu = l_pf = l_pd = 0.0
point = wc * c_med + wl * l_close
lo = wc * c_q10 + wl * l_lo
hi = wc * c_q90 + wl * l_hi
if cf is not None:
cp_up, cp_fl, cp_dn = _chronos_direction(cf.samples, base_close, h)
else:
cp_up = cp_fl = cp_dn = 0.0
# direction prob: source 마다 weights 동일하게 가중평균
if lf is not None and cf is not None:
p_up = 0.5 * cp_up + 0.5 * l_pu
p_fl = 0.5 * cp_fl + 0.5 * l_pf
p_dn = 0.5 * cp_dn + 0.5 * l_pd
elif cf is not None:
p_up, p_fl, p_dn = cp_up, cp_fl, cp_dn
else:
p_up, p_fl, p_dn = l_pu, l_pf, l_pd
# 정규화 (혹시 합이 0 가 아닐 때)
s = max(p_up + p_fl + p_dn, 1e-9)
p_up, p_fl, p_dn = p_up / s, p_fl / s, p_dn / s
dir_lbl = "up" if p_up >= max(p_fl, p_dn) else ("down" if p_dn >= p_fl else "flat")
steps.append(
EnsembleStep(
horizon=h,
target_idx=h - 1,
point_close=float(point),
ci_low=float(lo),
ci_high=float(hi),
prob_up=float(p_up),
prob_flat=float(p_fl),
prob_down=float(p_dn),
direction=dir_lbl,
expected_return=float(point / base_close - 1.0),
)
)
return EnsemblePrediction(
code=code,
base_close=base_close,
horizons=list(horizons),
steps=steps,
sources_used=sources_used,
)

180
backend/app/models/lgbm.py Normal file
View File

@@ -0,0 +1,180 @@
"""LightGBM 회귀 + 분류 모델. 종목 × horizon 별 별도 저장.
- 회귀: target = y_ret_h{H}. 예측 후 base_close*(1+pred) 로 가격 환산.
- 분류: target = y_dir_h{H}{-1, 0, +1}. 3-class softmax 로 prob_up/flat/down.
저장 경로: backend/data/models/{code}_h{H}_reg.pkl, _cls.pkl (joblib).
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from pathlib import Path
import joblib
import numpy as np
import pandas as pd
from app.models.features import build_features, feature_columns
logger = logging.getLogger(__name__)
MODEL_DIR = Path(os.environ.get("LGBM_MODEL_DIR", "/app/data/models"))
@dataclass
class LgbmForecast:
horizon: int
base_close: float
predicted_close: float
predicted_return: float
prob_up: float
prob_flat: float
prob_down: float
def _model_paths(code: str, horizon: int) -> tuple[Path, Path]:
MODEL_DIR.mkdir(parents=True, exist_ok=True)
return (
MODEL_DIR / f"{code}_h{horizon}_reg.pkl",
MODEL_DIR / f"{code}_h{horizon}_cls.pkl",
)
def _prepare_xy(code: str, horizon: int, lookback_days: int) -> tuple[pd.DataFrame, pd.Series, pd.Series, list[str]]:
ff = build_features(
code,
lookback_days=lookback_days,
horizons=(horizon,),
with_targets=True,
)
df = ff.df
if df.empty:
return df, pd.Series(dtype=float), pd.Series(dtype=int), []
y_ret_col = f"y_ret_h{horizon}"
y_dir_col = f"y_dir_h{horizon}"
# 타깃 NaN (마지막 H 행) 제거.
df = df.dropna(subset=[y_ret_col, y_dir_col])
feats = feature_columns(df)
if not feats:
return df, pd.Series(dtype=float), pd.Series(dtype=int), []
X = df[feats]
# LightGBM 은 NaN 자체 처리 가능.
y_ret = df[y_ret_col].astype(float)
y_dir = df[y_dir_col].astype(int)
return X, y_ret, y_dir, feats
def train_one(code: str, horizon: int, *, lookback_days: int = 365 * 3) -> dict:
"""1종목 × 1 horizon 학습. 저장된 모델 파일 경로 + 샘플 수 반환."""
import lightgbm as lgb
X, y_ret, y_dir, feats = _prepare_xy(code, horizon, lookback_days)
if X.empty or len(X) < 100:
return {"code": code, "horizon": horizon, "status": "skipped_too_few_rows", "n_rows": int(len(X))}
reg_params = dict(
objective="regression",
learning_rate=0.05,
num_leaves=31,
min_data_in_leaf=20,
feature_fraction=0.85,
bagging_fraction=0.8,
bagging_freq=5,
verbose=-1,
)
cls_params = dict(
objective="multiclass",
num_class=3,
learning_rate=0.05,
num_leaves=31,
min_data_in_leaf=20,
feature_fraction=0.85,
bagging_fraction=0.8,
bagging_freq=5,
verbose=-1,
)
# 분류는 -1/0/1 → 0/1/2 인덱스로 매핑.
y_dir_idx = (y_dir + 1).astype(int)
n = len(X)
split = int(n * 0.85)
X_tr, X_val = X.iloc[:split], X.iloc[split:]
yr_tr, yr_val = y_ret.iloc[:split], y_ret.iloc[split:]
yc_tr, yc_val = y_dir_idx.iloc[:split], y_dir_idx.iloc[split:]
reg_train = lgb.Dataset(X_tr, label=yr_tr)
reg_valid = lgb.Dataset(X_val, label=yr_val, reference=reg_train)
reg_model = lgb.train(
reg_params,
reg_train,
num_boost_round=400,
valid_sets=[reg_valid],
callbacks=[lgb.early_stopping(stopping_rounds=30, verbose=False)],
)
cls_train = lgb.Dataset(X_tr, label=yc_tr)
cls_valid = lgb.Dataset(X_val, label=yc_val, reference=cls_train)
cls_model = lgb.train(
cls_params,
cls_train,
num_boost_round=400,
valid_sets=[cls_valid],
callbacks=[lgb.early_stopping(stopping_rounds=30, verbose=False)],
)
reg_path, cls_path = _model_paths(code, horizon)
joblib.dump({"model": reg_model, "features": feats}, reg_path)
joblib.dump({"model": cls_model, "features": feats}, cls_path)
return {
"code": code,
"horizon": horizon,
"status": "ok",
"n_rows": int(len(X)),
"reg_best_iter": int(reg_model.best_iteration or 0),
"cls_best_iter": int(cls_model.best_iteration or 0),
"reg_path": str(reg_path),
"cls_path": str(cls_path),
}
def predict_one(code: str, horizon: int, *, lookback_days: int = 400) -> LgbmForecast | None:
"""1종목 × 1 horizon 추론. 모델 없으면 None.
가장 최신 영업일 피처를 사용. base_close 는 그 행의 close.
"""
reg_path, cls_path = _model_paths(code, horizon)
if not reg_path.exists() or not cls_path.exists():
return None
reg_blob = joblib.load(reg_path)
cls_blob = joblib.load(cls_path)
feats_reg = reg_blob["features"]
feats_cls = cls_blob["features"]
reg_model = reg_blob["model"]
cls_model = cls_blob["model"]
ff = build_features(code, lookback_days=lookback_days, horizons=(horizon,), with_targets=False)
df = ff.df
if df.empty:
return None
last = df.iloc[[-1]]
base_close = float(last["close"].iloc[0])
# 피처 정렬 (모델이 학습 당시 본 컬럼 순서대로).
X_reg = last.reindex(columns=feats_reg).fillna(value=np.nan)
X_cls = last.reindex(columns=feats_cls).fillna(value=np.nan)
pred_ret = float(reg_model.predict(X_reg)[0])
probs = cls_model.predict(X_cls)[0]
# 인덱스 0=-1(down), 1=0(flat), 2=+1(up)
prob_down, prob_flat, prob_up = float(probs[0]), float(probs[1]), float(probs[2])
return LgbmForecast(
horizon=horizon,
base_close=base_close,
predicted_close=base_close * (1.0 + pred_ret),
predicted_return=pred_ret,
prob_up=prob_up,
prob_flat=prob_flat,
prob_down=prob_down,
)

View File

@@ -0,0 +1,75 @@
"""ensemble_weights 테이블 IO. 기본 가중치 (chronos 0.6, lgbm 0.4)."""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import text
from app.db.connection import get_engine
@dataclass
class EnsembleWeights:
code: str
horizon: int
w_chronos: float
w_lgbm: float
DEFAULT_W_CHRONOS = 0.6
DEFAULT_W_LGBM = 0.4
def load_weights(code: str, horizon: int) -> EnsembleWeights:
eng = get_engine()
with eng.connect() as conn:
row = conn.execute(
text(
"SELECT w_chronos, w_lgbm FROM ensemble_weights "
"WHERE code = :code AND horizon = :h"
),
{"code": code, "h": horizon},
).first()
if not row:
return EnsembleWeights(code, horizon, DEFAULT_W_CHRONOS, DEFAULT_W_LGBM)
return EnsembleWeights(code, horizon, float(row[0]), float(row[1]))
def upsert_weights(
code: str,
horizon: int,
w_chronos: float,
w_lgbm: float,
*,
hit_rate_chronos: float | None = None,
hit_rate_lgbm: float | None = None,
sample_count: int | None = None,
) -> None:
eng = get_engine()
with eng.begin() as conn:
conn.execute(
text(
"""
INSERT INTO ensemble_weights
(code, horizon, w_chronos, w_lgbm, hit_rate_chronos, hit_rate_lgbm, sample_count, updated_at)
VALUES
(:code, :h, :wc, :wl, :hc, :hl, :n, NOW())
ON CONFLICT (code, horizon) DO UPDATE SET
w_chronos = EXCLUDED.w_chronos,
w_lgbm = EXCLUDED.w_lgbm,
hit_rate_chronos = EXCLUDED.hit_rate_chronos,
hit_rate_lgbm = EXCLUDED.hit_rate_lgbm,
sample_count = EXCLUDED.sample_count,
updated_at = NOW()
"""
),
{
"code": code,
"h": horizon,
"wc": float(w_chronos),
"wl": float(w_lgbm),
"hc": hit_rate_chronos,
"hl": hit_rate_lgbm,
"n": sample_count,
},
)