From da6c9598f92057e2fcbb206aa7466b6997a455f3 Mon Sep 17 00:00:00 2001 From: TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:18:30 +0900 Subject: feat(strategy): add EMA pullback entry and VWAP daily reset with deviation bands --- .../strategies/ema_crossover_strategy.py | 82 +++++++++++++++++++--- 1 file changed, 72 insertions(+), 10 deletions(-) (limited to 'services/strategy-engine/strategies/ema_crossover_strategy.py') 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 -- cgit v1.2.3