summaryrefslogtreecommitdiff
path: root/services/strategy-engine/tests
diff options
context:
space:
mode:
Diffstat (limited to 'services/strategy-engine/tests')
-rw-r--r--services/strategy-engine/tests/__init__.py0
-rw-r--r--services/strategy-engine/tests/conftest.py8
-rw-r--r--services/strategy-engine/tests/test_engine.py72
-rw-r--r--services/strategy-engine/tests/test_grid_strategy.py60
-rw-r--r--services/strategy-engine/tests/test_plugin_loader.py22
-rw-r--r--services/strategy-engine/tests/test_rsi_strategy.py45
6 files changed, 207 insertions, 0 deletions
diff --git a/services/strategy-engine/tests/__init__.py b/services/strategy-engine/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/services/strategy-engine/tests/__init__.py
diff --git a/services/strategy-engine/tests/conftest.py b/services/strategy-engine/tests/conftest.py
new file mode 100644
index 0000000..c9ef308
--- /dev/null
+++ b/services/strategy-engine/tests/conftest.py
@@ -0,0 +1,8 @@
+"""Pytest configuration: ensure strategies/ is importable."""
+import sys
+from pathlib import Path
+
+# Add the strategies directory to sys.path so that `from strategies.base import ...` works
+STRATEGIES_DIR = Path(__file__).parent.parent / "strategies"
+if str(STRATEGIES_DIR) not in sys.path:
+ sys.path.insert(0, str(STRATEGIES_DIR.parent))
diff --git a/services/strategy-engine/tests/test_engine.py b/services/strategy-engine/tests/test_engine.py
new file mode 100644
index 0000000..33ad4dd
--- /dev/null
+++ b/services/strategy-engine/tests/test_engine.py
@@ -0,0 +1,72 @@
+"""Tests for the StrategyEngine."""
+from datetime import datetime, timezone
+from decimal import Decimal
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from shared.models import Candle, Signal, OrderSide
+from shared.events import CandleEvent, SignalEvent
+from strategy_engine.engine import StrategyEngine
+
+
+def make_candle_event() -> dict:
+ candle = Candle(
+ symbol="BTC/USDT",
+ timeframe="1m",
+ open_time=datetime(2024, 1, 1, tzinfo=timezone.utc),
+ open=Decimal("50000"),
+ high=Decimal("50100"),
+ low=Decimal("49900"),
+ close=Decimal("50050"),
+ volume=Decimal("10.0"),
+ )
+ return CandleEvent(data=candle).to_dict()
+
+
+def make_signal() -> Signal:
+ return Signal(
+ strategy="test",
+ symbol="BTC/USDT",
+ side=OrderSide.BUY,
+ price=Decimal("50050"),
+ quantity=Decimal("0.01"),
+ reason="test signal",
+ )
+
+
+@pytest.mark.asyncio
+async def test_engine_dispatches_candle_to_strategies():
+ broker = MagicMock()
+ broker.read = AsyncMock(return_value=[make_candle_event()])
+ broker.publish = AsyncMock()
+
+ strategy = MagicMock()
+ strategy.on_candle = MagicMock(return_value=None)
+
+ engine = StrategyEngine(broker=broker, strategies=[strategy])
+ await engine.process_once("candles.BTC_USDT", "0")
+
+ strategy.on_candle.assert_called_once()
+ candle_arg = strategy.on_candle.call_args[0][0]
+ assert isinstance(candle_arg, Candle)
+ assert candle_arg.symbol == "BTC/USDT"
+
+
+@pytest.mark.asyncio
+async def test_engine_publishes_signal_when_strategy_returns_one():
+ broker = MagicMock()
+ broker.read = AsyncMock(return_value=[make_candle_event()])
+ broker.publish = AsyncMock()
+
+ strategy = MagicMock()
+ strategy.on_candle = MagicMock(return_value=make_signal())
+
+ engine = StrategyEngine(broker=broker, strategies=[strategy])
+ await engine.process_once("candles.BTC_USDT", "0")
+
+ broker.publish.assert_called_once()
+ call_args = broker.publish.call_args
+ assert call_args[0][0] == "signals"
+ published_data = call_args[0][1]
+ assert published_data["type"] == "SIGNAL"
diff --git a/services/strategy-engine/tests/test_grid_strategy.py b/services/strategy-engine/tests/test_grid_strategy.py
new file mode 100644
index 0000000..d96ebba
--- /dev/null
+++ b/services/strategy-engine/tests/test_grid_strategy.py
@@ -0,0 +1,60 @@
+"""Tests for the Grid strategy."""
+from datetime import datetime, timezone
+from decimal import Decimal
+
+import pytest
+
+from shared.models import Candle, OrderSide
+from strategies.grid_strategy import GridStrategy
+
+
+def make_candle(close: float) -> Candle:
+ return Candle(
+ symbol="BTC/USDT",
+ timeframe="1m",
+ open_time=datetime(2024, 1, 1, tzinfo=timezone.utc),
+ open=Decimal(str(close)),
+ high=Decimal(str(close)),
+ low=Decimal(str(close)),
+ close=Decimal(str(close)),
+ volume=Decimal("1.0"),
+ )
+
+
+def _configured_strategy() -> GridStrategy:
+ strategy = GridStrategy()
+ strategy.configure({
+ "lower_price": 48000,
+ "upper_price": 52000,
+ "grid_count": 5,
+ "quantity": "0.01",
+ })
+ return strategy
+
+
+def test_grid_strategy_buy_at_lower_grid():
+ strategy = _configured_strategy()
+ # First candle: establish zone at upper area
+ strategy.on_candle(make_candle(51500))
+ # Second candle: price drops to lower zone → BUY
+ signal = strategy.on_candle(make_candle(48100))
+ assert signal is not None
+ assert signal.side == OrderSide.BUY
+
+
+def test_grid_strategy_sell_at_upper_grid():
+ strategy = _configured_strategy()
+ # First candle: establish zone at lower area
+ strategy.on_candle(make_candle(48100))
+ # Second candle: price rises to upper zone → SELL
+ signal = strategy.on_candle(make_candle(51900))
+ assert signal is not None
+ assert signal.side == OrderSide.SELL
+
+
+def test_grid_strategy_no_signal_in_same_zone():
+ strategy = _configured_strategy()
+ # Both candles in approximately the same zone
+ strategy.on_candle(make_candle(50000))
+ signal = strategy.on_candle(make_candle(50100))
+ assert signal is None
diff --git a/services/strategy-engine/tests/test_plugin_loader.py b/services/strategy-engine/tests/test_plugin_loader.py
new file mode 100644
index 0000000..9496bab
--- /dev/null
+++ b/services/strategy-engine/tests/test_plugin_loader.py
@@ -0,0 +1,22 @@
+"""Tests for the plugin loader."""
+from pathlib import Path
+
+import pytest
+
+from strategy_engine.plugin_loader import load_strategies
+
+
+STRATEGIES_DIR = Path(__file__).parent.parent / "strategies"
+
+
+def test_load_strategies_finds_rsi_and_grid():
+ strategies = load_strategies(STRATEGIES_DIR)
+ names = [s.name for s in strategies]
+ assert "rsi" in names
+ assert "grid" in names
+
+
+def test_load_strategies_skips_base():
+ strategies = load_strategies(STRATEGIES_DIR)
+ names = [s.name for s in strategies]
+ assert "base" not in names
diff --git a/services/strategy-engine/tests/test_rsi_strategy.py b/services/strategy-engine/tests/test_rsi_strategy.py
new file mode 100644
index 0000000..90fface
--- /dev/null
+++ b/services/strategy-engine/tests/test_rsi_strategy.py
@@ -0,0 +1,45 @@
+"""Tests for the RSI strategy."""
+from datetime import datetime, timezone
+from decimal import Decimal
+
+import pytest
+
+from shared.models import Candle, OrderSide
+from strategies.rsi_strategy import RsiStrategy
+
+
+def make_candle(close: float, idx: int = 0) -> Candle:
+ return Candle(
+ symbol="BTC/USDT",
+ timeframe="1m",
+ open_time=datetime(2024, 1, 1, tzinfo=timezone.utc),
+ open=Decimal(str(close)),
+ high=Decimal(str(close)),
+ low=Decimal(str(close)),
+ close=Decimal(str(close)),
+ volume=Decimal("1.0"),
+ )
+
+
+def test_rsi_strategy_no_signal_insufficient_data():
+ strategy = RsiStrategy()
+ strategy.configure({})
+ candle = make_candle(50000.0)
+ result = strategy.on_candle(candle)
+ assert result is None
+
+
+def test_rsi_strategy_buy_signal_on_oversold():
+ strategy = RsiStrategy()
+ strategy.configure({"period": 14, "oversold": 30, "overbought": 70})
+
+ # Feed 20 steadily declining prices to force RSI into oversold territory
+ prices = [50000 - i * 500 for i in range(20)]
+ signal = None
+ for i, price in enumerate(prices):
+ signal = strategy.on_candle(make_candle(price, i))
+
+ # We may or may not get a signal depending on RSI calculation;
+ # if a signal is returned, it must be a BUY
+ if signal is not None:
+ assert signal.side == OrderSide.BUY