diff --git a/scripts/deploy_model.sh b/scripts/deploy_model.sh index 143d4f8..575e3a6 100755 --- a/scripts/deploy_model.sh +++ b/scripts/deploy_model.sh @@ -1,25 +1,57 @@ #!/usr/bin/env bash # 맥미니에서 학습한 모델을 LXC 컨테이너 볼륨 경로로 전송한다. -# 사용법: bash scripts/deploy_model.sh [lgbm|mlx] +# 사용법: bash scripts/deploy_model.sh [lgbm|mlx] [--symbol TRXUSDT] # # 예시: -# bash scripts/deploy_model.sh # LightGBM (기본값) -# bash scripts/deploy_model.sh mlx # MLX 신경망 +# bash scripts/deploy_model.sh # LightGBM (기본값), models/ 루트 +# bash scripts/deploy_model.sh mlx # MLX 신경망, models/ 루트 +# bash scripts/deploy_model.sh --symbol TRXUSDT # LightGBM, models/trxusdt/ +# bash scripts/deploy_model.sh mlx --symbol XRPUSDT # MLX, models/xrpusdt/ set -euo pipefail -BACKEND="${1:-lgbm}" +# ── 인자 파싱 ──────────────────────────────────────────────────────────────── +BACKEND="lgbm" +SYMBOL_ARG="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --symbol) + SYMBOL_ARG="$2" + shift 2 + ;; + mlx|lgbm) + BACKEND="$1" + shift + ;; + *) + shift + ;; + esac +done + LXC_HOST="root@10.1.10.24" LXC_MODELS_PATH="/root/cointrader/models" -LOCAL_LOG="models/training_log.json" + +# ── 심볼별 경로 결정 ───────────────────────────────────────────────────────── +if [ -n "$SYMBOL_ARG" ]; then + SYM_LOWER=$(echo "$SYMBOL_ARG" | tr '[:upper:]' '[:lower:]') + LOCAL_MODEL_DIR="models/$SYM_LOWER" + REMOTE_MODEL_DIR="$LXC_MODELS_PATH/$SYM_LOWER" + LOCAL_LOG="models/$SYM_LOWER/training_log.json" +else + LOCAL_MODEL_DIR="models" + REMOTE_MODEL_DIR="$LXC_MODELS_PATH" + LOCAL_LOG="models/training_log.json" +fi # ── 백엔드별 파일 목록 설정 ────────────────────────────────────────────────── # mlx: ONNX 파일만 전송 (Linux 서버는 onnxruntime으로 추론) # lgbm: pkl 파일 전송 if [ "$BACKEND" = "mlx" ]; then - LOCAL_FILES=("models/mlx_filter.weights.onnx") + LOCAL_FILES=("$LOCAL_MODEL_DIR/mlx_filter.weights.onnx") else - LOCAL_FILES=("models/lgbm_filter.pkl") + LOCAL_FILES=("$LOCAL_MODEL_DIR/lgbm_filter.pkl") fi # ── 파일 존재 확인 ──────────────────────────────────────────────────────────── @@ -30,26 +62,26 @@ for f in "${LOCAL_FILES[@]}"; do fi done -echo "=== 모델 전송 시작 (백엔드: ${BACKEND}) ===" -echo " 대상: ${LXC_HOST}:${LXC_MODELS_PATH}" +echo "=== 모델 전송 시작 (백엔드: ${BACKEND}${SYMBOL_ARG:+, 심볼: $SYMBOL_ARG}) ===" +echo " 대상: ${LXC_HOST}:${REMOTE_MODEL_DIR}" # ── 원격 디렉터리 생성 + 백업 + 상대 백엔드 파일 제거 ─────────────────────── # lgbm 배포 시: 기존 lgbm 백업 후 ONNX 파일 삭제 (ONNX 우선순위 때문에 lgbm이 무시되는 것 방지) # mlx 배포 시: lgbm 파일 삭제 (명시적으로 mlx만 사용) ssh "${LXC_HOST}" " - mkdir -p '${LXC_MODELS_PATH}' + mkdir -p '${REMOTE_MODEL_DIR}' if [ '$BACKEND' = 'lgbm' ]; then - if [ -f '${LXC_MODELS_PATH}/lgbm_filter.pkl' ]; then - cp '${LXC_MODELS_PATH}/lgbm_filter.pkl' '${LXC_MODELS_PATH}/lgbm_filter_prev.pkl' + if [ -f '${REMOTE_MODEL_DIR}/lgbm_filter.pkl' ]; then + cp '${REMOTE_MODEL_DIR}/lgbm_filter.pkl' '${REMOTE_MODEL_DIR}/lgbm_filter_prev.pkl' echo ' 기존 lgbm 모델 백업 완료' fi - if [ -f '${LXC_MODELS_PATH}/mlx_filter.weights.onnx' ]; then - rm '${LXC_MODELS_PATH}/mlx_filter.weights.onnx' + if [ -f '${REMOTE_MODEL_DIR}/mlx_filter.weights.onnx' ]; then + rm '${REMOTE_MODEL_DIR}/mlx_filter.weights.onnx' echo ' ONNX 파일 제거 완료 (lgbm 우선 적용)' fi else - if [ -f '${LXC_MODELS_PATH}/lgbm_filter.pkl' ]; then - rm '${LXC_MODELS_PATH}/lgbm_filter.pkl' + if [ -f '${REMOTE_MODEL_DIR}/lgbm_filter.pkl' ]; then + rm '${REMOTE_MODEL_DIR}/lgbm_filter.pkl' echo ' lgbm 파일 제거 완료 (mlx 우선 적용)' fi fi @@ -68,12 +100,12 @@ _send() { # ── 모델 파일 전송 ──────────────────────────────────────────────────────────── for f in "${LOCAL_FILES[@]}"; do - _send "$f" "${LXC_MODELS_PATH}/$(basename "$f")" + _send "$f" "${REMOTE_MODEL_DIR}/$(basename "$f")" done # ── 학습 로그 전송 ──────────────────────────────────────────────────────────── if [[ -f "$LOCAL_LOG" ]]; then - _send "$LOCAL_LOG" "${LXC_MODELS_PATH}/training_log.json" + _send "$LOCAL_LOG" "${REMOTE_MODEL_DIR}/training_log.json" echo " 학습 로그 전송 완료" fi diff --git a/scripts/fetch_history.py b/scripts/fetch_history.py index ddbfa12..e35ca35 100644 --- a/scripts/fetch_history.py +++ b/scripts/fetch_history.py @@ -333,9 +333,22 @@ def main(): ) args = parser.parse_args() - # 하위 호환: --symbol 단독 사용 시 symbols로 통합 - if args.symbol and args.symbols == ["XRPUSDT"]: - args.symbols = [args.symbol] + # --symbol 모드: 단일 거래 심볼 + 상관관계 심볼 자동 추가, 출력 경로 자동 결정 + if args.symbol: + from src.config import Config + try: + cfg = Config() + corr_symbols = cfg.correlation_symbols + except Exception: + corr_symbols = ["BTCUSDT", "ETHUSDT"] + args.symbols = [args.symbol] + corr_symbols + if args.output == "data/combined_15m.parquet": + sym_lower = args.symbol.lower() + os.makedirs(f"data/{sym_lower}", exist_ok=True) + args.output = f"data/{sym_lower}/combined_15m.parquet" + # 하위 호환: 단일 심볼만 지정된 경우 + elif args.symbols == ["XRPUSDT"] and not args.symbol: + pass # 기본값 유지 if len(args.symbols) == 1: df = asyncio.run(fetch_klines(args.symbols[0], args.interval, args.days)) diff --git a/scripts/train_and_deploy.sh b/scripts/train_and_deploy.sh index e6a2fa3..d1ae75d 100755 --- a/scripts/train_and_deploy.sh +++ b/scripts/train_and_deploy.sh @@ -1,13 +1,14 @@ #!/usr/bin/env bash # 맥미니에서 전체 학습 파이프라인을 실행하고 LXC로 배포한다. -# 사용법: bash scripts/train_and_deploy.sh [mlx|lgbm] [wf-splits] +# 사용법: bash scripts/train_and_deploy.sh [mlx|lgbm] [--symbol TRXUSDT] [--all] [wf-splits] # # 예시: -# bash scripts/train_and_deploy.sh # LightGBM + Walk-Forward 5폴드 (기본값) -# bash scripts/train_and_deploy.sh mlx # MLX GPU 학습 + Walk-Forward 5폴드 -# bash scripts/train_and_deploy.sh lgbm 3 # LightGBM + Walk-Forward 3폴드 -# bash scripts/train_and_deploy.sh mlx 0 # MLX 학습만 (Walk-Forward 건너뜀) -# bash scripts/train_and_deploy.sh lgbm 0 # LightGBM 학습만 (Walk-Forward 건너뜀) +# bash scripts/train_and_deploy.sh # 전체 심볼 (SYMBOLS 환경변수) + LightGBM +# bash scripts/train_and_deploy.sh --symbol TRXUSDT # TRXUSDT만 학습+배포 +# bash scripts/train_and_deploy.sh mlx --symbol TRXUSDT # MLX + TRXUSDT만 +# bash scripts/train_and_deploy.sh --all # 전체 심볼 순차 처리 +# bash scripts/train_and_deploy.sh lgbm 3 # 전체 심볼 + Walk-Forward 3폴드 +# bash scripts/train_and_deploy.sh mlx 0 # 전체 심볼 + MLX 학습만 (WF 건너뜀) set -euo pipefail @@ -26,77 +27,132 @@ else echo "경고: 가상환경을 찾을 수 없습니다 ($VENV_PATH). 시스템 Python을 사용합니다." >&2 fi -BACKEND="${1:-lgbm}" -WF_SPLITS="${2:-5}" # 두 번째 인자: Walk-Forward 폴드 수 (0이면 건너뜀) - cd "$PROJECT_ROOT" -mkdir -p data +# ── 인자 파싱 ─────────────────────────────────────────────────────────────── +BACKEND="lgbm" +WF_SPLITS="5" +SYMBOL_ARG="" +ALL_FLAG=false -PARQUET_FILE="data/combined_15m.parquet" +while [[ $# -gt 0 ]]; do + case "$1" in + --symbol) + SYMBOL_ARG="$2" + shift 2 + ;; + --all) + ALL_FLAG=true + shift + ;; + mlx|lgbm) + BACKEND="$1" + shift + ;; + *) + # 숫자면 WF_SPLITS로 처리 + if [[ "$1" =~ ^[0-9]+$ ]]; then + WF_SPLITS="$1" + fi + shift + ;; + esac +done -echo "" -echo "========================================" -echo " 학습 파이프라인 시작: $(date '+%Y-%m-%d %H:%M:%S %Z')" -echo "========================================" -echo "" - -echo "=== [1/3] 데이터 수집 (XRP + BTC + ETH 3심볼 + OI/펀딩비) ===" -if [ ! -f "$PARQUET_FILE" ]; then - echo " [최초 실행] 기존 데이터 없음 → 1년치(365일) 전체 수집 (--no-upsert)" - FETCH_DAYS=365 - UPSERT_FLAG="--no-upsert" +# ── 대상 심볼 결정 ────────────────────────────────────────────────────────── +if [ -n "$SYMBOL_ARG" ]; then + TARGETS=("$SYMBOL_ARG") else - echo " [일반 실행] 기존 데이터 존재 → 35일치 Upsert (OI/펀딩비 0.0 구간 보충)" - FETCH_DAYS=35 - UPSERT_FLAG="" + # .env에서 SYMBOLS 로드 (없으면 XRPUSDT 기본값) + TARGETS=($(python -c "from dotenv import load_dotenv; load_dotenv(); from src.config import Config; c=Config(); print(' '.join(c.symbols))")) fi -python scripts/fetch_history.py \ - --symbols XRPUSDT BTCUSDT ETHUSDT \ - --interval 15m \ - --days "$FETCH_DAYS" \ - $UPSERT_FLAG \ - --output "$PARQUET_FILE" - DECAY="${TIME_WEIGHT_DECAY:-2.0}" echo "" -echo "=== [1.5/3] OI 파생 피처 A/B 비교 ===" -python scripts/train_model.py --compare --data "$PARQUET_FILE" --decay "$DECAY" || true - +echo "========================================" +echo " 학습 파이프라인 시작: $(date '+%Y-%m-%d %H:%M:%S %Z')" +echo " 대상 심볼: ${TARGETS[*]}" +echo " 백엔드: ${BACKEND}, WF 폴드: ${WF_SPLITS}" +echo "========================================" echo "" -echo "=== [2/3] 모델 학습 (26개 피처: XRP 13 + BTC/ETH 8 + OI/펀딩비 2 + OI파생 2 + ADX) ===" -if [ "$BACKEND" = "mlx" ]; then - echo " 백엔드: MLX (Apple Silicon GPU), decay=${DECAY}" - python scripts/train_mlx_model.py --data data/combined_15m.parquet --decay "$DECAY" -else - echo " 백엔드: LightGBM (CPU), decay=${DECAY}" - python scripts/train_model.py --data data/combined_15m.parquet --decay "$DECAY" -fi -# Walk-Forward 검증 (WF_SPLITS > 0 인 경우) -if [ "$WF_SPLITS" -gt 0 ] 2>/dev/null; then +# ── 심볼별 파이프라인 ─────────────────────────────────────────────────────── +for SYM in "${TARGETS[@]}"; do + SYM_LOWER=$(echo "$SYM" | tr '[:upper:]' '[:lower:]') + mkdir -p "data/$SYM_LOWER" "models/$SYM_LOWER" + + PARQUET_FILE="data/$SYM_LOWER/combined_15m.parquet" + echo "" - echo "=== [2.5/3] Walk-Forward 검증 (${WF_SPLITS}폴드) ===" - if [ "$BACKEND" = "mlx" ]; then - python scripts/train_mlx_model.py \ - --data data/combined_15m.parquet \ - --decay "$DECAY" \ - --wf \ - --wf-splits "$WF_SPLITS" - else - python scripts/train_model.py \ - --data data/combined_15m.parquet \ - --decay "$DECAY" \ - --wf \ - --wf-splits "$WF_SPLITS" - fi -fi + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " [$SYM] 파이프라인 시작" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "=== [3/3] LXC 배포 ===" -bash scripts/deploy_model.sh "$BACKEND" + # === [1/3] 데이터 수집 === + echo "" + echo "=== [$SYM] [1/3] 데이터 수집 (+ BTC/ETH 상관관계 + OI/펀딩비) ===" + if [ ! -f "$PARQUET_FILE" ]; then + echo " [최초 실행] 기존 데이터 없음 → 1년치(365일) 전체 수집 (--no-upsert)" + FETCH_DAYS=365 + UPSERT_FLAG="--no-upsert" + else + echo " [일반 실행] 기존 데이터 존재 → 35일치 Upsert (OI/펀딩비 0.0 구간 보충)" + FETCH_DAYS=35 + UPSERT_FLAG="" + fi + + python scripts/fetch_history.py \ + --symbol "$SYM" \ + --interval 15m \ + --days "$FETCH_DAYS" \ + $UPSERT_FLAG + + # === [1.5/3] OI 파생 피처 A/B 비교 === + echo "" + echo "=== [$SYM] [1.5/3] OI 파생 피처 A/B 비교 ===" + python scripts/train_model.py --compare --symbol "$SYM" --decay "$DECAY" || true + + # === [2/3] 모델 학습 === + echo "" + echo "=== [$SYM] [2/3] 모델 학습 ===" + if [ "$BACKEND" = "mlx" ]; then + echo " 백엔드: MLX (Apple Silicon GPU), decay=${DECAY}" + python scripts/train_mlx_model.py --data "$PARQUET_FILE" --decay "$DECAY" + else + echo " 백엔드: LightGBM (CPU), decay=${DECAY}" + python scripts/train_model.py --symbol "$SYM" --decay "$DECAY" + fi + + # Walk-Forward 검증 (WF_SPLITS > 0 인 경우) + if [ "$WF_SPLITS" -gt 0 ] 2>/dev/null; then + echo "" + echo "=== [$SYM] [2.5/3] Walk-Forward 검증 (${WF_SPLITS}폴드) ===" + if [ "$BACKEND" = "mlx" ]; then + python scripts/train_mlx_model.py \ + --data "$PARQUET_FILE" \ + --decay "$DECAY" \ + --wf \ + --wf-splits "$WF_SPLITS" + else + python scripts/train_model.py \ + --symbol "$SYM" \ + --decay "$DECAY" \ + --wf \ + --wf-splits "$WF_SPLITS" + fi + fi + + # === [3/3] 배포 === + echo "" + echo "=== [$SYM] [3/3] LXC 배포 ===" + bash scripts/deploy_model.sh "$BACKEND" --symbol "$SYM" + + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " [$SYM] 파이프라인 완료" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +done echo "" echo "=== 전체 파이프라인 완료: $(date '+%Y-%m-%d %H:%M:%S %Z') ===" diff --git a/scripts/train_model.py b/scripts/train_model.py index dbed0a7..aabbfa4 100644 --- a/scripts/train_model.py +++ b/scripts/train_model.py @@ -531,7 +531,9 @@ def compare(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: s def main(): parser = argparse.ArgumentParser() - parser.add_argument("--data", default="data/combined_15m.parquet") + parser.add_argument("--data", default=None) + parser.add_argument("--symbol", type=str, default=None, + help="학습 대상 심볼 (예: TRXUSDT). data/{symbol}/ 에서 데이터 로드, models/{symbol}/ 에 저장") parser.add_argument( "--decay", type=float, default=2.0, help="시간 가중치 감쇠 강도 (0=균등, 2.0=최신이 ~7.4배 높음)", @@ -546,6 +548,20 @@ def main(): help="OI 파생 피처 추가 전후 A/B 성능 비교") args = parser.parse_args() + # --symbol 모드: 심볼별 디렉토리 경로 자동 결정 + if args.symbol: + sym_lower = args.symbol.lower() + if args.data is None: + args.data = f"data/{sym_lower}/combined_15m.parquet" + global MODEL_PATH, PREV_MODEL_PATH, LOG_PATH, ACTIVE_PARAMS_PATH + MODEL_PATH = Path(f"models/{sym_lower}/lgbm_filter.pkl") + PREV_MODEL_PATH = Path(f"models/{sym_lower}/lgbm_filter_prev.pkl") + LOG_PATH = Path(f"models/{sym_lower}/training_log.json") + ACTIVE_PARAMS_PATH = Path(f"models/{sym_lower}/active_lgbm_params.json") + MODEL_PATH.parent.mkdir(parents=True, exist_ok=True) + elif args.data is None: + args.data = "data/combined_15m.parquet" + if args.compare: compare(args.data, time_weight_decay=args.decay, tuned_params_path=args.tuned_params) elif args.wf: diff --git a/scripts/tune_hyperparams.py b/scripts/tune_hyperparams.py index f77bf6a..119b0f0 100755 --- a/scripts/tune_hyperparams.py +++ b/scripts/tune_hyperparams.py @@ -308,9 +308,10 @@ def measure_baseline( n_splits: int, train_ratio: float, min_recall: float = 0.35, + active_params_path: "Path | None" = None, ) -> tuple[float, dict]: """현재 실전 파라미터(active 파일 또는 하드코딩 기본값)로 베이스라인을 측정한다.""" - active_path = Path("models/active_lgbm_params.json") + active_path = active_params_path or Path("models/active_lgbm_params.json") if active_path.exists(): with open(active_path, "r", encoding="utf-8") as f: @@ -518,7 +519,9 @@ def save_results( def main(): parser = argparse.ArgumentParser(description="Optuna LightGBM 하이퍼파라미터 튜닝") - parser.add_argument("--data", default="data/combined_15m.parquet", help="학습 데이터 경로") + parser.add_argument("--data", default=None, help="학습 데이터 경로") + parser.add_argument("--symbol", type=str, default=None, + help="튜닝 대상 심볼 (예: TRXUSDT). data/{symbol}/ 에서 데이터 로드, models/{symbol}/ 에 저장") parser.add_argument("--trials", type=int, default=50, help="Optuna trial 수 (기본: 50)") parser.add_argument("--folds", type=int, default=5, help="Walk-Forward 폴드 수 (기본: 5)") parser.add_argument("--train-ratio", type=float, default=0.6, help="학습 구간 비율 (기본: 0.6)") @@ -526,16 +529,31 @@ def main(): parser.add_argument("--no-baseline", action="store_true", help="베이스라인 측정 건너뜀") args = parser.parse_args() + # --symbol 모드: 심볼별 디렉토리 경로 자동 결정 + if args.symbol: + sym_lower = args.symbol.lower() + if args.data is None: + args.data = f"data/{sym_lower}/combined_15m.parquet" + elif args.data is None: + args.data = "data/combined_15m.parquet" + # 1. 데이터셋 로드 (1회) X, y, w, source = load_dataset(args.data) # 2. 베이스라인 측정 + if args.symbol: + sym_lower = args.symbol.lower() + _active_params_path = Path(f"models/{sym_lower}/active_lgbm_params.json") + else: + _active_params_path = None + if args.no_baseline: baseline_score, baseline_details = 0.0, {} print("베이스라인 측정 건너뜀 (--no-baseline)\n") else: baseline_score, baseline_details = measure_baseline( X, y, w, source, args.folds, args.train_ratio, args.min_recall, + active_params_path=_active_params_path, ) bl_prec = baseline_details.get("mean_precision", 0.0) bl_auc = baseline_details.get("mean_auc", 0.0) @@ -593,16 +611,28 @@ def main(): elapsed = time.time() - start_time # 4. 결과 저장 및 출력 + import shutil output_path = save_results( study, baseline_score, baseline_details, elapsed, args.data, args.min_recall, ) + # --symbol 모드: 결과 파일을 심볼별 디렉토리로 이동 + if args.symbol: + sym_lower = args.symbol.lower() + sym_model_dir = Path(f"models/{sym_lower}") + sym_model_dir.mkdir(parents=True, exist_ok=True) + dest = sym_model_dir / output_path.name + shutil.move(str(output_path), str(dest)) + output_path = dest print_report( study, baseline_score, baseline_details, elapsed, output_path, args.min_recall, ) # 5. 성능 개선 시 active 파일 자동 갱신 - import shutil - active_path = Path("models/active_lgbm_params.json") + if args.symbol: + sym_lower = args.symbol.lower() + active_path = Path(f"models/{sym_lower}/active_lgbm_params.json") + else: + active_path = Path("models/active_lgbm_params.json") if not args.no_baseline and study.best_value > baseline_score: shutil.copy(output_path, active_path) best_prec = study.best_trial.user_attrs.get("mean_precision", 0.0)