feat: Add backtest API endpoints for ML training and strategy testing
This commit is contained in:
275
backend/app/api/endpoints/backtest.py
Normal file
275
backend/app/api/endpoints/backtest.py
Normal 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),
|
||||
}
|
||||
Reference in New Issue
Block a user