diff options
| author | TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> | 2026-04-02 10:06:36 +0900 |
|---|---|---|
| committer | TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> | 2026-04-02 10:06:36 +0900 |
| commit | 6f162e4696e8e90fcbd6ca84d0ad7f0d187dfb01 (patch) | |
| tree | a0d16b4674722587f609fe12cdff78b674a9d290 /services/strategy-engine/strategies/moc_strategy.py | |
| parent | 2446214389fb8f4644d1a24a19e5e3d7b55e8651 (diff) | |
feat(strategy): add Market on Close (MOC) strategy for US stocks
Diffstat (limited to 'services/strategy-engine/strategies/moc_strategy.py')
| -rw-r--r-- | services/strategy-engine/strategies/moc_strategy.py | 223 |
1 files changed, 223 insertions, 0 deletions
diff --git a/services/strategy-engine/strategies/moc_strategy.py b/services/strategy-engine/strategies/moc_strategy.py new file mode 100644 index 0000000..bb14e78 --- /dev/null +++ b/services/strategy-engine/strategies/moc_strategy.py @@ -0,0 +1,223 @@ +"""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 |
