from collections import deque from decimal import Decimal from shared.models import Candle, Signal, OrderSide from strategies.base import BaseStrategy class VwapStrategy(BaseStrategy): name: str = "vwap" def __init__(self) -> None: super().__init__() self._deviation_threshold: float = 0.002 self._quantity: Decimal = Decimal("0.01") self._cumulative_tp_vol: float = 0.0 self._cumulative_vol: float = 0.0 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: return 30 def configure(self, params: dict) -> None: self._deviation_threshold = float(params.get("deviation_threshold", 0.002)) self._quantity = Decimal(str(params.get("quantity", "0.01"))) if self._deviation_threshold <= 0: raise ValueError( f"VWAP deviation_threshold must be > 0, got {self._deviation_threshold}" ) 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: 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). 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) # 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) volume = float(candle.volume) typical_price = (high + low + close) / 3.0 self._cumulative_tp_vol += typical_price * volume self._cumulative_vol += volume self._candle_count += 1 if self._candle_count < self.warmup_period: return None if self._cumulative_vol == 0.0: return None 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: self._was_below_vwap = True 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 = _band_conviction(close) 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 conviction = _band_conviction(close) 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