feat: Add backtest API endpoints for ML training and strategy testing

This commit is contained in:
2026-03-06 21:26:56 +09:00
parent 943708e68e
commit d54785f79d

View File

@@ -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),
}