493 lines
16 KiB
Python
493 lines
16 KiB
Python
"""Unit tests for FundamentalAnalyst agent.
|
|
|
|
This module tests the FundamentalAnalyst class including fundamental analysis,
|
|
valuation metrics calculation, and report generation.
|
|
"""
|
|
|
|
import asyncio
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from openclaw.agents.base import ActivityType
|
|
from openclaw.agents.fundamental_analyst import (
|
|
FundamentalAnalyst,
|
|
FundamentalReport,
|
|
ValuationRecommendation,
|
|
)
|
|
from openclaw.core.economy import SurvivalStatus
|
|
|
|
|
|
class TestFundamentalAnalystInitialization:
|
|
"""Test FundamentalAnalyst initialization."""
|
|
|
|
def test_default_initialization(self):
|
|
"""Test agent with default parameters."""
|
|
agent = FundamentalAnalyst(agent_id="fundamental-1", initial_capital=10000.0)
|
|
|
|
assert agent.agent_id == "fundamental-1"
|
|
assert agent.balance == 10000.0
|
|
assert agent.skill_level == 0.5
|
|
assert agent.decision_cost == 0.10
|
|
assert agent._last_report is None
|
|
|
|
def test_custom_initialization(self):
|
|
"""Test agent with custom parameters."""
|
|
agent = FundamentalAnalyst(
|
|
agent_id="fundamental-2",
|
|
initial_capital=5000.0,
|
|
skill_level=0.8,
|
|
)
|
|
|
|
assert agent.agent_id == "fundamental-2"
|
|
assert agent.balance == 5000.0
|
|
assert agent.skill_level == 0.8
|
|
|
|
def test_inherits_from_base_agent(self):
|
|
"""Test that FundamentalAnalyst inherits from BaseAgent."""
|
|
from openclaw.agents.base import BaseAgent
|
|
|
|
agent = FundamentalAnalyst(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 FundamentalAnalyst(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.PAPER_TRADE]
|
|
|
|
def test_struggling_status_when_cannot_afford(self, agent):
|
|
"""Test struggling status when cannot afford analysis."""
|
|
agent.economic_tracker.balance = 0.20 # Very low, can't afford $0.10 with safety buffer
|
|
|
|
result = asyncio.run(agent.decide_activity())
|
|
|
|
assert result in [ActivityType.LEARN, ActivityType.PAPER_TRADE, ActivityType.REST]
|
|
|
|
def test_healthy_status_performs_analysis(self, agent):
|
|
"""Test healthy status with sufficient funds performs analysis."""
|
|
agent.economic_tracker.balance = 12000.0 # STABLE status (>110%)
|
|
|
|
result = asyncio.run(agent.decide_activity())
|
|
|
|
assert result == ActivityType.ANALYZE
|
|
|
|
|
|
class TestAnalyzeFundamentals:
|
|
"""Test analyze_fundamentals method."""
|
|
|
|
@pytest.fixture
|
|
def agent(self):
|
|
"""Create a test agent."""
|
|
return FundamentalAnalyst(agent_id="test", initial_capital=10000.0)
|
|
|
|
def test_returns_fundamental_report(self, agent):
|
|
"""Test that analyze_fundamentals returns FundamentalReport."""
|
|
result = asyncio.run(agent.analyze_fundamentals("AAPL"))
|
|
|
|
assert isinstance(result, FundamentalReport)
|
|
assert result.symbol == "AAPL"
|
|
|
|
def test_deducts_decision_cost(self, agent):
|
|
"""Test that analysis deducts $0.10 decision cost."""
|
|
initial_balance = agent.balance
|
|
|
|
asyncio.run(agent.analyze_fundamentals("AAPL"))
|
|
|
|
assert agent.balance == initial_balance - 0.10
|
|
|
|
def test_valuation_metrics_present(self, agent):
|
|
"""Test that valuation metrics are present in report."""
|
|
result = asyncio.run(agent.analyze_fundamentals("AAPL"))
|
|
|
|
assert "pe_ratio" in result.valuation_metrics
|
|
assert "pb_ratio" in result.valuation_metrics
|
|
assert "market_cap" in result.valuation_metrics
|
|
|
|
def test_profitability_metrics_present(self, agent):
|
|
"""Test that profitability metrics are present in report."""
|
|
result = asyncio.run(agent.analyze_fundamentals("TSLA"))
|
|
|
|
assert "roe" in result.profitability_metrics
|
|
assert "roa" in result.profitability_metrics
|
|
assert "profit_margin" in result.profitability_metrics
|
|
|
|
def test_growth_metrics_present(self, agent):
|
|
"""Test that growth metrics are present in report."""
|
|
result = asyncio.run(agent.analyze_fundamentals("MSFT"))
|
|
|
|
assert "revenue_growth" in result.growth_metrics
|
|
assert "earnings_growth" in result.growth_metrics
|
|
|
|
def test_overall_score_range(self, agent):
|
|
"""Test that overall score is in valid range (0-100)."""
|
|
result = asyncio.run(agent.analyze_fundamentals("GOOGL"))
|
|
|
|
assert 0 <= result.overall_score <= 100
|
|
|
|
def test_recommendation_valid(self, agent):
|
|
"""Test that recommendation is valid."""
|
|
result = asyncio.run(agent.analyze_fundamentals("AMZN"))
|
|
|
|
assert result.recommendation in [
|
|
ValuationRecommendation.UNDERVALUED,
|
|
ValuationRecommendation.FAIR,
|
|
ValuationRecommendation.OVERVALUED,
|
|
]
|
|
|
|
def test_stores_last_report(self, agent):
|
|
"""Test that analysis stores the last report."""
|
|
assert agent.get_last_report() is None
|
|
|
|
asyncio.run(agent.analyze_fundamentals("NVDA"))
|
|
|
|
assert agent.get_last_report() is not None
|
|
assert agent.get_last_report().symbol == "NVDA"
|
|
|
|
def test_timestamp_auto_generated(self, agent):
|
|
"""Test that timestamp is auto-generated."""
|
|
result = asyncio.run(agent.analyze_fundamentals("META"))
|
|
|
|
assert result.timestamp != ""
|
|
assert "T" in result.timestamp # ISO format
|
|
|
|
|
|
class TestCalculateValuationMetrics:
|
|
"""Test _calculate_valuation_metrics method."""
|
|
|
|
@pytest.fixture
|
|
def agent(self):
|
|
"""Create a test agent."""
|
|
return FundamentalAnalyst(agent_id="test", initial_capital=10000.0)
|
|
|
|
def test_calculates_pe_ratio(self, agent):
|
|
"""Test PE ratio calculation."""
|
|
data = {"price": 100.0, "eps": 5.0, "book_value": 50.0, "revenue": 1e9}
|
|
|
|
metrics = agent._calculate_valuation_metrics(data)
|
|
|
|
assert metrics["pe_ratio"] == 20.0 # 100 / 5
|
|
|
|
def test_calculates_pb_ratio(self, agent):
|
|
"""Test PB ratio calculation."""
|
|
data = {"price": 100.0, "eps": 5.0, "book_value": 50.0, "revenue": 1e9}
|
|
|
|
metrics = agent._calculate_valuation_metrics(data)
|
|
|
|
assert metrics["pb_ratio"] == 2.0 # 100 / 50
|
|
|
|
def test_handles_zero_values(self, agent):
|
|
"""Test handling of zero values."""
|
|
data = {"price": 100.0, "eps": 0, "book_value": 0, "revenue": 1e9}
|
|
|
|
metrics = agent._calculate_valuation_metrics(data)
|
|
|
|
assert metrics["pe_ratio"] == float("inf")
|
|
assert metrics["pb_ratio"] == float("inf")
|
|
|
|
|
|
class TestCalculateProfitabilityMetrics:
|
|
"""Test _calculate_profitability_metrics method."""
|
|
|
|
@pytest.fixture
|
|
def agent(self):
|
|
"""Create a test agent."""
|
|
return FundamentalAnalyst(agent_id="test", initial_capital=10000.0)
|
|
|
|
def test_calculates_roe(self, agent):
|
|
"""Test ROE calculation."""
|
|
data = {
|
|
"net_income": 1e9,
|
|
"shareholders_equity": 5e9,
|
|
"total_assets": 10e9,
|
|
"revenue": 10e9,
|
|
}
|
|
|
|
metrics = agent._calculate_profitability_metrics(data)
|
|
|
|
assert metrics["roe"] == 0.2 # 1e9 / 5e9
|
|
|
|
def test_calculates_roa(self, agent):
|
|
"""Test ROA calculation."""
|
|
data = {
|
|
"net_income": 1e9,
|
|
"shareholders_equity": 5e9,
|
|
"total_assets": 10e9,
|
|
"revenue": 10e9,
|
|
}
|
|
|
|
metrics = agent._calculate_profitability_metrics(data)
|
|
|
|
assert metrics["roa"] == 0.1 # 1e9 / 10e9
|
|
|
|
def test_calculates_profit_margin(self, agent):
|
|
"""Test profit margin calculation."""
|
|
data = {
|
|
"net_income": 2e9,
|
|
"shareholders_equity": 5e9,
|
|
"total_assets": 10e9,
|
|
"revenue": 10e9,
|
|
}
|
|
|
|
metrics = agent._calculate_profitability_metrics(data)
|
|
|
|
assert metrics["profit_margin"] == 0.2 # 2e9 / 10e9
|
|
|
|
|
|
class TestCalculateGrowthMetrics:
|
|
"""Test _calculate_growth_metrics method."""
|
|
|
|
@pytest.fixture
|
|
def agent(self):
|
|
"""Create a test agent."""
|
|
return FundamentalAnalyst(agent_id="test", initial_capital=10000.0)
|
|
|
|
def test_extracts_growth_rates(self, agent):
|
|
"""Test growth rate extraction."""
|
|
data = {"revenue_growth": 0.15, "earnings_growth": 0.25}
|
|
|
|
metrics = agent._calculate_growth_metrics(data)
|
|
|
|
assert metrics["revenue_growth"] == 0.15
|
|
assert metrics["earnings_growth"] == 0.25
|
|
|
|
|
|
class TestCalculateOverallScore:
|
|
"""Test _calculate_overall_score method."""
|
|
|
|
@pytest.fixture
|
|
def agent(self):
|
|
"""Create a test agent."""
|
|
return FundamentalAnalyst(agent_id="test", initial_capital=10000.0)
|
|
|
|
def test_high_score_for_good_metrics(self, agent):
|
|
"""Test high score for strong fundamentals."""
|
|
valuation = {"pe_ratio": 10, "pb_ratio": 1.0}
|
|
profitability = {"roe": 0.20, "roa": 0.10, "profit_margin": 0.25}
|
|
growth = {"revenue_growth": 0.15, "earnings_growth": 0.20}
|
|
|
|
score = agent._calculate_overall_score(valuation, profitability, growth)
|
|
|
|
assert score >= 70 # Should be high score
|
|
|
|
def test_low_score_for_poor_metrics(self, agent):
|
|
"""Test low score for weak fundamentals."""
|
|
valuation = {"pe_ratio": 50, "pb_ratio": 5.0}
|
|
profitability = {"roe": 0.03, "roa": 0.02, "profit_margin": 0.03}
|
|
growth = {"revenue_growth": -0.05, "earnings_growth": -0.10}
|
|
|
|
score = agent._calculate_overall_score(valuation, profitability, growth)
|
|
|
|
assert score <= 50 # Should be low score
|
|
|
|
def test_score_clamped_to_100(self, agent):
|
|
"""Test score doesn't exceed 100."""
|
|
valuation = {"pe_ratio": 5, "pb_ratio": 0.5}
|
|
profitability = {"roe": 0.50, "roa": 0.30, "profit_margin": 0.50}
|
|
growth = {"revenue_growth": 0.50, "earnings_growth": 0.50}
|
|
|
|
score = agent._calculate_overall_score(valuation, profitability, growth)
|
|
|
|
assert score <= 100
|
|
|
|
def test_score_clamped_to_0(self, agent):
|
|
"""Test score doesn't go below 0."""
|
|
valuation = {"pe_ratio": 100, "pb_ratio": 10.0}
|
|
profitability = {"roe": -0.10, "roa": -0.05, "profit_margin": -0.05}
|
|
growth = {"revenue_growth": -0.30, "earnings_growth": -0.30}
|
|
|
|
score = agent._calculate_overall_score(valuation, profitability, growth)
|
|
|
|
assert score >= 0
|
|
|
|
|
|
class TestGenerateRecommendation:
|
|
"""Test _generate_recommendation method."""
|
|
|
|
@pytest.fixture
|
|
def agent(self):
|
|
"""Create a test agent."""
|
|
return FundamentalAnalyst(agent_id="test", initial_capital=10000.0)
|
|
|
|
def test_undervalued_recommendation(self, agent):
|
|
"""Test undervalued recommendation for high score and low PE."""
|
|
valuation = {"pe_ratio": 10}
|
|
|
|
rec = agent._generate_recommendation(75, valuation)
|
|
|
|
assert rec == ValuationRecommendation.UNDERVALUED
|
|
|
|
def test_overvalued_recommendation(self, agent):
|
|
"""Test overvalued recommendation for low score or very high PE."""
|
|
valuation = {"pe_ratio": 50}
|
|
|
|
rec = agent._generate_recommendation(35, valuation)
|
|
|
|
assert rec == ValuationRecommendation.OVERVALUED
|
|
|
|
def test_fair_recommendation(self, agent):
|
|
"""Test fair recommendation for neutral conditions."""
|
|
valuation = {"pe_ratio": 20}
|
|
|
|
rec = agent._generate_recommendation(55, valuation)
|
|
|
|
assert rec == ValuationRecommendation.FAIR
|
|
|
|
|
|
class TestAnalyze:
|
|
"""Test analyze method (async BaseAgent interface)."""
|
|
|
|
@pytest.fixture
|
|
def agent(self):
|
|
"""Create a test agent."""
|
|
return FundamentalAnalyst(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 "overall_score" in result
|
|
assert "recommendation" in result
|
|
assert "valuation_metrics" in result
|
|
assert "profitability_metrics" in result
|
|
assert "growth_metrics" 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
|
|
|
|
def test_analyze_includes_cost_in_result(self, agent):
|
|
"""Test that analyze result includes cost."""
|
|
result = asyncio.run(agent.analyze("AAPL"))
|
|
|
|
assert result["cost"] == 0.10
|
|
|
|
|
|
class TestGetLastReport:
|
|
"""Test get_last_report method."""
|
|
|
|
@pytest.fixture
|
|
def agent(self):
|
|
"""Create a test agent."""
|
|
return FundamentalAnalyst(agent_id="test", initial_capital=10000.0)
|
|
|
|
def test_returns_none_when_no_analysis(self, agent):
|
|
"""Test returns None when no analysis performed."""
|
|
assert agent.get_last_report() is None
|
|
|
|
def test_returns_report_after_analysis(self, agent):
|
|
"""Test returns report after analysis."""
|
|
asyncio.run(agent.analyze_fundamentals("AAPL"))
|
|
|
|
report = agent.get_last_report()
|
|
|
|
assert report is not None
|
|
assert report.symbol == "AAPL"
|
|
|
|
|
|
class TestGetReportHistory:
|
|
"""Test get_report_history method."""
|
|
|
|
@pytest.fixture
|
|
def agent(self):
|
|
"""Create a test agent."""
|
|
return FundamentalAnalyst(agent_id="test", initial_capital=10000.0)
|
|
|
|
def test_returns_empty_list_when_no_analysis(self, agent):
|
|
"""Test returns empty list when no analysis performed."""
|
|
assert agent.get_report_history() == []
|
|
|
|
def test_returns_list_with_report_after_analysis(self, agent):
|
|
"""Test returns list with report after analysis."""
|
|
asyncio.run(agent.analyze_fundamentals("AAPL"))
|
|
|
|
history = agent.get_report_history()
|
|
|
|
assert len(history) == 1
|
|
assert history[0].symbol == "AAPL"
|
|
|
|
|
|
class TestFundamentalReport:
|
|
"""Test FundamentalReport dataclass."""
|
|
|
|
def test_report_creation(self):
|
|
"""Test creating a FundamentalReport."""
|
|
report = FundamentalReport(
|
|
symbol="AAPL",
|
|
valuation_metrics={"pe_ratio": 20.0},
|
|
profitability_metrics={"roe": 0.15},
|
|
growth_metrics={"revenue_growth": 0.10},
|
|
overall_score=75.0,
|
|
recommendation=ValuationRecommendation.UNDERVALUED,
|
|
)
|
|
|
|
assert report.symbol == "AAPL"
|
|
assert report.overall_score == 75.0
|
|
assert report.recommendation == ValuationRecommendation.UNDERVALUED
|
|
|
|
def test_timestamp_auto_generated(self):
|
|
"""Test that timestamp is auto-generated."""
|
|
report = FundamentalReport(
|
|
symbol="AAPL",
|
|
valuation_metrics={},
|
|
profitability_metrics={},
|
|
growth_metrics={},
|
|
overall_score=75.0,
|
|
recommendation=ValuationRecommendation.FAIR,
|
|
)
|
|
|
|
assert report.timestamp != ""
|
|
assert "T" in report.timestamp # ISO format
|
|
|
|
def test_custom_timestamp(self):
|
|
"""Test FundamentalReport with custom timestamp."""
|
|
report = FundamentalReport(
|
|
symbol="AAPL",
|
|
valuation_metrics={},
|
|
profitability_metrics={},
|
|
growth_metrics={},
|
|
overall_score=75.0,
|
|
recommendation=ValuationRecommendation.FAIR,
|
|
timestamp="2024-01-01T00:00:00",
|
|
)
|
|
|
|
assert report.timestamp == "2024-01-01T00:00:00"
|
|
|
|
|
|
class TestValuationRecommendation:
|
|
"""Test ValuationRecommendation enum."""
|
|
|
|
def test_recommendation_values(self):
|
|
"""Test recommendation enum values."""
|
|
assert ValuationRecommendation.UNDERVALUED == "undervalued"
|
|
assert ValuationRecommendation.FAIR == "fair"
|
|
assert ValuationRecommendation.OVERVALUED == "overvalued"
|