diff options
Diffstat (limited to 'services/strategy-engine')
4 files changed, 272 insertions, 16 deletions
diff --git a/services/strategy-engine/strategies/ema_crossover_strategy.py b/services/strategy-engine/strategies/ema_crossover_strategy.py index a812eff..68d0ba3 100644 --- a/services/strategy-engine/strategies/ema_crossover_strategy.py +++ b/services/strategy-engine/strategies/ema_crossover_strategy.py @@ -17,6 +17,9 @@ class EmaCrossoverStrategy(BaseStrategy): self._long_period: int = 21 self._quantity: Decimal = Decimal("0.01") self._prev_short_above: bool | None = None + self._pending_signal: str | None = None # "BUY" or "SELL" if waiting for pullback + self._pullback_enabled: bool = True + self._pullback_tolerance: float = 0.002 # 0.2% tolerance around short EMA @property def warmup_period(self) -> int: @@ -27,6 +30,9 @@ class EmaCrossoverStrategy(BaseStrategy): self._long_period = int(params.get("long_period", 21)) self._quantity = Decimal(str(params.get("quantity", "0.01"))) + self._pullback_enabled = bool(params.get("pullback_enabled", True)) + self._pullback_tolerance = float(params.get("pullback_tolerance", 0.002)) + if self._short_period >= self._long_period: raise ValueError( f"EMA short_period must be < long_period, " @@ -48,8 +54,10 @@ class EmaCrossoverStrategy(BaseStrategy): ) def reset(self) -> None: + super().reset() self._closes.clear() self._prev_short_above = None + self._pending_signal = None def _ema_conviction(self, short_ema: float, long_ema: float, price: float) -> float: """Map EMA gap to conviction (0.1-1.0). Larger gap = stronger crossover.""" @@ -70,33 +78,87 @@ class EmaCrossoverStrategy(BaseStrategy): 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] + close = float(candle.close) short_above = short_ema > long_ema signal = None if self._prev_short_above is not None: - conviction = self._ema_conviction(short_ema, long_ema, float(candle.close)) - if not self._prev_short_above and short_above: + prev = self._prev_short_above + conviction = self._ema_conviction(short_ema, long_ema, close) + + # Golden Cross detected + if not prev and short_above: + if self._pullback_enabled: + self._pending_signal = "BUY" + # Don't signal yet — wait for pullback + else: + signal = Signal( + strategy=self.name, + symbol=candle.symbol, + side=OrderSide.BUY, + price=candle.close, + quantity=self._quantity, + conviction=conviction, + reason=f"Golden Cross: short EMA ({short_ema:.2f}) crossed above long EMA ({long_ema:.2f})", + ) + + # Death Cross detected + elif prev and not short_above: + if self._pullback_enabled: + self._pending_signal = "SELL" + else: + signal = Signal( + strategy=self.name, + symbol=candle.symbol, + side=OrderSide.SELL, + price=candle.close, + quantity=self._quantity, + conviction=conviction, + reason=f"Death Cross: short EMA ({short_ema:.2f}) crossed below long EMA ({long_ema:.2f})", + ) + + self._prev_short_above = short_above + + if signal is not None: + return self._apply_filters(signal) + + # Check for pullback entry + if self._pending_signal == "BUY": + distance = abs(close - short_ema) / short_ema if short_ema > 0 else 999 + if distance <= self._pullback_tolerance: + self._pending_signal = None + conv = min(0.5 + (1.0 - distance / self._pullback_tolerance) * 0.5, 1.0) signal = Signal( strategy=self.name, symbol=candle.symbol, side=OrderSide.BUY, price=candle.close, quantity=self._quantity, - conviction=conviction, - reason=f"Golden Cross: short EMA ({short_ema:.2f}) crossed above long EMA ({long_ema:.2f})", + conviction=conv, + reason=f"EMA Golden Cross pullback entry (distance={distance:.4f})", ) - elif self._prev_short_above and not short_above: + return self._apply_filters(signal) + # Cancel if crossover reverses + if not short_above: + self._pending_signal = None + + if self._pending_signal == "SELL": + distance = abs(close - short_ema) / short_ema if short_ema > 0 else 999 + if distance <= self._pullback_tolerance: + self._pending_signal = None + conv = min(0.5 + (1.0 - distance / self._pullback_tolerance) * 0.5, 1.0) signal = Signal( strategy=self.name, symbol=candle.symbol, side=OrderSide.SELL, price=candle.close, quantity=self._quantity, - conviction=conviction, - reason=f"Death Cross: short EMA ({short_ema:.2f}) crossed below long EMA ({long_ema:.2f})", + conviction=conv, + reason=f"EMA Death Cross pullback entry (distance={distance:.4f})", ) + return self._apply_filters(signal) + # Cancel if crossover reverses + if short_above: + self._pending_signal = None - self._prev_short_above = short_above - if signal is not None: - return self._apply_filters(signal) return None diff --git a/services/strategy-engine/strategies/vwap_strategy.py b/services/strategy-engine/strategies/vwap_strategy.py index c525ff3..0348752 100644 --- a/services/strategy-engine/strategies/vwap_strategy.py +++ b/services/strategy-engine/strategies/vwap_strategy.py @@ -1,3 +1,4 @@ +from collections import deque from decimal import Decimal from shared.models import Candle, Signal, OrderSide @@ -16,6 +17,9 @@ class VwapStrategy(BaseStrategy): self._candle_count: int = 0 self._was_below_vwap: bool = False self._was_above_vwap: bool = False + self._current_date: str | None = None # Track date for daily reset + self._tp_values: deque[float] = deque(maxlen=500) # For std calculation + self._vwap_values: deque[float] = deque(maxlen=500) @property def warmup_period(self) -> int: @@ -41,11 +45,15 @@ class VwapStrategy(BaseStrategy): ) def reset(self) -> None: + super().reset() self._cumulative_tp_vol = 0.0 self._cumulative_vol = 0.0 self._candle_count = 0 self._was_below_vwap = False self._was_above_vwap = False + self._current_date = None + self._tp_values.clear() + self._vwap_values.clear() def _vwap_conviction(self, deviation: float) -> float: """Map VWAP deviation magnitude to conviction (0.1-1.0). @@ -58,6 +66,20 @@ class VwapStrategy(BaseStrategy): def on_candle(self, candle: Candle) -> Signal | None: self._update_filter_data(candle) + + # Daily reset + candle_date = candle.open_time.strftime("%Y-%m-%d") + if self._current_date is not None and candle_date != self._current_date: + # New day — reset VWAP + self._cumulative_tp_vol = 0.0 + self._cumulative_vol = 0.0 + self._candle_count = 0 + self._was_below_vwap = False + self._was_above_vwap = False + self._tp_values.clear() + self._vwap_values.clear() + self._current_date = candle_date + high = float(candle.high) low = float(candle.low) close = float(candle.close) @@ -77,6 +99,19 @@ class VwapStrategy(BaseStrategy): vwap = self._cumulative_tp_vol / self._cumulative_vol if vwap == 0.0: return None + + # Track values for deviation band calculation + self._tp_values.append(typical_price) + self._vwap_values.append(vwap) + + # Standard deviation of (TP - VWAP) for bands + std_dev = 0.0 + if len(self._tp_values) >= 2: + diffs = [tp - v for tp, v in zip(self._tp_values, self._vwap_values)] + mean_diff = sum(diffs) / len(diffs) + variance = sum((d - mean_diff) ** 2 for d in diffs) / len(diffs) + std_dev = variance ** 0.5 + deviation = (close - vwap) / vwap if deviation < -self._deviation_threshold: @@ -84,10 +119,20 @@ class VwapStrategy(BaseStrategy): if deviation > self._deviation_threshold: self._was_above_vwap = True + # Determine conviction based on deviation bands + def _band_conviction(price: float) -> float: + if std_dev > 0 and len(self._tp_values) >= 2: + dist_from_vwap = abs(price - vwap) + if dist_from_vwap >= 2 * std_dev: + return 0.9 + elif dist_from_vwap >= std_dev: + return 0.6 + return 0.5 + # Mean reversion from below: was below VWAP, now back near it if self._was_below_vwap and abs(deviation) <= self._deviation_threshold: self._was_below_vwap = False - conviction = self._vwap_conviction(deviation) + conviction = _band_conviction(close) signal = Signal( strategy=self.name, symbol=candle.symbol, @@ -102,7 +147,7 @@ class VwapStrategy(BaseStrategy): # Mean reversion from above: was above VWAP, now back near it if self._was_above_vwap and abs(deviation) <= self._deviation_threshold: self._was_above_vwap = False - conviction = self._vwap_conviction(deviation) + conviction = _band_conviction(close) signal = Signal( strategy=self.name, symbol=candle.symbol, 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 |
