"""Unit tests for BullResearcher Agent. This module tests the BullResearcher class including bull case generation, counter-arguments, price targets, and decision cost deduction. """ import asyncio from unittest.mock import MagicMock, patch import pytest from openclaw.agents.base import ActivityType from openclaw.agents.bull_researcher import BullReport, BullResearcher from openclaw.agents.trader import MarketAnalysis from openclaw.core.economy import SurvivalStatus class TestBullReport: """Test BullReport dataclass.""" def test_default_creation(self): """Test creating BullReport with defaults.""" report = BullReport(symbol="AAPL") assert report.symbol == "AAPL" assert report.bullish_factors == [] assert report.counter_arguments == {} assert report.price_target == 0.0 assert report.conviction_level == 0.5 assert report.summary == "" assert report.risk_factors == [] assert report.catalysts == [] def test_full_creation(self): """Test creating BullReport with all fields.""" report = BullReport( symbol="TSLA", bullish_factors=["Strong growth", "Market leadership"], counter_arguments={"Overvalued": "Growth justifies premium"}, price_target=250.0, conviction_level=0.75, summary="Bull case for TSLA", risk_factors=["Competition", "Regulation"], catalysts=["Earnings beat", "New product launch"], ) assert report.symbol == "TSLA" assert len(report.bullish_factors) == 2 assert report.price_target == 250.0 assert report.conviction_level == 0.75 def test_conviction_bounds(self): """Test conviction level is bounded between 0 and 1.""" report_high = BullReport(symbol="AAPL", conviction_level=1.5) assert report_high.conviction_level == 1.0 report_low = BullReport(symbol="AAPL", conviction_level=-0.5) assert report_low.conviction_level == 0.0 class TestBullResearcherInitialization: """Test BullResearcher initialization.""" def test_default_initialization(self): """Test agent with default parameters.""" agent = BullResearcher(agent_id="bull-1", initial_capital=10000.0) assert agent.agent_id == "bull-1" assert agent.balance == 10000.0 assert agent.skill_level == 0.5 assert agent.decision_cost == 0.15 assert agent._last_report is None assert agent._report_history == [] def test_custom_initialization(self): """Test agent with custom parameters.""" agent = BullResearcher( agent_id="bull-2", initial_capital=5000.0, skill_level=0.8, ) assert agent.agent_id == "bull-2" assert agent.balance == 5000.0 assert agent.skill_level == 0.8 def test_inherits_from_base_agent(self): """Test that BullResearcher inherits from BaseAgent.""" from openclaw.agents.base import BaseAgent agent = BullResearcher(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 BullResearcher(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_stable_status_prefers_analysis(self, agent): """Test stable status leads to analysis/paper trade.""" agent.economic_tracker.balance = 12000.0 # Stable result = asyncio.run(agent.decide_activity()) assert result in [ActivityType.ANALYZE, ActivityType.PAPER_TRADE] def test_thriving_status_prefers_analysis(self, agent): """Test thriving status leads to analysis.""" agent.economic_tracker.balance = 20000.0 # Thriving result = asyncio.run(agent.decide_activity()) assert result == ActivityType.ANALYZE class TestAnalyze: """Test analyze method (async).""" @pytest.fixture def agent(self): """Create a test agent.""" return BullResearcher(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 "bull_report" 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 - 0.15 def test_analyze_stores_last_report(self, agent): """Test that analyze stores the report.""" assert agent._last_report is None asyncio.run(agent.analyze("TSLA")) assert agent._last_report is not None assert agent._last_report.symbol == "TSLA" def test_analyze_adds_to_history(self, agent): """Test that analyze adds to report history.""" assert len(agent._report_history) == 0 asyncio.run(agent.analyze("AAPL")) asyncio.run(agent.analyze("TSLA")) assert len(agent._report_history) == 2 class TestGenerateBullCase: """Test generate_bull_case method.""" @pytest.fixture def agent(self): """Create a test agent.""" return BullResearcher(agent_id="test", initial_capital=10000.0) def test_returns_bull_report(self, agent): """Test that generate_bull_case returns BullReport.""" result = asyncio.run(agent.generate_bull_case("AAPL")) assert isinstance(result, BullReport) assert result.symbol == "AAPL" def test_deducts_decision_cost(self, agent): """Test that decision cost is deducted.""" initial_balance = agent.balance asyncio.run(agent.generate_bull_case("AAPL")) assert agent.balance == initial_balance - 0.15 def test_includes_bullish_factors(self, agent): """Test that bull report includes bullish factors.""" result = asyncio.run(agent.generate_bull_case("AAPL")) assert isinstance(result.bullish_factors, list) assert len(result.bullish_factors) > 0 def test_includes_counter_arguments(self, agent): """Test that bull report includes counter-arguments.""" result = asyncio.run(agent.generate_bull_case("AAPL")) assert isinstance(result.counter_arguments, dict) assert len(result.counter_arguments) > 0 def test_includes_price_target(self, agent): """Test that bull report includes price target.""" result = asyncio.run(agent.generate_bull_case("AAPL")) assert isinstance(result.price_target, float) assert result.price_target >= 0 def test_includes_conviction_level(self, agent): """Test that bull report includes conviction level.""" result = asyncio.run(agent.generate_bull_case("AAPL")) assert 0.0 <= result.conviction_level <= 1.0 def test_includes_summary(self, agent): """Test that bull report includes summary.""" result = asyncio.run(agent.generate_bull_case("AAPL")) assert isinstance(result.summary, str) assert len(result.summary) > 0 def test_includes_risk_factors(self, agent): """Test that bull report includes risk factors.""" result = asyncio.run(agent.generate_bull_case("AAPL")) assert isinstance(result.risk_factors, list) assert len(result.risk_factors) > 0 def test_includes_catalysts(self, agent): """Test that bull report includes catalysts.""" result = asyncio.run(agent.generate_bull_case("AAPL")) assert isinstance(result.catalysts, list) assert len(result.catalysts) > 0 def test_stores_report_in_history(self, agent): """Test that generated report is stored in history.""" assert len(agent._report_history) == 0 asyncio.run(agent.generate_bull_case("AAPL")) assert len(agent._report_history) == 1 assert agent._last_report is not None class TestExtractBullishFactors: """Test bullish factor extraction.""" @pytest.fixture def agent(self): """Create a test agent.""" return BullResearcher(agent_id="test", initial_capital=10000.0) def test_extract_from_technical_dict(self, agent): """Test extracting factors from technical report (dict format).""" technical = { "trend": "uptrend", "indicators": {"rsi": 35.0, "macd": 0.5, "current_price": 100.0}, } factors = agent._extract_bullish_factors(technical, None, None) assert any("uptrend" in f.lower() for f in factors) assert any("RSI" in f or "oversold" in f.lower() for f in factors) def test_extract_from_technical_object(self, agent): """Test extracting factors from technical report (object format).""" technical = MarketAnalysis( symbol="AAPL", trend="uptrend", volatility=0.2, volume_trend="increasing", support_level=90.0, resistance_level=110.0, indicators={"rsi": 35.0, "macd": 0.5, "current_price": 100.0}, ) factors = agent._extract_bullish_factors(technical, None, None) assert any("uptrend" in f.lower() for f in factors) def test_extract_from_sentiment_dict(self, agent): """Test extracting factors from sentiment report (dict format).""" sentiment = {"sentiment": "bullish", "score": 0.75} factors = agent._extract_bullish_factors(None, sentiment, None) assert any("sentiment" in f.lower() for f in factors) def test_extract_from_fundamental_dict(self, agent): """Test extracting factors from fundamental report (dict format).""" fundamental = { "valuation": "undervalued", "growth_rate": 0.25, "pe_ratio": 15.0, } factors = agent._extract_fundamental_bullish_factors(fundamental) assert any("undervalued" in f.lower() for f in factors) assert any("growth" in f.lower() for f in factors) def test_empty_reports_placeholder(self, agent): """Test placeholder factor when no reports provided.""" factors = agent._extract_bullish_factors(None, None, None) assert len(factors) == 1 assert "pending" in factors[0].lower() class TestGenerateCounterArguments: """Test counter-argument generation.""" @pytest.fixture def agent(self): """Create a test agent.""" return BullResearcher(agent_id="test", initial_capital=10000.0) def test_includes_common_counters(self, agent): """Test that common counter-arguments are included.""" counters = agent._generate_counter_arguments(None, None, None) assert "Stock is overbought" in counters assert "Valuation is stretched" in counters assert "Recent rally is unsustainable" in counters assert "Market sentiment is too optimistic" in counters def test_counters_are_strings(self, agent): """Test that all counter-arguments are strings.""" counters = agent._generate_counter_arguments(None, None, None) for key, value in counters.items(): assert isinstance(key, str) assert isinstance(value, str) assert len(value) > 0 class TestCalculateConviction: """Test conviction calculation.""" @pytest.fixture def agent(self): """Create a test agent.""" return BullResearcher(agent_id="test", initial_capital=10000.0) def test_base_conviction(self, agent): """Test base conviction level.""" conviction = agent._calculate_conviction([], None, None, None) assert conviction >= 0.5 # Base conviction assert conviction <= 1.0 def test_factors_boost_conviction(self, agent): """Test that more factors increase conviction.""" low_factors = ["Factor 1"] high_factors = ["Factor 1", "Factor 2", "Factor 3", "Factor 4", "Factor 5"] low_conviction = agent._calculate_conviction(low_factors, None, None, None) high_conviction = agent._calculate_conviction(high_factors, None, None, None) assert high_conviction >= low_conviction def test_all_reports_max_conviction(self, agent): """Test that having all reports allows higher conviction.""" factors = ["Factor 1", "Factor 2", "Factor 3"] partial_conviction = agent._calculate_conviction(factors, None, None, None) full_conviction = agent._calculate_conviction( factors, {"trend": "up"}, {"sentiment": "bullish"}, {"pe": 15} ) # With all reports, max conviction is higher assert full_conviction >= partial_conviction class TestPriceTarget: """Test price target generation.""" @pytest.fixture def agent(self): """Create a test agent.""" return BullResearcher(agent_id="test", initial_capital=10000.0) def test_default_price(self, agent): """Test default price when no reports.""" target = agent._generate_price_target("AAPL", None, None) # Default current price is 100, with 10-20% upside assert target >= 110.0 assert target <= 130.0 def test_price_from_technical(self, agent): """Test price target from technical report.""" technical = {"indicators": {"current_price": 150.0}} target = agent._generate_price_target("AAPL", None, technical) # Target should be above current price assert target > 150.0 def test_fundamental_boosts_target(self, agent): """Test that fundamental report adds upside.""" technical = {"indicators": {"current_price": 100.0}} fundamental = {"pe_ratio": 15.0} target_without = agent._generate_price_target("AAPL", None, technical) target_with = agent._generate_price_target("AAPL", fundamental, technical) # With fundamental, target should be higher assert target_with > target_without class TestIdentifyCatalysts: """Test catalyst identification.""" @pytest.fixture def agent(self): """Create a test agent.""" return BullResearcher(agent_id="test", initial_capital=10000.0) def test_basic_catalysts(self, agent): """Test that basic catalysts are identified.""" catalysts = agent._identify_catalysts(None, None, None) assert len(catalysts) >= 4 assert any("Earnings" in c for c in catalysts) assert any("Institutional" in c for c in catalysts) def test_high_skill_extra_catalyst(self, agent): """Test that high skill adds extra catalysts.""" agent.state.skill_level = 0.8 catalysts = agent._identify_catalysts(None, None, None) assert any("sector" in c.lower() for c in catalysts) class TestIdentifyRisks: """Test risk identification.""" @pytest.fixture def agent(self): """Create a test agent.""" return BullResearcher(agent_id="test", initial_capital=10000.0) def test_basic_risks(self, agent): """Test that basic risks are identified.""" risks = agent._identify_risks(None, None, None) assert len(risks) >= 3 assert any("market" in r.lower() for r in risks) assert any("earnings" in r.lower() for r in risks) def test_high_skill_extra_risk(self, agent): """Test that high skill adds extra risks.""" agent.state.skill_level = 0.7 risks = agent._identify_risks(None, None, None) assert any("regulatory" in r.lower() for r in risks) class TestGetLastReport: """Test get_last_report method.""" @pytest.fixture def agent(self): """Create a test agent.""" return BullResearcher(agent_id="test", initial_capital=10000.0) def test_no_report_returns_none(self, agent): """Test that None is returned when no reports.""" result = agent.get_last_report() assert result is None def test_returns_last_report(self, agent): """Test that last report is returned.""" asyncio.run(agent.generate_bull_case("AAPL")) result = agent.get_last_report() assert result is not None assert result.symbol == "AAPL" class TestGetReportHistory: """Test get_report_history method.""" @pytest.fixture def agent(self): """Create a test agent.""" return BullResearcher(agent_id="test", initial_capital=10000.0) def test_empty_history(self, agent): """Test empty history.""" history = agent.get_report_history() assert history == [] def test_returns_copy(self, agent): """Test that history returns a copy.""" asyncio.run(agent.generate_bull_case("AAPL")) history = agent.get_report_history() history.append(None) # Modify the copy # Original should be unchanged assert len(agent._report_history) == 1 def test_multiple_reports(self, agent): """Test history with multiple reports.""" asyncio.run(agent.generate_bull_case("AAPL")) asyncio.run(agent.generate_bull_case("TSLA")) asyncio.run(agent.generate_bull_case("NVDA")) history = agent.get_report_history() assert len(history) == 3 class TestGetBullishRecommendation: """Test get_bullish_recommendation method.""" @pytest.fixture def agent(self): """Create a test agent.""" return BullResearcher(agent_id="test", initial_capital=10000.0) def test_no_analysis_returns_hold(self, agent): """Test HOLD recommendation when no analysis.""" result = agent.get_bullish_recommendation("AAPL") assert result["symbol"] == "AAPL" assert result["recommendation"] == "HOLD" assert result["conviction"] == 0.0 def test_strong_buy_for_high_conviction(self, agent): """Test STRONG_BUY for high conviction.""" # Create a high conviction report report = BullReport( symbol="AAPL", conviction_level=0.8, bullish_factors=["F1", "F2", "F3"], price_target=200.0, ) agent._last_report = report result = agent.get_bullish_recommendation("AAPL") assert result["recommendation"] == "STRONG_BUY" def test_buy_for_moderate_conviction(self, agent): """Test BUY for moderate conviction.""" report = BullReport( symbol="AAPL", conviction_level=0.65, bullish_factors=["F1", "F2"], price_target=150.0, ) agent._last_report = report result = agent.get_bullish_recommendation("AAPL") assert result["recommendation"] == "BUY" def test_accumulate_for_low_conviction(self, agent): """Test ACCUMULATE for lower conviction.""" report = BullReport( symbol="AAPL", conviction_level=0.5, bullish_factors=["F1"], price_target=120.0, ) agent._last_report = report result = agent.get_bullish_recommendation("AAPL") assert result["recommendation"] == "ACCUMULATE" def test_different_symbol_returns_hold(self, agent): """Test HOLD when asking for different symbol than last analyzed.""" report = BullReport(symbol="AAPL", conviction_level=0.8) agent._last_report = report result = agent.get_bullish_recommendation("TSLA") assert result["recommendation"] == "HOLD" class TestDecisionCost: """Test decision cost deduction.""" @pytest.fixture def agent(self): """Create a test agent.""" return BullResearcher(agent_id="test", initial_capital=10000.0) def test_decision_cost_constant(self): """Test that decision cost is $0.15.""" agent = BullResearcher(agent_id="test", initial_capital=10000.0) assert agent.decision_cost == 0.15 def test_analyze_deducts_fixed_cost(self, agent): """Test that analyze deducts exactly $0.15.""" initial_balance = agent.balance asyncio.run(agent.analyze("AAPL")) assert agent.balance == initial_balance - 0.15 def test_generate_bull_case_deducts_fixed_cost(self, agent): """Test that generate_bull_case deducts exactly $0.15.""" initial_balance = agent.balance asyncio.run(agent.generate_bull_case("AAPL")) assert agent.balance == initial_balance - 0.15 def test_multiple_calls_deduct_multiple_times(self, agent): """Test that each call deducts cost.""" initial_balance = agent.balance asyncio.run(agent.generate_bull_case("AAPL")) asyncio.run(agent.generate_bull_case("TSLA")) asyncio.run(agent.generate_bull_case("NVDA")) expected_balance = initial_balance - (0.15 * 3) assert agent.balance == expected_balance class TestSkillLevelImpact: """Test impact of skill level on analysis.""" def test_high_skill_higher_conviction(self): """Test that high skill produces higher conviction.""" low_skill = BullResearcher(agent_id="low", initial_capital=10000.0, skill_level=0.3) high_skill = BullResearcher(agent_id="high", initial_capital=10000.0, skill_level=0.9) low_report = asyncio.run(low_skill.generate_bull_case("AAPL")) high_report = asyncio.run(high_skill.generate_bull_case("AAPL")) assert high_report.conviction_level >= low_report.conviction_level def test_high_skill_more_catalysts(self): """Test that high skill identifies more catalysts.""" low_skill = BullResearcher(agent_id="low", initial_capital=10000.0, skill_level=0.3) high_skill = BullResearcher(agent_id="high", initial_capital=10000.0, skill_level=0.9) low_catalysts = low_skill._identify_catalysts(None, None, None) high_catalysts = high_skill._identify_catalysts(None, None, None) assert len(high_catalysts) >= len(low_catalysts) def test_high_skill_more_risks(self): """Test that high skill identifies more risks.""" low_skill = BullResearcher(agent_id="low", initial_capital=10000.0, skill_level=0.3) high_skill = BullResearcher(agent_id="high", initial_capital=10000.0, skill_level=0.7) low_risks = low_skill._identify_risks(None, None, None) high_risks = high_skill._identify_risks(None, None, None) assert len(high_risks) >= len(low_risks)