feat: Add bulk backtest endpoints for entire stock universe
This commit is contained in:
@@ -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():
|
||||||
|
|||||||
Reference in New Issue
Block a user