553 lines
18 KiB
Python
553 lines
18 KiB
Python
"""Unit tests for MarketAnalyst agent.
|
|
|
|
This module tests the MarketAnalyst class including technical indicator
|
|
calculations, trend identification, and signal generation.
|
|
"""
|
|
|
|
import asyncio
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import pytest
|
|
|
|
from openclaw.agents.base import ActivityType
|
|
from openclaw.agents.market_analyst import MarketAnalyst, TechnicalReport
|
|
from openclaw.core.economy import SurvivalStatus
|
|
|
|
|
|
class TestMarketAnalystInitialization:
|
|
"""Test MarketAnalyst initialization."""
|
|
|
|
def test_default_initialization(self):
|
|
"""Test agent with default parameters."""
|
|
agent = MarketAnalyst(agent_id="analyst-1", initial_capital=1000.0)
|
|
|
|
assert agent.agent_id == "analyst-1"
|
|
assert agent.balance == 1000.0
|
|
assert agent.skill_level == 0.5
|
|
assert agent.decision_cost == 0.05
|
|
assert agent._last_report is None
|
|
|
|
def test_custom_initialization(self):
|
|
"""Test agent with custom parameters."""
|
|
agent = MarketAnalyst(
|
|
agent_id="analyst-2",
|
|
initial_capital=500.0,
|
|
skill_level=0.8,
|
|
)
|
|
|
|
assert agent.agent_id == "analyst-2"
|
|
assert agent.balance == 500.0
|
|
assert agent.skill_level == 0.8
|
|
|
|
def test_inherits_from_base_agent(self):
|
|
"""Test that MarketAnalyst inherits from BaseAgent."""
|
|
from openclaw.agents.base import BaseAgent
|
|
|
|
agent = MarketAnalyst(agent_id="test", initial_capital=1000.0)
|
|
|
|
assert isinstance(agent, BaseAgent)
|
|
|
|
|
|
class TestDecideActivity:
|
|
"""Test decide_activity method."""
|
|
|
|
@pytest.fixture
|
|
def agent(self):
|
|
"""Create a test agent."""
|
|
return MarketAnalyst(agent_id="test", initial_capital=1000.0)
|
|
|
|
def test_bankrupt_agent_only_rests(self, agent):
|
|
"""Test that bankrupt agent can only rest."""
|
|
agent.economic_tracker.balance = 0
|
|
|
|
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 = 350.0
|
|
agent.state.skill_level = 0.5
|
|
|
|
result = asyncio.run(agent.decide_activity())
|
|
|
|
assert result in [ActivityType.LEARN, ActivityType.ANALYZE]
|
|
|
|
def test_stable_status_prefers_analysis(self, agent):
|
|
"""Test stable status leads to analysis."""
|
|
agent.economic_tracker.balance = 2000.0
|
|
|
|
result = asyncio.run(agent.decide_activity())
|
|
|
|
assert result == ActivityType.ANALYZE
|
|
|
|
|
|
class TestAnalyze:
|
|
"""Test analyze method."""
|
|
|
|
@pytest.fixture
|
|
def agent(self):
|
|
"""Create a test agent."""
|
|
return MarketAnalyst(agent_id="test", initial_capital=1000.0)
|
|
|
|
@pytest.fixture
|
|
def sample_data(self):
|
|
"""Create sample price data."""
|
|
np.random.seed(42)
|
|
n_periods = 100
|
|
returns = np.random.normal(0.001, 0.02, n_periods)
|
|
prices = 100 * np.exp(np.cumsum(returns))
|
|
|
|
return pd.DataFrame(
|
|
{
|
|
"open": prices * (1 + np.random.normal(0, 0.001, n_periods)),
|
|
"high": prices * (1 + abs(np.random.normal(0, 0.01, n_periods))),
|
|
"low": prices * (1 - abs(np.random.normal(0, 0.01, n_periods))),
|
|
"close": prices,
|
|
"volume": np.random.randint(1000000, 10000000, n_periods),
|
|
}
|
|
)
|
|
|
|
def test_analyze_returns_technical_report(self, agent, sample_data):
|
|
"""Test that analyze returns a TechnicalReport."""
|
|
result = asyncio.run(agent.analyze("AAPL", sample_data))
|
|
|
|
assert isinstance(result, TechnicalReport)
|
|
assert result.symbol == "AAPL"
|
|
|
|
def test_analyze_deducts_decision_cost(self, agent, sample_data):
|
|
"""Test that analyze deducts the $0.05 decision cost."""
|
|
initial_balance = agent.balance
|
|
|
|
asyncio.run(agent.analyze("AAPL", sample_data))
|
|
|
|
assert agent.balance == initial_balance - 0.05
|
|
|
|
def test_analyze_stores_last_report(self, agent, sample_data):
|
|
"""Test that analyze stores the report."""
|
|
assert agent._last_report is None
|
|
|
|
asyncio.run(agent.analyze("TSLA", sample_data))
|
|
|
|
assert agent._last_report is not None
|
|
assert agent._last_report.symbol == "TSLA"
|
|
|
|
def test_analyze_without_data_uses_sample(self, agent):
|
|
"""Test that analyze generates sample data if none provided."""
|
|
result = asyncio.run(agent.analyze("AAPL"))
|
|
|
|
assert isinstance(result, TechnicalReport)
|
|
assert result.symbol == "AAPL"
|
|
assert len(result.indicators) > 0
|
|
|
|
def test_get_last_report_returns_report(self, agent, sample_data):
|
|
"""Test get_last_report method."""
|
|
assert agent.get_last_report() is None
|
|
|
|
asyncio.run(agent.analyze("NVDA", sample_data))
|
|
|
|
report = agent.get_last_report()
|
|
assert report is not None
|
|
assert report.symbol == "NVDA"
|
|
|
|
|
|
class TestCalculateIndicators:
|
|
"""Test technical indicator calculations."""
|
|
|
|
@pytest.fixture
|
|
def agent(self):
|
|
"""Create a test agent."""
|
|
return MarketAnalyst(agent_id="test", initial_capital=1000.0)
|
|
|
|
@pytest.fixture
|
|
def sample_data(self):
|
|
"""Create sample price data."""
|
|
np.random.seed(42)
|
|
n_periods = 100
|
|
returns = np.random.normal(0.001, 0.02, n_periods)
|
|
prices = 100 * np.exp(np.cumsum(returns))
|
|
|
|
return pd.DataFrame(
|
|
{
|
|
"open": prices * (1 + np.random.normal(0, 0.001, n_periods)),
|
|
"high": prices * (1 + abs(np.random.normal(0, 0.01, n_periods))),
|
|
"low": prices * (1 - abs(np.random.normal(0, 0.01, n_periods))),
|
|
"close": prices,
|
|
"volume": np.random.randint(1000000, 10000000, n_periods),
|
|
}
|
|
)
|
|
|
|
def test_indicators_structure(self, agent, sample_data):
|
|
"""Test that all expected indicators are present."""
|
|
indicators = agent._calculate_indicators(sample_data)
|
|
|
|
expected_keys = [
|
|
"current_price",
|
|
"ma_20",
|
|
"ma_50",
|
|
"ema_12",
|
|
"ema_26",
|
|
"rsi",
|
|
"macd",
|
|
"macd_signal",
|
|
"macd_histogram",
|
|
"bb_upper",
|
|
"bb_middle",
|
|
"bb_lower",
|
|
]
|
|
|
|
for key in expected_keys:
|
|
assert key in indicators
|
|
|
|
def test_current_price_present(self, agent, sample_data):
|
|
"""Test that current price is calculated."""
|
|
indicators = agent._calculate_indicators(sample_data)
|
|
|
|
assert indicators["current_price"] is not None
|
|
assert isinstance(indicators["current_price"], (int, float))
|
|
assert indicators["current_price"] > 0
|
|
|
|
def test_rsi_in_valid_range(self, agent, sample_data):
|
|
"""Test that RSI is within 0-100 range."""
|
|
indicators = agent._calculate_indicators(sample_data)
|
|
|
|
rsi = indicators.get("rsi")
|
|
if rsi is not None:
|
|
assert 0 <= rsi <= 100
|
|
|
|
def test_bollinger_bands_relationship(self, agent, sample_data):
|
|
"""Test Bollinger Bands mathematical relationships."""
|
|
indicators = agent._calculate_indicators(sample_data)
|
|
|
|
upper = indicators.get("bb_upper")
|
|
middle = indicators.get("bb_middle")
|
|
lower = indicators.get("bb_lower")
|
|
|
|
if all(v is not None for v in [upper, middle, lower]):
|
|
assert upper >= middle
|
|
assert middle >= lower
|
|
assert upper > lower
|
|
|
|
|
|
class TestIdentifyTrend:
|
|
"""Test trend identification."""
|
|
|
|
@pytest.fixture
|
|
def agent(self):
|
|
"""Create a test agent."""
|
|
return MarketAnalyst(agent_id="test", initial_capital=1000.0)
|
|
|
|
def test_identify_uptrend(self, agent):
|
|
"""Test uptrend identification."""
|
|
# Create uptrend data with enough periods for 50-period MA
|
|
np.random.seed(42)
|
|
n_periods = 60
|
|
trend = np.linspace(100, 150, n_periods) # Upward trend
|
|
noise = np.random.normal(0, 0.5, n_periods)
|
|
prices = trend + noise
|
|
|
|
data = pd.DataFrame(
|
|
{
|
|
"open": prices * 0.99,
|
|
"high": prices * 1.01,
|
|
"low": prices * 0.98,
|
|
"close": prices,
|
|
"volume": [1000000] * n_periods,
|
|
}
|
|
)
|
|
|
|
indicators = agent._calculate_indicators(data)
|
|
trend = agent._identify_trend(data, indicators)
|
|
|
|
assert trend in ["uptrend", "sideways"]
|
|
|
|
def test_identify_downtrend(self, agent):
|
|
"""Test downtrend identification."""
|
|
# Create downtrend data with enough periods for 50-period MA
|
|
np.random.seed(42)
|
|
n_periods = 60
|
|
trend = np.linspace(150, 100, n_periods) # Downward trend
|
|
noise = np.random.normal(0, 0.5, n_periods)
|
|
prices = trend + noise
|
|
|
|
data = pd.DataFrame(
|
|
{
|
|
"open": prices * 0.99,
|
|
"high": prices * 1.01,
|
|
"low": prices * 0.98,
|
|
"close": prices,
|
|
"volume": [1000000] * n_periods,
|
|
}
|
|
)
|
|
|
|
indicators = agent._calculate_indicators(data)
|
|
trend = agent._identify_trend(data, indicators)
|
|
|
|
assert trend in ["downtrend", "sideways"]
|
|
|
|
def test_trend_returned_in_report(self, agent):
|
|
"""Test that trend is included in analysis report."""
|
|
result = asyncio.run(agent.analyze("AAPL"))
|
|
|
|
assert result.trend in ["uptrend", "downtrend", "sideways", "insufficient_data"]
|
|
|
|
|
|
class TestGenerateSignals:
|
|
"""Test signal generation."""
|
|
|
|
@pytest.fixture
|
|
def agent(self):
|
|
"""Create a test agent."""
|
|
return MarketAnalyst(agent_id="test", initial_capital=1000.0)
|
|
|
|
def test_signals_structure(self, agent):
|
|
"""Test that all expected signals are present."""
|
|
indicators = {
|
|
"rsi": 50.0,
|
|
"macd": 0.0,
|
|
"macd_signal": 0.0,
|
|
"current_price": 100.0,
|
|
"bb_upper": 110.0,
|
|
"bb_lower": 90.0,
|
|
}
|
|
|
|
signals = agent._generate_signals(indicators)
|
|
|
|
expected_keys = ["overall", "rsi_signal", "macd_signal", "bb_signal"]
|
|
for key in expected_keys:
|
|
assert key in signals
|
|
|
|
def test_oversold_rsi_generates_buy_signal(self, agent):
|
|
"""Test that oversold RSI generates buy signal."""
|
|
indicators = {
|
|
"rsi": 25.0,
|
|
"macd": 0.5,
|
|
"macd_signal": 0.0,
|
|
"current_price": 100.0,
|
|
"bb_upper": 110.0,
|
|
"bb_lower": 90.0,
|
|
}
|
|
|
|
signals = agent._generate_signals(indicators)
|
|
|
|
assert signals["rsi_signal"] == "buy"
|
|
|
|
def test_overbought_rsi_generates_sell_signal(self, agent):
|
|
"""Test that overbought RSI generates sell signal."""
|
|
indicators = {
|
|
"rsi": 75.0,
|
|
"macd": -0.5,
|
|
"macd_signal": 0.0,
|
|
"current_price": 100.0,
|
|
"bb_upper": 110.0,
|
|
"bb_lower": 90.0,
|
|
}
|
|
|
|
signals = agent._generate_signals(indicators)
|
|
|
|
assert signals["rsi_signal"] == "sell"
|
|
|
|
def test_macd_bullish_generates_buy_signal(self, agent):
|
|
"""Test that MACD above signal generates buy signal."""
|
|
indicators = {
|
|
"rsi": 50.0,
|
|
"macd": 0.5,
|
|
"macd_signal": 0.0,
|
|
"current_price": 100.0,
|
|
"bb_upper": 110.0,
|
|
"bb_lower": 90.0,
|
|
}
|
|
|
|
signals = agent._generate_signals(indicators)
|
|
|
|
assert signals["macd_signal"] == "buy"
|
|
|
|
def test_macd_bearish_generates_sell_signal(self, agent):
|
|
"""Test that MACD below signal generates sell signal."""
|
|
indicators = {
|
|
"rsi": 50.0,
|
|
"macd": -0.5,
|
|
"macd_signal": 0.0,
|
|
"current_price": 100.0,
|
|
"bb_upper": 110.0,
|
|
"bb_lower": 90.0,
|
|
}
|
|
|
|
signals = agent._generate_signals(indicators)
|
|
|
|
assert signals["macd_signal"] == "sell"
|
|
|
|
def test_bb_price_above_upper_generates_sell(self, agent):
|
|
"""Test price above upper Bollinger Band generates sell signal."""
|
|
indicators = {
|
|
"rsi": 50.0,
|
|
"macd": 0.0,
|
|
"macd_signal": 0.0,
|
|
"current_price": 115.0,
|
|
"bb_upper": 110.0,
|
|
"bb_lower": 90.0,
|
|
}
|
|
|
|
signals = agent._generate_signals(indicators)
|
|
|
|
assert signals["bb_signal"] == "sell"
|
|
|
|
def test_bb_price_below_lower_generates_buy(self, agent):
|
|
"""Test price below lower Bollinger Band generates buy signal."""
|
|
indicators = {
|
|
"rsi": 50.0,
|
|
"macd": 0.0,
|
|
"macd_signal": 0.0,
|
|
"current_price": 85.0,
|
|
"bb_upper": 110.0,
|
|
"bb_lower": 90.0,
|
|
}
|
|
|
|
signals = agent._generate_signals(indicators)
|
|
|
|
assert signals["bb_signal"] == "buy"
|
|
|
|
def test_consensus_buy_signal(self, agent):
|
|
"""Test overall buy signal when multiple indicators agree."""
|
|
indicators = {
|
|
"rsi": 25.0,
|
|
"macd": 0.5,
|
|
"macd_signal": 0.0,
|
|
"current_price": 85.0,
|
|
"bb_upper": 110.0,
|
|
"bb_lower": 90.0,
|
|
}
|
|
|
|
signals = agent._generate_signals(indicators)
|
|
|
|
assert signals["overall"] == "buy"
|
|
|
|
def test_consensus_sell_signal(self, agent):
|
|
"""Test overall sell signal when multiple indicators agree."""
|
|
indicators = {
|
|
"rsi": 75.0,
|
|
"macd": -0.5,
|
|
"macd_signal": 0.0,
|
|
"current_price": 115.0,
|
|
"bb_upper": 110.0,
|
|
"bb_lower": 90.0,
|
|
}
|
|
|
|
signals = agent._generate_signals(indicators)
|
|
|
|
assert signals["overall"] == "sell"
|
|
|
|
|
|
class TestCalculateConfidence:
|
|
"""Test confidence calculation."""
|
|
|
|
@pytest.fixture
|
|
def agent(self):
|
|
"""Create a test agent."""
|
|
return MarketAnalyst(agent_id="test", initial_capital=1000.0)
|
|
|
|
def test_confidence_within_range(self, agent):
|
|
"""Test that confidence is within 0-1 range."""
|
|
indicators = {"rsi": 50.0}
|
|
signals = {"overall": "neutral"}
|
|
|
|
confidence = agent._calculate_confidence(indicators, signals)
|
|
|
|
assert 0.0 <= confidence <= 1.0
|
|
|
|
def test_high_skill_increases_confidence(self):
|
|
"""Test that high skill level increases confidence."""
|
|
low_skill_agent = MarketAnalyst(agent_id="low", initial_capital=1000.0, skill_level=0.3)
|
|
high_skill_agent = MarketAnalyst(agent_id="high", initial_capital=1000.0, skill_level=0.9)
|
|
|
|
indicators = {"rsi": 70.0}
|
|
signals = {"overall": "sell", "rsi_signal": "sell", "macd_signal": "sell", "bb_signal": "neutral"}
|
|
|
|
low_confidence = low_skill_agent._calculate_confidence(indicators, signals)
|
|
high_confidence = high_skill_agent._calculate_confidence(indicators, signals)
|
|
|
|
assert high_confidence >= low_confidence
|
|
|
|
def test_extreme_rsi_increases_confidence(self, agent):
|
|
"""Test that extreme RSI values increase confidence."""
|
|
neutral_indicators = {"rsi": 50.0}
|
|
extreme_indicators = {"rsi": 90.0}
|
|
signals = {"overall": "neutral"}
|
|
|
|
neutral_confidence = agent._calculate_confidence(neutral_indicators, signals)
|
|
extreme_confidence = agent._calculate_confidence(extreme_indicators, signals)
|
|
|
|
assert extreme_confidence > neutral_confidence
|
|
|
|
|
|
class TestTechnicalReport:
|
|
"""Test TechnicalReport dataclass."""
|
|
|
|
def test_report_creation(self):
|
|
"""Test creating a TechnicalReport."""
|
|
report = TechnicalReport(
|
|
symbol="AAPL",
|
|
trend="uptrend",
|
|
indicators={"rsi": 50.0, "macd": 0.5},
|
|
signals={"overall": "buy", "rsi_signal": "buy"},
|
|
confidence=0.75,
|
|
)
|
|
|
|
assert report.symbol == "AAPL"
|
|
assert report.trend == "uptrend"
|
|
assert report.indicators["rsi"] == 50.0
|
|
assert report.signals["overall"] == "buy"
|
|
assert report.confidence == 0.75
|
|
|
|
|
|
class TestIntegration:
|
|
"""Integration tests for MarketAnalyst."""
|
|
|
|
@pytest.fixture
|
|
def agent(self):
|
|
"""Create a test agent."""
|
|
return MarketAnalyst(agent_id="test", initial_capital=1000.0)
|
|
|
|
def test_full_analysis_workflow(self, agent):
|
|
"""Test the complete analysis workflow."""
|
|
# Create trending data
|
|
np.random.seed(42)
|
|
n_periods = 100
|
|
trend = np.linspace(0, 20, n_periods)
|
|
noise = np.random.normal(0, 1, n_periods)
|
|
prices = 100 + trend + np.cumsum(noise * 0.1)
|
|
|
|
data = pd.DataFrame(
|
|
{
|
|
"open": prices * 0.99,
|
|
"high": prices * 1.01,
|
|
"low": prices * 0.98,
|
|
"close": prices,
|
|
"volume": np.random.randint(1000000, 10000000, n_periods),
|
|
}
|
|
)
|
|
|
|
result = asyncio.run(agent.analyze("AAPL", data))
|
|
|
|
assert isinstance(result, TechnicalReport)
|
|
assert result.symbol == "AAPL"
|
|
assert result.trend in ["uptrend", "downtrend", "sideways", "insufficient_data"]
|
|
assert len(result.indicators) > 0
|
|
assert len(result.signals) > 0
|
|
assert 0.0 <= result.confidence <= 1.0
|
|
|
|
# Verify cost was deducted
|
|
assert agent.balance == 1000.0 - 0.05
|
|
|
|
def test_multiple_analyses_accumulate_costs(self, agent):
|
|
"""Test that multiple analyses accumulate decision costs."""
|
|
initial_balance = agent.balance
|
|
|
|
asyncio.run(agent.analyze("AAPL"))
|
|
asyncio.run(agent.analyze("TSLA"))
|
|
asyncio.run(agent.analyze("NVDA"))
|
|
|
|
expected_balance = initial_balance - (0.05 * 3)
|
|
assert agent.balance == pytest.approx(expected_balance, rel=1e-9)
|