566 lines
20 KiB
Python
566 lines
20 KiB
Python
""""Unit tests for RiskManager agent.
|
|
|
|
This module tests the RiskManager class including risk assessment,
|
|
VaR calculation, portfolio risk metrics, and decision cost deduction.
|
|
"""
|
|
|
|
import asyncio
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from openclaw.agents.base import ActivityType
|
|
from openclaw.agents.risk_manager import (
|
|
PortfolioRiskMetrics,
|
|
RiskManager,
|
|
RiskReport,
|
|
)
|
|
from openclaw.core.economy import SurvivalStatus
|
|
|
|
|
|
class TestRiskManagerInitialization:
|
|
""""Test RiskManager initialization."""
|
|
|
|
def test_default_initialization(self):
|
|
""""Test agent with default parameters."""
|
|
agent = RiskManager(agent_id="risk-1", initial_capital=10000.0)
|
|
|
|
assert agent.agent_id == "risk-1"
|
|
assert agent.balance == 10000.0
|
|
assert agent.skill_level == 0.5
|
|
assert agent.max_risk_per_trade == 0.02
|
|
assert agent.max_portfolio_var == 0.05
|
|
assert agent.decision_cost == 0.20
|
|
assert agent._risk_history == []
|
|
assert agent._portfolio_risk_history == []
|
|
|
|
def test_custom_initialization(self):
|
|
""""Test agent with custom parameters."""
|
|
agent = RiskManager(
|
|
agent_id="risk-2",
|
|
initial_capital=5000.0,
|
|
skill_level=0.8,
|
|
max_risk_per_trade=0.03,
|
|
max_portfolio_var=0.08,
|
|
)
|
|
|
|
assert agent.agent_id == "risk-2"
|
|
assert agent.balance == 5000.0
|
|
assert agent.skill_level == 0.8
|
|
assert agent.max_risk_per_trade == 0.03
|
|
assert agent.max_portfolio_var == 0.08
|
|
|
|
def test_inherits_from_base_agent(self):
|
|
""""Test that RiskManager inherits from BaseAgent."""
|
|
from openclaw.agents.base import BaseAgent
|
|
|
|
agent = RiskManager(agent_id="test", initial_capital=10000.0)
|
|
|
|
assert isinstance(agent, BaseAgent)
|
|
|
|
|
|
class TestDecideActivity:
|
|
""""Test decide_activity method."""
|
|
|
|
@pytest.fixture
|
|
def agent(self):
|
|
"""Create a test agent."""
|
|
return RiskManager(agent_id="test", initial_capital=10000.0)
|
|
|
|
def test_bankrupt_agent_only_rests(self, agent):
|
|
""""Test that bankrupt agent can only rest."""
|
|
agent.economic_tracker.balance = 0 # Bankrupt
|
|
|
|
result = asyncio.run(agent.decide_activity())
|
|
|
|
assert result == ActivityType.REST
|
|
|
|
def test_critical_status_prefers_learning(self, agent):
|
|
""""Test critical status leads to learning."""
|
|
agent.economic_tracker.balance = 3500.0 # Critical
|
|
agent.state.skill_level = 0.5
|
|
|
|
result = asyncio.run(agent.decide_activity())
|
|
|
|
assert result in [ActivityType.LEARN, ActivityType.ANALYZE]
|
|
|
|
def test_thriving_status_prefers_analyzing(self, agent):
|
|
""""Test thriving status leads to analyzing."""
|
|
agent.economic_tracker.balance = 20000.0 # Thriving
|
|
|
|
result = asyncio.run(agent.decide_activity())
|
|
|
|
assert result == ActivityType.ANALYZE
|
|
|
|
def test_low_skill_leads_to_learning(self, agent):
|
|
""""Test that low skill level leads to learning."""
|
|
agent.economic_tracker.balance = 15000.0 # Stable
|
|
agent.state.skill_level = 0.3
|
|
|
|
result = asyncio.run(agent.decide_activity())
|
|
|
|
assert result == ActivityType.LEARN
|
|
|
|
|
|
class TestAssessRisk:
|
|
""""Test assess_risk method."""
|
|
|
|
@pytest.fixture
|
|
def agent(self):
|
|
"""Create a test agent."""
|
|
return RiskManager(agent_id="test", initial_capital=10000.0)
|
|
|
|
def test_returns_risk_report(self, agent):
|
|
""""Test that assess_risk returns a RiskReport."""
|
|
result = asyncio.run(agent.assess_risk("AAPL", position_size=1000.0))
|
|
|
|
assert isinstance(result, RiskReport)
|
|
assert result.symbol == "AAPL"
|
|
assert result.risk_level in ["low", "medium", "high", "extreme"]
|
|
assert result.volatility > 0
|
|
assert result.var_95 >= 0
|
|
assert result.var_99 >= 0
|
|
|
|
def test_deducts_decision_cost(self, agent):
|
|
""""Test that assess_risk deducts the $0.20 decision cost."""
|
|
initial_balance = agent.balance
|
|
|
|
asyncio.run(agent.assess_risk("AAPL", position_size=1000.0))
|
|
|
|
assert agent.balance == initial_balance - 0.20
|
|
|
|
def test_risk_report_contains_warnings_for_high_volatility(self, agent):
|
|
""""Test that high volatility generates warnings."""
|
|
with patch.object(agent, '_estimate_volatility', return_value=0.60):
|
|
result = asyncio.run(agent.assess_risk("TSLA", position_size=5000.0))
|
|
|
|
assert len(result.warnings) > 0
|
|
assert any("volatility" in w.lower() for w in result.warnings)
|
|
|
|
def test_position_size_recommendation(self, agent):
|
|
""""Test that position size recommendation is calculated."""
|
|
result = asyncio.run(agent.assess_risk("AAPL", position_size=5000.0))
|
|
|
|
assert result.position_size_recommendation >= 0
|
|
|
|
def test_extreme_volatility_reduces_recommendation(self, agent):
|
|
""""Test that extreme volatility significantly reduces recommendation."""
|
|
with patch.object(agent, '_estimate_volatility', return_value=0.60):
|
|
result = asyncio.run(agent.assess_risk("TSLA", position_size=5000.0))
|
|
|
|
# Should recommend much less than requested due to high volatility
|
|
assert result.position_size_recommendation < 5000.0
|
|
|
|
def test_cannot_afford_assessment(self, agent):
|
|
""""Test behavior when agent cannot afford assessment."""
|
|
agent.economic_tracker.balance = 0.10 # Less than decision cost
|
|
|
|
result = asyncio.run(agent.assess_risk("AAPL", position_size=1000.0))
|
|
|
|
assert result.risk_level == "extreme"
|
|
assert "cannot afford" in result.warnings[0].lower()
|
|
|
|
def test_risk_history_updated(self, agent):
|
|
""""Test that risk assessment is recorded in history."""
|
|
initial_history_len = len(agent._risk_history)
|
|
|
|
asyncio.run(agent.assess_risk("AAPL", position_size=1000.0))
|
|
|
|
assert len(agent._risk_history) == initial_history_len + 1
|
|
assert agent._risk_history[-1].symbol == "AAPL"
|
|
|
|
|
|
class TestAnalyze:
|
|
""""Test analyze method (async)."""
|
|
|
|
@pytest.fixture
|
|
def agent(self):
|
|
"""Create a test agent."""
|
|
return RiskManager(agent_id="test", initial_capital=10000.0)
|
|
|
|
def test_analyze_returns_dict(self, agent):
|
|
""""Test that analyze returns a dictionary."""
|
|
result = asyncio.run(agent.analyze("AAPL"))
|
|
|
|
assert isinstance(result, dict)
|
|
assert result["symbol"] == "AAPL"
|
|
assert "risk_level" in result
|
|
assert "volatility" in result
|
|
assert "var_95" in result
|
|
assert "var_99" in result
|
|
assert "cost" in result
|
|
|
|
def test_analyze_deducts_cost(self, agent):
|
|
""""Test that analyze deducts decision cost."""
|
|
initial_balance = agent.balance
|
|
|
|
asyncio.run(agent.analyze("AAPL"))
|
|
|
|
assert agent.balance < initial_balance
|
|
|
|
|
|
class TestPortfolioRisk:
|
|
""""Test portfolio risk assessment methods."""
|
|
|
|
@pytest.fixture
|
|
def agent(self):
|
|
"""Create a test agent."""
|
|
return RiskManager(agent_id="test", initial_capital=10000.0)
|
|
|
|
def test_assess_portfolio_risk_returns_metrics(self, agent):
|
|
""""Test that assess_portfolio_risk returns PortfolioRiskMetrics."""
|
|
positions = {"AAPL": 3000.0, "GOOGL": 2000.0, "MSFT": 2500.0}
|
|
|
|
result = agent.assess_portfolio_risk("portfolio-1", positions)
|
|
|
|
assert isinstance(result, PortfolioRiskMetrics)
|
|
assert result.portfolio_id == "portfolio-1"
|
|
assert result.total_exposure == 7500.0
|
|
assert 0 <= result.concentration_risk <= 1
|
|
assert 0 <= result.correlation_risk <= 1
|
|
assert result.portfolio_var_95 >= 0
|
|
assert result.portfolio_var_99 >= 0
|
|
|
|
def test_empty_portfolio(self, agent):
|
|
""""Test portfolio risk with empty positions."""
|
|
result = agent.assess_portfolio_risk("empty-portfolio", {})
|
|
|
|
assert result.total_exposure == 0.0
|
|
assert result.concentration_risk == 0.0
|
|
assert result.portfolio_var_95 == 0.0
|
|
|
|
def test_single_position_portfolio(self, agent):
|
|
""""Test portfolio risk with single position."""
|
|
positions = {"AAPL": 5000.0}
|
|
|
|
result = agent.assess_portfolio_risk("single-portfolio", positions)
|
|
|
|
assert result.total_exposure == 5000.0
|
|
assert result.concentration_risk == 1.0 # Fully concentrated
|
|
|
|
def test_portfolio_history_updated(self, agent):
|
|
""""Test that portfolio assessment is recorded in history."""
|
|
initial_history_len = len(agent._portfolio_risk_history)
|
|
|
|
agent.assess_portfolio_risk("portfolio-1", {"AAPL": 1000.0})
|
|
|
|
assert len(agent._portfolio_risk_history) == initial_history_len + 1
|
|
|
|
|
|
class TestVolatilityEstimation:
|
|
""""Test volatility estimation methods."""
|
|
|
|
@pytest.fixture
|
|
def agent(self):
|
|
"""Create a test agent."""
|
|
return RiskManager(agent_id="test", initial_capital=10000.0)
|
|
|
|
def test_volatility_in_valid_range(self, agent):
|
|
""""Test that estimated volatility is in valid range."""
|
|
volatility = agent._estimate_volatility("AAPL")
|
|
|
|
assert 0 < volatility <= 1.0
|
|
|
|
def test_high_vol_symbols_have_higher_volatility(self, agent):
|
|
""""Test that high volatility symbols have higher estimated volatility."""
|
|
normal_vol = agent._estimate_volatility("JNJ")
|
|
high_vol = agent._estimate_volatility("TSLA")
|
|
|
|
assert high_vol >= normal_vol * 1.2 # Should be significantly higher
|
|
|
|
def test_high_skill_more_accurate(self):
|
|
""""Test that high skill produces more consistent estimates."""
|
|
high_skill_agent = RiskManager(
|
|
agent_id="high", initial_capital=10000.0, skill_level=0.9
|
|
)
|
|
low_skill_agent = RiskManager(
|
|
agent_id="low", initial_capital=10000.0, skill_level=0.3
|
|
)
|
|
|
|
# Multiple estimates
|
|
high_skill_vols = [high_skill_agent._estimate_volatility("AAPL") for _ in range(10)]
|
|
low_skill_vols = [low_skill_agent._estimate_volatility("AAPL") for _ in range(10)]
|
|
|
|
# High skill should have lower variance
|
|
high_variance = max(high_skill_vols) - min(high_skill_vols)
|
|
low_variance = max(low_skill_vols) - min(low_skill_vols)
|
|
|
|
assert high_variance <= low_variance * 1.5 # Allow some randomness
|
|
|
|
|
|
class TestVaRCalculation:
|
|
""""Test VaR calculation methods."""
|
|
|
|
@pytest.fixture
|
|
def agent(self):
|
|
"""Create a test agent."""
|
|
return RiskManager(agent_id="test", initial_capital=10000.0)
|
|
|
|
def test_var_95_less_than_var_99(self, agent):
|
|
""""Test that 95% VaR is less than 99% VaR."""
|
|
volatility = 0.25
|
|
|
|
var_95 = agent._calculate_var(volatility, confidence=0.95, position_size=10000.0)
|
|
var_99 = agent._calculate_var(volatility, confidence=0.99, position_size=10000.0)
|
|
|
|
assert var_95 < var_99
|
|
|
|
def test_var_increases_with_volatility(self, agent):
|
|
""""Test that VaR increases with higher volatility."""
|
|
var_low = agent._calculate_var(0.20, position_size=10000.0)
|
|
var_high = agent._calculate_var(0.40, position_size=10000.0)
|
|
|
|
assert var_high > var_low
|
|
|
|
def test_var_increases_with_position_size(self, agent):
|
|
""""Test that VaR increases with position size."""
|
|
volatility = 0.25
|
|
|
|
var_small = agent._calculate_var(volatility, position_size=1000.0)
|
|
var_large = agent._calculate_var(volatility, position_size=5000.0)
|
|
|
|
assert var_large > var_small
|
|
|
|
|
|
class TestRiskLevelClassification:
|
|
""""Test risk level classification."""
|
|
|
|
@pytest.fixture
|
|
def agent(self):
|
|
"""Create a test agent."""
|
|
return RiskManager(agent_id="test", initial_capital=10000.0)
|
|
|
|
def test_low_risk(self, agent):
|
|
""""Test low risk classification."""
|
|
assert agent._classify_risk_level(0.10) == "low"
|
|
assert agent._classify_risk_level(0.19) == "low"
|
|
|
|
def test_medium_risk(self, agent):
|
|
""""Test medium risk classification."""
|
|
assert agent._classify_risk_level(0.20) == "medium"
|
|
assert agent._classify_risk_level(0.34) == "medium"
|
|
|
|
def test_high_risk(self, agent):
|
|
""""Test high risk classification."""
|
|
assert agent._classify_risk_level(0.35) == "high"
|
|
assert agent._classify_risk_level(0.49) == "high"
|
|
|
|
def test_extreme_risk(self, agent):
|
|
""""Test extreme risk classification."""
|
|
assert agent._classify_risk_level(0.50) == "extreme"
|
|
assert agent._classify_risk_level(0.80) == "extreme"
|
|
|
|
|
|
class TestConcentrationRisk:
|
|
""""Test concentration risk calculation."""
|
|
|
|
@pytest.fixture
|
|
def agent(self):
|
|
"""Create a test agent."""
|
|
return RiskManager(agent_id="test", initial_capital=10000.0)
|
|
|
|
def test_equal_weights_low_concentration(self, agent):
|
|
""""Test equal weights have lower concentration risk."""
|
|
positions = {"A": 1000.0, "B": 1000.0, "C": 1000.0, "D": 1000.0}
|
|
|
|
concentration = agent._calculate_concentration_risk(positions)
|
|
|
|
# Equal weights should have concentration = 1/n = 0.25
|
|
assert abs(concentration - 0.25) < 0.01
|
|
|
|
def test_single_position_max_concentration(self, agent):
|
|
""""Test single position has maximum concentration."""
|
|
positions = {"A": 1000.0}
|
|
|
|
concentration = agent._calculate_concentration_risk(positions)
|
|
|
|
assert concentration == 1.0
|
|
|
|
def test_empty_positions_zero_concentration(self, agent):
|
|
""""Test empty positions have zero concentration."""
|
|
concentration = agent._calculate_concentration_risk({})
|
|
|
|
assert concentration == 0.0
|
|
|
|
|
|
class TestRiskHistory:
|
|
""""Test risk history methods."""
|
|
|
|
@pytest.fixture
|
|
def agent(self):
|
|
"""Create a test agent."""
|
|
return RiskManager(agent_id="test", initial_capital=10000.0)
|
|
|
|
def test_get_risk_history_returns_copy(self, agent):
|
|
""""Test that get_risk_history returns a copy."""
|
|
asyncio.run(agent.assess_risk("AAPL", position_size=1000.0))
|
|
|
|
history = agent.get_risk_history()
|
|
history.append(None) # Modify the copy
|
|
|
|
# Original should be unchanged
|
|
assert len(agent._risk_history) == 1
|
|
|
|
def test_get_latest_risk_assessment(self, agent):
|
|
""""Test getting latest assessment for a symbol."""
|
|
asyncio.run(agent.assess_risk("AAPL", position_size=1000.0))
|
|
asyncio.run(agent.assess_risk("GOOGL", position_size=2000.0))
|
|
asyncio.run(agent.assess_risk("AAPL", position_size=1500.0)) # Second AAPL
|
|
|
|
latest = agent.get_latest_risk_assessment("AAPL")
|
|
|
|
assert latest is not None
|
|
assert latest.symbol == "AAPL"
|
|
assert latest.var_95 > 0
|
|
|
|
def test_get_latest_returns_none_if_no_assessment(self, agent):
|
|
""""Test getting latest when no assessment exists."""
|
|
latest = agent.get_latest_risk_assessment("UNKNOWN")
|
|
|
|
assert latest is None
|
|
|
|
def test_clear_history(self, agent):
|
|
""""Test clearing risk history."""
|
|
asyncio.run(agent.assess_risk("AAPL", position_size=1000.0))
|
|
agent.assess_portfolio_risk("portfolio-1", {"AAPL": 1000.0})
|
|
|
|
agent.clear_history()
|
|
|
|
assert len(agent._risk_history) == 0
|
|
assert len(agent._portfolio_risk_history) == 0
|
|
|
|
|
|
class TestDecisionCost:
|
|
""""Test decision cost deduction."""
|
|
|
|
def test_decision_cost_is_20_cents(self):
|
|
""""Test that decision cost is $0.20."""
|
|
agent = RiskManager(agent_id="test", initial_capital=10000.0)
|
|
|
|
assert agent.decision_cost == 0.20
|
|
|
|
def test_multiple_assessments_deduct_each_time(self):
|
|
""""Test that each assessment deducts $0.20."""
|
|
agent = RiskManager(agent_id="test", initial_capital=10000.0)
|
|
initial_balance = agent.balance
|
|
|
|
asyncio.run(agent.assess_risk("AAPL", position_size=1000.0))
|
|
asyncio.run(agent.assess_risk("GOOGL", position_size=1000.0))
|
|
asyncio.run(agent.assess_risk("MSFT", position_size=1000.0))
|
|
|
|
expected_deduction = 0.20 * 3
|
|
assert agent.balance == initial_balance - expected_deduction
|
|
|
|
|
|
class TestPositionRecommendation:
|
|
""""Test position size recommendation."""
|
|
|
|
@pytest.fixture
|
|
def agent(self):
|
|
"""Create a test agent."""
|
|
return RiskManager(agent_id="test", initial_capital=10000.0)
|
|
|
|
def test_recommendation_with_low_volatility(self, agent):
|
|
""""Test recommendation with low volatility."""
|
|
# Use higher max_risk_per_trade to test volatility factor without risk cap
|
|
agent.max_risk_per_trade = 0.50 # 50% to avoid risk-based capping
|
|
recommendation = agent._calculate_position_recommendation(
|
|
requested_size=5000.0, volatility=0.15, portfolio=None
|
|
)
|
|
|
|
# Low volatility should allow full or near-full position
|
|
assert recommendation > 4000.0
|
|
|
|
def test_recommendation_with_high_volatility(self, agent):
|
|
""""Test recommendation with high volatility."""
|
|
recommendation = agent._calculate_position_recommendation(
|
|
requested_size=5000.0, volatility=0.50, portfolio=None
|
|
)
|
|
|
|
# High volatility should significantly reduce position
|
|
assert recommendation < 2500.0
|
|
|
|
def test_high_skill_increases_recommendation(self):
|
|
""""Test that high skill increases recommendation."""
|
|
high_skill_agent = RiskManager(
|
|
agent_id="high", initial_capital=10000.0, skill_level=0.9
|
|
)
|
|
low_skill_agent = RiskManager(
|
|
agent_id="low", initial_capital=10000.0, skill_level=0.3
|
|
)
|
|
|
|
high_rec = high_skill_agent._calculate_position_recommendation(
|
|
requested_size=5000.0, volatility=0.30, portfolio=None
|
|
)
|
|
low_rec = low_skill_agent._calculate_position_recommendation(
|
|
requested_size=5000.0, volatility=0.30, portfolio=None
|
|
)
|
|
|
|
assert high_rec >= low_rec
|
|
|
|
|
|
class TestRiskReport:
|
|
""""Test RiskReport dataclass."""
|
|
|
|
def test_risk_report_creation(self):
|
|
""""Test creating a RiskReport."""
|
|
report = RiskReport(
|
|
symbol="AAPL",
|
|
risk_level="medium",
|
|
volatility=0.25,
|
|
var_95=100.0,
|
|
var_99=150.0,
|
|
max_drawdown_estimate=-0.35,
|
|
position_size_recommendation=2000.0,
|
|
warnings=["High volatility"],
|
|
)
|
|
|
|
assert report.symbol == "AAPL"
|
|
assert report.risk_level == "medium"
|
|
assert report.volatility == 0.25
|
|
assert report.var_95 == 100.0
|
|
assert report.var_99 == 150.0
|
|
assert report.max_drawdown_estimate == -0.35
|
|
assert report.position_size_recommendation == 2000.0
|
|
assert report.warnings == ["High volatility"]
|
|
|
|
def test_risk_report_default_warnings(self):
|
|
""""Test RiskReport with default empty warnings."""
|
|
report = RiskReport(
|
|
symbol="AAPL",
|
|
risk_level="low",
|
|
volatility=0.15,
|
|
var_95=50.0,
|
|
var_99=75.0,
|
|
max_drawdown_estimate=-0.20,
|
|
position_size_recommendation=5000.0,
|
|
)
|
|
|
|
assert report.warnings == []
|
|
|
|
|
|
class TestPortfolioRiskMetrics:
|
|
""""Test PortfolioRiskMetrics dataclass."""
|
|
|
|
def test_portfolio_risk_metrics_creation(self):
|
|
""""Test creating PortfolioRiskMetrics."""
|
|
metrics = PortfolioRiskMetrics(
|
|
portfolio_id="portfolio-1",
|
|
total_exposure=10000.0,
|
|
concentration_risk=0.35,
|
|
correlation_risk=0.25,
|
|
portfolio_var_95=500.0,
|
|
portfolio_var_99=750.0,
|
|
sector_exposure={"tech": 0.4, "healthcare": 0.3},
|
|
risk_adjusted_return=0.15,
|
|
)
|
|
|
|
assert metrics.portfolio_id == "portfolio-1"
|
|
assert metrics.total_exposure == 10000.0
|
|
assert metrics.concentration_risk == 0.35
|
|
assert metrics.correlation_risk == 0.25
|
|
assert metrics.portfolio_var_95 == 500.0
|
|
assert metrics.portfolio_var_99 == 750.0
|
|
assert metrics.sector_exposure == {"tech": 0.4, "healthcare": 0.3}
|
|
assert metrics.risk_adjusted_return == 0.15
|