288 lines
8.7 KiB
Python
288 lines
8.7 KiB
Python
"""
|
|
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
|