"""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