diff options
Diffstat (limited to 'services/strategy-engine/strategies')
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, |
