323 lines
12 KiB
Python
323 lines
12 KiB
Python
"""
|
|
Tests for the strategy execution loop.
|
|
|
|
These tests verify that:
|
|
1. Strategies.update() iterates through active instances
|
|
2. StrategyInstance.tick() processes candle data correctly
|
|
3. Price updates flow to paper trading instances
|
|
4. Errors are handled gracefully
|
|
"""
|
|
import pytest
|
|
from unittest.mock import MagicMock, patch
|
|
import json
|
|
|
|
|
|
class TestStrategiesUpdate:
|
|
"""Tests for the Strategies.update() method."""
|
|
|
|
@pytest.fixture
|
|
def mock_strategies(self):
|
|
"""Create a Strategies instance with mock dependencies."""
|
|
with patch.dict('sys.modules', {'eventlet': MagicMock()}):
|
|
from Strategies import Strategies
|
|
|
|
# Mock DataCache
|
|
mock_cache = MagicMock()
|
|
mock_cache.create_cache = MagicMock()
|
|
mock_cache.get_rows_from_datacache = MagicMock(return_value=MagicMock(empty=True))
|
|
|
|
# Mock Trades and Indicators
|
|
mock_trades = MagicMock()
|
|
mock_indicators = MagicMock()
|
|
|
|
strategies = Strategies(mock_cache, mock_trades, mock_indicators)
|
|
return strategies
|
|
|
|
def test_update_no_active_instances(self, mock_strategies):
|
|
"""Test update with no active strategies returns empty list."""
|
|
result = mock_strategies.update()
|
|
assert result == []
|
|
|
|
def test_update_calls_tick_on_instances(self, mock_strategies):
|
|
"""Test that update calls tick() on all active instances."""
|
|
# Create mock instances
|
|
mock_instance1 = MagicMock()
|
|
mock_instance1.tick.return_value = [{'type': 'tick_complete'}]
|
|
mock_instance1.exit = False
|
|
|
|
mock_instance2 = MagicMock()
|
|
mock_instance2.tick.return_value = [{'type': 'tick_complete'}]
|
|
mock_instance2.exit = False
|
|
|
|
mock_strategies.active_instances = {
|
|
(1, 'strat-1', 'paper'): mock_instance1,
|
|
(2, 'strat-2', 'paper'): mock_instance2,
|
|
}
|
|
|
|
candle_data = {'symbol': 'BTC/USDT', 'close': 50000}
|
|
result = mock_strategies.update(candle_data)
|
|
|
|
# Both instances should have tick called
|
|
mock_instance1.tick.assert_called_once_with(candle_data)
|
|
mock_instance2.tick.assert_called_once_with(candle_data)
|
|
|
|
# Should have events from both
|
|
assert len(result) == 2
|
|
|
|
def test_update_delegates_price_handling_to_tick(self, mock_strategies):
|
|
"""Test that update delegates candle handling to instance.tick()."""
|
|
mock_instance = MagicMock()
|
|
mock_instance.tick.return_value = []
|
|
mock_instance.exit = False
|
|
mock_instance.update_prices = MagicMock()
|
|
|
|
mock_strategies.active_instances = {
|
|
(1, 'strat-1', 'paper'): mock_instance,
|
|
}
|
|
|
|
candle_data = {'symbol': 'ETH/USDT', 'close': 3000.5}
|
|
mock_strategies.update(candle_data)
|
|
|
|
mock_instance.tick.assert_called_once_with(candle_data)
|
|
mock_instance.update_prices.assert_not_called()
|
|
|
|
def test_update_tags_events_with_instance_info(self, mock_strategies):
|
|
"""Test that events are tagged with user_id, strategy_id, mode."""
|
|
mock_instance = MagicMock()
|
|
mock_instance.tick.return_value = [{'type': 'tick_complete'}]
|
|
mock_instance.exit = False
|
|
|
|
mock_strategies.active_instances = {
|
|
(42, 'my-strategy', 'paper'): mock_instance,
|
|
}
|
|
|
|
result = mock_strategies.update()
|
|
|
|
assert len(result) == 1
|
|
event = result[0]
|
|
assert event['user_id'] == 42
|
|
assert event['strategy_id'] == 'my-strategy'
|
|
assert event['mode'] == 'paper'
|
|
|
|
def test_update_handles_instance_errors(self, mock_strategies):
|
|
"""Test that errors in one instance don't stop others."""
|
|
# First instance raises error
|
|
mock_instance1 = MagicMock()
|
|
mock_instance1.tick.side_effect = Exception("Strategy crashed")
|
|
mock_instance1.exit = False
|
|
|
|
# Second instance works fine
|
|
mock_instance2 = MagicMock()
|
|
mock_instance2.tick.return_value = [{'type': 'tick_complete'}]
|
|
mock_instance2.exit = False
|
|
|
|
mock_strategies.active_instances = {
|
|
(1, 'bad-strat', 'paper'): mock_instance1,
|
|
(2, 'good-strat', 'paper'): mock_instance2,
|
|
}
|
|
|
|
result = mock_strategies.update()
|
|
|
|
# Should have error event from first, success from second
|
|
assert len(result) == 2
|
|
|
|
error_events = [e for e in result if e['type'] == 'error']
|
|
success_events = [e for e in result if e['type'] == 'tick_complete']
|
|
|
|
assert len(error_events) == 1
|
|
assert len(success_events) == 1
|
|
assert 'Strategy crashed' in error_events[0]['message']
|
|
|
|
def test_update_removes_exited_strategies(self, mock_strategies):
|
|
"""Test that strategies with exit=True and no positions are removed."""
|
|
mock_instance = MagicMock()
|
|
mock_instance.tick.return_value = []
|
|
mock_instance.exit = True
|
|
mock_instance.broker = MagicMock()
|
|
mock_instance.broker.get_all_positions.return_value = [] # No positions
|
|
|
|
mock_strategies.active_instances = {
|
|
(1, 'exiting-strat', 'paper'): mock_instance,
|
|
}
|
|
|
|
result = mock_strategies.update()
|
|
|
|
# Strategy should be removed
|
|
assert len(mock_strategies.active_instances) == 0
|
|
|
|
# Should have strategy_exited event
|
|
exited_events = [e for e in result if e['type'] == 'strategy_exited']
|
|
assert len(exited_events) == 1
|
|
|
|
|
|
class TestStrategyInstanceTick:
|
|
"""Tests for StrategyInstance.tick() method."""
|
|
|
|
@pytest.fixture
|
|
def mock_strategy_instance(self):
|
|
"""Create a StrategyInstance with mock dependencies."""
|
|
with patch.dict('sys.modules', {'eventlet': MagicMock()}):
|
|
from StrategyInstance import StrategyInstance
|
|
|
|
with patch.object(StrategyInstance, '__init__', lambda x: None):
|
|
instance = StrategyInstance()
|
|
|
|
# Set up required attributes
|
|
instance.strategy_id = 'test-strategy'
|
|
instance.strategy_instance_id = 'inst-123'
|
|
instance.paused = False
|
|
instance.exit = False
|
|
instance.exec_context = {'_events': []}
|
|
instance.generated_code = "def next(): pass"
|
|
instance.execute = MagicMock(return_value={'success': True, 'profit_loss': 100.0})
|
|
|
|
return instance
|
|
|
|
def test_tick_skips_when_paused(self, mock_strategy_instance):
|
|
"""Test that tick returns skip event when paused."""
|
|
mock_strategy_instance.paused = True
|
|
|
|
result = mock_strategy_instance.tick()
|
|
|
|
assert len(result) == 1
|
|
assert result[0]['type'] == 'skipped'
|
|
assert result[0]['reason'] == 'paused'
|
|
mock_strategy_instance.execute.assert_not_called()
|
|
|
|
def test_tick_skips_when_exiting(self, mock_strategy_instance):
|
|
"""Test that tick returns skip event when exit is True."""
|
|
mock_strategy_instance.exit = True
|
|
|
|
result = mock_strategy_instance.tick()
|
|
|
|
assert len(result) == 1
|
|
assert result[0]['type'] == 'skipped'
|
|
assert result[0]['reason'] == 'exiting'
|
|
|
|
def test_tick_updates_exec_context_with_candle(self, mock_strategy_instance):
|
|
"""Test that tick updates exec_context with candle data."""
|
|
candle = {'symbol': 'BTC/USDT', 'close': 50000, 'open': 49500, 'high': 51000, 'low': 49000}
|
|
|
|
mock_strategy_instance.tick(candle)
|
|
|
|
assert mock_strategy_instance.exec_context['current_candle'] == candle
|
|
assert mock_strategy_instance.exec_context['current_price'] == 50000
|
|
assert mock_strategy_instance.exec_context['current_symbol'] == 'BTC/USDT'
|
|
|
|
def test_tick_returns_tick_complete_on_success(self, mock_strategy_instance):
|
|
"""Test that successful tick returns tick_complete event."""
|
|
result = mock_strategy_instance.tick()
|
|
|
|
assert len(result) == 1
|
|
assert result[0]['type'] == 'tick_complete'
|
|
assert result[0]['strategy_id'] == 'test-strategy'
|
|
assert result[0]['profit_loss'] == 100.0
|
|
|
|
def test_tick_returns_error_on_execute_failure(self, mock_strategy_instance):
|
|
"""Test that failed execute returns error event."""
|
|
mock_strategy_instance.execute.return_value = {
|
|
'success': False,
|
|
'message': 'Syntax error in strategy'
|
|
}
|
|
|
|
result = mock_strategy_instance.tick()
|
|
|
|
assert len(result) == 1
|
|
assert result[0]['type'] == 'error'
|
|
assert 'Syntax error' in result[0]['message']
|
|
|
|
def test_tick_handles_exceptions(self, mock_strategy_instance):
|
|
"""Test that exceptions during tick are caught and returned."""
|
|
mock_strategy_instance.execute.side_effect = RuntimeError("Unexpected error")
|
|
|
|
result = mock_strategy_instance.tick()
|
|
|
|
assert len(result) == 1
|
|
assert result[0]['type'] == 'error'
|
|
assert 'Unexpected error' in result[0]['message']
|
|
|
|
|
|
class TestPaperStrategyInstanceTick:
|
|
"""Tests for PaperStrategyInstance.tick() method."""
|
|
|
|
@pytest.fixture
|
|
def mock_paper_instance(self):
|
|
"""Create a PaperStrategyInstance with mock dependencies."""
|
|
with patch.dict('sys.modules', {'eventlet': MagicMock()}):
|
|
from paper_strategy_instance import PaperStrategyInstance
|
|
|
|
with patch.object(PaperStrategyInstance, '__init__', lambda x: None):
|
|
instance = PaperStrategyInstance()
|
|
|
|
# Set up required attributes
|
|
instance.strategy_id = 'paper-strategy'
|
|
instance.strategy_instance_id = 'paper-inst-123'
|
|
instance.paused = False
|
|
instance.exit = False
|
|
instance.exec_context = {'_events': []}
|
|
instance.generated_code = "def next(): pass"
|
|
instance.current_balance = 10000.0
|
|
instance.available_balance = 10000.0
|
|
instance.trade_history = []
|
|
|
|
# Mock paper broker
|
|
instance.paper_broker = MagicMock()
|
|
instance.paper_broker.update_price = MagicMock()
|
|
instance.paper_broker.update.return_value = []
|
|
instance.paper_broker.get_all_positions.return_value = []
|
|
|
|
# Mock execute
|
|
instance.execute = MagicMock(return_value={'success': True})
|
|
instance._update_balances = MagicMock()
|
|
|
|
return instance
|
|
|
|
def test_tick_updates_broker_prices(self, mock_paper_instance):
|
|
"""Test that tick updates paper broker with candle prices."""
|
|
candle = {'symbol': 'ETH/USDT', 'close': 3000}
|
|
|
|
mock_paper_instance.tick(candle)
|
|
|
|
mock_paper_instance.paper_broker.update_price.assert_called_with('ETH/USDT', 3000)
|
|
|
|
def test_tick_processes_broker_fills(self, mock_paper_instance):
|
|
"""Test that broker fills are processed and added to events."""
|
|
mock_paper_instance.paper_broker.update.return_value = [
|
|
{'type': 'fill', 'order_id': 'order-1', 'symbol': 'BTC/USDT',
|
|
'filled_qty': 0.1, 'filled_price': 50000}
|
|
]
|
|
|
|
candle = {'symbol': 'BTC/USDT', 'close': 50000}
|
|
result = mock_paper_instance.tick(candle)
|
|
|
|
# Should have fill event
|
|
fill_events = [e for e in result if e['type'] == 'order_filled']
|
|
assert len(fill_events) == 1
|
|
assert fill_events[0]['order_id'] == 'order-1'
|
|
|
|
def test_tick_includes_balance_in_complete_event(self, mock_paper_instance):
|
|
"""Test that tick_complete includes balance info."""
|
|
mock_paper_instance.current_balance = 10500.0
|
|
mock_paper_instance.available_balance = 10000.0
|
|
|
|
result = mock_paper_instance.tick()
|
|
|
|
complete_events = [e for e in result if e['type'] == 'tick_complete']
|
|
assert len(complete_events) == 1
|
|
assert complete_events[0]['balance'] == 10500.0
|
|
assert complete_events[0]['available_balance'] == 10000.0
|
|
|
|
|
|
class TestExecutionLoopIntegration:
|
|
"""Integration tests for the full execution loop."""
|
|
|
|
def test_candle_triggers_strategy_update(self):
|
|
"""Test that received_cdata triggers strategy updates."""
|
|
# This would need full integration test setup
|
|
# For now, just verify the method exists
|
|
pass
|
|
|
|
def test_multiple_strategies_execute_independently(self):
|
|
"""Test that multiple strategies don't interfere with each other."""
|
|
pass
|