Files
auto-stock/backend/app/api/endpoints/kr_backtest.py
shuim de7921d270 fix: CRITICAL/HIGH 버그 30건 수정 + 전체 코드 주석 추가 + 중복 정리
## CRITICAL 수정 (20건)
- day_trading: global 선언 누락, loss_tracker 연동, ban 체크 추가
- trade_executor: BUY 체결가 order_price로 수정, remaining_pct 누적 계산
- risk_validator: get_current_price dict 파싱, MAX_POSITION_SIZE 동기화, DB 세션 누수
- risk_management/recommendations: date.today() → KST 변환
- mdd_manager: Lock 밖 executed_key 참조 수정
- correlation_filter: output → output2 키 수정
- portfolio: 해외 종목 USD/KRW 환율 적용 분리
- Sharpe Ratio 거래빈도 기반 연간화
- telegram_notifier: send_message() 공개 래퍼 추가
- position_manager: 앱 재시작 시 DB 포지션 복원
- swing exit_service: remaining_pct 조건 범위 수정
- run_backtest: original_qty 기반 remaining_pct 계산
- us_market_schedule: 2027년 미국 공휴일 추가
- Login.tsx: res.ok 검증 추가
- mean_reversion_strategy: RSI 로그 하드코딩 수정

## HIGH 수정 (10건)
- db_worker: DLQ 파일 백업 메커니즘 추가
- korea_investment_api: 만료 토큰 임의 연장 제거
- market_data: chart_cache 최대 100개 + TTL 만료
- main.py: rate_limit_store 1시간 미접근 IP 정리
- recommendations: fire-and-forget try/except 추가
- swing/scheduler: DB 세션 3개 분리
- Dashboard: wsMessages 전체 순회
- Layout: /health 엔드포인트 + 30초 폴링
- ml_ensemble: POST /train require_api_key 추가
- day_trading: record_trade_result_safe() 전환

## 코드 정리
- 프론트엔드: isUsStock, formatTime 공통 유틸 추출
- 백엔드: dead code 제거, 중복 import 통합
- MCP: JSON 파싱 헬퍼 추출 (~60줄 감소)
- 전체 ~100개 파일 한국어 주석 추가
- 테스트 13건 코드 기준 수정 (468 passed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:05:41 +09:00

281 lines
9.8 KiB
Python

"""
국내 주식 백테스트 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)