From c3ec7f68a0a3933ed2f2ca6faa1376fe1ab7ea83 Mon Sep 17 00:00:00 2001 From: zephyrdark Date: Tue, 3 Feb 2026 09:33:44 +0900 Subject: [PATCH] docs: add Phase 5 backtest engine design Co-Authored-By: Claude Opus 4.5 --- .../2026-02-03-phase5-backtest-engine.md | 479 ++++++++++++++++++ 1 file changed, 479 insertions(+) create mode 100644 docs/plans/2026-02-03-phase5-backtest-engine.md diff --git a/docs/plans/2026-02-03-phase5-backtest-engine.md b/docs/plans/2026-02-03-phase5-backtest-engine.md new file mode 100644 index 0000000..c7114d8 --- /dev/null +++ b/docs/plans/2026-02-03-phase5-backtest-engine.md @@ -0,0 +1,479 @@ +# Phase 5: Backtest Engine 설계 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 퀀트 전략 백테스트 엔진 구현 - 과거 데이터 기반 전략 성과 시뮬레이션 + +**핵심 기능:** +- 비동기 백테스트 실행 (스레드 기반) +- DB 저장으로 결과 조회/비교 가능 +- 벤치마크(KOSPI) 대비 성과 비교 +- 거래 비용(수수료, 슬리피지) 반영 +- 리밸런싱 주기: 월별/분기별/반기별/연별 + +--- + +## 1. 데이터 모델 + +### 1.1 backtests (백테스트 작업) +```sql +CREATE TABLE backtests ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id), + strategy_type VARCHAR(50) NOT NULL, -- multi_factor/quality/value_momentum + strategy_params JSONB, -- 전략 파라미터 + start_date DATE NOT NULL, + end_date DATE NOT NULL, + rebalance_period VARCHAR(20) NOT NULL, -- monthly/quarterly/semi_annual/annual + initial_capital DECIMAL(20,2) NOT NULL, + commission_rate DECIMAL(10,6) DEFAULT 0.00015, + slippage_rate DECIMAL(10,6) DEFAULT 0.001, + benchmark VARCHAR(20) DEFAULT 'KOSPI', + status VARCHAR(20) DEFAULT 'pending', -- pending/running/completed/failed + created_at TIMESTAMP DEFAULT NOW(), + completed_at TIMESTAMP, + error_message TEXT +); +``` + +### 1.2 backtest_results (결과 지표) +```sql +CREATE TABLE backtest_results ( + backtest_id INTEGER PRIMARY KEY REFERENCES backtests(id), + total_return DECIMAL(10,4), -- 총 수익률 (%) + cagr DECIMAL(10,4), -- 연평균 수익률 (%) + mdd DECIMAL(10,4), -- 최대 낙폭 (%) + sharpe_ratio DECIMAL(10,4), + volatility DECIMAL(10,4), -- 연간 변동성 (%) + benchmark_return DECIMAL(10,4), -- 벤치마크 수익률 (%) + excess_return DECIMAL(10,4) -- 초과 수익률 (%) +); +``` + +### 1.3 backtest_equity_curve (자산 추이) +```sql +CREATE TABLE backtest_equity_curve ( + backtest_id INTEGER REFERENCES backtests(id), + date DATE, + portfolio_value DECIMAL(20,2), + benchmark_value DECIMAL(20,2), + drawdown DECIMAL(10,4), + PRIMARY KEY (backtest_id, date) +); +``` + +### 1.4 backtest_holdings (리밸런싱 시점 보유 종목) +```sql +CREATE TABLE backtest_holdings ( + backtest_id INTEGER REFERENCES backtests(id), + rebalance_date DATE, + ticker VARCHAR(20), + name VARCHAR(100), + weight DECIMAL(10,4), + shares INTEGER, + price DECIMAL(12,2), + PRIMARY KEY (backtest_id, rebalance_date, ticker) +); +``` + +### 1.5 backtest_transactions (거래 내역) +```sql +CREATE TABLE backtest_transactions ( + id SERIAL PRIMARY KEY, + backtest_id INTEGER REFERENCES backtests(id), + date DATE, + ticker VARCHAR(20), + action VARCHAR(10), -- buy/sell + shares INTEGER, + price DECIMAL(12,2), + commission DECIMAL(12,2) +); +``` + +--- + +## 2. API 엔드포인트 + +| Method | Endpoint | 설명 | +|--------|----------|------| +| POST | /api/backtest | 백테스트 생성 (작업 ID 반환) | +| GET | /api/backtest | 내 백테스트 목록 조회 | +| GET | /api/backtest/{id} | 백테스트 상태/결과 조회 | +| GET | /api/backtest/{id}/equity-curve | 자산 추이 데이터 | +| GET | /api/backtest/{id}/holdings | 리밸런싱별 보유 종목 | +| GET | /api/backtest/{id}/transactions | 거래 내역 | +| DELETE | /api/backtest/{id} | 백테스트 삭제 | + +### 요청 예시 (POST /api/backtest) +```json +{ + "strategy_type": "multi_factor", + "strategy_params": { + "weights": {"value": 0.3, "quality": 0.3, "momentum": 0.4}, + "top_n": 30 + }, + "start_date": "2020-01-01", + "end_date": "2024-12-31", + "rebalance_period": "quarterly", + "initial_capital": 100000000, + "commission_rate": 0.00015, + "slippage_rate": 0.001 +} +``` + +### 응답 예시 (GET /api/backtest/{id}) +```json +{ + "id": 1, + "status": "completed", + "strategy_type": "multi_factor", + "start_date": "2020-01-01", + "end_date": "2024-12-31", + "result": { + "total_return": 125.4, + "cagr": 17.8, + "mdd": -32.5, + "sharpe_ratio": 0.85, + "volatility": 22.3, + "benchmark_return": 45.2, + "excess_return": 80.2 + } +} +``` + +--- + +## 3. 서비스 구조 + +``` +backend/app/services/backtest/ +├── __init__.py +├── engine.py # 메인 백테스트 엔진 +├── portfolio.py # 가상 포트폴리오 관리 +├── metrics.py # 성과 지표 계산 +└── worker.py # 백그라운드 작업 처리 +``` + +### 3.1 BacktestEngine (engine.py) +```python +class BacktestEngine: + def __init__(self, db: Session): + self.db = db + + def run(self, backtest_id: int) -> None: + """백테스트 실행 메인 로직""" + backtest = self._get_backtest(backtest_id) + + # 1. 리밸런싱 날짜 목록 생성 + rebalance_dates = self._generate_rebalance_dates( + backtest.start_date, + backtest.end_date, + backtest.rebalance_period + ) + + # 2. 가상 포트폴리오 초기화 + portfolio = VirtualPortfolio(backtest.initial_capital) + + # 3. 벤치마크 데이터 로드 + benchmark_prices = self._load_benchmark_prices( + backtest.benchmark, + backtest.start_date, + backtest.end_date + ) + + # 4. 전략 인스턴스 생성 + strategy = self._create_strategy( + backtest.strategy_type, + backtest.strategy_params + ) + + # 5. 시뮬레이션 실행 + equity_curve = [] + holdings_history = [] + transactions = [] + + for date in self._get_trading_days(backtest.start_date, backtest.end_date): + prices = self._get_prices_for_date(date) + + # 리밸런싱 날짜인 경우 + if date in rebalance_dates: + # 전략 실행하여 종목 선정 + targets = strategy.run(date) + + # 포트폴리오 리밸런싱 + txns = portfolio.rebalance( + targets, + prices, + backtest.commission_rate, + backtest.slippage_rate + ) + transactions.extend(txns) + + # 보유 종목 기록 + holdings_history.append({ + 'date': date, + 'holdings': portfolio.get_holdings_with_weights(prices) + }) + + # 일별 가치 기록 + equity_curve.append({ + 'date': date, + 'portfolio_value': portfolio.get_value(prices), + 'benchmark_value': benchmark_prices.get(date) + }) + + # 6. 성과 지표 계산 + metrics = MetricsCalculator.calculate_all(equity_curve) + + # 7. 결과 저장 + self._save_results(backtest_id, metrics, equity_curve, holdings_history, transactions) +``` + +### 3.2 VirtualPortfolio (portfolio.py) +```python +class VirtualPortfolio: + def __init__(self, initial_capital: Decimal): + self.cash = initial_capital + self.holdings: Dict[str, int] = {} # ticker -> shares + + def rebalance( + self, + targets: List[StockFactor], + prices: Dict[str, Decimal], + commission_rate: Decimal, + slippage_rate: Decimal + ) -> List[Transaction]: + """목표 비중으로 리밸런싱""" + transactions = [] + total_value = self.get_value(prices) + + # 목표 비중 계산 (동일 비중) + target_weight = Decimal("1") / len(targets) + target_tickers = {t.ticker for t in targets} + + # 매도: 목표에 없는 종목 + for ticker in list(self.holdings.keys()): + if ticker not in target_tickers: + shares = self.holdings[ticker] + price = prices[ticker] * (1 - slippage_rate) + proceeds = shares * price + commission = proceeds * commission_rate + + self.cash += proceeds - commission + del self.holdings[ticker] + + transactions.append(Transaction( + ticker=ticker, + action='sell', + shares=shares, + price=price, + commission=commission + )) + + # 매수: 목표 비중에 맞게 + for target in targets: + ticker = target.ticker + target_value = total_value * target_weight + current_value = self.holdings.get(ticker, 0) * prices.get(ticker, 0) + + if target_value > current_value: + buy_value = target_value - current_value + price = prices[ticker] * (1 + slippage_rate) + shares = int(buy_value / price) + + if shares > 0: + cost = shares * price + commission = cost * commission_rate + + if self.cash >= cost + commission: + self.cash -= cost + commission + self.holdings[ticker] = self.holdings.get(ticker, 0) + shares + + transactions.append(Transaction( + ticker=ticker, + action='buy', + shares=shares, + price=price, + commission=commission + )) + + return transactions + + def get_value(self, prices: Dict[str, Decimal]) -> Decimal: + """총 포트폴리오 가치""" + holdings_value = sum( + shares * prices.get(ticker, Decimal("0")) + for ticker, shares in self.holdings.items() + ) + return self.cash + holdings_value +``` + +### 3.3 MetricsCalculator (metrics.py) +```python +class MetricsCalculator: + @staticmethod + def calculate_all(equity_curve: List[dict]) -> BacktestMetrics: + values = [e['portfolio_value'] for e in equity_curve] + benchmark_values = [e['benchmark_value'] for e in equity_curve] + + returns = MetricsCalculator._calculate_returns(values) + + return BacktestMetrics( + total_return=MetricsCalculator.calculate_total_return(values), + cagr=MetricsCalculator.calculate_cagr(values, len(values) / 252), + mdd=MetricsCalculator.calculate_mdd(values), + sharpe_ratio=MetricsCalculator.calculate_sharpe(returns), + volatility=MetricsCalculator.calculate_volatility(returns), + benchmark_return=MetricsCalculator.calculate_total_return(benchmark_values), + excess_return=... # total_return - benchmark_return + ) + + @staticmethod + def calculate_total_return(values: List[Decimal]) -> Decimal: + return (values[-1] - values[0]) / values[0] * 100 + + @staticmethod + def calculate_cagr(values: List[Decimal], years: float) -> Decimal: + total_return = values[-1] / values[0] + return (total_return ** (1 / years) - 1) * 100 + + @staticmethod + def calculate_mdd(values: List[Decimal]) -> Decimal: + peak = values[0] + max_dd = Decimal("0") + for value in values: + if value > peak: + peak = value + dd = (peak - value) / peak * 100 + if dd > max_dd: + max_dd = dd + return -max_dd + + @staticmethod + def calculate_sharpe(returns: List[Decimal], risk_free: Decimal = Decimal("0.03")) -> Decimal: + avg_return = sum(returns) / len(returns) * 252 + std_return = ... # 표준편차 * sqrt(252) + return (avg_return - risk_free) / std_return +``` + +### 3.4 Worker (worker.py) +```python +from concurrent.futures import ThreadPoolExecutor + +executor = ThreadPoolExecutor(max_workers=2) + +def submit_backtest(backtest_id: int, db_url: str): + """백테스트 작업 제출""" + executor.submit(_run_backtest_job, backtest_id, db_url) + +def _run_backtest_job(backtest_id: int, db_url: str): + """백그라운드에서 실행""" + engine = create_engine(db_url) + Session = sessionmaker(bind=engine) + db = Session() + + try: + # status → running + backtest = db.query(Backtest).get(backtest_id) + backtest.status = 'running' + db.commit() + + # 백테스트 실행 + engine = BacktestEngine(db) + engine.run(backtest_id) + + # status → completed + backtest.status = 'completed' + backtest.completed_at = datetime.utcnow() + db.commit() + + except Exception as e: + backtest.status = 'failed' + backtest.error_message = str(e) + db.commit() + finally: + db.close() +``` + +--- + +## 4. 프론트엔드 페이지 + +### 4.1 /backtest (목록 + 생성) + +**새 백테스트 폼:** +- 전략 선택 드롭다운 +- 전략 파라미터 (전략별 동적 폼) +- 기간 선택 (DatePicker) +- 리밸런싱 주기 라디오 버튼 +- 초기 자본금 입력 +- 거래 비용 설정 (접이식 고급 옵션) +- 실행 버튼 + +**백테스트 목록 테이블:** +| 전략 | 기간 | 수익률 | CAGR | MDD | 상태 | 생성일 | + +### 4.2 /backtest/[id] (결과 상세) + +**성과 요약 카드:** +``` +┌─────────────────────────────────────────────────────┐ +│ 총 수익률 CAGR MDD 샤프 변동성 │ +│ +125.4% 17.8% -32.5% 0.85 22.3% │ +├─────────────────────────────────────────────────────┤ +│ 벤치마크 수익률: +45.2% 초과 수익률: +80.2% │ +└─────────────────────────────────────────────────────┘ +``` + +**자산 추이 차트:** +- 포트폴리오 라인 (파란색) +- 벤치마크 라인 (회색) +- 낙폭 영역 (빨간색, 아래 서브차트) + +**탭: 리밸런싱 이력 / 거래 내역** + +--- + +## 5. 구현 태스크 + +### Task 1: Backtest Pydantic 스키마 +- `backend/app/schemas/backtest.py` 생성 +- `backend/app/schemas/__init__.py` 업데이트 + +### Task 2: Backtest SQLAlchemy 모델 +- `backend/app/models/backtest.py` 생성 +- `backend/app/models/__init__.py` 업데이트 +- Alembic 마이그레이션 생성/실행 + +### Task 3: MetricsCalculator 서비스 +- `backend/app/services/backtest/metrics.py` 생성 + +### Task 4: VirtualPortfolio 서비스 +- `backend/app/services/backtest/portfolio.py` 생성 + +### Task 5: BacktestEngine 서비스 +- `backend/app/services/backtest/engine.py` 생성 +- `backend/app/services/backtest/__init__.py` 생성 + +### Task 6: Worker 서비스 +- `backend/app/services/backtest/worker.py` 생성 + +### Task 7: Backtest API 엔드포인트 +- `backend/app/api/backtest.py` 생성 +- `backend/app/api/__init__.py` 업데이트 +- `backend/app/main.py` 업데이트 + +### Task 8: Frontend 백테스트 목록/생성 페이지 +- `frontend/src/app/backtest/page.tsx` 생성 + +### Task 9: Frontend 백테스트 결과 페이지 +- `frontend/src/app/backtest/[id]/page.tsx` 생성 + +### Task 10: 통합 검증 +- Frontend 빌드 확인 +- Git 커밋 히스토리 확인 + +--- + +**문서 버전:** 1.0 +**작성일:** 2026-02-03