"""Unit tests for strategy portfolio management module. Tests cover weight allocation algorithms, signal aggregation, rebalancing logic, and StrategyPortfolio class functionality. """ import sys from datetime import datetime, timedelta from typing import Any, Dict import numpy as np import pandas as pd import pytest # Add src to path for imports sys.path.insert(0, "src") from openclaw.portfolio.weights import ( WeightMethod, apply_weight_constraints, calculate_equal_weights, calculate_inverse_volatility_weights, calculate_momentum_weights, calculate_risk_parity_weights, normalize_weights, validate_weights, ) from openclaw.portfolio.signal_aggregator import ( AggregationMethod, AggregatedSignal, SignalAggregator, StrategySignal, ) from openclaw.portfolio.rebalancer import ( RebalanceResult, RebalanceTrigger, Rebalancer, TransactionCostModel, ) from openclaw.portfolio.strategy_portfolio import ( StrategyConfig, StrategyPerformance, StrategyPortfolio, StrategyStatus, ) # ==================== Test Weight Allocation ==================== class TestWeightAllocation: """Test suite for weight allocation algorithms.""" def test_equal_weights(self) -> None: """Test equal weight allocation.""" strategies = ["s1", "s2", "s3", "s4"] weights = calculate_equal_weights(strategies) assert len(weights) == 4 assert all(w == 0.25 for w in weights.values()) assert validate_weights(weights) def test_equal_weights_empty(self) -> None: """Test equal weights with empty list.""" weights = calculate_equal_weights([]) assert weights == {} def test_risk_parity_weights(self) -> None: """Test risk parity weight allocation.""" strategies = ["low_vol", "high_vol"] # Create returns with different volatilities np.random.seed(42) low_vol_returns = np.random.normal(0.001, 0.01, 100) high_vol_returns = np.random.normal(0.001, 0.05, 100) returns_data = pd.DataFrame({ "low_vol": low_vol_returns, "high_vol": high_vol_returns, }) weights = calculate_risk_parity_weights(strategies, returns_data) assert len(weights) == 2 assert weights["low_vol"] > weights["high_vol"] assert validate_weights(weights) def test_risk_parity_no_data(self) -> None: """Test risk parity fallback to equal weights.""" strategies = ["s1", "s2"] weights = calculate_risk_parity_weights(strategies, None) assert weights["s1"] == weights["s2"] == 0.5 def test_momentum_weights(self) -> None: """Test momentum-based weight allocation.""" strategies = ["winner", "loser"] # Create returns with different momentum np.random.seed(42) winner_returns = np.random.normal(0.005, 0.02, 60) loser_returns = np.random.normal(-0.002, 0.02, 60) returns_data = pd.DataFrame({ "winner": winner_returns, "loser": loser_returns, }) weights = calculate_momentum_weights(strategies, returns_data) assert len(weights) == 2 assert weights["winner"] > weights["loser"] assert validate_weights(weights) def test_inverse_volatility_weights(self) -> None: """Test inverse volatility weight allocation.""" strategies = ["stable", "volatile"] np.random.seed(42) stable_returns = np.random.normal(0.001, 0.01, 60) volatile_returns = np.random.normal(0.001, 0.08, 60) returns_data = pd.DataFrame({ "stable": stable_returns, "volatile": volatile_returns, }) weights = calculate_inverse_volatility_weights(strategies, returns_data) assert len(weights) == 2 assert weights["stable"] > weights["volatile"] assert validate_weights(weights) def test_normalize_weights(self) -> None: """Test weight normalization.""" weights = {"s1": 0.3, "s2": 0.3, "s3": 0.3} normalized = normalize_weights(weights) assert abs(sum(normalized.values()) - 1.0) < 0.001 def test_normalize_weights_zero_sum(self) -> None: """Test normalization with zero sum.""" weights = {"s1": 0.0, "s2": 0.0} normalized = normalize_weights(weights) assert normalized["s1"] == normalized["s2"] == 0.5 def test_apply_weight_constraints(self) -> None: """Test weight constraint application.""" weights = {"s1": 0.8, "s2": 0.1, "s3": 0.1} constrained = apply_weight_constraints(weights, min_weight=0.15, max_weight=0.5) # s1 should be capped at max_weight assert constrained["s1"] == 0.5 # s2 and s3 should receive redistributed weight assert constrained["s2"] >= 0.15 assert constrained["s3"] >= 0.15 # Sum should be 1.0 assert abs(sum(constrained.values()) - 1.0) < 0.001 # ==================== Test Signal Aggregation ==================== class TestSignalAggregation: """Test suite for signal aggregation.""" def test_voting_aggregation(self) -> None: """Test simple voting aggregation.""" signals = [ StrategySignal("s1", "buy", 0.8), StrategySignal("s2", "buy", 0.7), StrategySignal("s3", "sell", 0.6), ] aggregator = SignalAggregator(method=AggregationMethod.VOTING) result = aggregator.aggregate(signals) assert result.aggregated_signal == "buy" assert result.confidence > 0.5 assert result.method == AggregationMethod.VOTING def test_weighted_aggregation(self) -> None: """Test weighted signal aggregation.""" signals = [ StrategySignal("s1", "buy", 0.8), StrategySignal("s2", "sell", 0.6), ] weights = {"s1": 0.7, "s2": 0.3} aggregator = SignalAggregator(method=AggregationMethod.WEIGHTED) result = aggregator.aggregate(signals, weights=weights) assert result.aggregated_signal == "buy" # s1 has higher weight assert result.method == AggregationMethod.WEIGHTED def test_confidence_threshold_filtering(self) -> None: """Test confidence threshold aggregation.""" signals = [ StrategySignal("s1", "buy", 0.9), StrategySignal("s2", "sell", 0.4), # Below threshold StrategySignal("s3", "buy", 0.8), ] aggregator = SignalAggregator( method=AggregationMethod.CONFIDENCE_THRESHOLD, confidence_threshold=0.5, ) result = aggregator.aggregate(signals) assert result.aggregated_signal == "buy" # Should only consider s1 and s3 (both above threshold) def test_majority_vote(self) -> None: """Test majority vote aggregation.""" signals = [ StrategySignal("s1", "buy", 0.8), StrategySignal("s2", "buy", 0.7), StrategySignal("s3", "sell", 0.6), ] aggregator = SignalAggregator(method=AggregationMethod.MAJORITY_VOTE) result = aggregator.aggregate(signals) assert result.aggregated_signal == "buy" def test_unanimous_agreement(self) -> None: """Test unanimous vote aggregation.""" signals = [ StrategySignal("s1", "buy", 0.8), StrategySignal("s2", "buy", 0.7), StrategySignal("s3", "buy", 0.9), ] aggregator = SignalAggregator(method=AggregationMethod.UNANIMOUS) result = aggregator.aggregate(signals) assert result.aggregated_signal == "buy" def test_unanimous_disagreement(self) -> None: """Test unanimous when strategies disagree.""" signals = [ StrategySignal("s1", "buy", 0.8), StrategySignal("s2", "sell", 0.7), ] aggregator = SignalAggregator(method=AggregationMethod.UNANIMOUS) result = aggregator.aggregate(signals) assert result.aggregated_signal == "hold" def test_empty_signals(self) -> None: """Test aggregation with no signals.""" aggregator = SignalAggregator() result = aggregator.aggregate([]) assert result.aggregated_signal == "hold" assert result.confidence == 0.0 def test_aggregated_signal_properties(self) -> None: """Test AggregatedSignal helper properties.""" signal = AggregatedSignal( aggregated_signal="buy", confidence=0.8, ) assert signal.is_bullish assert not signal.is_bearish assert not signal.is_neutral # ==================== Test Rebalancer ==================== class TestRebalancer: """Test suite for rebalancing logic.""" def test_drift_calculation(self) -> None: """Test portfolio drift calculation.""" rebalancer = Rebalancer() current = {"s1": 0.4, "s2": 0.6} target = {"s1": 0.5, "s2": 0.5} drift = rebalancer.calculate_drift(current, target) assert abs(drift - 0.1) < 0.0001 def test_rebalance_needed_periodic(self) -> None: """Test periodic rebalance trigger.""" rebalancer = Rebalancer( trigger_type=RebalanceTrigger.PERIODIC, rebalance_frequency=30, ) current = {"s1": 0.5, "s2": 0.5} target = {"s1": 0.5, "s2": 0.5} # Initial rebalance needed needed, trigger = rebalancer.check_rebalance_needed(current, target) assert needed assert trigger == RebalanceTrigger.PERIODIC def test_rebalance_needed_threshold(self) -> None: """Test threshold-based rebalance trigger.""" rebalancer = Rebalancer( trigger_type=RebalanceTrigger.THRESHOLD, drift_threshold=0.05, ) current = {"s1": 0.6, "s2": 0.4} target = {"s1": 0.5, "s2": 0.5} needed, trigger = rebalancer.check_rebalance_needed(current, target) assert needed assert trigger == RebalanceTrigger.THRESHOLD def test_rebalance_not_needed(self) -> None: """Test when rebalance is not needed.""" rebalancer = Rebalancer( trigger_type=RebalanceTrigger.THRESHOLD, drift_threshold=0.1, ) current = {"s1": 0.52, "s2": 0.48} target = {"s1": 0.5, "s2": 0.5} needed, _ = rebalancer.check_rebalance_needed(current, target) assert not needed def test_transaction_cost_calculation(self) -> None: """Test transaction cost model.""" model = TransactionCostModel( fixed_cost=5.0, percentage_cost=0.001, ) cost = model.calculate_cost(trade_value=10000.0) expected = 5.0 + 10000.0 * 0.001 # 15.0 assert cost == expected def test_rebalance_execution(self) -> None: """Test rebalance execution.""" rebalancer = Rebalancer() current = {"s1": 0.6, "s2": 0.4} target = {"s1": 0.5, "s2": 0.5} result = rebalancer.rebalance( current_weights=current, target_weights=target, portfolio_value=100000.0, force=True, ) assert result is not None assert result.trades_executed == 2 assert result.old_weights == current assert result.new_weights == target def test_rebalance_turnover(self) -> None: """Test turnover calculation.""" result = RebalanceResult( timestamp=datetime.now(), old_weights={"s1": 0.6, "s2": 0.4}, new_weights={"s1": 0.5, "s2": 0.5}, ) # Turnover = (|0.5-0.6| + |0.5-0.4|) / 2 = 0.1 assert abs(result.total_turnover - 0.1) < 0.0001 # ==================== Test StrategyPortfolio ==================== class TestStrategyPortfolio: """Test suite for StrategyPortfolio class.""" def test_portfolio_initialization(self) -> None: """Test portfolio initialization.""" portfolio = StrategyPortfolio( portfolio_id="test_portfolio", initial_capital=100000.0, ) assert portfolio.portfolio_id == "test_portfolio" assert portfolio.initial_capital == 100000.0 assert portfolio.list_strategies() == [] def test_add_strategy(self) -> None: """Test adding strategies to portfolio.""" portfolio = StrategyPortfolio("test") result = portfolio.add_strategy("s1", weight=0.5) assert result assert "s1" in portfolio.list_strategies() def test_add_duplicate_strategy(self) -> None: """Test adding duplicate strategy fails.""" portfolio = StrategyPortfolio("test") portfolio.add_strategy("s1") result = portfolio.add_strategy("s1") assert not result def test_remove_strategy(self) -> None: """Test removing strategies.""" portfolio = StrategyPortfolio("test") portfolio.add_strategy("s1") result = portfolio.remove_strategy("s1") assert result assert "s1" not in portfolio.list_strategies() def test_strategy_lifecycle(self) -> None: """Test strategy enable/disable/pause.""" portfolio = StrategyPortfolio("test") portfolio.add_strategy("s1", status=StrategyStatus.ACTIVE) assert portfolio.get_strategy_status("s1") == StrategyStatus.ACTIVE portfolio.disable_strategy("s1") assert portfolio.get_strategy_status("s1") == StrategyStatus.DISABLED portfolio.enable_strategy("s1") assert portfolio.get_strategy_status("s1") == StrategyStatus.ACTIVE portfolio.pause_strategy("s1") assert portfolio.get_strategy_status("s1") == StrategyStatus.PAUSED def test_list_active_strategies(self) -> None: """Test listing only active strategies.""" portfolio = StrategyPortfolio("test") portfolio.add_strategy("s1", status=StrategyStatus.ACTIVE) portfolio.add_strategy("s2", status=StrategyStatus.DISABLED) portfolio.add_strategy("s3", status=StrategyStatus.ACTIVE) active = portfolio.list_strategies(active_only=True) assert "s1" in active assert "s2" not in active assert "s3" in active def test_weight_management(self) -> None: """Test weight management.""" portfolio = StrategyPortfolio("test") portfolio.add_strategy("s1") portfolio.add_strategy("s2") # With equal weight method, should auto-calculate weights = portfolio.get_target_weights() assert weights["s1"] == weights["s2"] == 0.5 def test_set_custom_weights(self) -> None: """Test setting custom weights.""" portfolio = StrategyPortfolio("test") portfolio.add_strategy("s1") portfolio.add_strategy("s2") portfolio.add_strategy("s3") custom_weights = {"s1": 0.5, "s2": 0.3, "s3": 0.2} portfolio.set_weights(custom_weights) weights = portfolio.get_target_weights() assert weights["s1"] == 0.5 assert weights["s2"] == 0.3 assert weights["s3"] == 0.2 def test_signal_aggregation(self) -> None: """Test signal aggregation through portfolio.""" portfolio = StrategyPortfolio("test") portfolio.add_strategy("s1", status=StrategyStatus.ACTIVE) portfolio.add_strategy("s2", status=StrategyStatus.ACTIVE) signals = { "s1": {"signal": "buy", "confidence": 0.8}, "s2": {"signal": "buy", "confidence": 0.7}, } result = portfolio.aggregate_signals(signals) assert result.aggregated_signal == "buy" def test_signal_aggregation_disabled_skipped(self) -> None: """Test that disabled strategies are skipped in aggregation.""" portfolio = StrategyPortfolio("test") portfolio.add_strategy("s1", status=StrategyStatus.ACTIVE) portfolio.add_strategy("s2", status=StrategyStatus.DISABLED) signals = { "s1": {"signal": "buy", "confidence": 0.8}, "s2": {"signal": "sell", "confidence": 0.9}, } result = portfolio.aggregate_signals(signals) # Should only consider s1 since s2 is disabled assert result.aggregated_signal == "buy" def test_performance_tracking(self) -> None: """Test performance tracking.""" portfolio = StrategyPortfolio("test") portfolio.add_strategy("s1") portfolio.update_performance("s1", { "total_return": 10.0, "sharpe_ratio": 1.5, "max_drawdown": -5.0, }) perf = portfolio.get_performance("s1") assert perf["total_return"] == 10.0 assert perf["sharpe_ratio"] == 1.5 def test_rebalance_check(self) -> None: """Test rebalance checking.""" portfolio = StrategyPortfolio( "test", rebalance_trigger=RebalanceTrigger.THRESHOLD, ) portfolio.add_strategy("s1") portfolio.add_strategy("s2") # After adding strategies, weights should be equal # Set up a scenario where drift exceeds threshold portfolio.set_rebalance_config(drift_threshold=0.05) # Force weight mismatch portfolio._current_weights = {"s1": 0.7, "s2": 0.3} portfolio._target_weights = {"s1": 0.5, "s2": 0.5} assert portfolio.check_rebalance() def test_portfolio_state(self) -> None: """Test portfolio state management.""" portfolio = StrategyPortfolio("test") portfolio.add_strategy("s1") state = portfolio.get_state() assert state.portfolio_id == "test" assert "s1" in state.strategies def test_portfolio_reset(self) -> None: """Test portfolio reset.""" portfolio = StrategyPortfolio("test") portfolio.add_strategy("s1") portfolio.add_strategy("s2") portfolio.reset() assert portfolio.list_strategies() == [] assert portfolio.get_weights() == {} def test_portfolio_to_dict(self) -> None: """Test portfolio serialization.""" portfolio = StrategyPortfolio( "test", weight_method=WeightMethod.EQUAL, aggregation_method=AggregationMethod.WEIGHTED, ) portfolio.add_strategy("s1", status=StrategyStatus.ACTIVE) portfolio.update_performance("s1", {"total_return": 5.0}) data = portfolio.to_dict() assert data["portfolio_id"] == "test" assert data["weight_method"] == "equal" assert data["aggregation_method"] == "weighted" assert "s1" in data["strategies"] assert "performance" in data def test_update_returns(self) -> None: """Test updating returns history.""" portfolio = StrategyPortfolio("test") returns = pd.DataFrame({ "s1": np.random.normal(0.001, 0.02, 100), "s2": np.random.normal(0.001, 0.02, 100), }) portfolio.update_returns(returns) assert not portfolio._returns_history.empty # ==================== Test Integration ==================== class TestPortfolioIntegration: """Integration tests for portfolio components.""" def test_full_portfolio_workflow(self) -> None: """Test complete portfolio workflow.""" # Create portfolio portfolio = StrategyPortfolio( "integration_test", weight_method=WeightMethod.EQUAL, aggregation_method=AggregationMethod.WEIGHTED, ) # Add strategies portfolio.add_strategy("trend_following", status=StrategyStatus.ACTIVE) portfolio.add_strategy("mean_reversion", status=StrategyStatus.ACTIVE) portfolio.add_strategy("breakout", status=StrategyStatus.DISABLED) # Verify strategies assert len(portfolio.list_strategies()) == 3 assert len(portfolio.list_strategies(active_only=True)) == 2 # Check weights weights = portfolio.get_target_weights() assert len(weights) == 2 # Only active strategies assert abs(sum(weights.values()) - 1.0) < 0.001 # Aggregate signals signals = { "trend_following": {"signal": "buy", "confidence": 0.8}, "mean_reversion": {"signal": "hold", "confidence": 0.6}, "breakout": {"signal": "sell", "confidence": 0.9}, # Disabled, should be ignored } result = portfolio.aggregate_signals(signals) assert result.aggregated_signal == "buy" # Update performance portfolio.update_performance("trend_following", { "total_return": 15.0, "sharpe_ratio": 1.8, }) perf = portfolio.get_performance() assert "trend_following" in perf def test_risk_parity_weight_update(self) -> None: """Test risk parity with returns data.""" portfolio = StrategyPortfolio( "test", weight_method=WeightMethod.RISK_PARITY, ) portfolio.add_strategy("low_risk") portfolio.add_strategy("high_risk") # Add returns data np.random.seed(42) returns_data = pd.DataFrame({ "low_risk": np.random.normal(0.001, 0.01, 100), "high_risk": np.random.normal(0.001, 0.08, 100), }) portfolio.update_returns(returns_data) portfolio.update_weight_method(WeightMethod.RISK_PARITY) weights = portfolio.get_target_weights() # Low risk strategy should have higher weight assert weights["low_risk"] > weights["high_risk"] if __name__ == "__main__": pytest.main([__file__, "-v"])