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