diff options
| author | TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> | 2026-04-01 18:44:20 +0900 |
|---|---|---|
| committer | TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> | 2026-04-01 18:44:20 +0900 |
| commit | cb55c81dbc43df83ef4d5b717fe22b4d04a93d2e (patch) | |
| tree | 26ef6f6a89233fa8cf74ea6467b07f1158d75ff1 | |
| parent | 0b0aace94fa633cd7a90c95ee89658167a8afd35 (diff) | |
feat(strategy): apply filters, conviction scoring, and ATR stops to all strategies
8 files changed, 166 insertions, 16 deletions
diff --git a/services/strategy-engine/strategies/bollinger_strategy.py b/services/strategy-engine/strategies/bollinger_strategy.py index 1354182..e53ecaa 100644 --- a/services/strategy-engine/strategies/bollinger_strategy.py +++ b/services/strategy-engine/strategies/bollinger_strategy.py @@ -37,12 +37,32 @@ class BollingerStrategy(BaseStrategy): if self._quantity <= 0: raise ValueError(f"Quantity must be positive, got {self._quantity}") + self._init_filters( + require_trend=False, + adx_threshold=float(params.get("adx_threshold", 25.0)), + min_volume_ratio=float(params.get("min_volume_ratio", 0.5)), + atr_stop_multiplier=float(params.get("atr_stop_multiplier", 2.0)), + atr_tp_multiplier=float(params.get("atr_tp_multiplier", 3.0)), + ) + def reset(self) -> None: self._closes.clear() self._was_below_lower = False self._was_above_upper = False + def _bollinger_conviction(self, price: float, band: float, sma: float) -> float: + """Map distance from band to conviction (0.1-1.0). + + Further from band (relative to band width) = stronger signal. + """ + if sma == 0: + return 0.5 + distance = abs(price - band) / sma + # Scale: 0% distance -> 0.1, 2%+ distance -> ~1.0 + return min(1.0, max(0.1, distance * 50)) + def on_candle(self, candle: Candle) -> Signal | None: + self._update_filter_data(candle) self._closes.append(float(candle.close)) if len(self._closes) < self._period: @@ -70,25 +90,31 @@ class BollingerStrategy(BaseStrategy): # BUY: was below lower band and recovered back inside if self._was_below_lower and price >= lower: self._was_below_lower = False - return Signal( + conviction = self._bollinger_conviction(price, lower, sma) + signal = Signal( strategy=self.name, symbol=candle.symbol, side=OrderSide.BUY, price=candle.close, quantity=self._quantity, + conviction=conviction, reason=f"Price recovered above lower Bollinger Band ({lower:.2f})", ) + return self._apply_filters(signal) # SELL: was above upper band and recovered back inside if self._was_above_upper and price <= upper: self._was_above_upper = False - return Signal( + conviction = self._bollinger_conviction(price, upper, sma) + signal = Signal( strategy=self.name, symbol=candle.symbol, side=OrderSide.SELL, price=candle.close, quantity=self._quantity, + conviction=conviction, reason=f"Price recovered below upper Bollinger Band ({upper:.2f})", ) + return self._apply_filters(signal) return None diff --git a/services/strategy-engine/strategies/combined_strategy.py b/services/strategy-engine/strategies/combined_strategy.py index c70538d..be1cbed 100644 --- a/services/strategy-engine/strategies/combined_strategy.py +++ b/services/strategy-engine/strategies/combined_strategy.py @@ -60,11 +60,11 @@ class CombinedStrategy(BaseStrategy): signal = strategy.on_candle(candle) if signal is not None: if signal.side == OrderSide.BUY: - score += weight - reasons.append(f"{strategy.name}:BUY({weight})") + score += weight * signal.conviction + reasons.append(f"{strategy.name}:BUY({weight}*{signal.conviction:.2f})") elif signal.side == OrderSide.SELL: - score -= weight - reasons.append(f"{strategy.name}:SELL({weight})") + score -= weight * signal.conviction + reasons.append(f"{strategy.name}:SELL({weight}*{signal.conviction:.2f})") normalized = score / total_weight # Range: -1.0 to 1.0 diff --git a/services/strategy-engine/strategies/ema_crossover_strategy.py b/services/strategy-engine/strategies/ema_crossover_strategy.py index bc36f36..a812eff 100644 --- a/services/strategy-engine/strategies/ema_crossover_strategy.py +++ b/services/strategy-engine/strategies/ema_crossover_strategy.py @@ -39,11 +39,28 @@ class EmaCrossoverStrategy(BaseStrategy): if self._quantity <= 0: raise ValueError(f"Quantity must be positive, got {self._quantity}") + self._init_filters( + require_trend=True, + adx_threshold=float(params.get("adx_threshold", 25.0)), + min_volume_ratio=float(params.get("min_volume_ratio", 0.5)), + atr_stop_multiplier=float(params.get("atr_stop_multiplier", 2.0)), + atr_tp_multiplier=float(params.get("atr_tp_multiplier", 3.0)), + ) + def reset(self) -> None: self._closes.clear() self._prev_short_above = 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.""" + if price == 0: + return 0.5 + gap_pct = abs(short_ema - long_ema) / price + # Scale: 0% gap -> 0.1, 1%+ gap -> ~1.0 + return min(1.0, max(0.1, gap_pct * 100)) + def on_candle(self, candle: Candle) -> Signal | None: + self._update_filter_data(candle) self._closes.append(float(candle.close)) if len(self._closes) < self._long_period: @@ -57,6 +74,7 @@ class EmaCrossoverStrategy(BaseStrategy): 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: signal = Signal( strategy=self.name, @@ -64,6 +82,7 @@ class EmaCrossoverStrategy(BaseStrategy): 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})", ) elif self._prev_short_above and not short_above: @@ -73,8 +92,11 @@ class EmaCrossoverStrategy(BaseStrategy): 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 - return signal + if signal is not None: + return self._apply_filters(signal) + return None diff --git a/services/strategy-engine/strategies/grid_strategy.py b/services/strategy-engine/strategies/grid_strategy.py index 1244eda..70443ec 100644 --- a/services/strategy-engine/strategies/grid_strategy.py +++ b/services/strategy-engine/strategies/grid_strategy.py @@ -44,6 +44,14 @@ class GridStrategy(BaseStrategy): ) self._last_zone = None + self._init_filters( + require_trend=False, + adx_threshold=float(params.get("adx_threshold", 20.0)), + min_volume_ratio=float(params.get("min_volume_ratio", 0.5)), + atr_stop_multiplier=float(params.get("atr_stop_multiplier", 2.0)), + atr_tp_multiplier=float(params.get("atr_tp_multiplier", 3.0)), + ) + def reset(self) -> None: self._last_zone = None @@ -59,6 +67,7 @@ class GridStrategy(BaseStrategy): return len(self._grid_levels) def on_candle(self, candle: Candle) -> Signal | None: + self._update_filter_data(candle) price = float(candle.close) current_zone = self._get_zone(price) @@ -71,23 +80,27 @@ class GridStrategy(BaseStrategy): if current_zone < prev_zone: # Price moved to a lower zone → BUY - return Signal( + signal = Signal( strategy=self.name, symbol=candle.symbol, side=OrderSide.BUY, price=candle.close, quantity=self._quantity, + conviction=0.5, reason=f"Grid: price crossed down from zone {prev_zone} to {current_zone}", ) + return self._apply_filters(signal) elif current_zone > prev_zone: # Price moved to a higher zone → SELL - return Signal( + signal = Signal( strategy=self.name, symbol=candle.symbol, side=OrderSide.SELL, price=candle.close, quantity=self._quantity, + conviction=0.5, reason=f"Grid: price crossed up from zone {prev_zone} to {current_zone}", ) + return self._apply_filters(signal) return None diff --git a/services/strategy-engine/strategies/macd_strategy.py b/services/strategy-engine/strategies/macd_strategy.py index bf30ed3..67c5e44 100644 --- a/services/strategy-engine/strategies/macd_strategy.py +++ b/services/strategy-engine/strategies/macd_strategy.py @@ -43,11 +43,30 @@ class MacdStrategy(BaseStrategy): if self._quantity <= 0: raise ValueError(f"Quantity must be positive, got {self._quantity}") + self._init_filters( + require_trend=True, + adx_threshold=float(params.get("adx_threshold", 25.0)), + min_volume_ratio=float(params.get("min_volume_ratio", 0.5)), + atr_stop_multiplier=float(params.get("atr_stop_multiplier", 2.0)), + atr_tp_multiplier=float(params.get("atr_tp_multiplier", 3.0)), + ) + def reset(self) -> None: self._closes.clear() self._prev_histogram = None + def _macd_conviction(self, histogram_value: float, price: float) -> float: + """Map histogram magnitude to conviction (0.1-1.0). + + Normalize by price to make it scale-independent. + """ + if price == 0: + return 0.5 + normalized = abs(histogram_value) / price * 1000 # scale to reasonable range + return min(1.0, max(0.1, normalized)) + def on_candle(self, candle: Candle) -> Signal | None: + self._update_filter_data(candle) self._closes.append(float(candle.close)) if len(self._closes) < self.warmup_period: @@ -65,6 +84,7 @@ class MacdStrategy(BaseStrategy): signal = None if self._prev_histogram is not None: + conviction = self._macd_conviction(current_histogram, float(candle.close)) # Bullish crossover: histogram crosses from negative to positive if self._prev_histogram <= 0 and current_histogram > 0: signal = Signal( @@ -73,6 +93,7 @@ class MacdStrategy(BaseStrategy): side=OrderSide.BUY, price=candle.close, quantity=self._quantity, + conviction=conviction, reason=f"MACD bullish crossover: histogram {self._prev_histogram:.6f} -> {current_histogram:.6f}", ) # Bearish crossover: histogram crosses from positive to negative @@ -83,8 +104,11 @@ class MacdStrategy(BaseStrategy): side=OrderSide.SELL, price=candle.close, quantity=self._quantity, + conviction=conviction, reason=f"MACD bearish crossover: histogram {self._prev_histogram:.6f} -> {current_histogram:.6f}", ) self._prev_histogram = current_histogram - return signal + if signal is not None: + return self._apply_filters(signal) + return None diff --git a/services/strategy-engine/strategies/rsi_strategy.py b/services/strategy-engine/strategies/rsi_strategy.py index 490a8a9..0ec6780 100644 --- a/services/strategy-engine/strategies/rsi_strategy.py +++ b/services/strategy-engine/strategies/rsi_strategy.py @@ -55,10 +55,34 @@ class RsiStrategy(BaseStrategy): if self._quantity <= 0: raise ValueError(f"Quantity must be positive, got {self._quantity}") + self._init_filters( + require_trend=False, + adx_threshold=float(params.get("adx_threshold", 25.0)), + min_volume_ratio=float(params.get("min_volume_ratio", 0.5)), + atr_stop_multiplier=float(params.get("atr_stop_multiplier", 2.0)), + atr_tp_multiplier=float(params.get("atr_tp_multiplier", 3.0)), + ) + def reset(self) -> None: self._closes.clear() + def _rsi_conviction(self, rsi_value: float) -> float: + """Map RSI value to conviction strength (0.0-1.0). + + For BUY (oversold): lower RSI = higher conviction. + For SELL (overbought): higher RSI = higher conviction. + Linear scale from the threshold to the extreme (0 or 100). + """ + if rsi_value < self._oversold: + # RSI 0 -> 1.0, RSI at oversold threshold -> 0.0 + return min(1.0, max(0.1, (self._oversold - rsi_value) / self._oversold)) + elif rsi_value > self._overbought: + # RSI 100 -> 1.0, RSI at overbought threshold -> 0.0 + return min(1.0, max(0.1, (rsi_value - self._overbought) / (100.0 - self._overbought))) + return 0.0 + def on_candle(self, candle: Candle) -> Signal | None: + self._update_filter_data(candle) self._closes.append(float(candle.close)) if len(self._closes) < self._period + 1: @@ -71,22 +95,26 @@ class RsiStrategy(BaseStrategy): return None if rsi_value < self._oversold: - return Signal( + signal = Signal( strategy=self.name, symbol=candle.symbol, side=OrderSide.BUY, price=candle.close, quantity=self._quantity, + conviction=self._rsi_conviction(rsi_value), reason=f"RSI {rsi_value:.2f} below oversold threshold {self._oversold}", ) + return self._apply_filters(signal) elif rsi_value > self._overbought: - return Signal( + signal = Signal( strategy=self.name, symbol=candle.symbol, side=OrderSide.SELL, price=candle.close, quantity=self._quantity, + conviction=self._rsi_conviction(rsi_value), reason=f"RSI {rsi_value:.2f} above overbought threshold {self._overbought}", ) + return self._apply_filters(signal) return None diff --git a/services/strategy-engine/strategies/volume_profile_strategy.py b/services/strategy-engine/strategies/volume_profile_strategy.py index 2cfa87a..324f1c2 100644 --- a/services/strategy-engine/strategies/volume_profile_strategy.py +++ b/services/strategy-engine/strategies/volume_profile_strategy.py @@ -43,6 +43,14 @@ class VolumeProfileStrategy(BaseStrategy): if self._quantity <= 0: raise ValueError(f"Quantity must be positive, got {self._quantity}") + self._init_filters( + require_trend=False, + adx_threshold=float(params.get("adx_threshold", 25.0)), + min_volume_ratio=float(params.get("min_volume_ratio", 0.5)), + atr_stop_multiplier=float(params.get("atr_stop_multiplier", 2.0)), + atr_tp_multiplier=float(params.get("atr_tp_multiplier", 3.0)), + ) + def reset(self) -> None: self._candles.clear() self._was_below_va = False @@ -106,6 +114,7 @@ class VolumeProfileStrategy(BaseStrategy): return (poc, va_low, va_high) def on_candle(self, candle: Candle) -> Signal | None: + self._update_filter_data(candle) close = float(candle.close) volume = float(candle.volume) self._candles.append((close, volume)) @@ -124,25 +133,29 @@ class VolumeProfileStrategy(BaseStrategy): # BUY: was below VA, price bounces back between va_low and poc if self._was_below_va and va_low <= close <= poc: self._was_below_va = False - return Signal( + signal = Signal( strategy=self.name, symbol=candle.symbol, side=OrderSide.BUY, price=candle.close, quantity=self._quantity, + conviction=0.6, reason=f"Price bounced from below VA low {va_low:.2f} to {close:.2f} (POC {poc:.2f})", ) + return self._apply_filters(signal) # SELL: was above VA, price pulls back between poc and va_high if self._was_above_va and poc <= close <= va_high: self._was_above_va = False - return Signal( + signal = Signal( strategy=self.name, symbol=candle.symbol, side=OrderSide.SELL, price=candle.close, quantity=self._quantity, + conviction=0.6, reason=f"Price rejected from above VA high {va_high:.2f} to {close:.2f} (POC {poc:.2f})", ) + 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 d220832..c525ff3 100644 --- a/services/strategy-engine/strategies/vwap_strategy.py +++ b/services/strategy-engine/strategies/vwap_strategy.py @@ -32,6 +32,14 @@ class VwapStrategy(BaseStrategy): if self._quantity <= 0: raise ValueError(f"Quantity must be positive, got {self._quantity}") + self._init_filters( + require_trend=False, + adx_threshold=float(params.get("adx_threshold", 25.0)), + min_volume_ratio=float(params.get("min_volume_ratio", 0.5)), + atr_stop_multiplier=float(params.get("atr_stop_multiplier", 2.0)), + atr_tp_multiplier=float(params.get("atr_tp_multiplier", 3.0)), + ) + def reset(self) -> None: self._cumulative_tp_vol = 0.0 self._cumulative_vol = 0.0 @@ -39,7 +47,17 @@ class VwapStrategy(BaseStrategy): self._was_below_vwap = False self._was_above_vwap = False + def _vwap_conviction(self, deviation: float) -> float: + """Map VWAP deviation magnitude to conviction (0.1-1.0). + + Further from VWAP = stronger mean reversion signal. + """ + magnitude = abs(deviation) + # Scale: at threshold -> 0.3, at 5x threshold -> ~1.0 + return min(1.0, max(0.1, magnitude / self._deviation_threshold * 0.3)) + def on_candle(self, candle: Candle) -> Signal | None: + self._update_filter_data(candle) high = float(candle.high) low = float(candle.low) close = float(candle.close) @@ -69,25 +87,31 @@ class VwapStrategy(BaseStrategy): # 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 - return Signal( + conviction = self._vwap_conviction(deviation) + signal = Signal( strategy=self.name, symbol=candle.symbol, side=OrderSide.BUY, price=candle.close, quantity=self._quantity, + conviction=conviction, reason=f"VWAP mean reversion BUY: deviation {deviation:.4f} within threshold {self._deviation_threshold}", ) + return self._apply_filters(signal) # 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 - return Signal( + conviction = self._vwap_conviction(deviation) + signal = Signal( strategy=self.name, symbol=candle.symbol, side=OrderSide.SELL, price=candle.close, quantity=self._quantity, + conviction=conviction, reason=f"VWAP mean reversion SELL: deviation {deviation:.4f} within threshold {self._deviation_threshold}", ) + return self._apply_filters(signal) return None |
