diff options
| author | TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> | 2026-04-01 16:16:03 +0900 |
|---|---|---|
| committer | TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> | 2026-04-01 16:16:03 +0900 |
| commit | 99fb46f40619b7c7be1698419ed105252dae7cdd (patch) | |
| tree | 54737a31d4b3101f8ee67a81752a9b0a1981c5a8 | |
| parent | 380b8c079a9f92ece128ecccdff6c62fdef8f3b2 (diff) | |
feat(strategy): add EMA Crossover strategy
3 files changed, 169 insertions, 0 deletions
diff --git a/services/strategy-engine/strategies/config/ema_crossover_strategy.yaml b/services/strategy-engine/strategies/config/ema_crossover_strategy.yaml new file mode 100644 index 0000000..6127e1a --- /dev/null +++ b/services/strategy-engine/strategies/config/ema_crossover_strategy.yaml @@ -0,0 +1,3 @@ +short_period: 9 +long_period: 21 +quantity: "0.01" diff --git a/services/strategy-engine/strategies/ema_crossover_strategy.py b/services/strategy-engine/strategies/ema_crossover_strategy.py new file mode 100644 index 0000000..17234a3 --- /dev/null +++ b/services/strategy-engine/strategies/ema_crossover_strategy.py @@ -0,0 +1,67 @@ +from collections import deque +from decimal import Decimal + +import pandas as pd + +from shared.models import Candle, Signal, OrderSide +from strategies.base import BaseStrategy + + +class EmaCrossoverStrategy(BaseStrategy): + name: str = "ema_crossover" + + def __init__(self) -> None: + self._closes: deque[float] = deque(maxlen=500) + self._short_period: int = 9 + self._long_period: int = 21 + self._quantity: Decimal = Decimal("0.01") + self._prev_short_above: bool | None = None + + @property + def warmup_period(self) -> int: + return self._long_period + + def configure(self, params: dict) -> None: + self._short_period = int(params.get("short_period", 9)) + self._long_period = int(params.get("long_period", 21)) + self._quantity = Decimal(str(params.get("quantity", "0.01"))) + + def reset(self) -> None: + self._closes.clear() + self._prev_short_above = None + + def on_candle(self, candle: Candle) -> Signal | None: + self._closes.append(float(candle.close)) + + if len(self._closes) < self._long_period: + return None + + series = pd.Series(list(self._closes)) + short_ema = series.ewm(span=self._short_period, adjust=False).mean().iloc[-1] + long_ema = series.ewm(span=self._long_period, adjust=False).mean().iloc[-1] + + short_above = short_ema > long_ema + + signal = None + if self._prev_short_above is not None: + if not self._prev_short_above and short_above: + signal = Signal( + strategy=self.name, + symbol=candle.symbol, + side=OrderSide.BUY, + price=candle.close, + quantity=self._quantity, + reason=f"Golden Cross: short EMA ({short_ema:.2f}) crossed above long EMA ({long_ema:.2f})", + ) + elif self._prev_short_above and not short_above: + signal = Signal( + strategy=self.name, + symbol=candle.symbol, + side=OrderSide.SELL, + price=candle.close, + quantity=self._quantity, + reason=f"Death Cross: short EMA ({short_ema:.2f}) crossed below long EMA ({long_ema:.2f})", + ) + + self._prev_short_above = short_above + return signal diff --git a/services/strategy-engine/tests/test_ema_crossover_strategy.py b/services/strategy-engine/tests/test_ema_crossover_strategy.py new file mode 100644 index 0000000..5a40319 --- /dev/null +++ b/services/strategy-engine/tests/test_ema_crossover_strategy.py @@ -0,0 +1,99 @@ +"""Tests for the EMA Crossover strategy.""" +from datetime import datetime, timezone +from decimal import Decimal + +import pytest + +from shared.models import Candle, OrderSide +from strategies.ema_crossover_strategy import EmaCrossoverStrategy + + +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 _make_strategy(short: int = 3, long: int = 6) -> EmaCrossoverStrategy: + s = EmaCrossoverStrategy() + s.configure({"short_period": short, "long_period": long, "quantity": "0.01"}) + return s + + +def test_ema_warmup_period(): + strategy = _make_strategy(short=3, long=6) + assert strategy.warmup_period == 6 + + +def test_ema_no_signal_insufficient_data(): + strategy = _make_strategy(short=3, long=6) + # Feed fewer candles than warmup_period + for price in [100, 101, 102, 103, 104]: + result = strategy.on_candle(make_candle(price)) + assert result is None + + +def test_ema_buy_signal_golden_cross(): + strategy = _make_strategy(short=3, long=6) + + # Declining prices so short EMA stays below long EMA + declining = [100, 98, 96, 94, 92, 90, 88, 86, 84, 82] + for price in declining: + strategy.on_candle(make_candle(price)) + + # Sharp rise to force short EMA above long EMA (golden cross) + rising = [120, 140, 160] + signal = None + for price in rising: + result = strategy.on_candle(make_candle(price)) + if result is not None: + signal = result + + assert signal is not None + assert signal.side == OrderSide.BUY + assert "Golden Cross" in signal.reason + + +def test_ema_sell_signal_death_cross(): + strategy = _make_strategy(short=3, long=6) + + # Rising prices so short EMA stays above long EMA + rising = [80, 82, 84, 86, 88, 90, 92, 94, 96, 98] + for price in rising: + strategy.on_candle(make_candle(price)) + + # Sharp decline to force short EMA below long EMA (death cross) + declining = [60, 40, 20] + signal = None + for price in declining: + result = strategy.on_candle(make_candle(price)) + if result is not None: + signal = result + + assert signal is not None + assert signal.side == OrderSide.SELL + assert "Death Cross" in signal.reason + + +def test_ema_reset_clears_state(): + strategy = _make_strategy(short=3, long=6) + + # Feed enough data to produce a signal + for price in [100, 98, 96, 94, 92, 90, 88, 86, 84, 82]: + strategy.on_candle(make_candle(price)) + + strategy.reset() + + # After reset, insufficient data again — no signal + result = strategy.on_candle(make_candle(100)) + assert result is None + # Internal state should be cleared + assert len(strategy._closes) == 1 + assert strategy._prev_short_above is None |
