summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2026-04-01 18:44:20 +0900
committerTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2026-04-01 18:44:20 +0900
commitcb55c81dbc43df83ef4d5b717fe22b4d04a93d2e (patch)
tree26ef6f6a89233fa8cf74ea6467b07f1158d75ff1
parent0b0aace94fa633cd7a90c95ee89658167a8afd35 (diff)
feat(strategy): apply filters, conviction scoring, and ATR stops to all strategies
-rw-r--r--services/strategy-engine/strategies/bollinger_strategy.py30
-rw-r--r--services/strategy-engine/strategies/combined_strategy.py8
-rw-r--r--services/strategy-engine/strategies/ema_crossover_strategy.py24
-rw-r--r--services/strategy-engine/strategies/grid_strategy.py17
-rw-r--r--services/strategy-engine/strategies/macd_strategy.py26
-rw-r--r--services/strategy-engine/strategies/rsi_strategy.py32
-rw-r--r--services/strategy-engine/strategies/volume_profile_strategy.py17
-rw-r--r--services/strategy-engine/strategies/vwap_strategy.py28
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