summaryrefslogtreecommitdiff
path: root/services/strategy-engine/tests
diff options
context:
space:
mode:
authorTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2026-04-02 09:18:30 +0900
committerTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2026-04-02 09:18:30 +0900
commitda6c9598f92057e2fcbb206aa7466b6997a455f3 (patch)
treec4b00aba741648c80e30487b569b99fe9f472454 /services/strategy-engine/tests
parent828682de5904c8c1d05664a961f7931ebe60fabd (diff)
feat(strategy): add EMA pullback entry and VWAP daily reset with deviation bands
Diffstat (limited to 'services/strategy-engine/tests')
-rw-r--r--services/strategy-engine/tests/test_ema_crossover_strategy.py107
-rw-r--r--services/strategy-engine/tests/test_vwap_strategy.py50
2 files changed, 153 insertions, 4 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
diff --git a/services/strategy-engine/tests/test_vwap_strategy.py b/services/strategy-engine/tests/test_vwap_strategy.py
index 5d76b04..2cc4766 100644
--- a/services/strategy-engine/tests/test_vwap_strategy.py
+++ b/services/strategy-engine/tests/test_vwap_strategy.py
@@ -1,6 +1,6 @@
"""Tests for the VWAP strategy."""
-from datetime import datetime, timezone
+from datetime import datetime, timezone, timedelta
from decimal import Decimal
@@ -13,15 +13,18 @@ def make_candle(
high: float | None = None,
low: float | None = None,
volume: float = 1.0,
+ open_time: datetime | None = None,
) -> Candle:
if high is None:
high = close
if low is None:
low = close
+ if open_time is None:
+ open_time = datetime(2024, 1, 1, tzinfo=timezone.utc)
return Candle(
symbol="BTC/USDT",
timeframe="1m",
- open_time=datetime(2024, 1, 1, tzinfo=timezone.utc),
+ open_time=open_time,
open=Decimal(str(close)),
high=Decimal(str(high)),
low=Decimal(str(low)),
@@ -99,3 +102,46 @@ def test_vwap_reset_clears_state():
assert strategy._candle_count == 0
assert strategy._was_below_vwap is False
assert strategy._was_above_vwap is False
+ assert strategy._current_date is None
+ assert len(strategy._tp_values) == 0
+ assert len(strategy._vwap_values) == 0
+
+
+def test_vwap_daily_reset():
+ """Candles from two different dates cause VWAP to reset."""
+ strategy = _configured_strategy()
+
+ day1 = datetime(2024, 1, 1, tzinfo=timezone.utc)
+ day2 = datetime(2024, 1, 2, tzinfo=timezone.utc)
+
+ # Feed 35 candles on day 1 to build VWAP state
+ for i in range(35):
+ strategy.on_candle(make_candle(100.0, high=101.0, low=99.0, open_time=day1))
+
+ # Verify state is built up
+ assert strategy._candle_count == 35
+ assert strategy._cumulative_vol > 0
+ assert strategy._current_date == "2024-01-01"
+
+ # Feed first candle of day 2 — should reset
+ strategy.on_candle(make_candle(100.0, high=101.0, low=99.0, open_time=day2))
+
+ # After reset, candle_count should be 1 (the new candle)
+ assert strategy._candle_count == 1
+ assert strategy._current_date == "2024-01-02"
+
+
+def test_vwap_reset_clears_date():
+ """Verify reset() clears _current_date and deviation band state."""
+ strategy = _configured_strategy()
+
+ for _ in range(35):
+ strategy.on_candle(make_candle(100.0))
+
+ assert strategy._current_date is not None
+
+ strategy.reset()
+
+ assert strategy._current_date is None
+ assert len(strategy._tp_values) == 0
+ assert len(strategy._vwap_values) == 0