Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ __pycache__/
.venv/
.idea/
mlruns/
models/
19 changes: 19 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
84 changes: 84 additions & 0 deletions app/mlops/dataset.py
Original file line number Diff line number Diff line change
@@ -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)
71 changes: 71 additions & 0 deletions app/mlops/features.py
Original file line number Diff line number Diff line change
@@ -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]
120 changes: 120 additions & 0 deletions app/mlops/train.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion app/recommendation/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Expand Down
22 changes: 19 additions & 3 deletions app/trading_ai/predictor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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


Expand Down
2 changes: 1 addition & 1 deletion app/trading_ai/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

# 로깅은 이 요청의 일부다. 나중에 따로 하는 것이 아니다.
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading