""" 국내 주식 백테스트 API v1 엔드포인트 POST /api/v1/kr-backtest/run -- 백테스트 실행 GET /api/v1/kr-backtest/strategies -- 사용 가능한 전략 목록 POST /api/v1/kr-backtest/walk-forward -- Walk-Forward 최적화 실행 """ import logging from dataclasses import asdict from datetime import datetime from typing import Dict, List, Any, Optional from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field from app.services.kr_backtest_engine import ( KrBacktestEngine, get_available_strategies, ) from app.services.walk_forward import ( WalkForwardOptimizer, WalkForwardConfig, ) router = APIRouter() logger = logging.getLogger(__name__) # ── Pydantic 스키마 ────────────────────────────────────────────────────── class KrBacktestRunRequest(BaseModel): stock_codes: List[str] = Field( ..., description="대상 종목 코드 목록 (예: ['005930', '035720'])" ) start_date: str = Field(..., description="시작일 (YYYY-MM-DD)") end_date: str = Field(..., description="종료일 (YYYY-MM-DD)") strategy: str = Field( "mean_reversion", description="전략 키 (mean_reversion 등)" ) params: Optional[Dict[str, Any]] = Field( None, description="전략 파라미터 (None이면 기본값)" ) initial_capital: float = Field( 10_000_000, ge=100_000, description="초기 자본금 (원)" ) market: str = Field( "KOSPI", description="시장 구분 (KOSPI/KOSDAQ) — 세금 계산용" ) random_seed: Optional[int] = Field( 42, description="슬리피지 재현성 시드 (None이면 매번 다름)" ) # ── 백테스트 실행 / 전략 목록 API ────────────────────────────────────── @router.get("/strategies") async def api_get_strategies() -> List[Dict[str, Any]]: """사용 가능한 백테스트 전략 목록 반환""" return get_available_strategies() @router.post("/run") async def api_run_kr_backtest(req: KrBacktestRunRequest) -> Dict[str, Any]: """한국 주식 백테스트 실행 stock_daily_prices 테이블의 일봉 데이터로 전략 시뮬레이션을 수행합니다. 수수료 모델: 매수 0.015%, 매도 0.015% + 증권거래세 (KOSPI 0.18% / KOSDAQ 0.23%) 슬리피지 모델: 시장가 기준 +/-0.1% 랜덤 """ # 날짜 파싱 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)") if start_date >= end_date: raise HTTPException( status_code=400, detail="start_date는 end_date보다 이전이어야 합니다", ) if not req.stock_codes: raise HTTPException(status_code=400, detail="stock_codes를 1개 이상 지정하세요") if len(req.stock_codes) > 50: raise HTTPException(status_code=400, detail="종목 수 최대 50개 제한") # 전략 유효성 검사 available = [s["key"] for s in get_available_strategies()] if req.strategy not in available: raise HTTPException( status_code=400, detail=f"알 수 없는 전략: {req.strategy}. 사용 가능: {available}", ) # 백테스트 실행 engine = KrBacktestEngine() try: result = await engine.run( stock_codes=req.stock_codes, start_date=start_date, end_date=end_date, strategy_name=req.strategy, params=req.params, initial_capital=req.initial_capital, market=req.market, random_seed=req.random_seed, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error("백테스트 실행 오류: %s", e, exc_info=True) raise HTTPException( status_code=500, detail=f"백테스트 실행 중 오류: {str(e)}", ) # dataclass -> dict 변환 metrics_dict = asdict(result.metrics) return { "metrics": metrics_dict, "equity_curve": result.equity_curve, "trades": result.trades, "drawdown_curve": result.drawdown_curve, "config": result.config, "summary": { "total_return": f"{metrics_dict['total_return_pct']:.2f}%", "cagr": f"{metrics_dict['cagr_pct']:.2f}%", "sharpe": f"{metrics_dict['sharpe_ratio']:.4f}", "sortino": f"{metrics_dict['sortino_ratio']:.4f}", "max_drawdown": f"{metrics_dict['max_drawdown_pct']:.2f}%", "win_rate": f"{metrics_dict['win_rate']:.1f}%", "profit_factor": f"{metrics_dict['profit_factor']:.2f}", "total_trades": metrics_dict["total_trades"], }, } # ── Walk-Forward 최적화 ───────────────────────────────────────────────── class WalkForwardRequest(BaseModel): stock_codes: List[str] = Field( ..., description="대상 종목 코드 목록" ) start_date: str = Field( ..., description="시작일 (YYYY-MM-DD), 최소 8개월 이상의 기간 필요" ) end_date: str = Field( ..., description="종료일 (YYYY-MM-DD)" ) initial_capital: float = Field( 10_000_000, description="초기 자본금" ) max_positions: int = Field( 5, description="최대 동시 보유 종목 수" ) position_size: float = Field( 0.2, description="종목당 포지션 비율 (0.2 = 20%)" ) # 롤링 윈도우 설정 is_months: int = Field( 6, ge=2, le=24, description="In-Sample 기간 (월, 기본 6)" ) oos_months: int = Field( 2, ge=1, le=12, description="Out-of-Sample 기간 (월, 기본 2)" ) step_months: int = Field( 1, ge=1, le=6, description="슬라이딩 간격 (월, 기본 1)" ) # Optuna 설정 n_trials: int = Field( 50, ge=5, le=200, description="윈도우당 최대 Optuna trial 수" ) optuna_timeout: int = Field( 300, ge=30, le=1800, description="윈도우당 최대 시간 (초)" ) # ── API 엔드포인트 ─────────────────────────────────────────────────────── @router.post("/walk-forward") async def api_walk_forward(req: WalkForwardRequest) -> Dict[str, Any]: """ Walk-Forward 최적화 실행 전체 기간을 In-Sample/Out-of-Sample 윈도우로 분할하여 Optuna 기반 파라미터 최적화와 과적합 탐지를 수행합니다. 탐색 대상 파라미터: - RSI 임계값 (25~45) - 볼린저 밴드 기간 (15~25) - ATR 손절 배수 (1.5~3.5) - 손절 비율 (4%~10%) 목적함수: In-Sample Sharpe Ratio 최대화 과적합 탐지: IS vs OOS Sharpe 비교 (50% 이상 감소 시 경고) 응답에 포함되는 항목: - 각 윈도우별 최적 파라미터, IS/OOS 성과 - 종합 OOS 성과 (평균 Sharpe, 수익률, MDD, 승률) - 과적합 분석 (경고 윈도우 수, 감소율) - 최종 추천 파라미터 (OOS Sharpe 가중평균) """ # 날짜 파싱 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 형식을 사용하세요.", ) if start_date >= end_date: raise HTTPException( status_code=400, detail="start_date는 end_date보다 이전이어야 합니다.", ) # 최소 기간 검증 (IS + OOS 이상) min_months = req.is_months + req.oos_months diff_days = (end_date - start_date).days if diff_days < min_months * 28: raise HTTPException( status_code=400, detail=( f"기간이 너무 짧습니다. IS({req.is_months}개월) + " f"OOS({req.oos_months}개월) = 최소 {min_months}개월이 " f"필요하지만, 입력 기간은 약 {diff_days // 30}개월입니다." ), ) if not req.stock_codes: raise HTTPException( status_code=400, detail="stock_codes가 비어 있습니다.", ) # WalkForwardConfig 생성 wf_config = WalkForwardConfig( stock_codes=req.stock_codes, start_date=start_date, end_date=end_date, initial_capital=req.initial_capital, max_positions=req.max_positions, position_size=req.position_size, is_months=req.is_months, oos_months=req.oos_months, step_months=req.step_months, n_trials=req.n_trials, optuna_timeout=req.optuna_timeout, ) logger.info( f"Walk-Forward API 호출: {len(req.stock_codes)}종목, " f"{req.start_date}~{req.end_date}, " f"IS={req.is_months}m, OOS={req.oos_months}m, trials={req.n_trials}" ) try: optimizer = WalkForwardOptimizer() result = await optimizer.run(wf_config) except ImportError as e: raise HTTPException( status_code=500, detail=f"필요한 패키지가 설치되지 않았습니다: {e}", ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except RuntimeError as e: raise HTTPException(status_code=500, detail=str(e)) # WalkForwardResult -> dict (dataclass asdict는 이미 dict 필드) return asdict(result)