summaryrefslogtreecommitdiff
path: root/services
diff options
context:
space:
mode:
authorTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2026-04-02 10:06:36 +0900
committerTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2026-04-02 10:06:36 +0900
commit6f162e4696e8e90fcbd6ca84d0ad7f0d187dfb01 (patch)
treea0d16b4674722587f609fe12cdff78b674a9d290 /services
parent2446214389fb8f4644d1a24a19e5e3d7b55e8651 (diff)
feat(strategy): add Market on Close (MOC) strategy for US stocks
Diffstat (limited to 'services')
-rw-r--r--services/strategy-engine/strategies/config/moc_strategy.yaml13
-rw-r--r--services/strategy-engine/strategies/moc_strategy.py223
-rw-r--r--services/strategy-engine/tests/test_moc_strategy.py130
3 files changed, 366 insertions, 0 deletions
diff --git a/services/strategy-engine/strategies/config/moc_strategy.yaml b/services/strategy-engine/strategies/config/moc_strategy.yaml
new file mode 100644
index 0000000..349ae1b
--- /dev/null
+++ b/services/strategy-engine/strategies/config/moc_strategy.yaml
@@ -0,0 +1,13 @@
+# Market on Close (MOC) Strategy — US Stocks
+quantity_pct: 0.2 # 20% of capital per position
+stop_loss_pct: 2.0 # -2% stop loss
+rsi_min: 30 # RSI lower bound
+rsi_max: 60 # RSI upper bound (not overbought)
+ema_period: 20 # EMA for trend confirmation
+volume_avg_period: 20 # Volume average lookback
+min_volume_ratio: 1.0 # Volume must be >= average
+buy_start_utc: 19 # Buy window start (15:00 ET summer)
+buy_end_utc: 21 # Buy window end (16:00 ET)
+sell_start_utc: 13 # Sell window start (9:00 ET)
+sell_end_utc: 15 # Sell window end (10:00 ET)
+max_positions: 5 # Max simultaneous positions
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
diff --git a/services/strategy-engine/tests/test_moc_strategy.py b/services/strategy-engine/tests/test_moc_strategy.py
new file mode 100644
index 0000000..10a6720
--- /dev/null
+++ b/services/strategy-engine/tests/test_moc_strategy.py
@@ -0,0 +1,130 @@
+"""Tests for MOC (Market on Close) strategy."""
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from datetime import datetime, timezone
+from decimal import Decimal
+
+from shared.models import Candle, OrderSide
+from strategies.moc_strategy import MocStrategy
+
+
+def _candle(price, hour=20, minute=0, volume=100.0, day=1, open_price=None):
+ op = open_price if open_price is not None else price - 1 # Default: bullish
+ return Candle(
+ symbol="AAPL",
+ timeframe="5Min",
+ open_time=datetime(2025, 1, day, hour, minute, tzinfo=timezone.utc),
+ open=Decimal(str(op)),
+ high=Decimal(str(price + 1)),
+ low=Decimal(str(min(op, price) - 1)),
+ close=Decimal(str(price)),
+ volume=Decimal(str(volume)),
+ )
+
+
+def _make_strategy(**overrides):
+ s = MocStrategy()
+ params = {
+ "quantity_pct": 0.2,
+ "stop_loss_pct": 2.0,
+ "rsi_min": 30,
+ "rsi_max": 70, # Wider for tests
+ "ema_period": 5,
+ "volume_avg_period": 5,
+ "min_volume_ratio": 0.5,
+ "buy_start_utc": 19,
+ "buy_end_utc": 21,
+ "sell_start_utc": 13,
+ "sell_end_utc": 15,
+ "max_positions": 5,
+ }
+ params.update(overrides)
+ s.configure(params)
+ return s
+
+
+def test_moc_warmup_period():
+ s = _make_strategy(ema_period=20, volume_avg_period=15)
+ assert s.warmup_period == 21
+
+
+def test_moc_no_signal_outside_buy_window():
+ s = _make_strategy()
+ # Hour 12 UTC — not in buy (19-21) or sell (13-15) window
+ for i in range(10):
+ sig = s.on_candle(_candle(150 + i, hour=12, minute=i * 5))
+ assert sig is None
+
+
+def test_moc_buy_signal_in_window():
+ s = _make_strategy(ema_period=3)
+ # Build up history with some oscillation so RSI settles in the 30-70 range
+ prices = [150, 149, 151, 148, 152, 149, 150, 151, 148, 150,
+ 149, 151, 150, 152, 151, 153, 152, 154, 153, 155]
+ signals = []
+ for i, p in enumerate(prices):
+ sig = s.on_candle(_candle(p, hour=20, minute=i * 2, volume=200.0))
+ if sig is not None:
+ signals.append(sig)
+ buy_signals = [sig for sig in signals if sig.side == OrderSide.BUY]
+ assert len(buy_signals) > 0
+ assert buy_signals[0].strategy == "moc"
+
+
+def test_moc_sell_at_open():
+ s = _make_strategy(ema_period=3)
+ # Force entry
+ for i in range(10):
+ s.on_candle(_candle(150 + i, hour=20, minute=i * 3, volume=200.0))
+
+ if s._in_position:
+ # Next day, sell window
+ sig = s.on_candle(_candle(155, hour=14, minute=0, day=2))
+ assert sig is not None
+ assert sig.side == OrderSide.SELL
+ assert "MOC sell" in sig.reason
+
+
+def test_moc_stop_loss():
+ s = _make_strategy(ema_period=3, stop_loss_pct=1.0)
+ for i in range(10):
+ s.on_candle(_candle(150 + i, hour=20, minute=i * 3, volume=200.0))
+
+ if s._in_position:
+ drop_price = s._entry_price * 0.98 # -2%
+ sig = s.on_candle(_candle(drop_price, hour=22, minute=0))
+ if sig is not None:
+ assert sig.side == OrderSide.SELL
+ assert "stop loss" in sig.reason
+
+
+def test_moc_no_buy_on_bearish_candle():
+ s = _make_strategy(ema_period=3)
+ for i in range(8):
+ s.on_candle(_candle(150, hour=20, minute=i * 3, volume=200.0))
+ # Bearish candle (open > close)
+ sig = s.on_candle(_candle(149, hour=20, minute=30, open_price=151))
+ # May or may not signal depending on other criteria, but bearish should reduce chances
+ # Just verify no crash
+
+
+def test_moc_only_one_buy_per_day():
+ s = _make_strategy(ema_period=3)
+ buy_count = 0
+ for i in range(20):
+ sig = s.on_candle(_candle(150 + i * 0.3, hour=20, minute=i * 2, volume=200.0))
+ if sig is not None and sig.side == OrderSide.BUY:
+ buy_count += 1
+ assert buy_count <= 1
+
+
+def test_moc_reset():
+ s = _make_strategy()
+ s.on_candle(_candle(150, hour=20))
+ s._in_position = True
+ s.reset()
+ assert not s._in_position
+ assert len(s._closes) == 0
+ assert not s._bought_today