stock/tests/unit/test_bull_researcher.py
ZhangPeng 9aecdd036c Initial commit: OpenClaw Trading - AI多智能体量化交易系统
- 添加项目核心代码和配置
- 添加前端界面 (Next.js)
- 添加单元测试
- 更新 .gitignore 排除缓存和依赖
2026-02-27 03:47:40 +08:00

682 lines
23 KiB
Python

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