summaryrefslogtreecommitdiff
path: root/services/strategy-engine/strategies/moc_strategy.py
diff options
context:
space:
mode:
Diffstat (limited to 'services/strategy-engine/strategies/moc_strategy.py')
-rw-r--r--services/strategy-engine/strategies/moc_strategy.py230
1 files changed, 230 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..cbc8440
--- /dev/null
+++ b/services/strategy-engine/strategies/moc_strategy.py
@@ -0,0 +1,230 @@
+"""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 datetime import datetime
+from decimal import Decimal
+
+import pandas as pd
+
+from shared.models import Candle, OrderSide, Signal
+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