summaryrefslogtreecommitdiff
path: root/services/strategy-engine/strategies/ema_crossover_strategy.py
diff options
context:
space:
mode:
Diffstat (limited to 'services/strategy-engine/strategies/ema_crossover_strategy.py')
-rw-r--r--services/strategy-engine/strategies/ema_crossover_strategy.py82
1 files changed, 72 insertions, 10 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