From d54785f79debd8a8d50455b4aa0e2457bce45286 Mon Sep 17 00:00:00 2001 From: YoungWooSeo Date: Fri, 6 Mar 2026 21:26:56 +0900 Subject: [PATCH] feat: Add backtest API endpoints for ML training and strategy testing --- backend/app/api/endpoints/backtest.py | 275 ++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 backend/app/api/endpoints/backtest.py diff --git a/backend/app/api/endpoints/backtest.py b/backend/app/api/endpoints/backtest.py new file mode 100644 index 0000000..fde844b --- /dev/null +++ b/backend/app/api/endpoints/backtest.py @@ -0,0 +1,275 @@ +""" +백테스트 API 엔드포인트 + +POST /api/backtest/generate-training-data — ML 학습 데이터 생성 +POST /api/backtest/run — 전략 성과 백테스트 +POST /api/backtest/compare-presets — 프리셋 비교 +POST /api/backtest/train-model — 생성된 데이터로 ML 모델 학습 +""" + +import asyncio +import logging +from datetime import date, datetime +from typing import Dict, List, Any, Optional + +from fastapi import APIRouter, HTTPException, BackgroundTasks +from pydantic import BaseModel, Field + +from app.services.backtest_engine import ( + BacktestEngine, + BacktestConfig, + TrainingDataConfig, + generate_training_data, +) +from app.services.ml_ensemble import get_ensemble +from app.services.feature_engineering import prepare_training_data, db_data_to_dataframe, extract_features + +router = APIRouter() +logger = logging.getLogger(__name__) + +# 전역 변수: 생성된 학습 데이터 임시 저장 +_cached_training_data: Optional[Dict[str, Any]] = None + + +# ── Pydantic 스키마 ──────────────────────────────────────────────────────────── + +class GenerateTrainingDataRequest(BaseModel): + stock_codes: List[str] = Field(..., description="대상 종목 코드 목록") + start_date: str = Field(..., description="시작일 (YYYY-MM-DD)") + end_date: str = Field(..., description="종료일 (YYYY-MM-DD)") + forward_days: int = Field(5, description="N일 후 수익률로 라벨링") + up_threshold: float = Field(0.03, description="UP 레이블 임계값 (e.g., 0.03 = +3%)") + down_threshold: float = Field(-0.03, description="DOWN 레이블 임계값 (e.g., -0.03 = -3%)") + min_samples_per_stock: int = Field(50, description="종목당 최소 샘플 수") + + +class RunBacktestRequest(BaseModel): + stock_codes: List[str] = Field(..., description="대상 종목 코드") + start_date: str = Field(..., description="시작일 (YYYY-MM-DD)") + end_date: str = Field(..., description="종료일 (YYYY-MM-DD)") + initial_capital: float = Field(10_000_000, description="초기 자본") + strategy_params: Optional[Dict[str, Any]] = Field(None, description="전략 파라미터 (프리셋 값)") + max_positions: int = Field(5, description="최대 보유 종목 수") + position_size: float = Field(0.2, description="종목당 포지션 비율 (0.2 = 20%)") + + +class ComparePresetsRequest(BaseModel): + stock_codes: List[str] + start_date: str + end_date: str + initial_capital: float = 10_000_000 + presets: List[str] = Field(["conservative", "balanced", "aggressive"], description="비교할 프리셋 목록") + + +# ── API 엔드포인트 ───────────────────────────────────────────────────────────── + +@router.post("/generate-training-data") +async def api_generate_training_data(req: GenerateTrainingDataRequest) -> Dict[str, Any]: + """ + ML 학습 데이터 생성 (TensorFlow/PyTorch 학습용) + + 과거 데이터를 분석하여 "N일 후 +X% 상승 = UP" 형태로 라벨링합니다. + 생성된 데이터는 /train-model 엔드포인트로 학습할 수 있습니다. + """ + global _cached_training_data + + try: + start_date = datetime.strptime(req.start_date, "%Y-%m-%d").date() + end_date = datetime.strptime(req.end_date, "%Y-%m-%d").date() + except ValueError: + raise HTTPException(status_code=400, detail="날짜 형식 오류 (YYYY-MM-DD 형식 사용)") + + config = TrainingDataConfig( + stock_codes=req.stock_codes, + start_date=start_date, + end_date=end_date, + forward_days=req.forward_days, + up_threshold=req.up_threshold, + down_threshold=req.down_threshold, + min_samples_per_stock=req.min_samples_per_stock, + ) + + result = await generate_training_data(config) + + if "error" in result: + raise HTTPException(status_code=500, detail=result["error"]) + + # 데이터 캠싱 (CSV 다운로드용) + _cached_training_data = result + + # DataFrame을 직렬화할 수 없으므로 통계만 반환 + return { + "total_samples": result["total_samples"], + "label_distribution": result["label_distribution"], + "config": result["config"], + "message": "학습 데이터 생성 완료. /train-model로 학습하거나 /download-training-data로 다운로드하세요." + } + + +@router.get("/download-training-data") +async def api_download_training_data(): + """생성된 학습 데이터 CSV 다운로드""" + from fastapi.responses import StreamingResponse + import io + + if _cached_training_data is None or "data" not in _cached_training_data: + raise HTTPException(status_code=404, detail="생성된 데이터가 없습니다. 먼저 /generate-training-data를 호출하세요.") + + df = _cached_training_data["data"] + csv_buffer = io.StringIO() + df.to_csv(csv_buffer, index=False, encoding="utf-8-sig") + csv_buffer.seek(0) + + return StreamingResponse( + iter([csv_buffer.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=training_data.csv"} + ) + + +@router.post("/train-model") +async def api_train_model(background_tasks: BackgroundTasks) -> Dict[str, Any]: + """ + 생성된 학습 데이터로 ML 모델 학습 (LightGBM + LSTM + RF) + + 백그라운드에서 실행되며, /ml-ensemble/status로 진행 상황을 확인할 수 있습니다. + """ + global _cached_training_data + + if _cached_training_data is None or "data" not in _cached_training_data: + raise HTTPException(status_code=400, detail="학습 데이터가 없습니다. 먼저 /generate-training-data를 호출하세요.") + + df = _cached_training_data["data"] + + # 피처와 레이블 분리 + from app.services.feature_engineering import FEATURE_COLUMNS + X_tabular = df[FEATURE_COLUMNS].values + y = df["label"].values + + # LSTM 시퀀스는 간략화 (실제로는 시계열 데이터 필요) + # TODO: 실제 구현 시 db_data를 활용하여 X_seq 생성 + X_seq = np.zeros((len(X_tabular), 60, 5)) # Placeholder + + async def train_task(): + ensemble = get_ensemble() + metrics = await ensemble.train_async(X_tabular, X_seq, y) + logger.info(f"ML 모델 학습 완료: {metrics}") + + background_tasks.add_task(train_task) + + return { + "status": "training_started", + "message": "모델 학습이 백그라운드에서 실행 중입니다. /ml-ensemble/status로 확인하세요.", + "total_samples": len(X_tabular) + } + + +@router.post("/run") +async def api_run_backtest(req: RunBacktestRequest) -> Dict[str, Any]: + """ + 전략 성과 백테스트 실행 + + 과거 데이터로 전략을 실행하여 수익률, MDD, 승률 등을 계산합니다. + """ + try: + start_date = datetime.strptime(req.start_date, "%Y-%m-%d").date() + end_date = datetime.strptime(req.end_date, "%Y-%m-%d").date() + except ValueError: + raise HTTPException(status_code=400, detail="날짜 형식 오류") + + config = BacktestConfig( + stock_codes=req.stock_codes, + start_date=start_date, + end_date=end_date, + initial_capital=req.initial_capital, + strategy_params=req.strategy_params, + max_positions=req.max_positions, + position_size=req.position_size, + ) + + engine = BacktestEngine() + result = await engine.run_backtest(config) + + # BacktestResult를 dict로 변환 + from dataclasses import asdict + return asdict(result) + + +@router.post("/compare-presets") +async def api_compare_presets(req: ComparePresetsRequest) -> Dict[str, Any]: + """ + 프리셋 비교 백테스트 + + 보수적/균형형/공격적 프리셋의 과거 성과를 비교합니다. + """ + try: + start_date = datetime.strptime(req.start_date, "%Y-%m-%d").date() + end_date = datetime.strptime(req.end_date, "%Y-%m-%d").date() + except ValueError: + raise HTTPException(status_code=400, detail="날짜 형식 오류") + + # 프리셋 정의 (frontend/src/utils/strategyPresets.ts와 동기화) + PRESETS = { + "conservative": { + "name": "보수적", + "params": { + "MEAN_REVERSION_STOP_LOSS_PCT": -5.0, + "MEAN_REVERSION_TAKE_PROFIT_PCT": 8.0, + "MAX_TOTAL_POSITIONS": 3, + "MEAN_REVERSION_RSI_THRESHOLD": 25, + } + }, + "balanced": { + "name": "균형형", + "params": { + "MEAN_REVERSION_STOP_LOSS_PCT": -6.0, + "MEAN_REVERSION_TAKE_PROFIT_PCT": 12.0, + "MAX_TOTAL_POSITIONS": 5, + "MEAN_REVERSION_RSI_THRESHOLD": 30, + } + }, + "aggressive": { + "name": "공격적", + "params": { + "MEAN_REVERSION_STOP_LOSS_PCT": -8.0, + "MEAN_REVERSION_TAKE_PROFIT_PCT": 20.0, + "MAX_TOTAL_POSITIONS": 8, + "MEAN_REVERSION_RSI_THRESHOLD": 35, + } + }, + } + + results = {} + + for preset_key in req.presets: + if preset_key not in PRESETS: + continue + + preset = PRESETS[preset_key] + config = BacktestConfig( + stock_codes=req.stock_codes, + start_date=start_date, + end_date=end_date, + initial_capital=req.initial_capital, + strategy_params=preset["params"], + max_positions=preset["params"]["MAX_TOTAL_POSITIONS"], + ) + + engine = BacktestEngine() + result = await engine.run_backtest(config) + + results[preset_key] = { + "name": preset["name"], + "total_return_pct": result.total_return_pct, + "mdd_pct": result.mdd_pct, + "win_rate": result.win_rate, + "sharpe_ratio": result.sharpe_ratio, + "total_trades": result.total_trades, + "equity_curve": result.equity_curve, + } + + return { + "presets": results, + "period": f"{req.start_date} ~ {req.end_date}", + "stock_count": len(req.stock_codes), + }