Files
auto-stock/backend/app/api/endpoints/backtest.py

428 lines
16 KiB
Python

"""
백테스트 API 엔드포인트
POST /api/backtest/generate-training-data — ML 학습 데이터 생성
POST /api/backtest/run — 전략 성과 백테스트
POST /api/backtest/compare-presets — 프리셋 비교
POST /api/backtest/train-model — 생성된 데이터로 ML 모델 학습
GET /api/backtest/universe-presets — 종목 유니버스 프리셋 목록
POST /api/backtest/run-universe — 전체 종목 백테스트 (최상위 N개)
"""
import asyncio
import logging
from datetime import date, datetime
from typing import Dict, List, Any, Optional
import numpy as np
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.stock_universe import StockUniverse, StockFilter
from app.services.ml_ensemble import get_ensemble
from app.services.feature_engineering import FEATURE_COLUMNS
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="비교할 프리셋 목록")
class RunUniverseBacktestRequest(BaseModel):
"""전체 종목 백테스트 요청"""
universe_preset: str = Field("large_cap", description="유니버스 프리셋 (large_cap, kospi_all, ...)")
start_date: str
end_date: str
strategy_preset: str = Field("balanced", description="전략 프리셋")
initial_capital: float = 10_000_000
max_positions: int = 5
top_n_results: int = Field(20, description="성과 상위 N개만 반환")
# ── API 엔드포인트 ─────────────────────────────────────────────────────────────
@router.get("/universe-presets")
async def api_get_universe_presets() -> Dict[str, Any]:
"""
사전 정의된 종목 유니버스 프리셋 목록
Returns:
{'large_cap': {...}, 'kospi_all': {...}, ...}
"""
presets = StockUniverse.get_preset_universes()
# StockFilter를 dict로 변환
from dataclasses import asdict
return {
key: {
**asdict(filter_obj),
"description": _get_preset_description(key)
}
for key, filter_obj in presets.items()
}
def _get_preset_description(preset_key: str) -> str:
"""프리셋 설명"""
descriptions = {
"all": "전체 종목 (관리종목 제외)",
"kospi_all": "KOSPI 전체",
"kosdaq_all": "KOSDAQ 전체",
"large_cap": "대형주 (KOSPI 시총 1조 이상, 상위 100)",
"mid_cap": "중형주 (KOSPI 시총 3천억 이상, 상위 200)",
"liquid_kosdaq": "유동성 높은 KOSDAQ (거래량 20만주+, 상위 100)",
"penny_stocks": "저가주 (1~5천원, 고거래량, 상위 50)",
}
return descriptions.get(preset_key, "")
@router.post("/run-universe")
async def api_run_universe_backtest(req: RunUniverseBacktestRequest) -> Dict[str, Any]:
"""
전체 종목 백테스트 (병렬 실행)
선택한 유니버스의 모든 종목을 각각 백테스트하여 성과 상위 N개를 반환합니다.
예: large_cap 100종목을 모두 테스트 → 수익률 Top 20 반환
"""
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="날짜 형식 오류")
# 1. 유니버스 프리셋에서 종목 리스트 가져오기
presets = StockUniverse.get_preset_universes()
if req.universe_preset not in presets:
raise HTTPException(status_code=400, detail=f"잘못된 유니버스 프리셋: {req.universe_preset}")
stock_filter = presets[req.universe_preset]
stock_codes = await StockUniverse.get_filtered_stocks(stock_filter)
if not stock_codes:
raise HTTPException(status_code=404, detail="해당 유니버스에 종목이 없습니다")
logger.info(f"전체 백테스트 시작: {len(stock_codes)}종목")
# 2. 전략 파라미터
PRESETS = {
"conservative": {
"MEAN_REVERSION_STOP_LOSS_PCT": -5.0,
"MEAN_REVERSION_TAKE_PROFIT_PCT": 8.0,
"MAX_TOTAL_POSITIONS": 3,
"MEAN_REVERSION_RSI_THRESHOLD": 25,
},
"balanced": {
"MEAN_REVERSION_STOP_LOSS_PCT": -6.0,
"MEAN_REVERSION_TAKE_PROFIT_PCT": 12.0,
"MAX_TOTAL_POSITIONS": 5,
"MEAN_REVERSION_RSI_THRESHOLD": 30,
},
"aggressive": {
"MEAN_REVERSION_STOP_LOSS_PCT": -8.0,
"MEAN_REVERSION_TAKE_PROFIT_PCT": 20.0,
"MAX_TOTAL_POSITIONS": 8,
"MEAN_REVERSION_RSI_THRESHOLD": 35,
},
}
strategy_params = PRESETS.get(req.strategy_preset, PRESETS["balanced"])
# 3. 각 종목별로 백테스트 (병렬)
async def backtest_single(code: str) -> Optional[Dict[str, Any]]:
try:
config = BacktestConfig(
stock_codes=[code],
start_date=start_date,
end_date=end_date,
initial_capital=req.initial_capital,
strategy_params=strategy_params,
max_positions=1, # 단일 종목
position_size=1.0,
)
engine = BacktestEngine()
result = await engine.run_backtest(config)
return {
"stock_code": code,
"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,
}
except Exception as e:
logger.error(f"{code} 백테스트 실패: {e}")
return None
# 병렬 실행 (10개씩 배치)
results = []
batch_size = 10
for i in range(0, len(stock_codes), batch_size):
batch = stock_codes[i:i + batch_size]
batch_results = await asyncio.gather(*[backtest_single(code) for code in batch])
results.extend([r for r in batch_results if r is not None])
logger.info(f"진행률: {len(results)}/{len(stock_codes)}")
# 4. 수익률 상위 N개 정렬
results_sorted = sorted(results, key=lambda x: x["total_return_pct"], reverse=True)
top_results = results_sorted[:req.top_n_results]
logger.info(f"전체 백테스트 완료: {len(results)}종목 테스트, 상위 {len(top_results)}개 반환")
return {
"total_tested": len(results),
"top_performers": top_results,
"universe_preset": req.universe_preset,
"strategy_preset": req.strategy_preset,
"period": f"{req.start_date} ~ {req.end_date}",
"average_return": np.mean([r["total_return_pct"] for r in results]) if results else 0.0,
}
@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"]
# 피처와 레이블 분리
X_tabular = df[FEATURE_COLUMNS].values
y = df["label"].values
# LSTM 시퀀스는 간략화 (실제로는 시계열 데이터 필요)
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),
}