## 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>
281 lines
9.8 KiB
Python
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)
|