diff options
| author | TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> | 2026-04-02 09:18:30 +0900 |
|---|---|---|
| committer | TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> | 2026-04-02 09:18:30 +0900 |
| commit | da6c9598f92057e2fcbb206aa7466b6997a455f3 (patch) | |
| tree | c4b00aba741648c80e30487b569b99fe9f472454 /services/strategy-engine/tests | |
| parent | 828682de5904c8c1d05664a961f7931ebe60fabd (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.py | 107 | ||||
| -rw-r--r-- | services/strategy-engine/tests/test_vwap_strategy.py | 50 |
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 |
