summaryrefslogtreecommitdiff
path: root/services/strategy-engine/tests/test_ema_crossover_strategy.py
diff options
context:
space:
mode:
Diffstat (limited to 'services/strategy-engine/tests/test_ema_crossover_strategy.py')
-rw-r--r--services/strategy-engine/tests/test_ema_crossover_strategy.py107
1 files changed, 105 insertions, 2 deletions
diff --git a/services/strategy-engine/tests/test_ema_crossover_strategy.py b/services/strategy-engine/tests/test_ema_crossover_strategy.py
index 0cf767b..ee26a33 100644
--- a/services/strategy-engine/tests/test_ema_crossover_strategy.py
+++ b/services/strategy-engine/tests/test_ema_crossover_strategy.py
@@ -21,9 +21,9 @@ def make_candle(close: float) -> Candle:
)
-def _make_strategy(short: int = 3, long: int = 6) -> EmaCrossoverStrategy:
+def _make_strategy(short: int = 3, long: int = 6, pullback_enabled: bool = False) -> EmaCrossoverStrategy:
s = EmaCrossoverStrategy()
- s.configure({"short_period": short, "long_period": long, "quantity": "0.01"})
+ s.configure({"short_period": short, "long_period": long, "quantity": "0.01", "pullback_enabled": pullback_enabled})
return s
@@ -97,3 +97,106 @@ def test_ema_reset_clears_state():
# Internal state should be cleared
assert len(strategy._closes) == 1
assert strategy._prev_short_above is None
+ assert strategy._pending_signal is None
+
+
+def test_ema_pullback_entry():
+ """Crossover detected, then pullback to short EMA triggers signal."""
+ strategy = EmaCrossoverStrategy()
+ strategy.configure({
+ "short_period": 3,
+ "long_period": 6,
+ "quantity": "0.01",
+ "pullback_enabled": True,
+ "pullback_tolerance": 0.05, # 5% tolerance for test simplicity
+ })
+
+ # 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 golden cross — with pullback enabled, no signal yet
+ rising = [120, 140, 160]
+ signal = None
+ for price in rising:
+ result = strategy.on_candle(make_candle(price))
+ if result is not None:
+ signal = result
+
+ # With pullback enabled, crossover should NOT produce immediate signal
+ # but _pending_signal should be set
+ assert strategy._pending_signal == "BUY"
+
+ # Now feed a candle whose close is near the short EMA (pullback)
+ # The short EMA will be tracking recent prices; feed a price that pulls back
+ # toward it. We use a moderate price to get close to short EMA.
+ import pandas as pd
+ series = pd.Series(list(strategy._closes))
+ short_ema_val = series.ewm(span=3, adjust=False).mean().iloc[-1]
+ # Feed a candle at approximately the short EMA value
+ result = strategy.on_candle(make_candle(short_ema_val))
+ assert result is not None
+ assert result.side == OrderSide.BUY
+ assert "pullback" in result.reason
+
+
+def test_ema_pullback_cancelled_on_reversal():
+ """Crossover detected, then reversal cancels the pending signal."""
+ strategy = EmaCrossoverStrategy()
+ strategy.configure({
+ "short_period": 3,
+ "long_period": 6,
+ "quantity": "0.01",
+ "pullback_enabled": True,
+ "pullback_tolerance": 0.001, # Very tight tolerance — won't trigger easily
+ })
+
+ # Declining prices
+ 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 golden cross
+ for price in [120, 140, 160]:
+ strategy.on_candle(make_candle(price))
+
+ assert strategy._pending_signal == "BUY"
+
+ # Now sharp decline to reverse the crossover (death cross)
+ for price in [60, 40, 20]:
+ strategy.on_candle(make_candle(price))
+
+ # The BUY pending signal should be cancelled because short EMA fell below long EMA.
+ # A new death cross may set _pending_signal to "SELL", but the original "BUY" is gone.
+ assert strategy._pending_signal != "BUY"
+
+
+def test_ema_immediate_mode():
+ """With pullback_enabled=False, original immediate entry works."""
+ strategy = EmaCrossoverStrategy()
+ strategy.configure({
+ "short_period": 3,
+ "long_period": 6,
+ "quantity": "0.01",
+ "pullback_enabled": False,
+ })
+
+ # 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 golden cross — immediate mode should fire signal
+ 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
+ # No pending signal should be set
+ assert strategy._pending_signal is None