""" 백테스트 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 from app.services.strategy_config_service import strategy_cfg router = APIRouter() logger = logging.getLogger(__name__) # ── 전략 프리셋 정의 (모듈 레벨) ─────────────────────────────────────────────── _STRATEGY_PRESETS: Dict[str, Dict[str, Any]] = { "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, }, }, } async def _get_preset(preset_key: str) -> Optional[Dict[str, Any]]: """프리셋 이름으로 파라미터 반환. 'current'이면 DB 현재 설정 로드.""" if preset_key == "current": await strategy_cfg._refresh() params = { "MEAN_REVERSION_STOP_LOSS_PCT": strategy_cfg.get("STOP_LOSS_PERCENT", -6.0), "MEAN_REVERSION_TAKE_PROFIT_PCT": strategy_cfg.get("TAKE_PROFIT_PERCENT", 12.0), "MAX_TOTAL_POSITIONS": strategy_cfg.get("MAX_HOLDINGS_COUNT", 5), "MEAN_REVERSION_RSI_THRESHOLD": strategy_cfg.get("MEAN_REVERSION_RSI_THRESHOLD", 30.0), } return {"name": "현재 설정", "params": params} return _STRATEGY_PRESETS.get(preset_key) # 전역 변수: 생성된 학습 데이터 임시 저장 _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 ComparePresetsUniverseRequest(BaseModel): universe_preset: str = Field("large_cap", description="유니버스 프리셋") 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개만 반환") class GenerateTrainingDataUniverseRequest(BaseModel): """전체 종목 ML 학습 데이터 생성 요청""" universe_preset: str = Field("large_cap", 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 레이블 임계값") down_threshold: float = Field(-0.03, description="DOWN 레이블 임계값") min_samples_per_stock: int = Field(50, description="종목당 최소 샘플 수") # ── API 엔드포인트 ───────────────────────────────────────────────────── @router.get("/universe-presets") async def api_get_universe_presets() -> Dict[str, Any]: """ 사전 정의된 종목 유니버스 프리셋 목록 Returns: {'large_cap': {'description': '...', 'filter': {...}}, ...} """ presets = StockUniverse.get_preset_universes() # StockFilter를 dict로 변환 from dataclasses import asdict result = {} for key, preset_data in presets.items(): result[key] = { "description": preset_data["description"], "filter": asdict(preset_data["filter"]) } return result @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}") # 새로운 구조: {'description': '...', 'filter': StockFilter(...)} stock_filter = presets[req.universe_preset]["filter"] 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. 전략 파라미터 ('current'이면 DB에서 로드) preset = await _get_preset(req.strategy_preset) or await _get_preset("balanced") strategy_params = preset["params"] # 3. 각 종목별로 백테스트 (병렬) no_data_count = 0 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) # DB에 해당 종목/기간 데이터 없으면 제외 if not result.equity_curve: return None 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]) valid = [r for r in batch_results if r is not None] no_data_count += len(batch) - len(valid) results.extend(valid) logger.info(f"진행률: {len(results)}/{len(stock_codes)} (데이터없음: {no_data_count})") # 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), "no_data_count": no_data_count, "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-universe") async def api_generate_training_data_universe(req: GenerateTrainingDataUniverseRequest) -> Dict[str, Any]: """ 전체 종목 ML 학습 데이터 생성 (유니버스 프리셋 기반) 유니버스에 속한 모든 종목의 과거 데이터를 병렬로 수집하여 학습 데이터를 생성합니다. """ 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)") # 유니버스 프리셋에서 종목 리스트 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]["filter"] 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)}종목") config = TrainingDataConfig( stock_codes=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"]) _cached_training_data = result return { "total_samples": result["total_samples"], "label_distribution": result["label_distribution"], "config": result["config"], "universe_preset": req.universe_preset, "total_stocks_requested": len(stock_codes), "message": f"{len(stock_codes)}종목에서 {result['total_samples']}개 샘플 생성 완료. /train-model로 학습하거나 /download-training-data로 다운로드하세요.", } @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]: """ 전략 성과 백테스트 실행 strategy_params가 없으면 전략 설정 페이지의 현재 DB 값을 자동 사용합니다. """ 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="날짜 형식 오류") # strategy_params 없으면 DB 현재 설정 자동 로드 strategy_params = req.strategy_params if strategy_params is None: preset = await _get_preset("current") strategy_params = preset["params"] logger.info(f"strategy_params 미지정 → DB 현재 설정 사용: {strategy_params}") config = BacktestConfig( stock_codes=req.stock_codes, start_date=start_date, end_date=end_date, initial_capital=req.initial_capital, strategy_params=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="날짜 형식 오류") results = {} for preset_key in req.presets: preset = await _get_preset(preset_key) if preset is None: continue 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), } @router.post("/compare-presets-universe") async def api_compare_presets_universe(req: ComparePresetsUniverseRequest) -> 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="날짜 형식 오류") presets_map = StockUniverse.get_preset_universes() if req.universe_preset not in presets_map: raise HTTPException(status_code=400, detail=f"잘못된 유니버스 프리셋: {req.universe_preset}") stock_filter = presets_map[req.universe_preset]["filter"] stock_codes = await StockUniverse.get_filtered_stocks(stock_filter) if not stock_codes: raise HTTPException(status_code=404, detail="해당 유니버스에 종목이 없습니다") logger.info(f"유니버스 프리셋 비교 시작: {req.universe_preset} ({len(stock_codes)}종목)") results = {} for preset_key in req.presets: preset = await _get_preset(preset_key) if preset is None: continue config = BacktestConfig( stock_codes=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(stock_codes), "universe_preset": req.universe_preset, }