docs: add Phase 5 backtest engine design
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
aa2047a922
commit
c3ec7f68a0
479
docs/plans/2026-02-03-phase5-backtest-engine.md
Normal file
479
docs/plans/2026-02-03-phase5-backtest-engine.md
Normal file
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user