import logging from datetime import date 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 ShortTermMAVWAPStrategy(BaseStrategy): """Fast EMA cross above slow EMA with price above daily VWAP. Exits (re-evaluated every cycle while in position): - hard stop loss at -stop_loss_pct (always honoured) - max holding period reached - fast EMA below slow EMA, once profit >= min_profit_pct """ def __init__(self, ib: IB, bar_manager: BarManager, tracker: PositionTracker): super().__init__(ib, bar_manager, tracker) self.cfg = config.short_term self.fast_period = self.cfg.fast_ma_period self.slow_period = self.cfg.slow_ma_period self.bar_seconds = parse_bar_size_seconds(self.cfg.bar_size) self.contracts: dict[str, Contract] = {} async def on_start(self): hold_desc = "unlimited" if self.cfg.max_hold_days <= 0 else f"{self.cfg.max_hold_days} days" logger.info( "Short-term MA+VWAP strategy started: symbols=%s, fast=%d, slow=%d, value=$%.0f, " "max_hold=%s, stop_loss=%.1f%%, min_profit=%.1f%%, confirm_bars=%d", self.cfg.symbols, self.fast_period, self.slow_period, self.cfg.trade_value_usd, hold_desc, self.cfg.stop_loss_pct, self.cfg.min_profit_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("ShortTerm %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("ShortTerm %s: subscribed, owned=%s", symbol, self.tracker.get(self.name, symbol)) @staticmethod def _calc_vwap(df: pd.DataFrame) -> pd.Series: """VWAP reset each trading day (vectorized, no groupby-apply).""" typical_price = (df["high"] + df["low"] + df["close"]) / 3 pv = typical_price * df["volume"] day = df["date"].dt.date cum_pv = pv.groupby(day).cumsum() cum_vol = df["volume"].groupby(day).cumsum() return cum_pv / cum_vol.replace(0, float("nan")) @staticmethod def _calc_ema(series: pd.Series, period: int) -> pd.Series: return series.ewm(span=period, adjust=False).mean() 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("ShortTerm %s: error: %s", symbol, e) async def _process_symbol(self, symbol: str, contract: Contract): df = await self._get_df(contract) if len(df) < self.slow_period + 2: return if not is_market_active(df, self.bar_seconds): return # market closed / stale data - never trade df = df.copy() df["fast_ema"] = self._calc_ema(df["close"], self.fast_period) df["slow_ema"] = self._calc_ema(df["close"], self.slow_period) df["vwap"] = self._calc_vwap(df) last = df.iloc[-1] prev = df.iloc[-2] fast_above = last["fast_ema"] > last["slow_ema"] # entry confirmation: the cross happened (confirm_bars-1) bars back and # the fast EMA is still above the slow EMA now -> filters 1-bar whipsaws cb = self.cfg.entry_confirm_bars ref = df.iloc[-cb] ref_prev = df.iloc[-cb - 1] cross_then = ref["fast_ema"] > ref["slow_ema"] and ref_prev["fast_ema"] <= ref_prev["slow_ema"] confirmed = cross_then and fast_above price_above_vwap = last["close"] > last["vwap"] owned = self.tracker.get(self.name, symbol) if owned: entry = owned["entry_price"] profit_pct = (last["close"] - entry) / entry * 100 # 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( "ShortTerm 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 entry_date = date.fromisoformat(owned["entry_date"]) if owned.get("entry_date") else None if entry_date and self.cfg.max_hold_days > 0: hold_days = (date.today() - entry_date).days if hold_days >= self.cfg.max_hold_days: logger.info("ShortTerm SELL %s: max hold reached (%d days)", symbol, hold_days) await self._sell(symbol, contract, owned["quantity"], "MaxHold") return if not fast_above: # re-checked every cycle: exits as soon as profit requirement is met if profit_pct >= self.cfg.min_profit_pct: logger.info( "ShortTerm SELL: %s (fast EMA %.2f < slow EMA %.2f, profit=%.2f%%)", symbol, last["fast_ema"], last["slow_ema"], profit_pct, ) await self._sell(symbol, contract, owned["quantity"], "SignalExit") else: logger.debug( "ShortTerm SELL WAITING: %s profit %.2f%% < min %.1f%%", symbol, profit_pct, self.cfg.min_profit_pct, ) else: if confirmed and price_above_vwap: if self._in_stop_cooldown(symbol): logger.info("ShortTerm BUY SKIPPED: %s (stop-out cooldown %d min)", symbol, 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("ShortTerm BUY SKIPPED: %s (%s)", symbol, reason) return qty = self._order_quantity(symbol, last["close"]) logger.info( "ShortTerm BUY: %s x%d (cross confirmed over %d bars, fast EMA %.2f > slow EMA %.2f, close %.2f > VWAP %.2f)", symbol, qty, self.cfg.entry_confirm_bars, last["fast_ema"], last["slow_ema"], last["close"], last["vwap"], ) await self._buy(symbol, contract, qty) async def _buy(self, symbol: str, contract: Contract, quantity: int, reason: str = "EMACross+VWAP"): 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( "ShortTerm 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, )