""" 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