feat: Add bulk backtest endpoints for entire stock universe

This commit is contained in:
2026-03-06 21:55:45 +09:00
parent da4732f1e9
commit 87b4205fb6

View File

@@ -5,6 +5,8 @@ POST /api/backtest/generate-training-data — ML 학습 데이터 생성
POST /api/backtest/run — 전략 성과 백테스트 POST /api/backtest/run — 전략 성과 백테스트
POST /api/backtest/compare-presets — 프리셋 비교 POST /api/backtest/compare-presets — 프리셋 비교
POST /api/backtest/train-model — 생성된 데이터로 ML 모델 학습 POST /api/backtest/train-model — 생성된 데이터로 ML 모델 학습
GET /api/backtest/universe-presets — 종목 유니버스 프리셋 목록
POST /api/backtest/run-universe — 전체 종목 백테스트 (최상위 N개)
""" """
import asyncio import asyncio
@@ -12,6 +14,7 @@ import logging
from datetime import date, datetime from datetime import date, datetime
from typing import Dict, List, Any, Optional from typing import Dict, List, Any, Optional
import numpy as np
from fastapi import APIRouter, HTTPException, BackgroundTasks from fastapi import APIRouter, HTTPException, BackgroundTasks
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -21,8 +24,9 @@ from app.services.backtest_engine import (
TrainingDataConfig, TrainingDataConfig,
generate_training_data, generate_training_data,
) )
from app.services.stock_universe import StockUniverse, StockFilter
from app.services.ml_ensemble import get_ensemble from app.services.ml_ensemble import get_ensemble
from app.services.feature_engineering import prepare_training_data, db_data_to_dataframe, extract_features from app.services.feature_engineering import FEATURE_COLUMNS
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -61,8 +65,158 @@ class ComparePresetsRequest(BaseModel):
presets: List[str] = Field(["conservative", "balanced", "aggressive"], description="비교할 프리셋 목록") 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 엔드포인트 ───────────────────────────────────────────────────────────── # ── 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") @router.post("/generate-training-data")
async def api_generate_training_data(req: GenerateTrainingDataRequest) -> Dict[str, Any]: async def api_generate_training_data(req: GenerateTrainingDataRequest) -> Dict[str, Any]:
""" """
@@ -142,12 +296,10 @@ async def api_train_model(background_tasks: BackgroundTasks) -> Dict[str, Any]:
df = _cached_training_data["data"] df = _cached_training_data["data"]
# 피처와 레이블 분리 # 피처와 레이블 분리
from app.services.feature_engineering import FEATURE_COLUMNS
X_tabular = df[FEATURE_COLUMNS].values X_tabular = df[FEATURE_COLUMNS].values
y = df["label"].values y = df["label"].values
# LSTM 시퀀스는 간략화 (실제로는 시계열 데이터 필요) # LSTM 시퀀스는 간략화 (실제로는 시계열 데이터 필요)
# TODO: 실제 구현 시 db_data를 활용하여 X_seq 생성
X_seq = np.zeros((len(X_tabular), 60, 5)) # Placeholder X_seq = np.zeros((len(X_tabular), 60, 5)) # Placeholder
async def train_task(): async def train_task():