From 79dd0c687c81ffb346885a74561d0f695f25837a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Wed, 26 Aug 2026 15:19:49 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat=20::=20=ED=94=BC=EC=B2=98=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EB=AA=A8=EB=93=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/mlops/features.py | 71 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 app/mlops/features.py diff --git a/app/mlops/features.py b/app/mlops/features.py new file mode 100644 index 0000000..8d25c83 --- /dev/null +++ b/app/mlops/features.py @@ -0,0 +1,71 @@ +"""피처 계산. Spring Boot 와 맞춰야 하는 계약의 원본이다. + +Spring 이 `/internal/ai/trading-decisions` 로 보내는 features 는 여기 정의와 +같은 공식으로 계산되어야 한다. 이름과 개수가 맞아도 정의가 다르면 모델은 +에러 없이 틀린 예측을 낸다. + +ma5 / ma20 / macd 가 종가로 나눈 **비율**이라는 점이 핵심이다. 절대값을 쓰면 +AAPL(300 규모)과 BTC(70000 규모)이 같은 피처 공간에 들어가지 않는다. +""" + +import numpy as np +import pandas as pd + +from app.shared.enums import Market + +RSI_PERIOD = 14 +MA_SHORT = 5 +MA_LONG = 20 +MACD_FAST = 12 +MACD_SLOW = 26 +VOL_WINDOW = 20 + +# 학습과 추론이 공유하는 정순서. LightGBM Booster 는 이름이 아니라 열 순서로 +# 매칭하므로 이 순서가 계약이다. +FEATURE_NAMES = ["rsi", "ma5", "ma20", "volumeChange", "macd", "volatility", "market"] + +CATEGORICAL_FEATURES = ["market"] + +# 피처 생성에 필요한 최소 행 수. EMA26 이 가장 길다. +MIN_ROWS = MACD_SLOW + VOL_WINDOW + + +def market_code(market: Market) -> int: + return {Market.KR: 0, Market.US: 1, Market.COIN: 2}[market] + + +def rsi(close: pd.Series, period: int = RSI_PERIOD) -> pd.Series: + """0~1 로 정규화한 RSI. 통상 0~100 인 값을 100 으로 나눈다.""" + delta = close.diff() + gain = delta.clip(lower=0).ewm(alpha=1 / period, adjust=False).mean() + loss = (-delta.clip(upper=0)).ewm(alpha=1 / period, adjust=False).mean() + # loss 가 0이면(하락이 없으면) RSI 는 1.0 이다. 0으로 나누기를 NaN 으로 두고 채운다. + rs = gain / loss.replace(0, np.nan) + return (1 - 1 / (1 + rs)).fillna(1.0) + + +def build_features(df: pd.DataFrame, market: Market) -> pd.DataFrame: + """OHLCV -> FEATURE_NAMES 순서의 피처 프레임. + + df 는 close, volume 열과 DatetimeIndex 를 가져야 한다. + 선행 구간(이동평균이 정의되지 않는 앞부분)은 호출자가 dropna 로 잘라낸다. + """ + close, volume = df["close"], df["volume"] + + ema_fast = close.ewm(span=MACD_FAST, adjust=False).mean() + ema_slow = close.ewm(span=MACD_SLOW, adjust=False).mean() + returns = close.pct_change() + + out = pd.DataFrame( + { + "rsi": rsi(close), + "ma5": close.rolling(MA_SHORT).mean() / close, + "ma20": close.rolling(MA_LONG).mean() / close, + "volumeChange": volume / volume.rolling(VOL_WINDOW).mean() - 1, + "macd": (ema_fast - ema_slow) / close, + "volatility": returns.rolling(VOL_WINDOW).std(), + "market": market_code(market), + }, + index=df.index, + ) + return out[FEATURE_NAMES] From 850b4283d969b6c7468780a975d4b5593300fbc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Wed, 26 Aug 2026 15:19:49 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat=20::=20=EC=8B=9C=EC=84=B8=20=EC=88=98?= =?UTF-8?q?=EC=A7=91=EA=B3=BC=20=EB=9D=BC=EB=B2=A8=EB=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/mlops/dataset.py | 84 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 app/mlops/dataset.py diff --git a/app/mlops/dataset.py b/app/mlops/dataset.py new file mode 100644 index 0000000..4dc3357 --- /dev/null +++ b/app/mlops/dataset.py @@ -0,0 +1,84 @@ +"""시세 수집과 라벨링. + +Yahoo Finance chart API 를 쓴다. 키가 필요 없고 US 주식과 COIN 을 모두 준다. +이 환경에서 Binance / Coinbase / Kraken / stooq 는 전부 연결이 막혀 있었다. +""" + +import json +import logging +import ssl +import urllib.request + +import certifi +import pandas as pd + +from app.mlops.features import MIN_ROWS, build_features +from app.shared.enums import Market + +logger = logging.getLogger(__name__) + +# KR 은 브로커 계좌 대기 중이라 비활성이다. +DEFAULT_UNIVERSE: dict[Market, list[str]] = { + Market.US: ["AAPL", "MSFT", "NVDA", "AMZN", "GOOGL", "META", "TSLA", "SPY"], + Market.COIN: ["BTC-USD", "ETH-USD", "SOL-USD", "XRP-USD"], +} +DEFAULT_HORIZON = 5 +DEFAULT_RANGE = "10y" + +_CHART_URL = "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}" +# urllib 은 macOS 시스템 CA 를 못 찾아 CERTIFICATE_VERIFY_FAILED 를 낸다. certifi 를 명시한다. +_SSL_CONTEXT = ssl.create_default_context(cafile=certifi.where()) + + +def fetch_ohlcv(symbol: str, range_: str = DEFAULT_RANGE) -> pd.DataFrame: + """일봉 close / volume. 실패하면 예외를 그대로 올린다.""" + url = f"{_CHART_URL.format(symbol=symbol)}?range={range_}&interval=1d" + request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + # ponytail: 캐싱하지 않는다. 학습은 가끔 돌리므로 매번 받는 편이 단순하다. + with urllib.request.urlopen(request, timeout=30, context=_SSL_CONTEXT) as response: + payload = json.load(response) + + result = payload["chart"]["result"][0] + quote = result["indicators"]["quote"][0] + return pd.DataFrame( + {"close": quote["close"], "volume": quote["volume"]}, + index=pd.to_datetime(result["timestamp"], unit="s"), + ).dropna() + + +def build_dataset( + universe: dict[Market, list[str]] | None = None, + horizon: int = DEFAULT_HORIZON, + range_: str = DEFAULT_RANGE, +) -> pd.DataFrame: + """피처 + label + symbol + date 프레임. 종목별로 만들어 세로로 붙인다.""" + universe = universe or DEFAULT_UNIVERSE + frames = [] + + for market, symbols in universe.items(): + for symbol in symbols: + try: + ohlcv = fetch_ohlcv(symbol, range_) + except Exception: + logger.exception("%s 수집 실패 - 건너뛴다", symbol) + continue + + if len(ohlcv) < MIN_ROWS + horizon: + logger.warning("%s 행 부족(%d) - 건너뛴다", symbol, len(ohlcv)) + continue + + frame = build_features(ohlcv, market) + # 라벨: horizon 일 뒤 종가가 오늘보다 높은가. + frame["label"] = (ohlcv["close"].shift(-horizon) > ohlcv["close"]).astype("Int64") + # 꼬리 horizon 행은 미래를 모른다. shift 가 NaN 을 남기므로 dropna 로 함께 잘린다. + frame["label"] = frame["label"].where(ohlcv["close"].shift(-horizon).notna()) + frame["symbol"] = symbol + frames.append(frame.dropna()) + logger.info("%s %d행", symbol, len(frames[-1])) + + if not frames: + raise RuntimeError("수집된 데이터가 없다") + + dataset = pd.concat(frames) + dataset["date"] = dataset.index + return dataset.reset_index(drop=True) From d2072b1e9c0aa47129966d18688dadda23d56819 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Wed, 26 Aug 2026 15:19:49 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat=20::=20LightGBM=20=ED=95=99=EC=8A=B5?= =?UTF-8?q?=EA=B3=BC=20=EC=8B=9C=EA=B3=84=EC=97=B4=20=EB=B0=B1=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + app/mlops/train.py | 120 +++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + 3 files changed, 122 insertions(+) create mode 100644 app/mlops/train.py diff --git a/.gitignore b/.gitignore index 9c345ea..d0f5cc6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ __pycache__/ .venv/ .idea/ mlruns/ +models/ diff --git a/app/mlops/train.py b/app/mlops/train.py new file mode 100644 index 0000000..6a5fff6 --- /dev/null +++ b/app/mlops/train.py @@ -0,0 +1,120 @@ +"""학습 + 백테스트. `python -m app.mlops.train` 으로 실행한다. + +시계열이므로 랜덤 split 을 쓰지 않는다. 날짜로 정렬해 뒤쪽을 홀드아웃으로 남긴다. +랜덤으로 나누면 미래 정보가 학습에 새어 AUC 가 비현실적으로 높게 나온다. +""" + +import json +import logging +from pathlib import Path + +import lightgbm as lgb +import pandas as pd +from sklearn.metrics import accuracy_score, roc_auc_score + +from app.mlops.dataset import DEFAULT_HORIZON, build_dataset +from app.mlops.features import CATEGORICAL_FEATURES, FEATURE_NAMES + +logger = logging.getLogger(__name__) + +MODEL_DIR = Path("models") +MODEL_PATH = MODEL_DIR / "trading_lgbm.txt" +METRICS_PATH = MODEL_DIR / "metrics.json" + +HOLDOUT_RATIO = 0.2 +PARAMS = { + "objective": "binary", + "metric": "auc", + "learning_rate": 0.03, + "num_leaves": 15, + "min_data_in_leaf": 100, + "feature_fraction": 0.8, + "bagging_fraction": 0.8, + "bagging_freq": 5, + "verbose": -1, +} +NUM_ROUNDS = 500 +EARLY_STOPPING = 50 + + +def split_by_date(dataset: pd.DataFrame, ratio: float = HOLDOUT_RATIO): + """날짜 기준 시계열 분할. 경계 날짜는 홀드아웃에 넣어 같은 날이 양쪽에 걸치지 않게 한다.""" + dates = dataset["date"].sort_values().unique() + cutoff = dates[int(len(dates) * (1 - ratio))] + train = dataset[dataset["date"] < cutoff] + holdout = dataset[dataset["date"] >= cutoff] + return train, holdout, pd.Timestamp(cutoff) + + +def evaluate(booster: lgb.Booster, frame: pd.DataFrame) -> dict: + probability = booster.predict(frame[FEATURE_NAMES]) + label = frame["label"].astype(int) + metrics = { + "rows": len(frame), + "positive_rate": round(float(label.mean()), 4), + "auc": round(float(roc_auc_score(label, probability)), 4), + "accuracy": round(float(accuracy_score(label, probability >= 0.5)), 4), + } + # 임계값별 정밀도 - THRESHOLDS 튜닝의 근거가 된다. + for threshold in (0.55, 0.6, 0.65, 0.7): + picked = probability >= threshold + metrics[f"precision@{threshold}"] = ( + round(float(label[picked].mean()), 4) if picked.any() else None + ) + metrics[f"coverage@{threshold}"] = round(float(picked.mean()), 4) + return metrics + + +def main() -> None: + logging.basicConfig(level=logging.INFO, format="%(message)s") + + dataset = build_dataset(horizon=DEFAULT_HORIZON) + train, holdout, cutoff = split_by_date(dataset) + logger.info( + "\n전체 %d행 | 학습 %d행 | 홀드아웃 %d행 | 분할 기준 %s", + len(dataset), len(train), len(holdout), cutoff.date(), + ) + + train_set = lgb.Dataset( + train[FEATURE_NAMES], train["label"].astype(int), categorical_feature=CATEGORICAL_FEATURES + ) + valid_set = lgb.Dataset( + holdout[FEATURE_NAMES], holdout["label"].astype(int), reference=train_set + ) + booster = lgb.train( + PARAMS, + train_set, + num_boost_round=NUM_ROUNDS, + valid_sets=[valid_set], + callbacks=[lgb.early_stopping(EARLY_STOPPING, verbose=False)], + ) + + # 학습과 추론의 계약. 어긋난 모델을 저장하면 서비스가 조용히 틀린 예측을 낸다. + assert booster.feature_name() == FEATURE_NAMES, ( + f"피처 순서 불일치: {booster.feature_name()} != {FEATURE_NAMES}" + ) + + metrics = { + "horizon": DEFAULT_HORIZON, + "best_iteration": booster.best_iteration, + "cutoff": str(cutoff.date()), + "features": FEATURE_NAMES, + "train": evaluate(booster, train), + "holdout": evaluate(booster, holdout), + } + + MODEL_DIR.mkdir(exist_ok=True) + booster.save_model(str(MODEL_PATH), num_iteration=booster.best_iteration) + METRICS_PATH.write_text(json.dumps(metrics, indent=2, ensure_ascii=False) + "\n") + + logger.info("\n%s", json.dumps(metrics, indent=2, ensure_ascii=False)) + logger.info("\n모델 저장: %s", MODEL_PATH) + holdout_auc = metrics["holdout"]["auc"] + if holdout_auc < 0.52: + logger.warning("홀드아웃 AUC %.4f - 무작위와 다를 바 없다. 붙이지 마라.", holdout_auc) + elif holdout_auc > 0.70: + logger.warning("홀드아웃 AUC %.4f - 너무 높다. 데이터 누수를 의심하라.", holdout_auc) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt index f767275..18d178f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ lightgbm==4.6.0 shap==0.52.0 mlflow==3.14.0 pandas==2.3.3 +scikit-learn==1.9.0 anthropic==1.0.0 pydantic==2.13.4 SQLAlchemy==2.0.51 From c27c2cd4bdd896c90ec4d1a4c7608c6b70446b1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Wed, 26 Aug 2026 15:19:49 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix=20::=20=ED=94=BC=EC=B2=98=EB=A5=BC=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EC=88=9C=EC=84=9C=EB=8C=80=EB=A1=9C=20?= =?UTF-8?q?=EC=9E=AC=EB=B0=B0=EC=97=B4=ED=95=B4=20=EC=9E=98=EB=AA=BB?= =?UTF-8?q?=EB=90=9C=20=EC=98=88=EC=B8=A1=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/recommendation/router.py | 2 +- app/trading_ai/predictor.py | 22 +++++++++++++++++++--- app/trading_ai/router.py | 2 +- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/app/recommendation/router.py b/app/recommendation/router.py index 2af1a91..c03649b 100644 --- a/app/recommendation/router.py +++ b/app/recommendation/router.py @@ -21,7 +21,7 @@ def create_recommendation( body: RecommendationRequest, db: Session = Depends(get_db), ) -> RecommendationResponse: - scored = [(predictor.predict(candidate.features), candidate) for candidate in body.candidates] + scored = [(predictor.predict(candidate.features, body.market), candidate) for candidate in body.candidates] # 스텁 모델은 모든 후보가 같은 확률이라 첫 번째가 뽑힌다. max는 동점에서 앞선 것을 유지한다. (probability, model_version), best = max(scored, key=lambda item: item[0][0]) diff --git a/app/trading_ai/predictor.py b/app/trading_ai/predictor.py index 6a4ae27..faf1bb3 100644 --- a/app/trading_ai/predictor.py +++ b/app/trading_ai/predictor.py @@ -2,6 +2,7 @@ import os from pathlib import Path +from app.mlops.features import market_code from app.shared.enums import AiStrategy, Market, TradingAction logger = logging.getLogger(__name__) @@ -49,15 +50,30 @@ def load_model() -> None: logger.info("모델 로드 완료: %s", _version) -def predict(features: dict[str, float]) -> tuple[float, str]: - """(상승 확률, 모델 버전).""" +def predict(features: dict[str, float], market: Market) -> tuple[float, str]: + """(상승 확률, 모델 버전). + + LightGBM Booster는 피처 이름이 아니라 **열 순서**로 매칭한다. DataFrame으로 + 넘겨도 이름을 보지 않아서, dict 키 순서가 학습 때와 다르면 에러 없이 다른 + 값이 나오고 이름 오타는 조용히 통과한다. 그래서 booster가 기대하는 순서로 + 직접 재배열하고 누락을 명시적으로 잡는다. + """ if _booster is None: # ponytail: 스텁. 학습된 Booster가 생기면 load_model()이 채우고 이 분기는 안 탄다. return 0.5, STUB_VERSION import pandas as pd - probability = float(_booster.predict(pd.DataFrame([features]))[0]) + # market은 요청 본문의 별도 필드로 오므로 여기서 피처에 합친다. + values = {**features, "market": market_code(market)} + expected = _booster.feature_name() + + missing = [name for name in expected if name not in values] + if missing: + raise ValueError(f"피처 누락: {missing} (기대: {expected})") + + row = pd.DataFrame([[values[name] for name in expected]], columns=expected) + probability = float(_booster.predict(row)[0]) return probability, _version diff --git a/app/trading_ai/router.py b/app/trading_ai/router.py index 9761618..00b36d0 100644 --- a/app/trading_ai/router.py +++ b/app/trading_ai/router.py @@ -16,7 +16,7 @@ def create_trading_decision( body: TradingDecisionRequest, db: Session = Depends(get_db), ) -> TradingDecisionResponse: - probability, model_version = predictor.predict(body.features) + probability, model_version = predictor.predict(body.features, body.market) action = predictor.decide_action(probability, body.market, body.ai_strategy) # 로깅은 이 요청의 일부다. 나중에 따로 하는 것이 아니다. From e65dabadd6f9fdcc0137f6d9721a336ef0507a7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Wed, 26 Aug 2026 15:19:49 +0900 Subject: [PATCH 5/6] =?UTF-8?q?test=20::=20=ED=94=BC=EC=B2=98=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=EA=B3=BC=20=ED=94=BC=EC=B2=98=20=EC=88=9C=EC=84=9C=20?= =?UTF-8?q?=ED=9A=8C=EA=B7=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_features.py | 97 +++++++++++++++++++++++++++++++++ tests/test_recommendations.py | 2 +- tests/test_trading_decisions.py | 63 ++++++++++++++++++++- 3 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 tests/test_features.py diff --git a/tests/test_features.py b/tests/test_features.py new file mode 100644 index 0000000..c274a2d --- /dev/null +++ b/tests/test_features.py @@ -0,0 +1,97 @@ +import unittest +from unittest.mock import patch + +import numpy as np +import pandas as pd + +from app.mlops.dataset import build_dataset +from app.mlops.features import FEATURE_NAMES, build_features, market_code, rsi +from app.shared.enums import Market + + +def ohlcv(closes, volumes=None): + index = pd.date_range("2024-01-01", periods=len(closes)) + volumes = volumes if volumes is not None else [1_000_000] * len(closes) + return pd.DataFrame({"close": closes, "volume": volumes}, index=index) + + +class RsiTest(unittest.TestCase): + def test_monotonic_rise_is_one(self): + self.assertAlmostEqual(rsi(pd.Series(np.arange(100, 140, dtype=float))).iloc[-1], 1.0) + + def test_monotonic_fall_is_zero(self): + self.assertAlmostEqual(rsi(pd.Series(np.arange(140, 100, -1, dtype=float))).iloc[-1], 0.0) + + def test_stays_normalized(self): + rng = np.random.default_rng(0) + values = rsi(pd.Series(100 + rng.normal(size=300).cumsum())) + self.assertTrue(((values >= 0) & (values <= 1)).all()) + + +class BuildFeaturesTest(unittest.TestCase): + def setUp(self): + rng = np.random.default_rng(1) + self.closes = 100 + rng.normal(size=200).cumsum() + + def test_columns_match_feature_names_exactly(self): + frame = build_features(ohlcv(self.closes), Market.US) + self.assertEqual(list(frame.columns), FEATURE_NAMES) + + def test_no_nan_after_warmup(self): + frame = build_features(ohlcv(self.closes), Market.US).dropna() + self.assertFalse(frame.isna().any().any()) + self.assertGreater(len(frame), 150) + + def test_price_scale_does_not_change_ratios(self): + # 가격이 100배여도 비율 피처는 같아야 한다. 이게 단일 모델로 US와 COIN을 + # 함께 학습할 수 있는 근거다. + small = build_features(ohlcv(self.closes), Market.US).dropna() + large = build_features(ohlcv(self.closes * 100), Market.US).dropna() + for column in ("ma5", "ma20", "macd", "volatility", "rsi"): + np.testing.assert_allclose(small[column], large[column], rtol=1e-9) + + def test_ratio_features_are_near_one(self): + frame = build_features(ohlcv(self.closes), Market.US).dropna() + for column in ("ma5", "ma20"): + self.assertTrue((frame[column].between(0.5, 1.5)).all(), f"{column} 범위 이탈") + + def test_market_is_encoded(self): + for market, expected in ((Market.KR, 0), (Market.US, 1), (Market.COIN, 2)): + self.assertEqual(market_code(market), expected) + frame = build_features(ohlcv(self.closes), market) + self.assertTrue((frame["market"] == expected).all()) + + +class LabelLeakageTest(unittest.TestCase): + """라벨 꼬리 누수 방어. build_dataset이 미래를 모르는 행을 남기면 안 된다.""" + + HORIZON = 5 + + def test_naive_comparison_silently_hides_missing_future(self): + # NaN 비교는 False가 되므로 shift() > close 만 쓰면 꼬리가 0으로 학습된다. + # dataset.py가 .where()로 이걸 막는 이유다. + close = pd.Series(np.arange(100, 130, dtype=float)) + self.assertEqual((close.shift(-self.HORIZON) > close).isna().sum(), 0) + + def test_build_dataset_drops_unlabelable_tail(self): + rng = np.random.default_rng(2) + rows = 200 + frame = ohlcv(100 + rng.normal(size=rows).cumsum()) + + with patch("app.mlops.dataset.fetch_ohlcv", return_value=frame): + dataset = build_dataset({Market.US: ["FAKE"]}, horizon=self.HORIZON) + + # 마지막 horizon일은 라벨을 만들 수 없으므로 데이터셋에 없어야 한다. + self.assertLessEqual(dataset["date"].max(), frame.index[-1 - self.HORIZON]) + self.assertFalse(dataset["label"].isna().any()) + + def test_label_matches_future_close(self): + # 단조 상승이면 라벨은 전부 1이어야 한다. + frame = ohlcv(np.arange(100, 300, dtype=float)) + with patch("app.mlops.dataset.fetch_ohlcv", return_value=frame): + dataset = build_dataset({Market.US: ["FAKE"]}, horizon=self.HORIZON) + self.assertEqual(dataset["label"].astype(int).sum(), len(dataset)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_recommendations.py b/tests/test_recommendations.py index d00693c..3bf63cf 100644 --- a/tests/test_recommendations.py +++ b/tests/test_recommendations.py @@ -58,7 +58,7 @@ def test_highest_probability_candidate_wins(self): # AAPL 0.4, MSFT 0.8 -> MSFT가 뽑혀야 한다. probabilities = {55.0: 0.4, 48.0: 0.8} - def fake_predict(features): + def fake_predict(features, market): return probabilities[features["rsi"]], "fake-1" with patch("app.trading_ai.predictor.predict", side_effect=fake_predict): diff --git a/tests/test_trading_decisions.py b/tests/test_trading_decisions.py index e213a62..ea7bce4 100644 --- a/tests/test_trading_decisions.py +++ b/tests/test_trading_decisions.py @@ -1,12 +1,14 @@ import os import unittest -from unittest.mock import patch +from unittest.mock import MagicMock, patch from fastapi.testclient import TestClient from app.main import app from app.shared.db import get_db from app.shared.enums import AiStrategy, DecisionType, Market, TradingAction +from app.mlops.features import FEATURE_NAMES +from app.trading_ai import predictor from app.trading_ai.predictor import decide_action API_KEY = "test-key" @@ -72,6 +74,65 @@ def test_inference_failure_returns_500(self): self.assertEqual(response.json()["error"]["message"], "internal server error") +class FeatureOrderingTest(unittest.TestCase): + """LightGBM Booster는 피처 이름이 아니라 열 순서로 매칭한다. + + predictor가 booster.feature_name() 순서로 재배열하지 않으면, Spring이 보내는 + JSON 필드 순서가 학습 때와 다를 때 에러 없이 다른 확률이 나온다. 스텁은 항상 + 0.5라 이 버그가 드러나지 않으므로 가짜 booster로 실제 동작을 검증한다. + """ + + FEATURES = { + "rsi": 0.55, + "ma5": 1.01, + "ma20": 0.99, + "volumeChange": 0.1, + "macd": 0.002, + "volatility": 0.015, + } + + def setUp(self): + # 열 순서에 의존하는 booster를 흉내내: 받은 순서 그대로 가중합한다. + self.booster = MagicMock() + self.booster.feature_name.return_value = FEATURE_NAMES + self.booster.predict.side_effect = lambda frame: [ + float(sum((i + 1) * v for i, v in enumerate(frame.iloc[0]))) + ] + + def test_key_order_does_not_change_prediction(self): + with patch.object(predictor, "_booster", self.booster): + straight, _ = predictor.predict(self.FEATURES, Market.US) + reversed_, _ = predictor.predict( + {k: self.FEATURES[k] for k in reversed(list(self.FEATURES))}, Market.US + ) + self.assertEqual(straight, reversed_) + + def test_columns_are_reordered_to_booster_order(self): + with patch.object(predictor, "_booster", self.booster): + predictor.predict({k: self.FEATURES[k] for k in reversed(list(self.FEATURES))}, Market.US) + frame = self.booster.predict.call_args.args[0] + self.assertEqual(list(frame.columns), FEATURE_NAMES) + + def test_missing_feature_raises(self): + broken = {k: v for k, v in self.FEATURES.items() if k != "macd"} + with patch.object(predictor, "_booster", self.booster): + with self.assertRaises(ValueError): + predictor.predict(broken, Market.US) + + def test_misspelled_feature_raises(self): + # 이름만 틀리고 개수가 맞으면 LightGBM은 조용히 통과시킨다. 여기서 잡아야 한다. + typo = {("volume_change" if k == "volumeChange" else k): v for k, v in self.FEATURES.items()} + with patch.object(predictor, "_booster", self.booster): + with self.assertRaises(ValueError): + predictor.predict(typo, Market.US) + + def test_market_reaches_the_model(self): + with patch.object(predictor, "_booster", self.booster): + us, _ = predictor.predict(self.FEATURES, Market.US) + coin, _ = predictor.predict(self.FEATURES, Market.COIN) + self.assertNotEqual(us, coin) + + class DecideActionTest(unittest.TestCase): def test_thresholds_are_separated_per_market(self): # COIN은 변동성이 커서 US와 같은 확률에 같은 결정이 나오면 안 된다. From 89f39f623c7d9c615951597cb287cafb4889cca8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=95=98=EB=AF=BC?= Date: Wed, 26 Aug 2026 15:19:50 +0900 Subject: [PATCH 6/6] =?UTF-8?q?docs=20::=20=ED=94=BC=EC=B2=98=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=20=ED=91=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index c5c055d..f3cf151 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,6 +61,25 @@ Claude is an inference-only dependency here — never attempt to fine-tune it. N - Feature engineering (moving averages, RSI, volume, MACD, volatility) is computed by the Spring Boot backend and sent in the request — both `/internal/ai/trading-decisions` and `/internal/ai/recommendations` receive `features` rather than deriving them. - Decision thresholds, however, must stay separated per market — do not assume shared thresholds across markets with different volatility profiles (see `THRESHOLDS` in `app/trading_ai/predictor.py`). +### Feature Contract — must match the Spring Boot side exactly + +`app/mlops/features.py` is the executable source of these definitions; training and inference both read `FEATURE_NAMES` from it. Names and count matching is not enough — if Spring computes a value differently, the model returns a wrong prediction with no error. + +| Feature | Definition | Typical range | +|---|---|---| +| `rsi` | 14-day RSI divided by 100 | 0 ~ 1 | +| `ma5` | 5-day SMA / current close | 0.9 ~ 1.1 | +| `ma20` | 20-day SMA / current close | 0.8 ~ 1.2 | +| `volumeChange` | (volume / 20-day mean volume) − 1 | −1 ~ 3 | +| `macd` | (EMA12 − EMA26) / current close | −0.1 ~ 0.1 | +| `volatility` | 20-day stdev of daily returns | 0 ~ 0.1 | + +`ma5`, `ma20`, and `macd` are **ratios to close**, not absolute values. Absolute values put AAPL (~300) and BTC (~70,000) in incomparable feature spaces — a single model cannot learn both. + +`market` is appended as a categorical feature by this service (`KR=0, US=1, COIN=2`); Spring sends it as a separate request field, not inside `features`. + +**LightGBM matches features by column order, not by name.** `predict()` reorders the incoming dict to `booster.feature_name()` and raises on missing names — do not bypass that, or a reordered JSON payload will silently produce a different decision. + ## Decision Logging Requirements (non-negotiable) Every time `/internal/ai/trading-decisions` or `/internal/ai/recommendations` is called, persist: