From 87b4205fb6a99de6b5a3f028749087dd7f994953 Mon Sep 17 00:00:00 2001 From: YoungWooSeo Date: Fri, 6 Mar 2026 21:55:45 +0900 Subject: [PATCH] feat: Add bulk backtest endpoints for entire stock universe --- backend/app/api/endpoints/backtest.py | 158 +++++++++++++++++++++++++- 1 file changed, 155 insertions(+), 3 deletions(-) diff --git a/backend/app/api/endpoints/backtest.py b/backend/app/api/endpoints/backtest.py index fde844b..4db991d 100644 --- a/backend/app/api/endpoints/backtest.py +++ b/backend/app/api/endpoints/backtest.py @@ -5,6 +5,8 @@ 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 @@ -12,6 +14,7 @@ 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 @@ -21,8 +24,9 @@ from app.services.backtest_engine import ( 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 prepare_training_data, db_data_to_dataframe, extract_features +from app.services.feature_engineering import FEATURE_COLUMNS router = APIRouter() logger = logging.getLogger(__name__) @@ -61,8 +65,158 @@ class ComparePresetsRequest(BaseModel): 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]: """ @@ -142,12 +296,10 @@ async def api_train_model(background_tasks: BackgroundTasks) -> Dict[str, Any]: 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():