440 lines
15 KiB
Python
440 lines
15 KiB
Python
"""Tests for live trading mode functionality.
|
|
|
|
This module contains tests for LiveModeConfig, LiveModeManager,
|
|
and trade limit validations.
|
|
"""
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from openclaw.trading.live_mode import (
|
|
LiveModeConfig,
|
|
LiveModeManager,
|
|
LiveTradeLogEntry,
|
|
TradingMode,
|
|
)
|
|
|
|
|
|
class TestLiveModeConfig:
|
|
"""Tests for LiveModeConfig Pydantic model."""
|
|
|
|
def test_default_config_creation(self):
|
|
"""Test creating LiveModeConfig with default values."""
|
|
config = LiveModeConfig()
|
|
|
|
assert config.enabled is False
|
|
assert config.daily_trade_limit_usd == 10000.0
|
|
assert config.max_position_pct == 0.2
|
|
assert config.require_confirmation is True
|
|
assert config.confirmation_timeout_seconds == 30
|
|
assert config.audit_log_path == "logs/live_trades.jsonl"
|
|
assert config.alert_webhook_url is None
|
|
|
|
def test_custom_config_creation(self):
|
|
"""Test creating LiveModeConfig with custom values."""
|
|
config = LiveModeConfig(
|
|
enabled=True,
|
|
daily_trade_limit_usd=50000.0,
|
|
max_position_pct=0.5,
|
|
require_confirmation=False,
|
|
confirmation_timeout_seconds=60,
|
|
audit_log_path="logs/custom_trades.jsonl",
|
|
alert_webhook_url="https://hooks.example.com/alerts",
|
|
)
|
|
|
|
assert config.enabled is True
|
|
assert config.daily_trade_limit_usd == 50000.0
|
|
assert config.max_position_pct == 0.5
|
|
assert config.require_confirmation is False
|
|
assert config.confirmation_timeout_seconds == 60
|
|
assert config.audit_log_path == "logs/custom_trades.jsonl"
|
|
assert config.alert_webhook_url == "https://hooks.example.com/alerts"
|
|
|
|
def test_daily_trade_limit_validation(self):
|
|
"""Test daily_trade_limit_usd must be positive."""
|
|
with pytest.raises(ValidationError):
|
|
LiveModeConfig(daily_trade_limit_usd=0)
|
|
|
|
with pytest.raises(ValidationError):
|
|
LiveModeConfig(daily_trade_limit_usd=-1000.0)
|
|
|
|
def test_max_position_pct_validation(self):
|
|
"""Test max_position_pct must be between 0 and 1."""
|
|
with pytest.raises(ValidationError):
|
|
LiveModeConfig(max_position_pct=0)
|
|
|
|
with pytest.raises(ValidationError):
|
|
LiveModeConfig(max_position_pct=1.5)
|
|
|
|
with pytest.raises(ValidationError):
|
|
LiveModeConfig(max_position_pct=-0.1)
|
|
|
|
def test_confirmation_timeout_validation(self):
|
|
"""Test confirmation_timeout_seconds bounds."""
|
|
with pytest.raises(ValidationError):
|
|
LiveModeConfig(confirmation_timeout_seconds=4)
|
|
|
|
with pytest.raises(ValidationError):
|
|
LiveModeConfig(confirmation_timeout_seconds=301)
|
|
|
|
def test_webhook_url_validation(self):
|
|
"""Test webhook URL must be valid HTTP/HTTPS."""
|
|
# Valid URLs
|
|
config1 = LiveModeConfig(alert_webhook_url="https://example.com/hook")
|
|
assert config1.alert_webhook_url == "https://example.com/hook"
|
|
|
|
config2 = LiveModeConfig(alert_webhook_url="http://example.com/hook")
|
|
assert config2.alert_webhook_url == "http://example.com/hook"
|
|
|
|
# Invalid URL
|
|
with pytest.raises(ValidationError):
|
|
LiveModeConfig(alert_webhook_url="ftp://example.com/hook")
|
|
|
|
with pytest.raises(ValidationError):
|
|
LiveModeConfig(alert_webhook_url="not_a_url")
|
|
|
|
|
|
class TestLiveModeManager:
|
|
"""Tests for LiveModeManager class."""
|
|
|
|
def test_manager_initialization_default(self):
|
|
"""Test LiveModeManager initialization with default config."""
|
|
manager = LiveModeManager()
|
|
|
|
assert manager.is_live_mode is False
|
|
assert manager.is_simulated_mode is True
|
|
assert manager.config.enabled is False
|
|
|
|
def test_manager_initialization_live(self):
|
|
"""Test LiveModeManager initialization in live mode."""
|
|
config = LiveModeConfig(enabled=True)
|
|
manager = LiveModeManager(config=config)
|
|
|
|
assert manager.is_live_mode is True
|
|
assert manager.is_simulated_mode is False
|
|
|
|
def test_mode_indicator(self):
|
|
"""Test mode indicator string."""
|
|
config_sim = LiveModeConfig(enabled=False)
|
|
manager_sim = LiveModeManager(config=config_sim)
|
|
assert "SIMULATED" in manager_sim.mode_indicator
|
|
|
|
config_live = LiveModeConfig(enabled=True)
|
|
manager_live = LiveModeManager(config=config_live)
|
|
assert "LIVE" in manager_live.mode_indicator
|
|
|
|
def test_get_daily_limit(self):
|
|
"""Test getting daily trade limit."""
|
|
config = LiveModeConfig(daily_trade_limit_usd=25000.0)
|
|
manager = LiveModeManager(config=config)
|
|
|
|
assert manager.get_daily_limit() == 25000.0
|
|
|
|
def test_get_daily_limit_remaining_initial(self):
|
|
"""Test remaining limit at initialization."""
|
|
config = LiveModeConfig(enabled=True, daily_trade_limit_usd=10000.0)
|
|
manager = LiveModeManager(config=config)
|
|
|
|
assert manager.get_daily_limit_remaining() == 10000.0
|
|
|
|
def test_validate_live_trade_not_in_live_mode(self):
|
|
"""Test trade validation fails when not in live mode."""
|
|
config = LiveModeConfig(enabled=False)
|
|
manager = LiveModeManager(config=config)
|
|
|
|
is_valid, reason = manager.validate_live_trade(
|
|
symbol="AAPL",
|
|
amount=10.0,
|
|
price=150.0,
|
|
current_balance=10000.0,
|
|
)
|
|
|
|
assert is_valid is False
|
|
assert "Not in live trading mode" in reason
|
|
|
|
def test_validate_live_trade_exceeds_daily_limit(self):
|
|
"""Test trade validation fails when exceeding daily limit."""
|
|
config = LiveModeConfig(enabled=True, daily_trade_limit_usd=1000.0)
|
|
manager = LiveModeManager(config=config)
|
|
|
|
is_valid, reason = manager.validate_live_trade(
|
|
symbol="AAPL",
|
|
amount=10.0,
|
|
price=200.0, # $2000 trade value
|
|
current_balance=10000.0,
|
|
)
|
|
|
|
assert is_valid is False
|
|
assert "Daily limit exceeded" in reason
|
|
|
|
def test_validate_live_trade_exceeds_position_limit(self):
|
|
"""Test trade validation fails when exceeding position size limit."""
|
|
config = LiveModeConfig(
|
|
enabled=True,
|
|
daily_trade_limit_usd=100000.0,
|
|
max_position_pct=0.1, # 10% max position
|
|
)
|
|
manager = LiveModeManager(config=config)
|
|
|
|
is_valid, reason = manager.validate_live_trade(
|
|
symbol="AAPL",
|
|
amount=100.0,
|
|
price=200.0, # $20000 position
|
|
current_balance=10000.0, # max_position = $1000
|
|
)
|
|
|
|
assert is_valid is False
|
|
assert "Position size exceeds limit" in reason
|
|
|
|
def test_validate_live_trade_insufficient_balance(self):
|
|
"""Test trade validation fails with insufficient balance."""
|
|
config = LiveModeConfig(
|
|
enabled=True,
|
|
daily_trade_limit_usd=100000.0,
|
|
max_position_pct=1.0,
|
|
)
|
|
manager = LiveModeManager(config=config)
|
|
|
|
is_valid, reason = manager.validate_live_trade(
|
|
symbol="AAPL",
|
|
amount=1.0,
|
|
price=1000.0,
|
|
current_balance=1000.0, # Required with 1.5x buffer = $1500
|
|
)
|
|
|
|
assert is_valid is False
|
|
assert "Insufficient balance" in reason
|
|
|
|
def test_validate_live_trade_success(self):
|
|
"""Test successful trade validation."""
|
|
config = LiveModeConfig(
|
|
enabled=True,
|
|
daily_trade_limit_usd=100000.0,
|
|
max_position_pct=1.0,
|
|
)
|
|
manager = LiveModeManager(config=config)
|
|
|
|
is_valid, reason = manager.validate_live_trade(
|
|
symbol="AAPL",
|
|
amount=1.0,
|
|
price=100.0,
|
|
current_balance=10000.0,
|
|
)
|
|
|
|
assert is_valid is True
|
|
assert reason == "Validation passed"
|
|
|
|
def test_switch_mode_to_live(self):
|
|
"""Test switching to live mode."""
|
|
config = LiveModeConfig(enabled=True)
|
|
manager = LiveModeManager(config=config)
|
|
|
|
# Initially in live mode since enabled=True
|
|
assert manager.is_live_mode is True
|
|
|
|
# Switch to simulated
|
|
manager.switch_mode(TradingMode.SIMULATED)
|
|
assert manager.is_live_mode is False
|
|
assert manager.is_simulated_mode is True
|
|
|
|
# Switch back to live
|
|
manager.switch_mode(TradingMode.LIVE)
|
|
assert manager.is_live_mode is True
|
|
assert manager.is_simulated_mode is False
|
|
|
|
def test_switch_mode_to_live_not_enabled(self):
|
|
"""Test cannot switch to live mode when not enabled in config."""
|
|
config = LiveModeConfig(enabled=False)
|
|
manager = LiveModeManager(config=config)
|
|
|
|
result = manager.switch_mode(TradingMode.LIVE)
|
|
assert result is False
|
|
assert manager.is_live_mode is False
|
|
|
|
def test_enable_disable_live_mode(self):
|
|
"""Test enable and disable live mode methods."""
|
|
config = LiveModeConfig(enabled=False)
|
|
manager = LiveModeManager(config=config)
|
|
|
|
assert manager.is_live_mode is False
|
|
|
|
# Enable live mode
|
|
manager.enable_live_mode()
|
|
assert manager.is_live_mode is True
|
|
|
|
# Disable live mode
|
|
manager.disable_live_mode()
|
|
assert manager.is_live_mode is False
|
|
|
|
def test_request_confirmation_without_provider(self):
|
|
"""Test confirmation request without provider auto-confirms."""
|
|
config = LiveModeConfig(require_confirmation=True)
|
|
manager = LiveModeManager(config=config)
|
|
|
|
confirmed, code = manager.request_confirmation(
|
|
symbol="AAPL",
|
|
side="buy",
|
|
amount=10.0,
|
|
price=150.0,
|
|
)
|
|
|
|
assert confirmed is True
|
|
assert "AUTO" in code
|
|
|
|
def test_request_confirmation_not_required(self):
|
|
"""Test confirmation when not required."""
|
|
config = LiveModeConfig(require_confirmation=False)
|
|
manager = LiveModeManager(config=config)
|
|
|
|
confirmed, code = manager.request_confirmation(
|
|
symbol="AAPL",
|
|
side="buy",
|
|
amount=10.0,
|
|
price=150.0,
|
|
)
|
|
|
|
assert confirmed is True
|
|
assert code == "AUTO_CONFIRMED"
|
|
|
|
def test_get_live_stats(self):
|
|
"""Test getting live trading statistics."""
|
|
config = LiveModeConfig(
|
|
enabled=True,
|
|
daily_trade_limit_usd=50000.0,
|
|
max_position_pct=0.3,
|
|
require_confirmation=True,
|
|
)
|
|
manager = LiveModeManager(config=config)
|
|
|
|
stats = manager.get_live_stats()
|
|
|
|
assert stats["is_live"] is True
|
|
assert stats["mode"] == "live"
|
|
assert stats["daily_limit_usd"] == 50000.0
|
|
assert stats["max_position_pct"] == 0.3
|
|
assert stats["confirmation_required"] is True
|
|
assert "daily_traded_usd" in stats
|
|
assert "daily_remaining_usd" in stats
|
|
assert "trade_count_today" in stats
|
|
|
|
def test_repr(self):
|
|
"""Test string representation of LiveModeManager."""
|
|
config = LiveModeConfig(enabled=True, daily_trade_limit_usd=50000.0)
|
|
manager = LiveModeManager(config=config)
|
|
|
|
repr_str = repr(manager)
|
|
|
|
assert "LiveModeManager" in repr_str
|
|
assert "live" in repr_str
|
|
|
|
|
|
class TestLiveTradeLogEntry:
|
|
"""Tests for LiveTradeLogEntry model."""
|
|
|
|
def test_log_entry_creation(self):
|
|
"""Test creating a live trade log entry."""
|
|
entry = LiveTradeLogEntry(
|
|
timestamp="2024-01-01T10:00:00",
|
|
symbol="AAPL",
|
|
side="buy",
|
|
amount=10.0,
|
|
price=150.0,
|
|
order_id="order123",
|
|
confirmation_code="CONF123",
|
|
risk_checks_passed=True,
|
|
daily_limit_before=10000.0,
|
|
daily_limit_after=8500.0,
|
|
)
|
|
|
|
assert entry.symbol == "AAPL"
|
|
assert entry.side == "buy"
|
|
assert entry.amount == 10.0
|
|
assert entry.price == 150.0
|
|
assert entry.risk_checks_passed is True
|
|
assert entry.daily_limit_before == 10000.0
|
|
assert entry.daily_limit_after == 8500.0
|
|
|
|
def test_log_entry_amount_validation(self):
|
|
"""Test amount must be positive."""
|
|
with pytest.raises(ValidationError):
|
|
LiveTradeLogEntry(
|
|
timestamp="2024-01-01T10:00:00",
|
|
symbol="AAPL",
|
|
side="buy",
|
|
amount=0,
|
|
price=150.0,
|
|
order_id="order123",
|
|
confirmation_code="CONF123",
|
|
risk_checks_passed=True,
|
|
daily_limit_before=10000.0,
|
|
daily_limit_after=8500.0,
|
|
)
|
|
|
|
def test_log_entry_price_validation(self):
|
|
"""Test price must be positive."""
|
|
with pytest.raises(ValidationError):
|
|
LiveTradeLogEntry(
|
|
timestamp="2024-01-01T10:00:00",
|
|
symbol="AAPL",
|
|
side="buy",
|
|
amount=10.0,
|
|
price=0,
|
|
order_id="order123",
|
|
confirmation_code="CONF123",
|
|
risk_checks_passed=True,
|
|
daily_limit_before=10000.0,
|
|
daily_limit_after=8500.0,
|
|
)
|
|
|
|
def test_log_entry_limit_validation(self):
|
|
"""Test daily limits must be non-negative."""
|
|
with pytest.raises(ValidationError):
|
|
LiveTradeLogEntry(
|
|
timestamp="2024-01-01T10:00:00",
|
|
symbol="AAPL",
|
|
side="buy",
|
|
amount=10.0,
|
|
price=150.0,
|
|
order_id="order123",
|
|
confirmation_code="CONF123",
|
|
risk_checks_passed=True,
|
|
daily_limit_before=-1000.0,
|
|
daily_limit_after=8500.0,
|
|
)
|
|
|
|
def test_log_entry_default_metadata(self):
|
|
"""Test metadata defaults to empty dict."""
|
|
entry = LiveTradeLogEntry(
|
|
timestamp="2024-01-01T10:00:00",
|
|
symbol="AAPL",
|
|
side="buy",
|
|
amount=10.0,
|
|
price=150.0,
|
|
order_id="order123",
|
|
confirmation_code="CONF123",
|
|
risk_checks_passed=True,
|
|
daily_limit_before=10000.0,
|
|
daily_limit_after=8500.0,
|
|
)
|
|
|
|
assert entry.metadata == {}
|
|
|
|
def test_log_entry_with_metadata(self):
|
|
"""Test log entry with custom metadata."""
|
|
entry = LiveTradeLogEntry(
|
|
timestamp="2024-01-01T10:00:00",
|
|
symbol="AAPL",
|
|
side="buy",
|
|
amount=10.0,
|
|
price=150.0,
|
|
order_id="order123",
|
|
confirmation_code="CONF123",
|
|
risk_checks_passed=True,
|
|
daily_limit_before=10000.0,
|
|
daily_limit_after=8500.0,
|
|
metadata={"strategy": "momentum", "agent_id": "agent1"},
|
|
)
|
|
|
|
assert entry.metadata["strategy"] == "momentum"
|
|
assert entry.metadata["agent_id"] == "agent1"
|