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/bollinger_strategy.py65
-rw-r--r--services/strategy-engine/strategies/combined_strategy.py45
-rw-r--r--services/strategy-engine/strategies/config/grid_strategy.yaml6
-rw-r--r--services/strategy-engine/strategies/config/moc_strategy.yaml13
-rw-r--r--services/strategy-engine/strategies/ema_crossover_strategy.py82
-rw-r--r--services/strategy-engine/strategies/grid_strategy.py34
-rw-r--r--services/strategy-engine/strategies/indicators/__init__.py17
-rw-r--r--services/strategy-engine/strategies/indicators/momentum.py1
-rw-r--r--services/strategy-engine/strategies/indicators/trend.py3
-rw-r--r--services/strategy-engine/strategies/indicators/volatility.py4
-rw-r--r--services/strategy-engine/strategies/indicators/volume.py1
-rw-r--r--services/strategy-engine/strategies/macd_strategy.py50
-rw-r--r--services/strategy-engine/strategies/moc_strategy.py230
-rw-r--r--services/strategy-engine/strategies/rsi_strategy.py86
-rw-r--r--services/strategy-engine/strategies/volume_profile_strategy.py52
-rw-r--r--services/strategy-engine/strategies/vwap_strategy.py49
16 files changed, 694 insertions, 44 deletions
diff --git a/services/strategy-engine/strategies/bollinger_strategy.py b/services/strategy-engine/strategies/bollinger_strategy.py
index e53ecaa..ebe7967 100644
--- a/services/strategy-engine/strategies/bollinger_strategy.py
+++ b/services/strategy-engine/strategies/bollinger_strategy.py
@@ -19,6 +19,9 @@ class BollingerStrategy(BaseStrategy):
self._quantity: Decimal = Decimal("0.01")
self._was_below_lower: bool = False
self._was_above_upper: bool = False
+ self._squeeze_threshold: float = 0.01 # Bandwidth below this = squeeze
+ self._in_squeeze: bool = False
+ self._squeeze_bars: int = 0 # How many bars in squeeze
@property
def warmup_period(self) -> int:
@@ -28,6 +31,7 @@ class BollingerStrategy(BaseStrategy):
self._period = int(params.get("period", 20))
self._num_std = float(params.get("num_std", 2.0))
self._min_bandwidth = float(params.get("min_bandwidth", 0.02))
+ self._squeeze_threshold = float(params.get("squeeze_threshold", 0.01))
self._quantity = Decimal(str(params.get("quantity", "0.01")))
if self._period < 2:
@@ -46,9 +50,12 @@ class BollingerStrategy(BaseStrategy):
)
def reset(self) -> None:
+ super().reset()
self._closes.clear()
self._was_below_lower = False
self._was_above_upper = False
+ self._in_squeeze = False
+ self._squeeze_bars = 0
def _bollinger_conviction(self, price: float, band: float, sma: float) -> float:
"""Map distance from band to conviction (0.1-1.0).
@@ -75,12 +82,56 @@ class BollingerStrategy(BaseStrategy):
upper = sma + self._num_std * std
lower = sma - self._num_std * std
+ price = float(candle.close)
+
+ # %B calculation
+ bandwidth = (upper - lower) / sma if sma > 0 else 0
+ pct_b = (price - lower) / (upper - lower) if (upper - lower) > 0 else 0.5
+
+ # Squeeze detection
+ if bandwidth < self._squeeze_threshold:
+ self._in_squeeze = True
+ self._squeeze_bars += 1
+ return None # Don't trade during squeeze, wait for breakout
+ elif self._in_squeeze:
+ # Squeeze just ended — breakout!
+ self._in_squeeze = False
+ squeeze_duration = self._squeeze_bars
+ self._squeeze_bars = 0
+
+ if price > sma:
+ # Breakout upward
+ conv = min(0.5 + squeeze_duration * 0.1, 1.0)
+ return self._apply_filters(
+ Signal(
+ strategy=self.name,
+ symbol=candle.symbol,
+ side=OrderSide.BUY,
+ price=candle.close,
+ quantity=self._quantity,
+ conviction=conv,
+ reason=f"Bollinger squeeze breakout UP after {squeeze_duration} bars",
+ )
+ )
+ else:
+ # Breakout downward
+ conv = min(0.5 + squeeze_duration * 0.1, 1.0)
+ return self._apply_filters(
+ Signal(
+ strategy=self.name,
+ symbol=candle.symbol,
+ side=OrderSide.SELL,
+ price=candle.close,
+ quantity=self._quantity,
+ conviction=conv,
+ reason=f"Bollinger squeeze breakout DOWN after {squeeze_duration} bars",
+ )
+ )
+
# Bandwidth filter: skip sideways markets
- if sma != 0 and (upper - lower) / sma < self._min_bandwidth:
+ if sma != 0 and bandwidth < self._min_bandwidth:
return None
- price = float(candle.close)
-
# Track band penetration
if price < lower:
self._was_below_lower = True
@@ -90,14 +141,14 @@ 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
- conviction = self._bollinger_conviction(price, lower, sma)
+ conv = max(1.0 - pct_b, 0.3) # Closer to lower band = higher conviction
signal = Signal(
strategy=self.name,
symbol=candle.symbol,
side=OrderSide.BUY,
price=candle.close,
quantity=self._quantity,
- conviction=conviction,
+ conviction=conv,
reason=f"Price recovered above lower Bollinger Band ({lower:.2f})",
)
return self._apply_filters(signal)
@@ -105,14 +156,14 @@ class BollingerStrategy(BaseStrategy):
# SELL: was above upper band and recovered back inside
if self._was_above_upper and price <= upper:
self._was_above_upper = False
- conviction = self._bollinger_conviction(price, upper, sma)
+ conv = max(pct_b, 0.3) # Closer to upper band = higher conviction
signal = Signal(
strategy=self.name,
symbol=candle.symbol,
side=OrderSide.SELL,
price=candle.close,
quantity=self._quantity,
- conviction=conviction,
+ conviction=conv,
reason=f"Price recovered below upper Bollinger Band ({upper:.2f})",
)
return self._apply_filters(signal)
diff --git a/services/strategy-engine/strategies/combined_strategy.py b/services/strategy-engine/strategies/combined_strategy.py
index be1cbed..ba92485 100644
--- a/services/strategy-engine/strategies/combined_strategy.py
+++ b/services/strategy-engine/strategies/combined_strategy.py
@@ -20,6 +20,9 @@ class CombinedStrategy(BaseStrategy):
self._strategies: list[tuple[BaseStrategy, float]] = [] # (strategy, weight)
self._threshold: float = 0.5
self._quantity: Decimal = Decimal("0.01")
+ self._trade_history: dict[str, list[bool]] = {} # strategy_name -> [win, loss, ...]
+ self._adaptive_weights: bool = False
+ self._history_window: int = 20 # Last N signals to evaluate
@property
def warmup_period(self) -> int:
@@ -30,6 +33,8 @@ class CombinedStrategy(BaseStrategy):
def configure(self, params: dict) -> None:
self._threshold = float(params.get("threshold", 0.5))
self._quantity = Decimal(str(params.get("quantity", "0.01")))
+ self._adaptive_weights = bool(params.get("adaptive_weights", False))
+ self._history_window = int(params.get("history_window", 20))
if self._threshold <= 0:
raise ValueError(f"Threshold must be positive, got {self._threshold}")
if self._quantity <= 0:
@@ -41,6 +46,31 @@ class CombinedStrategy(BaseStrategy):
raise ValueError(f"Weight must be positive, got {weight}")
self._strategies.append((strategy, weight))
+ def record_result(self, strategy_name: str, is_win: bool) -> None:
+ """Record a trade result for adaptive weighting."""
+ if strategy_name not in self._trade_history:
+ self._trade_history[strategy_name] = []
+ self._trade_history[strategy_name].append(is_win)
+ # Keep only last N results
+ if len(self._trade_history[strategy_name]) > self._history_window:
+ self._trade_history[strategy_name] = self._trade_history[strategy_name][
+ -self._history_window :
+ ]
+
+ def _get_adaptive_weight(self, strategy_name: str, base_weight: float) -> float:
+ """Get weight adjusted by recent performance."""
+ if not self._adaptive_weights:
+ return base_weight
+
+ history = self._trade_history.get(strategy_name, [])
+ if len(history) < 5: # Not enough data, use base weight
+ return base_weight
+
+ win_rate = sum(1 for w in history if w) / len(history)
+ # Scale weight: 0.5x at 20% win rate, 1.0x at 50%, 1.5x at 80%
+ scale = 0.5 + win_rate # Range: 0.5 to 1.5
+ return base_weight * scale
+
def reset(self) -> None:
for strategy, _ in self._strategies:
strategy.reset()
@@ -49,7 +79,7 @@ class CombinedStrategy(BaseStrategy):
if not self._strategies:
return None
- total_weight = sum(w for _, w in self._strategies)
+ total_weight = sum(self._get_adaptive_weight(s.name, w) for s, w in self._strategies)
if total_weight == 0:
return None
@@ -59,12 +89,17 @@ class CombinedStrategy(BaseStrategy):
for strategy, weight in self._strategies:
signal = strategy.on_candle(candle)
if signal is not None:
+ effective_weight = self._get_adaptive_weight(strategy.name, weight)
if signal.side == OrderSide.BUY:
- score += weight * signal.conviction
- reasons.append(f"{strategy.name}:BUY({weight}*{signal.conviction:.2f})")
+ score += effective_weight * signal.conviction
+ reasons.append(
+ f"{strategy.name}:BUY({effective_weight}*{signal.conviction:.2f})"
+ )
elif signal.side == OrderSide.SELL:
- score -= weight * signal.conviction
- reasons.append(f"{strategy.name}:SELL({weight}*{signal.conviction:.2f})")
+ score -= effective_weight * signal.conviction
+ reasons.append(
+ f"{strategy.name}:SELL({effective_weight}*{signal.conviction:.2f})"
+ )
normalized = score / total_weight # Range: -1.0 to 1.0
diff --git a/services/strategy-engine/strategies/config/grid_strategy.yaml b/services/strategy-engine/strategies/config/grid_strategy.yaml
index 607f3df..338bb4c 100644
--- a/services/strategy-engine/strategies/config/grid_strategy.yaml
+++ b/services/strategy-engine/strategies/config/grid_strategy.yaml
@@ -1,4 +1,4 @@
-lower_price: 60000
-upper_price: 70000
+lower_price: 170
+upper_price: 190
grid_count: 5
-quantity: "0.01"
+quantity: "1"
diff --git a/services/strategy-engine/strategies/config/moc_strategy.yaml b/services/strategy-engine/strategies/config/moc_strategy.yaml
new file mode 100644
index 0000000..349ae1b
--- /dev/null
+++ b/services/strategy-engine/strategies/config/moc_strategy.yaml
@@ -0,0 +1,13 @@
+# Market on Close (MOC) Strategy — US Stocks
+quantity_pct: 0.2 # 20% of capital per position
+stop_loss_pct: 2.0 # -2% stop loss
+rsi_min: 30 # RSI lower bound
+rsi_max: 60 # RSI upper bound (not overbought)
+ema_period: 20 # EMA for trend confirmation
+volume_avg_period: 20 # Volume average lookback
+min_volume_ratio: 1.0 # Volume must be >= average
+buy_start_utc: 19 # Buy window start (15:00 ET summer)
+buy_end_utc: 21 # Buy window end (16:00 ET)
+sell_start_utc: 13 # Sell window start (9:00 ET)
+sell_end_utc: 15 # Sell window end (10:00 ET)
+max_positions: 5 # Max simultaneous positions
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/grid_strategy.py b/services/strategy-engine/strategies/grid_strategy.py
index 70443ec..283bfe5 100644
--- a/services/strategy-engine/strategies/grid_strategy.py
+++ b/services/strategy-engine/strategies/grid_strategy.py
@@ -18,6 +18,9 @@ class GridStrategy(BaseStrategy):
self._quantity: Decimal = Decimal("0.01")
self._grid_levels: list[float] = []
self._last_zone: Optional[int] = None
+ self._exit_threshold_pct: float = 5.0
+ self._out_of_range: bool = False
+ self._in_position: bool = False # Track if we have any grid positions
@property
def warmup_period(self) -> int:
@@ -29,11 +32,15 @@ class GridStrategy(BaseStrategy):
self._grid_count = int(params.get("grid_count", 5))
self._quantity = Decimal(str(params.get("quantity", "0.01")))
+ self._exit_threshold_pct = float(params.get("exit_threshold_pct", 5.0))
+
if self._lower_price >= self._upper_price:
raise ValueError(
f"Grid lower_price must be < upper_price, "
f"got lower={self._lower_price}, upper={self._upper_price}"
)
+ if self._exit_threshold_pct <= 0:
+ raise ValueError(f"exit_threshold_pct must be > 0, got {self._exit_threshold_pct}")
if self._grid_count < 2:
raise ValueError(f"Grid grid_count must be >= 2, got {self._grid_count}")
if self._quantity <= 0:
@@ -53,7 +60,9 @@ class GridStrategy(BaseStrategy):
)
def reset(self) -> None:
+ super().reset()
self._last_zone = None
+ self._out_of_range = False
def _get_zone(self, price: float) -> int:
"""Return the grid zone index for a given price.
@@ -69,6 +78,31 @@ class GridStrategy(BaseStrategy):
def on_candle(self, candle: Candle) -> Signal | None:
self._update_filter_data(candle)
price = float(candle.close)
+
+ # Check if price is out of grid range
+ if self._grid_levels:
+ lower_bound = self._grid_levels[0] * (1 - self._exit_threshold_pct / 100)
+ upper_bound = self._grid_levels[-1] * (1 + self._exit_threshold_pct / 100)
+
+ if price < lower_bound or price > upper_bound:
+ if not self._out_of_range:
+ self._out_of_range = True
+ # Exit signal — close positions
+ return self._apply_filters(
+ Signal(
+ strategy=self.name,
+ symbol=candle.symbol,
+ side=OrderSide.SELL,
+ price=candle.close,
+ quantity=self._quantity,
+ conviction=0.8,
+ reason=f"Grid: price {price:.2f} broke out of range [{self._grid_levels[0]:.2f}, {self._grid_levels[-1]:.2f}]",
+ )
+ )
+ return None # Already out of range, no more signals
+ else:
+ self._out_of_range = False
+
current_zone = self._get_zone(price)
if self._last_zone is None:
diff --git a/services/strategy-engine/strategies/indicators/__init__.py b/services/strategy-engine/strategies/indicators/__init__.py
index 1a54d59..3c713e6 100644
--- a/services/strategy-engine/strategies/indicators/__init__.py
+++ b/services/strategy-engine/strategies/indicators/__init__.py
@@ -1,12 +1,21 @@
"""Reusable technical indicator functions."""
+
from strategies.indicators.trend import ema, sma, macd, adx
from strategies.indicators.volatility import atr, bollinger_bands, keltner_channels
from strategies.indicators.momentum import rsi, stochastic
from strategies.indicators.volume import volume_sma, volume_ratio, obv
__all__ = [
- "ema", "sma", "macd", "adx",
- "atr", "bollinger_bands", "keltner_channels",
- "rsi", "stochastic",
- "volume_sma", "volume_ratio", "obv",
+ "ema",
+ "sma",
+ "macd",
+ "adx",
+ "atr",
+ "bollinger_bands",
+ "keltner_channels",
+ "rsi",
+ "stochastic",
+ "volume_sma",
+ "volume_ratio",
+ "obv",
]
diff --git a/services/strategy-engine/strategies/indicators/momentum.py b/services/strategy-engine/strategies/indicators/momentum.py
index 395c52d..c479452 100644
--- a/services/strategy-engine/strategies/indicators/momentum.py
+++ b/services/strategy-engine/strategies/indicators/momentum.py
@@ -1,4 +1,5 @@
"""Momentum indicators: RSI, Stochastic."""
+
import pandas as pd
import numpy as np
diff --git a/services/strategy-engine/strategies/indicators/trend.py b/services/strategy-engine/strategies/indicators/trend.py
index 10b69fa..c94a071 100644
--- a/services/strategy-engine/strategies/indicators/trend.py
+++ b/services/strategy-engine/strategies/indicators/trend.py
@@ -1,4 +1,5 @@
"""Trend indicators: EMA, SMA, MACD, ADX."""
+
import pandas as pd
import numpy as np
@@ -101,4 +102,4 @@ def adx(
for i in range(2 * period + 1, n):
adx_vals[i] = (adx_vals[i - 1] * (period - 1) + dx[i]) / period
- return pd.Series(adx_vals, index=closes.index if hasattr(closes, 'index') else None)
+ return pd.Series(adx_vals, index=closes.index if hasattr(closes, "index") else None)
diff --git a/services/strategy-engine/strategies/indicators/volatility.py b/services/strategy-engine/strategies/indicators/volatility.py
index d47eb86..c16143e 100644
--- a/services/strategy-engine/strategies/indicators/volatility.py
+++ b/services/strategy-engine/strategies/indicators/volatility.py
@@ -1,4 +1,5 @@
"""Volatility indicators: ATR, Bollinger Bands, Keltner Channels."""
+
import pandas as pd
import numpy as np
@@ -30,7 +31,7 @@ def atr(
for i in range(period, n):
atr_vals[i] = (atr_vals[i - 1] * (period - 1) + tr[i]) / period
- return pd.Series(atr_vals, index=closes.index if hasattr(closes, 'index') else None)
+ return pd.Series(atr_vals, index=closes.index if hasattr(closes, "index") else None)
def bollinger_bands(
@@ -62,6 +63,7 @@ def keltner_channels(
Returns: (upper_channel, middle_ema, lower_channel)
"""
from strategies.indicators.trend import ema as calc_ema
+
middle = calc_ema(closes, ema_period)
atr_vals = atr(highs, lows, closes, atr_period)
upper = middle + atr_multiplier * atr_vals
diff --git a/services/strategy-engine/strategies/indicators/volume.py b/services/strategy-engine/strategies/indicators/volume.py
index 323d427..502f1ce 100644
--- a/services/strategy-engine/strategies/indicators/volume.py
+++ b/services/strategy-engine/strategies/indicators/volume.py
@@ -1,4 +1,5 @@
"""Volume indicators: Volume SMA, Volume Ratio, OBV."""
+
import pandas as pd
import numpy as np
diff --git a/services/strategy-engine/strategies/macd_strategy.py b/services/strategy-engine/strategies/macd_strategy.py
index 67c5e44..356a42b 100644
--- a/services/strategy-engine/strategies/macd_strategy.py
+++ b/services/strategy-engine/strategies/macd_strategy.py
@@ -18,6 +18,8 @@ class MacdStrategy(BaseStrategy):
self._quantity: Decimal = Decimal("0.01")
self._closes: deque[float] = deque(maxlen=500)
self._prev_histogram: float | None = None
+ self._prev_macd: float | None = None
+ self._prev_signal: float | None = None
@property
def warmup_period(self) -> int:
@@ -54,6 +56,8 @@ class MacdStrategy(BaseStrategy):
def reset(self) -> None:
self._closes.clear()
self._prev_histogram = None
+ self._prev_macd = None
+ self._prev_signal = None
def _macd_conviction(self, histogram_value: float, price: float) -> float:
"""Map histogram magnitude to conviction (0.1-1.0).
@@ -81,13 +85,45 @@ class MacdStrategy(BaseStrategy):
histogram = macd_line - signal_line
current_histogram = float(histogram.iloc[-1])
- signal = None
+ macd_val = float(macd_line.iloc[-1])
+ signal_val = float(signal_line.iloc[-1])
+ result_signal = None
+
+ # Signal-line crossover detection (MACD crosses signal line directly)
+ if self._prev_macd is not None and self._prev_signal is not None:
+ # Bullish: MACD crosses above signal
+ if self._prev_macd <= self._prev_signal and macd_val > signal_val:
+ distance_from_zero = abs(macd_val) / float(candle.close) * 1000
+ conv = min(max(distance_from_zero, 0.3), 1.0)
+ result_signal = Signal(
+ strategy=self.name,
+ symbol=candle.symbol,
+ side=OrderSide.BUY,
+ price=candle.close,
+ quantity=self._quantity,
+ conviction=conv,
+ reason="MACD signal-line bullish crossover",
+ )
+ # Bearish: MACD crosses below signal
+ elif self._prev_macd >= self._prev_signal and macd_val < signal_val:
+ distance_from_zero = abs(macd_val) / float(candle.close) * 1000
+ conv = min(max(distance_from_zero, 0.3), 1.0)
+ result_signal = Signal(
+ strategy=self.name,
+ symbol=candle.symbol,
+ side=OrderSide.SELL,
+ price=candle.close,
+ quantity=self._quantity,
+ conviction=conv,
+ reason="MACD signal-line bearish crossover",
+ )
- if self._prev_histogram is not None:
+ # Histogram crossover detection (existing logic, as secondary signal)
+ if result_signal is None and 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(
+ result_signal = Signal(
strategy=self.name,
symbol=candle.symbol,
side=OrderSide.BUY,
@@ -98,7 +134,7 @@ class MacdStrategy(BaseStrategy):
)
# Bearish crossover: histogram crosses from positive to negative
elif self._prev_histogram >= 0 and current_histogram < 0:
- signal = Signal(
+ result_signal = Signal(
strategy=self.name,
symbol=candle.symbol,
side=OrderSide.SELL,
@@ -109,6 +145,8 @@ class MacdStrategy(BaseStrategy):
)
self._prev_histogram = current_histogram
- if signal is not None:
- return self._apply_filters(signal)
+ self._prev_macd = macd_val
+ self._prev_signal = signal_val
+ if result_signal is not None:
+ return self._apply_filters(result_signal)
return None
diff --git a/services/strategy-engine/strategies/moc_strategy.py b/services/strategy-engine/strategies/moc_strategy.py
new file mode 100644
index 0000000..7eaa59e
--- /dev/null
+++ b/services/strategy-engine/strategies/moc_strategy.py
@@ -0,0 +1,230 @@
+"""Market on Close (MOC) Strategy — US Stock 종가매매.
+
+Rules:
+- Buy: 15:50-16:00 ET (market close) when screening criteria met
+- Sell: 9:35-10:00 ET (market open next day)
+- Screening: bullish candle, volume above average, RSI 30-60, positive momentum
+- Risk: -2% stop loss, max 5 positions, 20% of capital per position
+"""
+
+from collections import deque
+from decimal import Decimal
+from datetime import datetime
+
+import pandas as pd
+
+from shared.models import Candle, Signal, OrderSide
+from strategies.base import BaseStrategy
+
+
+class MocStrategy(BaseStrategy):
+ """Market on Close strategy for overnight gap trading."""
+
+ name: str = "moc"
+
+ def __init__(self) -> None:
+ super().__init__()
+ # Parameters
+ self._quantity_pct: float = 0.2 # 20% of capital per trade
+ self._stop_loss_pct: float = 2.0
+ self._rsi_min: float = 30.0
+ self._rsi_max: float = 60.0
+ self._ema_period: int = 20
+ self._volume_avg_period: int = 20
+ self._min_volume_ratio: float = 1.0 # Volume must be above average
+ # Session times (UTC hours)
+ self._buy_start_utc: int = 19 # 15:00 ET = 19:00 UTC (summer) / 20:00 UTC (winter)
+ self._buy_end_utc: int = 21 # 16:00 ET = 20:00 UTC / 21:00 UTC
+ self._sell_start_utc: int = 13 # 9:00 ET = 13:00 UTC / 14:00 UTC
+ self._sell_end_utc: int = 15 # 10:00 ET = 14:00 UTC / 15:00 UTC
+ self._max_positions: int = 5
+ # State
+ self._closes: deque[float] = deque(maxlen=200)
+ self._volumes: deque[float] = deque(maxlen=200)
+ self._highs: deque[float] = deque(maxlen=200)
+ self._lows: deque[float] = deque(maxlen=200)
+ self._in_position: bool = False
+ self._entry_price: float = 0.0
+ self._today: str | None = None
+ self._bought_today: bool = False
+ self._sold_today: bool = False
+
+ @property
+ def warmup_period(self) -> int:
+ return max(self._ema_period, self._volume_avg_period) + 1
+
+ def configure(self, params: dict) -> None:
+ self._quantity_pct = float(params.get("quantity_pct", 0.2))
+ self._stop_loss_pct = float(params.get("stop_loss_pct", 2.0))
+ self._rsi_min = float(params.get("rsi_min", 30.0))
+ self._rsi_max = float(params.get("rsi_max", 60.0))
+ self._ema_period = int(params.get("ema_period", 20))
+ self._volume_avg_period = int(params.get("volume_avg_period", 20))
+ self._min_volume_ratio = float(params.get("min_volume_ratio", 1.0))
+ self._buy_start_utc = int(params.get("buy_start_utc", 19))
+ self._buy_end_utc = int(params.get("buy_end_utc", 21))
+ self._sell_start_utc = int(params.get("sell_start_utc", 13))
+ self._sell_end_utc = int(params.get("sell_end_utc", 15))
+ self._max_positions = int(params.get("max_positions", 5))
+
+ if self._quantity_pct <= 0 or self._quantity_pct > 1:
+ raise ValueError(f"quantity_pct must be 0-1, got {self._quantity_pct}")
+ if self._stop_loss_pct <= 0:
+ raise ValueError(f"stop_loss_pct must be positive, got {self._stop_loss_pct}")
+
+ def reset(self) -> None:
+ super().reset()
+ self._closes.clear()
+ self._volumes.clear()
+ self._highs.clear()
+ self._lows.clear()
+ self._in_position = False
+ self._entry_price = 0.0
+ self._today = None
+ self._bought_today = False
+ self._sold_today = False
+
+ def _is_buy_window(self, dt: datetime) -> bool:
+ """Check if in buy window (near market close)."""
+ hour = dt.hour
+ return self._buy_start_utc <= hour < self._buy_end_utc
+
+ def _is_sell_window(self, dt: datetime) -> bool:
+ """Check if in sell window (near market open)."""
+ hour = dt.hour
+ return self._sell_start_utc <= hour < self._sell_end_utc
+
+ def _compute_rsi(self, period: int = 14) -> float | None:
+ if len(self._closes) < period + 1:
+ return None
+ series = pd.Series(list(self._closes))
+ delta = series.diff()
+ gain = delta.clip(lower=0)
+ loss = -delta.clip(upper=0)
+ avg_gain = gain.ewm(com=period - 1, min_periods=period).mean()
+ avg_loss = loss.ewm(com=period - 1, min_periods=period).mean()
+ rs = avg_gain / avg_loss.replace(0, float("nan"))
+ rsi = 100 - (100 / (1 + rs))
+ val = rsi.iloc[-1]
+ return None if pd.isna(val) else float(val)
+
+ def _is_bullish_candle(self, candle: Candle) -> bool:
+ return float(candle.close) > float(candle.open)
+
+ def _price_above_ema(self) -> bool:
+ if len(self._closes) < self._ema_period:
+ return True
+ series = pd.Series(list(self._closes))
+ ema = series.ewm(span=self._ema_period, adjust=False).mean().iloc[-1]
+ return self._closes[-1] >= ema
+
+ def _volume_above_average(self) -> bool:
+ if len(self._volumes) < self._volume_avg_period:
+ return True
+ avg = sum(list(self._volumes)[-self._volume_avg_period :]) / self._volume_avg_period
+ return avg > 0 and self._volumes[-1] / avg >= self._min_volume_ratio
+
+ def _positive_momentum(self) -> bool:
+ """Check if price has positive short-term momentum (close > close 5 bars ago)."""
+ if len(self._closes) < 6:
+ return True
+ return self._closes[-1] > self._closes[-6]
+
+ def on_candle(self, candle: Candle) -> Signal | None:
+ self._update_filter_data(candle)
+
+ close = float(candle.close)
+ self._closes.append(close)
+ self._volumes.append(float(candle.volume))
+ self._highs.append(float(candle.high))
+ self._lows.append(float(candle.low))
+
+ # Daily reset
+ day = candle.open_time.strftime("%Y-%m-%d")
+ if self._today != day:
+ self._today = day
+ self._bought_today = False
+ self._sold_today = False
+
+ # --- SELL LOGIC (market open next day) ---
+ if self._in_position and self._is_sell_window(candle.open_time):
+ if not self._sold_today:
+ pnl_pct = (close - self._entry_price) / self._entry_price * 100
+ self._in_position = False
+ self._sold_today = True
+
+ conv = 0.8 if pnl_pct > 0 else 0.5
+ return self._apply_filters(
+ Signal(
+ strategy=self.name,
+ symbol=candle.symbol,
+ side=OrderSide.SELL,
+ price=candle.close,
+ quantity=Decimal(str(self._quantity_pct)),
+ conviction=conv,
+ reason=f"MOC sell at open, PnL {pnl_pct:.2f}%",
+ )
+ )
+
+ # --- STOP LOSS ---
+ if self._in_position:
+ pnl_pct = (close - self._entry_price) / self._entry_price * 100
+ if pnl_pct <= -self._stop_loss_pct:
+ self._in_position = False
+ return self._apply_filters(
+ Signal(
+ strategy=self.name,
+ symbol=candle.symbol,
+ side=OrderSide.SELL,
+ price=candle.close,
+ quantity=Decimal(str(self._quantity_pct)),
+ conviction=1.0,
+ stop_loss=candle.close,
+ reason=f"MOC stop loss {pnl_pct:.2f}% <= -{self._stop_loss_pct}%",
+ )
+ )
+
+ # --- BUY LOGIC (near market close) ---
+ if not self._in_position and self._is_buy_window(candle.open_time):
+ if self._bought_today:
+ return None
+
+ # Screening criteria
+ rsi = self._compute_rsi()
+ if rsi is None:
+ return None
+
+ checks = [
+ self._rsi_min <= rsi <= self._rsi_max, # RSI in sweet spot
+ self._is_bullish_candle(candle), # Bullish candle
+ self._price_above_ema(), # Above EMA (uptrend)
+ self._volume_above_average(), # Volume confirmation
+ self._positive_momentum(), # Short-term momentum
+ ]
+
+ if all(checks):
+ self._in_position = True
+ self._entry_price = close
+ self._bought_today = True
+
+ # Conviction based on RSI position within range
+ rsi_range = self._rsi_max - self._rsi_min
+ rsi_pos = (rsi - self._rsi_min) / rsi_range if rsi_range > 0 else 0.5
+ conv = 0.5 + (1.0 - rsi_pos) * 0.4 # Lower RSI = higher conviction
+
+ sl = candle.close * (1 - Decimal(str(self._stop_loss_pct / 100)))
+
+ return self._apply_filters(
+ Signal(
+ strategy=self.name,
+ symbol=candle.symbol,
+ side=OrderSide.BUY,
+ price=candle.close,
+ quantity=Decimal(str(self._quantity_pct)),
+ conviction=conv,
+ stop_loss=sl,
+ reason=f"MOC buy: RSI={rsi:.1f}, bullish candle, above EMA, vol OK",
+ )
+ )
+
+ return None
diff --git a/services/strategy-engine/strategies/rsi_strategy.py b/services/strategy-engine/strategies/rsi_strategy.py
index 0ec6780..0646d8c 100644
--- a/services/strategy-engine/strategies/rsi_strategy.py
+++ b/services/strategy-engine/strategies/rsi_strategy.py
@@ -34,6 +34,14 @@ class RsiStrategy(BaseStrategy):
self._oversold: float = 30.0
self._overbought: float = 70.0
self._quantity: Decimal = Decimal("0.01")
+ # Divergence detection state
+ self._price_lows: deque[float] = deque(maxlen=5)
+ self._price_highs: deque[float] = deque(maxlen=5)
+ self._rsi_at_lows: deque[float] = deque(maxlen=5)
+ self._rsi_at_highs: deque[float] = deque(maxlen=5)
+ self._prev_close: float | None = None
+ self._prev_prev_close: float | None = None
+ self._prev_rsi: float | None = None
@property
def warmup_period(self) -> int:
@@ -65,6 +73,13 @@ class RsiStrategy(BaseStrategy):
def reset(self) -> None:
self._closes.clear()
+ self._price_lows.clear()
+ self._price_highs.clear()
+ self._rsi_at_lows.clear()
+ self._rsi_at_highs.clear()
+ self._prev_close = None
+ self._prev_prev_close = None
+ self._prev_rsi = None
def _rsi_conviction(self, rsi_value: float) -> float:
"""Map RSI value to conviction strength (0.0-1.0).
@@ -86,14 +101,76 @@ class RsiStrategy(BaseStrategy):
self._closes.append(float(candle.close))
if len(self._closes) < self._period + 1:
+ self._prev_prev_close = self._prev_close
+ self._prev_close = float(candle.close)
return None
series = pd.Series(list(self._closes))
rsi_value = _compute_rsi(series, self._period)
if rsi_value is None:
+ self._prev_prev_close = self._prev_close
+ self._prev_close = float(candle.close)
return None
+ close = float(candle.close)
+
+ # Detect swing points for divergence
+ if self._prev_close is not None and self._prev_prev_close is not None:
+ # Swing low: prev_close < both neighbors
+ if self._prev_close < self._prev_prev_close and self._prev_close < close:
+ self._price_lows.append(self._prev_close)
+ self._rsi_at_lows.append(
+ self._prev_rsi if self._prev_rsi is not None else rsi_value
+ )
+ # Swing high: prev_close > both neighbors
+ if self._prev_close > self._prev_prev_close and self._prev_close > close:
+ self._price_highs.append(self._prev_close)
+ self._rsi_at_highs.append(
+ self._prev_rsi if self._prev_rsi is not None else rsi_value
+ )
+
+ # Check bullish divergence: price lower low, RSI higher low
+ if len(self._price_lows) >= 2:
+ if (
+ self._price_lows[-1] < self._price_lows[-2]
+ and self._rsi_at_lows[-1] > self._rsi_at_lows[-2]
+ ):
+ signal = Signal(
+ strategy=self.name,
+ symbol=candle.symbol,
+ side=OrderSide.BUY,
+ price=candle.close,
+ quantity=self._quantity,
+ conviction=0.9,
+ reason="RSI bullish divergence",
+ )
+ self._prev_rsi = rsi_value
+ self._prev_prev_close = self._prev_close
+ self._prev_close = close
+ return self._apply_filters(signal)
+
+ # Check bearish divergence: price higher high, RSI lower high
+ if len(self._price_highs) >= 2:
+ if (
+ self._price_highs[-1] > self._price_highs[-2]
+ and self._rsi_at_highs[-1] < self._rsi_at_highs[-2]
+ ):
+ signal = Signal(
+ strategy=self.name,
+ symbol=candle.symbol,
+ side=OrderSide.SELL,
+ price=candle.close,
+ quantity=self._quantity,
+ conviction=0.9,
+ reason="RSI bearish divergence",
+ )
+ self._prev_rsi = rsi_value
+ self._prev_prev_close = self._prev_close
+ self._prev_close = close
+ return self._apply_filters(signal)
+
+ # Existing oversold/overbought logic (secondary signals)
if rsi_value < self._oversold:
signal = Signal(
strategy=self.name,
@@ -104,6 +181,9 @@ class RsiStrategy(BaseStrategy):
conviction=self._rsi_conviction(rsi_value),
reason=f"RSI {rsi_value:.2f} below oversold threshold {self._oversold}",
)
+ self._prev_rsi = rsi_value
+ self._prev_prev_close = self._prev_close
+ self._prev_close = close
return self._apply_filters(signal)
elif rsi_value > self._overbought:
signal = Signal(
@@ -115,6 +195,12 @@ class RsiStrategy(BaseStrategy):
conviction=self._rsi_conviction(rsi_value),
reason=f"RSI {rsi_value:.2f} above overbought threshold {self._overbought}",
)
+ self._prev_rsi = rsi_value
+ self._prev_prev_close = self._prev_close
+ self._prev_close = close
return self._apply_filters(signal)
+ self._prev_rsi = rsi_value
+ self._prev_prev_close = self._prev_close
+ self._prev_close = close
return None
diff --git a/services/strategy-engine/strategies/volume_profile_strategy.py b/services/strategy-engine/strategies/volume_profile_strategy.py
index 324f1c2..ef2ae14 100644
--- a/services/strategy-engine/strategies/volume_profile_strategy.py
+++ b/services/strategy-engine/strategies/volume_profile_strategy.py
@@ -56,7 +56,8 @@ class VolumeProfileStrategy(BaseStrategy):
self._was_below_va = False
self._was_above_va = False
- def _compute_value_area(self) -> tuple[float, float, float] | None:
+ def _compute_value_area(self) -> tuple[float, float, float, list[float], list[float]] | None:
+ """Compute POC, VA low, VA high, HVN levels, LVN levels."""
data = list(self._candles)
if len(data) < self._lookback_period:
return None
@@ -67,7 +68,7 @@ class VolumeProfileStrategy(BaseStrategy):
min_price = prices.min()
max_price = prices.max()
if min_price == max_price:
- return (float(min_price), float(min_price), float(max_price))
+ return (float(min_price), float(min_price), float(max_price), [], [])
bin_edges = np.linspace(min_price, max_price, self._num_bins + 1)
vol_profile = np.zeros(self._num_bins)
@@ -84,7 +85,7 @@ class VolumeProfileStrategy(BaseStrategy):
# Value Area: expand from POC outward
total_volume = vol_profile.sum()
if total_volume == 0:
- return (poc, float(bin_edges[0]), float(bin_edges[-1]))
+ return (poc, float(bin_edges[0]), float(bin_edges[-1]), [], [])
target_volume = self._value_area_pct * total_volume
accumulated = vol_profile[poc_idx]
@@ -111,7 +112,20 @@ class VolumeProfileStrategy(BaseStrategy):
va_low = float(bin_edges[low_idx])
va_high = float(bin_edges[high_idx + 1])
- return (poc, va_low, va_high)
+ # HVN/LVN detection
+ mean_vol = vol_profile.mean()
+ std_vol = vol_profile.std()
+
+ hvn_levels: list[float] = []
+ lvn_levels: list[float] = []
+ for i in range(len(vol_profile)):
+ mid = float((bin_edges[i] + bin_edges[i + 1]) / 2)
+ if vol_profile[i] > mean_vol + std_vol:
+ hvn_levels.append(mid)
+ elif vol_profile[i] < mean_vol - 0.5 * std_vol and vol_profile[i] > 0:
+ lvn_levels.append(mid)
+
+ return (poc, va_low, va_high, hvn_levels, lvn_levels)
def on_candle(self, candle: Candle) -> Signal | None:
self._update_filter_data(candle)
@@ -123,13 +137,41 @@ class VolumeProfileStrategy(BaseStrategy):
if result is None:
return None
- poc, va_low, va_high = result
+ poc, va_low, va_high, hvn_levels, lvn_levels = result
if close < va_low:
self._was_below_va = True
if close > va_high:
self._was_above_va = True
+ # HVN bounce signals (stronger than regular VA bounces)
+ for hvn in hvn_levels:
+ if abs(close - hvn) / hvn < 0.005: # Within 0.5% of HVN
+ if self._was_below_va and close >= va_low:
+ self._was_below_va = False
+ signal = Signal(
+ strategy=self.name,
+ symbol=candle.symbol,
+ side=OrderSide.BUY,
+ price=candle.close,
+ quantity=self._quantity,
+ conviction=0.85,
+ reason=f"Price near HVN {hvn:.2f}, bounced from below VA low {va_low:.2f} to {close:.2f}",
+ )
+ return self._apply_filters(signal)
+ if self._was_above_va and close <= va_high:
+ self._was_above_va = False
+ signal = Signal(
+ strategy=self.name,
+ symbol=candle.symbol,
+ side=OrderSide.SELL,
+ price=candle.close,
+ quantity=self._quantity,
+ conviction=0.85,
+ reason=f"Price near HVN {hvn:.2f}, rejected from above VA high {va_high:.2f} to {close:.2f}",
+ )
+ return self._apply_filters(signal)
+
# 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
diff --git a/services/strategy-engine/strategies/vwap_strategy.py b/services/strategy-engine/strategies/vwap_strategy.py
index c525ff3..d64950e 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,