penti/backend/tests/test_backtest_engine.py

288 lines
8.7 KiB
Python
Raw Permalink Normal View History

2026-01-31 23:30:51 +09:00
"""
Backtest engine unit tests
"""
import pytest
from datetime import date, datetime
from decimal import Decimal
from app.backtest.engine import BacktestEngine
from app.backtest.portfolio import BacktestPortfolio, Position
from app.backtest.rebalancer import Rebalancer
from app.backtest.metrics import (
calculate_total_return,
calculate_cagr,
calculate_sharpe_ratio,
calculate_sortino_ratio,
calculate_max_drawdown,
calculate_volatility,
calculate_win_rate,
calculate_calmar_ratio,
)
@pytest.mark.unit
class TestBacktestMetrics:
"""Test backtest performance metrics"""
def test_total_return_positive(self):
"""Test total return calculation with profit"""
returns = [0.01, 0.02, -0.01, 0.03, 0.01]
result = calculate_total_return(returns)
assert result > 0
def test_total_return_negative(self):
"""Test total return calculation with loss"""
returns = [-0.01, -0.02, -0.01, 0.01, -0.01]
result = calculate_total_return(returns)
assert result < 0
def test_cagr_calculation(self):
"""Test CAGR calculation"""
initial = 10000000
final = 12000000
years = 2.0
cagr = calculate_cagr(initial, final, years)
# CAGR should be around 9.54%
assert 9.0 < cagr < 10.0
def test_sharpe_ratio_calculation(self):
"""Test Sharpe ratio calculation"""
returns = [0.01, 0.02, -0.01, 0.03, 0.01, 0.02]
sharpe = calculate_sharpe_ratio(returns, risk_free_rate=0.0)
# Positive returns should give positive Sharpe
assert sharpe > 0
def test_sharpe_ratio_zero_std(self):
"""Test Sharpe ratio with zero std dev"""
returns = [0.0, 0.0, 0.0]
sharpe = calculate_sharpe_ratio(returns)
# Should return 0 or handle gracefully
assert sharpe == 0.0
def test_sortino_ratio_calculation(self):
"""Test Sortino ratio calculation"""
returns = [0.01, 0.02, -0.01, 0.03, -0.02, 0.01]
sortino = calculate_sortino_ratio(returns)
# Should be calculated
assert isinstance(sortino, float)
def test_max_drawdown_calculation(self):
"""Test MDD calculation"""
equity_curve = [
{"date": "2023-01-01", "value": 10000000},
{"date": "2023-02-01", "value": 11000000},
{"date": "2023-03-01", "value": 9500000}, # Drawdown
{"date": "2023-04-01", "value": 10500000},
]
mdd = calculate_max_drawdown(equity_curve)
# Should be negative
assert mdd < 0
# Should be around -13.6% ((9500000 - 11000000) / 11000000)
assert -15 < mdd < -13
def test_max_drawdown_no_drawdown(self):
"""Test MDD with no drawdown (only upward)"""
equity_curve = [
{"date": "2023-01-01", "value": 10000000},
{"date": "2023-02-01", "value": 11000000},
{"date": "2023-03-01", "value": 12000000},
]
mdd = calculate_max_drawdown(equity_curve)
# Should be 0 or very small
assert mdd >= -0.01
def test_volatility_calculation(self):
"""Test volatility calculation"""
returns = [0.01, -0.01, 0.02, -0.02, 0.01]
volatility = calculate_volatility(returns)
# Annualized volatility should be positive
assert volatility > 0
def test_win_rate_calculation(self):
"""Test win rate calculation"""
trades = [
{"pnl": 100000},
{"pnl": -50000},
{"pnl": 200000},
{"pnl": -30000},
{"pnl": 150000},
]
win_rate = calculate_win_rate(trades)
# 3 wins out of 5 = 60%
assert win_rate == 60.0
def test_win_rate_all_wins(self):
"""Test win rate with all winning trades"""
trades = [
{"pnl": 100000},
{"pnl": 200000},
{"pnl": 150000},
]
win_rate = calculate_win_rate(trades)
assert win_rate == 100.0
def test_win_rate_no_trades(self):
"""Test win rate with no trades"""
trades = []
win_rate = calculate_win_rate(trades)
assert win_rate == 0.0
def test_calmar_ratio_calculation(self):
"""Test Calmar ratio calculation"""
cagr = 15.0
max_drawdown_pct = -20.0
calmar = calculate_calmar_ratio(cagr, max_drawdown_pct)
# Calmar = CAGR / abs(MDD) = 15 / 20 = 0.75
assert abs(calmar - 0.75) < 0.01
def test_calmar_ratio_zero_mdd(self):
"""Test Calmar ratio with zero MDD"""
cagr = 15.0
max_drawdown_pct = 0.0
calmar = calculate_calmar_ratio(cagr, max_drawdown_pct)
# Should return 0 or inf, handled gracefully
assert calmar >= 0
@pytest.mark.unit
class TestBacktestPortfolio:
"""Test backtest portfolio management"""
def test_add_position(self):
"""Test adding a position"""
portfolio = BacktestPortfolio(initial_cash=10000000, commission_rate=0.0015)
portfolio.add_position("005930", 100, 70000)
assert "005930" in portfolio.positions
assert portfolio.positions["005930"].quantity == 100
assert portfolio.positions["005930"].avg_price == 70000
# Cash should be reduced
expected_cash = 10000000 - (100 * 70000 * 1.0015)
assert abs(portfolio.cash - expected_cash) < 1
def test_remove_position(self):
"""Test removing a position"""
portfolio = BacktestPortfolio(initial_cash=10000000, commission_rate=0.0015)
portfolio.add_position("005930", 100, 70000)
portfolio.remove_position("005930", 100, 72000)
# Position should be removed
assert "005930" not in portfolio.positions or portfolio.positions["005930"].quantity == 0
# Cash should increase (profit)
assert portfolio.cash > 10000000 - (100 * 70000 * 1.0015)
def test_partial_remove_position(self):
"""Test partially removing a position"""
portfolio = BacktestPortfolio(initial_cash=10000000, commission_rate=0.0015)
portfolio.add_position("005930", 100, 70000)
portfolio.remove_position("005930", 50, 72000)
# Position should have 50 remaining
assert portfolio.positions["005930"].quantity == 50
def test_portfolio_value(self):
"""Test portfolio value calculation"""
portfolio = BacktestPortfolio(initial_cash=10000000, commission_rate=0.0015)
portfolio.add_position("005930", 100, 70000)
portfolio.add_position("000660", 50, 120000)
current_prices = {"005930": 75000, "000660": 125000}
total_value = portfolio.get_total_value(current_prices)
# Total = cash + (100 * 75000) + (50 * 125000)
positions_value = 100 * 75000 + 50 * 125000
expected_total = portfolio.cash + positions_value
assert abs(total_value - expected_total) < 1
@pytest.mark.unit
class TestRebalancer:
"""Test rebalancing logic"""
def test_rebalance_equal_weight(self):
"""Test equal-weight rebalancing"""
rebalancer = Rebalancer()
target_stocks = {
"005930": {"weight": 0.5},
"000660": {"weight": 0.5},
}
current_prices = {
"005930": 70000,
"000660": 120000,
}
current_positions = {}
available_cash = 10000000
sell_trades, buy_trades = rebalancer.rebalance(
target_stocks=target_stocks,
current_positions=current_positions,
current_prices=current_prices,
total_value=available_cash,
commission_rate=0.0015
)
# Should have buy trades for both stocks
assert len(sell_trades) == 0
assert len(buy_trades) == 2
def test_rebalance_with_existing_positions(self):
"""Test rebalancing with existing positions"""
rebalancer = Rebalancer()
target_stocks = {
"005930": {"weight": 0.6},
"000660": {"weight": 0.4},
}
current_prices = {
"005930": 70000,
"000660": 120000,
}
# Current: 50/50 split, need to rebalance to 60/40
current_positions = {
"005930": Position(ticker="005930", quantity=71, avg_price=70000),
"000660": Position(ticker="000660", quantity=41, avg_price=120000),
}
# Total value = 71 * 70000 + 41 * 120000 = 9,890,000
total_value = 71 * 70000 + 41 * 120000
sell_trades, buy_trades = rebalancer.rebalance(
target_stocks=target_stocks,
current_positions=current_positions,
current_prices=current_prices,
total_value=total_value,
commission_rate=0.0015
)
# Should have some rebalancing trades
assert len(sell_trades) + len(buy_trades) > 0