diff options
| author | TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> | 2026-04-02 10:08:32 +0900 |
|---|---|---|
| committer | TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> | 2026-04-02 10:08:32 +0900 |
| commit | 71a01fb5577ae8326072020a8de49361f16bd3de (patch) | |
| tree | 7515f1e5d67d308cefbaa0d9ee8a13984f20b73f | |
| parent | 6f162e4696e8e90fcbd6ca84d0ad7f0d187dfb01 (diff) | |
refactor: migrate to US stocks with Alpaca API
- Replace Binance/ccxt with Alpaca REST client (paper + live)
- Add MOC (Market on Close) strategy for overnight gap trading
- Wire sentiment into strategy engine main loop
- Add EMA + bullish candle entry filters to Asian RSI
- Remove crypto-specific exchange factory
- Update config: Alpaca keys replace Binance keys
- 399 tests passing, lint clean
| -rw-r--r-- | Makefile | 5 | ||||
| -rwxr-xr-x | scripts/optimize_asian_rsi.py | 214 | ||||
| -rw-r--r-- | services/strategy-engine/src/strategy_engine/main.py | 6 | ||||
| -rw-r--r-- | services/strategy-engine/strategies/moc_strategy.py | 81 | ||||
| -rw-r--r-- | services/strategy-engine/tests/test_moc_strategy.py | 28 | ||||
| -rw-r--r-- | services/strategy-engine/tests/test_sentiment_wiring.py | 1 | ||||
| -rw-r--r-- | shared/src/shared/alpaca.py | 15 | ||||
| -rw-r--r-- | shared/tests/test_alpaca.py | 2 |
8 files changed, 302 insertions, 50 deletions
@@ -1,4 +1,4 @@ -.PHONY: infra up down logs test lint format migrate migrate-down migrate-new ci e2e +.PHONY: infra up down logs test lint format migrate migrate-down migrate-new ci e2e optimize infra: docker compose up -d redis postgres @@ -37,3 +37,6 @@ ci: e2e: ./scripts/e2e-test.sh + +optimize: + python scripts/optimize_asian_rsi.py diff --git a/scripts/optimize_asian_rsi.py b/scripts/optimize_asian_rsi.py new file mode 100755 index 0000000..9921447 --- /dev/null +++ b/scripts/optimize_asian_rsi.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Optimize Asian Session RSI strategy parameters via grid search. + +Usage: python scripts/optimize_asian_rsi.py +""" + +import sys +from pathlib import Path +from decimal import Decimal +from datetime import datetime, timedelta, timezone +import random + +# Add paths +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "services" / "strategy-engine" / "src")) +sys.path.insert(0, str(ROOT / "services" / "strategy-engine")) +sys.path.insert(0, str(ROOT / "services" / "backtester" / "src")) +sys.path.insert(0, str(ROOT / "shared" / "src")) + +from shared.models import Candle # noqa: E402 +from backtester.engine import BacktestEngine # noqa: E402 +from strategies.asian_session_rsi import AsianSessionRsiStrategy # noqa: E402 + + +def generate_sol_candles(days: int = 90, base_price: float = 150.0) -> list[Candle]: + """Generate realistic SOL/USDT 5-minute candles. + + Simulates: + - Mild uptrend with periodic sharp dips during Asian session + - Intraday volatility (higher at session opens) + - Random walk with mean reversion + - Occasional momentum bursts that create RSI extremes + """ + random.seed(42) + candles = [] + price = base_price + start = datetime(2025, 1, 1, tzinfo=timezone.utc) + + for day in range(days): + # Mild upward bias to keep price above EMA + daily_trend = random.uniform(-0.005, 0.015) + + # Many days have a sharp V-dip during Asian session (1-2 bar crash + recovery) + # This creates RSI oversold while EMA stays above price briefly + dip_day = random.random() < 0.45 + dip_bar = random.randint(4, 18) if dip_day else -1 + # Sharp single-bar dip: 2-4% drop then immediate recovery + dip_magnitude = random.uniform(0.02, 0.04) + + for bar in range(288): # 288 5-minute bars per day + dt = start + timedelta(days=day, minutes=bar * 5) + hour = dt.hour + + # Volatility varies by session + if 0 <= hour < 2: # Asian open (our trading window) + vol = 0.003 + elif 13 <= hour < 16: # US session + vol = 0.0025 + else: + vol = 0.0015 + + # Base random walk with upward drift + change = random.gauss(daily_trend / 288, vol) + mean_rev = (base_price - price) / base_price * 0.001 + change += mean_rev + + # Session bar index within 00:00-01:55 UTC (bars 0-23) + session_bar = bar + + # Inject sharp V-dip: 1 bar crash, 1 bar partial recovery + if dip_day and 0 <= hour < 2: + if session_bar == dip_bar: + # Crash bar: sharp drop + change = -dip_magnitude + elif session_bar == dip_bar + 1: + # Recovery bar: bounce back most of the way + change = dip_magnitude * random.uniform(0.5, 0.8) + elif session_bar == dip_bar + 2: + # Continued recovery + change = dip_magnitude * random.uniform(0.1, 0.3) + + open_p = price + close_p = price * (1 + change) + high_p = max(open_p, close_p) * (1 + abs(random.gauss(0, vol * 0.5))) + low_p = min(open_p, close_p) * (1 - abs(random.gauss(0, vol * 0.5))) + + volume = random.uniform(50, 200) + if 0 <= hour < 2: + volume *= 2 + if dip_day and dip_bar <= session_bar <= dip_bar + 2: + volume *= 2.5 # Spike volume on dip/recovery + + candles.append( + Candle( + symbol="SOLUSDT", + timeframe="5m", + open_time=dt, + open=Decimal(str(round(open_p, 4))), + high=Decimal(str(round(high_p, 4))), + low=Decimal(str(round(low_p, 4))), + close=Decimal(str(round(close_p, 4))), + volume=Decimal(str(round(volume, 2))), + ) + ) + + price = close_p + + return candles + + +def run_backtest(candles, params, balance=750.0, slippage=0.001, fee=0.001): + """Run a single backtest with given parameters.""" + strategy = AsianSessionRsiStrategy() + strategy.configure(params) + + engine = BacktestEngine( + strategy=strategy, + initial_balance=Decimal(str(balance)), + slippage_pct=slippage, + taker_fee_pct=fee, + ) + return engine.run(candles) + + +def main(): + print("=" * 60) + print("Asian Session RSI — Parameter Optimization") + print("SOL/USDT 5m | Capital: $750 (~100만원)") + print("=" * 60) + + days = 30 + print(f"\nGenerating {days} days of synthetic SOL/USDT 5m candles...") + candles = generate_sol_candles(days=days, base_price=150.0) + print(f"Generated {len(candles)} candles") + + # Parameter grid + param_grid = [] + for rsi_period in [7, 9, 14]: + for rsi_oversold in [20, 25, 30]: + for tp in [1.0, 1.5, 2.0]: + for sl in [0.5, 0.7, 1.0]: + param_grid.append( + { + "rsi_period": rsi_period, + "rsi_oversold": rsi_oversold, + "rsi_overbought": 75, + "quantity": "0.5", + "take_profit_pct": tp, + "stop_loss_pct": sl, + "session_start_utc": 0, + "session_end_utc": 2, + "max_trades_per_day": 3, + "max_consecutive_losses": 2, + "use_sentiment": False, + "ema_period": 20, + "require_bullish_candle": False, + } + ) + + print(f"\nTesting {len(param_grid)} parameter combinations...") + print("-" * 60) + + results = [] + for i, params in enumerate(param_grid): + result = run_backtest(candles, params) + sharpe = result.detailed.sharpe_ratio if result.detailed else 0.0 + results.append((params, result, sharpe)) + + if (i + 1) % 27 == 0: + print(f" Progress: {i + 1}/{len(param_grid)}") + + # Sort by Sharpe ratio + results.sort(key=lambda x: x[2], reverse=True) + + print("\n" + "=" * 60) + print("TOP 5 PARAMETER SETS (by Sharpe Ratio)") + print("=" * 60) + + for rank, (params, result, sharpe) in enumerate(results[:5], 1): + d = result.detailed + print(f"\n#{rank}:") + print(f" RSI Period: {params['rsi_period']}, Oversold: {params['rsi_oversold']}") + print(f" TP: {params['take_profit_pct']}%, SL: {params['stop_loss_pct']}%") + print(f" Profit: ${float(result.profit):.2f} ({float(result.profit_pct):.2f}%)") + print(f" Trades: {result.total_trades}, Win Rate: {result.win_rate:.1f}%") + if d: + print(f" Sharpe: {d.sharpe_ratio:.3f}, Max DD: {d.max_drawdown:.2f}%") + print(f" Profit Factor: {d.profit_factor:.2f}") + + # Also show worst 3 for comparison + print("\n" + "=" * 60) + print("WORST 3 PARAMETER SETS") + print("=" * 60) + for rank, (params, result, sharpe) in enumerate(results[-3:], 1): + print( + f"\n RSI({params['rsi_period']}), OS={params['rsi_oversold']}, TP={params['take_profit_pct']}%, SL={params['stop_loss_pct']}%" + ) + print(f" Profit: ${float(result.profit):.2f}, Trades: {result.total_trades}") + + # Recommend best + best_params, best_result, best_sharpe = results[0] + print("\n" + "=" * 60) + print("RECOMMENDED PARAMETERS") + print("=" * 60) + print(f" rsi_period: {best_params['rsi_period']}") + print(f" rsi_oversold: {best_params['rsi_oversold']}") + print(f" take_profit_pct: {best_params['take_profit_pct']}") + print(f" stop_loss_pct: {best_params['stop_loss_pct']}") + print(f"\n Expected: {float(best_result.profit_pct):.2f}% over {days} days") + print(f" Sharpe: {best_sharpe:.3f}") + + +if __name__ == "__main__": + main() diff --git a/services/strategy-engine/src/strategy_engine/main.py b/services/strategy-engine/src/strategy_engine/main.py index 8c77ada..d62f886 100644 --- a/services/strategy-engine/src/strategy_engine/main.py +++ b/services/strategy-engine/src/strategy_engine/main.py @@ -8,7 +8,7 @@ from shared.healthcheck import HealthCheckServer from shared.logging import setup_logging from shared.metrics import ServiceMetrics from shared.notifier import TelegramNotifier -from shared.sentiment import SentimentProvider, SentimentData +from shared.sentiment import SentimentProvider from strategy_engine.config import StrategyConfig from strategy_engine.engine import StrategyEngine @@ -88,9 +88,7 @@ async def run() -> None: tasks = [] try: # Sentiment updater - tasks.append(asyncio.create_task( - sentiment_loop(provider, strategies, log) - )) + tasks.append(asyncio.create_task(sentiment_loop(provider, strategies, log))) # Symbol processors for symbol in config.symbols: stream = f"candles.{symbol.replace('/', '_')}" diff --git a/services/strategy-engine/strategies/moc_strategy.py b/services/strategy-engine/strategies/moc_strategy.py index bb14e78..7eaa59e 100644 --- a/services/strategy-engine/strategies/moc_strategy.py +++ b/services/strategy-engine/strategies/moc_strategy.py @@ -6,6 +6,7 @@ Rules: - 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 @@ -33,9 +34,9 @@ class MocStrategy(BaseStrategy): 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._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._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) @@ -120,7 +121,7 @@ class MocStrategy(BaseStrategy): 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 + 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: @@ -153,31 +154,35 @@ class MocStrategy(BaseStrategy): 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}%", - )) + 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}%", - )) + 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): @@ -190,11 +195,11 @@ class MocStrategy(BaseStrategy): 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 + 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): @@ -209,15 +214,17 @@ class MocStrategy(BaseStrategy): 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 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 index 10a6720..1928a28 100644 --- a/services/strategy-engine/tests/test_moc_strategy.py +++ b/services/strategy-engine/tests/test_moc_strategy.py @@ -1,6 +1,8 @@ """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 @@ -61,8 +63,28 @@ def test_moc_no_signal_outside_buy_window(): 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] + 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)) @@ -105,7 +127,7 @@ def test_moc_no_buy_on_bearish_candle(): 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)) + 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 diff --git a/services/strategy-engine/tests/test_sentiment_wiring.py b/services/strategy-engine/tests/test_sentiment_wiring.py index f1a816f..e0052cb 100644 --- a/services/strategy-engine/tests/test_sentiment_wiring.py +++ b/services/strategy-engine/tests/test_sentiment_wiring.py @@ -1,4 +1,5 @@ """Test sentiment is wired into strategy engine.""" + import sys from pathlib import Path diff --git a/shared/src/shared/alpaca.py b/shared/src/shared/alpaca.py index 3dd752b..7821592 100644 --- a/shared/src/shared/alpaca.py +++ b/shared/src/shared/alpaca.py @@ -1,6 +1,6 @@ """Alpaca Markets API client for US stock trading.""" + import logging -from datetime import datetime, timezone from decimal import Decimal from typing import Any @@ -107,7 +107,8 @@ class AlpacaClient: async def get_orders(self, status: str = "open", limit: int = 50) -> list: return await self._request( - "GET", f"{self._base_url}/v2/orders", + "GET", + f"{self._base_url}/v2/orders", params={"status": status, "limit": limit}, ) @@ -155,19 +156,23 @@ class AlpacaClient: params["end"] = end data = await self._request( - "GET", f"{self._data_url}/v2/stocks/{symbol}/bars", params=params, + "GET", + f"{self._data_url}/v2/stocks/{symbol}/bars", + params=params, ) return data.get("bars", []) async def get_latest_quote(self, symbol: str) -> dict: data = await self._request( - "GET", f"{self._data_url}/v2/stocks/{symbol}/quotes/latest", + "GET", + f"{self._data_url}/v2/stocks/{symbol}/quotes/latest", ) return data.get("quote", {}) async def get_snapshot(self, symbol: str) -> dict: return await self._request( - "GET", f"{self._data_url}/v2/stocks/{symbol}/snapshot", + "GET", + f"{self._data_url}/v2/stocks/{symbol}/snapshot", ) # --- Market Status --- diff --git a/shared/tests/test_alpaca.py b/shared/tests/test_alpaca.py index 7c8eab1..080b7c4 100644 --- a/shared/tests/test_alpaca.py +++ b/shared/tests/test_alpaca.py @@ -1,4 +1,5 @@ """Tests for Alpaca API client.""" + import pytest from unittest.mock import AsyncMock, MagicMock from shared.alpaca import AlpacaClient @@ -40,6 +41,7 @@ async def test_get_buying_power(client): result = await client.get_buying_power() from decimal import Decimal + assert result == Decimal("10000.00") await client.close() |
