BotDeepSeek/strategies/mean_reversion.py
2026-08-12 01:46:19 -07:00

244 lines
11 KiB
Python

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, trade_commission
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, buy_signal)
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"], "SoftStop")
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"], sell_signal)
async def _buy(self, symbol: str, contract: Contract, quantity: int,
reason: str = ""):
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,
reason=reason, commission=trade_commission(trade),
)
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,
reason: str = ""):
# 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,
reason="HardStopDuringCancel")
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,
reason=reason, commission=trade_commission(trade))
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,
)