v4.0.17 no-backtest

This commit is contained in:
Ichinga Samuel
2026-02-21 07:43:33 +01:00
parent 844e39194b
commit 97aea6952e
124 changed files with 8198 additions and 14349 deletions
+271 -68
View File
@@ -12,7 +12,7 @@ Tests cover:
"""
import pytest
from unittest.mock import patch, MagicMock, AsyncMock
from unittest.mock import patch
from aiomql.utils.utils import (
dict_to_string,
@@ -62,29 +62,43 @@ class TestDictToString:
assert "float: 3.14" in result
assert "bool: True" in result
def test_multi_false_uses_comma_separator(self):
"""Test that multi=False uses comma-space separator."""
result = dict_to_string({"a": 1, "b": 2}, multi=False)
assert "\n" not in result
assert ", " in result
def test_single_item_no_separator(self):
"""Test single item has no separator character."""
result = dict_to_string({"key": "val"})
assert "," not in result
assert "\n" not in result
class TestBackoffDecorator:
"""Tests for backoff_decorator."""
@pytest.mark.asyncio
async def test_successful_call_no_retry(self):
"""Test successful call does not retry."""
call_count = 0
@backoff_decorator
async def success_func():
nonlocal call_count
call_count += 1
return "success"
result = await success_func()
assert result == "success"
assert call_count == 1
@pytest.mark.asyncio
async def test_retry_on_exception(self):
"""Test retries on exception."""
"""Test retries on exception until success."""
call_count = 0
@backoff_decorator(max_retries=3)
async def failing_func():
nonlocal call_count
@@ -92,152 +106,245 @@ class TestBackoffDecorator:
if call_count < 3:
raise ValueError("Test error")
return "success"
with patch("aiomql.utils.utils.Config") as mock_config:
mock_config.return_value.mode = "backtest" # Skip sleep
result = await failing_func()
result = await failing_func()
assert result == "success"
assert call_count == 3
@pytest.mark.asyncio
async def test_max_retries_exceeded(self):
"""Test raises after max retries exceeded."""
call_count = 0
@backoff_decorator(max_retries=2)
async def always_fails():
nonlocal call_count
call_count += 1
raise ValueError("Always fails")
with patch("aiomql.utils.utils.Config") as mock_config:
mock_config.return_value.mode = "backtest"
with pytest.raises(ValueError, match="Always fails"):
await always_fails()
with pytest.raises(ValueError, match="Always fails"):
await always_fails()
assert call_count == 3 # Initial + 2 retries
async def test_backoff_delay_in_live_mode(self):
"""Test backoff delay applied in live mode."""
call_count = 0
@pytest.mark.asyncio
async def test_max_retries_logs_error(self):
"""Test logs error when max retries exceeded."""
@backoff_decorator(max_retries=1)
async def failing_func():
nonlocal call_count
call_count += 1
if call_count < 2:
raise ValueError("Test error")
return "success"
with patch("aiomql.utils.utils.Config") as mock_config:
mock_config.return_value.mode = "live"
with patch("aiomql.utils.utils.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
result = await failing_func()
mock_sleep.assert_called_once()
async def always_fails():
raise ValueError("Test error")
with patch("aiomql.utils.utils.logger") as mock_logger:
with pytest.raises(ValueError):
await always_fails()
mock_logger.error.assert_called_once()
@pytest.mark.asyncio
async def test_decorator_without_parentheses(self):
"""Test decorator can be used without parentheses."""
@backoff_decorator
async def simple_func():
return "result"
result = await simple_func()
assert result == "result"
@pytest.mark.asyncio
async def test_decorator_with_parentheses(self):
"""Test decorator can be used with parentheses."""
@backoff_decorator()
async def simple_func():
return "result"
result = await simple_func()
assert result == "result"
@pytest.mark.asyncio
async def test_passes_args_and_kwargs(self):
"""Test decorated function receives args and kwargs correctly."""
@backoff_decorator
async def add(a, b, c=0):
return a + b + c
result = await add(1, 2, c=3)
assert result == 6
@pytest.mark.asyncio
async def test_retries_reset_on_success(self):
"""Test retries counter resets after a successful call."""
call_count = 0
@backoff_decorator(max_retries=2)
async def intermittent_func():
nonlocal call_count
call_count += 1
if call_count == 1:
raise ValueError("First call fails")
return "success"
# First call succeeds after 1 retry
result = await intermittent_func()
assert result == "success"
# Reset call_count for second invocation
call_count = 10 # Won't fail since count != 1
# Second call should also work (retries were reset)
result = await intermittent_func()
assert result == "success"
@pytest.mark.asyncio
async def test_preserves_function_name(self):
"""Test decorator preserves original function name via @wraps."""
@backoff_decorator
async def my_function():
return True
assert my_function.__name__ == "my_function"
@pytest.mark.asyncio
async def test_custom_max_retries(self):
"""Test custom max_retries value is respected."""
call_count = 0
@backoff_decorator(max_retries=5)
async def failing_func():
nonlocal call_count
call_count += 1
if call_count < 5:
raise ValueError("Fail")
return "success"
result = await failing_func()
assert result == "success"
assert call_count == 5
class TestErrorHandler:
"""Tests for error_handler async decorator."""
@pytest.mark.asyncio
async def test_successful_call(self):
"""Test successful call returns result."""
@error_handler
async def success_func():
return "success"
result = await success_func()
assert result == "success"
@pytest.mark.asyncio
async def test_exception_returns_response(self):
"""Test exception returns configured response."""
@error_handler(response="default")
async def failing_func():
raise ValueError("Test error")
with patch("aiomql.utils.utils.logger"):
result = await failing_func()
assert result == "default"
@pytest.mark.asyncio
async def test_exception_returns_none_by_default(self):
"""Test exception returns None by default."""
@error_handler
async def failing_func():
raise ValueError("Test error")
with patch("aiomql.utils.utils.logger"):
result = await failing_func()
assert result is None
@pytest.mark.asyncio
async def test_custom_exception_type(self):
"""Test catches only specified exception type."""
@error_handler(exe=ValueError, response="caught")
async def failing_func():
raise ValueError("Test error")
with patch("aiomql.utils.utils.logger"):
result = await failing_func()
assert result == "caught"
@pytest.mark.asyncio
async def test_unmatched_exception_propagates(self):
"""Test unmatched exception propagates."""
@error_handler(exe=ValueError, response="caught")
async def failing_func():
raise TypeError("Wrong type")
with pytest.raises(TypeError):
await failing_func()
@pytest.mark.asyncio
async def test_logs_error_message(self):
"""Test logs error message."""
@error_handler
async def failing_func():
raise ValueError("Test error")
with patch("aiomql.utils.utils.logger") as mock_logger:
await failing_func()
mock_logger.error.assert_called_once()
@pytest.mark.asyncio
async def test_custom_error_message(self):
"""Test custom error message is logged."""
@error_handler(msg="Custom error message")
async def failing_func():
raise ValueError("Test error")
with patch("aiomql.utils.utils.logger") as mock_logger:
await failing_func()
mock_logger.error.assert_called_once_with("Custom error message")
@pytest.mark.asyncio
async def test_default_error_message_format(self):
"""Test default error message includes function name and error."""
@error_handler
async def my_func():
raise ValueError("specific error")
with patch("aiomql.utils.utils.logger") as mock_logger:
await my_func()
call_args = mock_logger.error.call_args[0][0]
assert "my_func" in call_args
assert "specific error" in call_args
@pytest.mark.asyncio
async def test_log_error_msg_false(self):
"""Test no logging when log_error_msg is False."""
@error_handler(log_error_msg=False)
async def failing_func():
raise ValueError("Test error")
with patch("aiomql.utils.utils.logger") as mock_logger:
await failing_func()
mock_logger.error.assert_not_called()
@pytest.mark.asyncio
async def test_preserves_function_name(self):
"""Test decorator preserves original function name via @wraps."""
@error_handler
async def my_special_func():
return True
assert my_special_func.__name__ == "my_special_func"
@pytest.mark.asyncio
async def test_passes_args_and_kwargs(self):
"""Test decorated function receives args and kwargs correctly."""
@error_handler
async def add(a, b, c=0):
return a + b + c
result = await add(1, 2, c=3)
assert result == 6
class TestErrorHandlerSync:
"""Tests for error_handler_sync decorator."""
@@ -247,7 +354,7 @@ class TestErrorHandlerSync:
@error_handler_sync
def success_func():
return "success"
result = success_func()
assert result == "success"
@@ -256,10 +363,10 @@ class TestErrorHandlerSync:
@error_handler_sync(response="default")
def failing_func():
raise ValueError("Test error")
with patch("aiomql.utils.utils.logger"):
result = failing_func()
assert result == "default"
def test_exception_returns_none_by_default(self):
@@ -267,10 +374,10 @@ class TestErrorHandlerSync:
@error_handler_sync
def failing_func():
raise ValueError("Test error")
with patch("aiomql.utils.utils.logger"):
result = failing_func()
assert result is None
def test_custom_exception_type(self):
@@ -278,10 +385,10 @@ class TestErrorHandlerSync:
@error_handler_sync(exe=ValueError, response="caught")
def failing_func():
raise ValueError("Test error")
with patch("aiomql.utils.utils.logger"):
result = failing_func()
assert result == "caught"
def test_unmatched_exception_propagates(self):
@@ -289,7 +396,7 @@ class TestErrorHandlerSync:
@error_handler_sync(exe=ValueError)
def failing_func():
raise TypeError("Wrong type")
with pytest.raises(TypeError):
failing_func()
@@ -298,21 +405,62 @@ class TestErrorHandlerSync:
@error_handler_sync
def failing_func():
raise ValueError("Test error")
with patch("aiomql.utils.utils.logger") as mock_logger:
failing_func()
mock_logger.error.assert_called_once()
def test_custom_error_message(self):
"""Test custom error message is logged."""
@error_handler_sync(msg="Custom sync error")
def failing_func():
raise ValueError("Test error")
with patch("aiomql.utils.utils.logger") as mock_logger:
failing_func()
# error_handler_sync uses: f"Error in {func.__name__}: {msg or err}"
call_args = mock_logger.error.call_args[0][0]
assert "Custom sync error" in call_args
def test_default_error_message_format(self):
"""Test default error message includes function name and error."""
@error_handler_sync
def my_sync_func():
raise ValueError("specific error")
with patch("aiomql.utils.utils.logger") as mock_logger:
my_sync_func()
call_args = mock_logger.error.call_args[0][0]
assert "my_sync_func" in call_args
assert "specific error" in call_args
def test_log_error_msg_false(self):
"""Test no logging when log_error_msg is False."""
@error_handler_sync(log_error_msg=False)
def failing_func():
raise ValueError("Test error")
with patch("aiomql.utils.utils.logger") as mock_logger:
failing_func()
mock_logger.error.assert_not_called()
def test_preserves_function_name(self):
"""Test decorator preserves original function name via @wraps."""
@error_handler_sync
def my_sync_special_func():
return True
assert my_sync_special_func.__name__ == "my_sync_special_func"
def test_passes_args_and_kwargs(self):
"""Test decorated function receives args and kwargs correctly."""
@error_handler_sync
def add(a, b, c=0):
return a + b + c
result = add(1, 2, c=3)
assert result == 6
class TestRoundDown:
"""Tests for round_down function."""
@@ -337,6 +485,11 @@ class TestRoundDown:
assert round_down(3, 5) == 0
assert round_down(9, 10) == 0
def test_round_down_large_number(self):
"""Test rounding down large numbers."""
assert round_down(997, 100) == 900
assert round_down(1050, 1000) == 1000
class TestRoundUp:
"""Tests for round_up function."""
@@ -361,6 +514,11 @@ class TestRoundUp:
assert round_up(1, 5) == 5
assert round_up(1, 10) == 10
def test_round_up_large_number(self):
"""Test rounding up large numbers."""
assert round_up(901, 100) == 1000
assert round_up(1001, 1000) == 2000
class TestRoundOff:
"""Tests for round_off function."""
@@ -390,78 +548,123 @@ class TestRoundOff:
assert round_off(5.5, 1) == 6.0
assert round_off(5.5, 1, round_down=True) == 5.0
def test_small_step_forex_lot(self):
"""Test with very small step (forex lot size precision)."""
assert round_off(0.0123, 0.01) == 0.02
assert round_off(0.0123, 0.01, round_down=True) == 0.01
def test_volume_step(self):
"""Test with volume step (common in trading)."""
assert round_off(0.15, 0.1) == 0.2
assert round_off(0.15, 0.1, round_down=True) == 0.1
class TestAsyncCache:
"""Tests for async_cache decorator."""
@pytest.mark.asyncio
async def test_caches_result(self):
"""Test result is cached."""
call_count = 0
@async_cache
async def cached_func():
nonlocal call_count
call_count += 1
return "result"
result1 = await cached_func()
result2 = await cached_func()
assert result1 == "result"
assert result2 == "result"
assert call_count == 1
@pytest.mark.asyncio
async def test_different_args_different_cache(self):
"""Test different args have different cache entries."""
call_count = 0
@async_cache
async def cached_func(x):
nonlocal call_count
call_count += 1
return x * 2
result1 = await cached_func(1)
result2 = await cached_func(2)
result3 = await cached_func(1) # Should be cached
assert result1 == 2
assert result2 == 4
assert result3 == 2
assert call_count == 2
@pytest.mark.asyncio
async def test_kwargs_in_cache_key(self):
"""Test kwargs are included in cache key."""
call_count = 0
@async_cache
async def cached_func(x, y=1):
nonlocal call_count
call_count += 1
return x + y
result1 = await cached_func(1, y=2)
result2 = await cached_func(1, y=3)
result3 = await cached_func(1, y=2) # Should be cached
assert result1 == 3
assert result2 == 4
assert result3 == 3
assert call_count == 2
@pytest.mark.asyncio
async def test_cache_has_lock(self):
"""Test cached function has lock attribute."""
@async_cache
async def cached_func():
return "result"
assert hasattr(cached_func, "lock")
assert hasattr(cached_func, "cache")
@pytest.mark.asyncio
async def test_cache_is_dict(self):
"""Test cache is a dictionary."""
@async_cache
async def cached_func():
return "result"
assert isinstance(cached_func.cache, dict)
@pytest.mark.asyncio
async def test_cache_can_be_cleared(self):
"""Test cache can be manually cleared."""
call_count = 0
@async_cache
async def cached_func():
nonlocal call_count
call_count += 1
return "result"
await cached_func()
assert call_count == 1
# Clear cache
cached_func.cache.clear()
# Should call function again
await cached_func()
assert call_count == 2
@pytest.mark.asyncio
async def test_preserves_function_name(self):
"""Test decorator preserves original function name via @wraps."""
@async_cache
async def my_cached_func():
return True
assert my_cached_func.__name__ == "my_cached_func"