import logging import pandas as pd from ib_insync import Contract, IB from bars import BarManager, is_market_active, parse_bar_size_seconds, to_completed_df from config import config from orders import execute_market_order from state import PositionTracker from strategies.base import BaseStrategy logger = logging.getLogger(__name__) class MeanReversionStrategy(BaseStrategy): """Buy oversold dips (RSI / lower Bollinger / rapid drop), sell on recovery. Exits (re-evaluated every cycle while in position): - hard stop loss at -stop_loss_pct (always honoured, overrides min profit) - RSI overbought / price back at Bollinger mid / recovery_pct reached, once profit >= min_profit_pct """ def __init__(self, ib: IB, bar_manager: BarManager, tracker: PositionTracker): super().__init__(ib, bar_manager, tracker) self.cfg = config.mean_reversion self.bar_seconds = parse_bar_size_seconds(self.cfg.bar_size) self.contracts: dict[str, Contract] = {} async def on_start(self): logger.info( "MeanReversion strategy started: symbols=%s, value=$%.0f, " "RSI(%d, %.0f/%.0f), BB(%d, %.1f), drop=%.1f%%/%dbars, recovery=%.1f%%, " "stop_loss=%.1f%%, confirm_bars=%d", self.cfg.symbols, self.cfg.trade_value_usd, self.cfg.rsi_period, self.cfg.rsi_oversold, self.cfg.rsi_overbought, self.cfg.bb_period, self.cfg.bb_std, self.cfg.rapid_drop_pct, self.cfg.rapid_drop_bars, self.cfg.recovery_pct, self.cfg.stop_loss_pct, self.cfg.entry_confirm_bars, ) for symbol in self.cfg.symbols: contract = self._contract(symbol) await self.ib.qualifyContractsAsync(contract) if not contract.conId: logger.error("MeanRev %s: failed to qualify contract, skipped", symbol) continue self.contracts[symbol] = contract await self.bar_manager.subscribe( contract, self.cfg.bar_size, f"{self.cfg.lookback_days} D", "TRADES" ) logger.info("MeanRev %s: subscribed, owned=%s", symbol, self.tracker.get(self.name, symbol)) @staticmethod def _calc_rsi(series: pd.Series, period: int = 14) -> pd.Series: """RSI with standard Wilder smoothing.""" delta = series.diff() gain = delta.where(delta > 0, 0.0) loss = -delta.where(delta < 0, 0.0) avg_gain = gain.ewm(alpha=1 / period, adjust=False).mean() avg_loss = loss.ewm(alpha=1 / period, adjust=False).mean() # no-loss division yields inf -> RSI 100; 0/0 (flat) yields NaN -> no signal rs = avg_gain / avg_loss return 100 - (100 / (1 + rs)) @staticmethod def _calc_bollinger(series: pd.Series, period: int = 20, num_std: float = 2.0): mid = series.rolling(period).mean() std = series.rolling(period).std() upper = mid + num_std * std lower = mid - num_std * std return mid, upper, lower async def _get_df(self, contract: Contract) -> pd.DataFrame: bars = self.bar_manager.get(contract, self.cfg.bar_size, "TRADES") if bars is None: bars = await self.bar_manager.subscribe( contract, self.cfg.bar_size, f"{self.cfg.lookback_days} D", "TRADES" ) return to_completed_df(bars, self.bar_seconds) async def on_bar(self): await self._sync_stop_orders() for symbol, contract in self.contracts.items(): try: await self._process_symbol(symbol, contract) except Exception as e: logger.exception("MeanRev %s: error: %s", symbol, e) async def _process_symbol(self, symbol: str, contract: Contract): df = await self._get_df(contract) min_bars = max(self.cfg.rsi_period, self.cfg.bb_period, self.cfg.rapid_drop_bars, self.cfg.trend_ma_period) + 5 if len(df) < min_bars: return if not is_market_active(df, self.bar_seconds): return # market closed / stale data - never trade df = df.copy() df["rsi"] = self._calc_rsi(df["close"], self.cfg.rsi_period) df["bb_mid"], df["bb_upper"], df["bb_lower"] = self._calc_bollinger( df["close"], self.cfg.bb_period, self.cfg.bb_std ) df["trend_ma"] = df["close"].rolling(self.cfg.trend_ma_period).mean() last = df.iloc[-1] owned = self.tracker.get(self.name, symbol) if not owned: buy_signal = None # entry confirmation: the oversold condition must hold on the last # confirm_bars consecutive bars -> filters 1-bar spikes cb = self.cfg.entry_confirm_bars recent_cb = df.iloc[-cb:] if (recent_cb["rsi"] < self.cfg.rsi_oversold).all(): buy_signal = "RSI" elif (recent_cb["close"] <= recent_cb["bb_lower"]).all(): buy_signal = "Bollinger" elif len(df) > self.cfg.rapid_drop_bars: recent = df.iloc[-self.cfg.rapid_drop_bars - 1:] price_change_pct = (recent.iloc[-1]["close"] - recent.iloc[0]["close"]) / recent.iloc[0]["close"] * 100 if price_change_pct <= -self.cfg.rapid_drop_pct: buy_signal = "RapidDrop" if buy_signal and last["close"] <= last["trend_ma"]: # trend filter: don't catch falling knives below the slow SMA logger.debug( "MeanRev BUY blocked by trend filter: %s [%s] close %.2f <= SMA%d %.2f", symbol, buy_signal, last["close"], self.cfg.trend_ma_period, last["trend_ma"], ) buy_signal = None if buy_signal: if self._in_stop_cooldown(symbol): logger.info("MeanRev BUY SKIPPED: %s [%s] (stop-out cooldown %d min)", symbol, buy_signal, self.cfg.stop_cooldown_minutes) return ok, reason = self._can_open_position(symbol, self._trade_value_usd(symbol), last["close"]) if not ok: logger.info("MeanRev BUY SKIPPED: %s [%s] (%s)", symbol, buy_signal, reason) return qty = self._order_quantity(symbol, last["close"]) logger.info( "MeanRev BUY: %s x%d [%s] RSI=%.1f, close=%.2f, BB_lower=%.2f", symbol, qty, buy_signal, last["rsi"], last["close"], last["bb_lower"], ) await self._buy(symbol, contract, qty) else: entry = owned["entry_price"] profit_pct = (last["close"] - entry) / entry * 100 # stop loss always honoured, overrides min profit; # soft stop is only a fallback: the exchange-side STP order is primary if not self._has_active_stop(symbol) and last["close"] <= entry * (1 - self.cfg.stop_loss_pct / 100): logger.info( "MeanRev STOP-LOSS SELL (soft fallback): %s close=%.2f entry=%.2f (%.2f%%)", symbol, last["close"], entry, profit_pct, ) await self._sell(symbol, contract, owned["quantity"]) self._mark_stop_cooldown(symbol) return sell_signal = None if last["rsi"] > self.cfg.rsi_overbought: sell_signal = "RSI" elif last["close"] >= last["bb_mid"]: sell_signal = "Bollinger" elif profit_pct >= self.cfg.recovery_pct: sell_signal = "Recovery" if sell_signal and profit_pct < self.cfg.min_profit_pct: logger.debug( "MeanRev SELL WAITING: %s [%s] profit=%.2f%% < min %.2f%%", symbol, sell_signal, profit_pct, self.cfg.min_profit_pct, ) sell_signal = None if sell_signal: logger.info( "MeanRev SELL: %s [%s] RSI=%.1f, close=%.2f, entry=%.2f, profit=%.2f%%", symbol, sell_signal, last["rsi"], last["close"], entry, profit_pct, ) await self._sell(symbol, contract, owned["quantity"]) async def _buy(self, symbol: str, contract: Contract, quantity: int): ref = f"{self.name}:{symbol}" trade = await execute_market_order(self.ib, contract, "BUY", quantity, ref) if trade and trade.orderStatus.filled > 0: self.tracker.record_buy( self.name, symbol, trade.orderStatus.filled, trade.orderStatus.avgFillPrice ) if self._use_hard_stop(): stop_price = self._target_stop_price( symbol, trade.orderStatus.avgFillPrice, None ) await self._place_stop( symbol, contract, trade.orderStatus.filled, stop_price ) else: # order rejected/timed out (e.g. insufficient buying power): # cool down to avoid retrying every cycle self._mark_cooldown(symbol, "order failed") async def _sell(self, symbol: str, contract: Contract, quantity: float): # cancel the hard stop first; it may have filled in the cancel race if self._use_hard_stop(): stop_filled, stop_price, cancel_confirmed = await self._cancel_stop(symbol) if stop_filled > 0: logger.info("%s: stop order filled %g during cancel", symbol, stop_filled) self.tracker.record_sell(self.name, symbol, stop_filled, stop_price) quantity -= stop_filled if quantity <= 0: return if not cancel_confirmed: # STP may still be live - a market sell could double-sell; # leave the position for the next cycle logger.warning( "MeanRev SELL ABORTED: %s (stop cancel not confirmed - " "may still be active, will retry next cycle)", symbol, ) return ref = f"{self.name}:{symbol}" trade = await execute_market_order(self.ib, contract, "SELL", quantity, ref) if trade and trade.orderStatus.filled > 0: self.tracker.record_sell(self.name, symbol, trade.orderStatus.filled, trade.orderStatus.avgFillPrice) async def on_tick(self): pass def _contract(self, symbol: str): return Contract( symbol=symbol, secType="STK", exchange=self.cfg.exchange, currency=self.cfg.currency, )