summaryrefslogtreecommitdiff
path: root/services/strategy-engine
diff options
context:
space:
mode:
authorTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2026-04-02 09:44:43 +0900
committerTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2026-04-02 09:44:43 +0900
commitb9d21e2e2f7ae096c2f8a01bb142a685683b5b90 (patch)
treea031989228ded9ff1e6d47840124ea5dcc9a9a3c /services/strategy-engine
parentbb2e387f870495703fd663ca8f525028c3a8ced5 (diff)
feat: add market sentiment filters (Fear & Greed, CryptoPanic, CryptoQuant)
- SentimentProvider: fetches Fear & Greed Index (free, no key), CryptoPanic news sentiment (free key), CryptoQuant exchange netflow (free key) - SentimentData: aggregated should_buy/should_block logic - Fear < 30 = buy opportunity, Greed > 80 = block buying - Negative news < -0.5 = block buying - Exchange outflow = bullish, inflow = bearish - Integrated into Asian Session RSI strategy as entry filter - All providers optional — disabled when API key missing - 14 sentiment tests + 386 total tests passing
Diffstat (limited to 'services/strategy-engine')
-rw-r--r--services/strategy-engine/strategies/asian_session_rsi.py106
-rw-r--r--services/strategy-engine/strategies/bollinger_strategy.py40
-rw-r--r--services/strategy-engine/strategies/combined_strategy.py12
-rw-r--r--services/strategy-engine/strategies/grid_strategy.py21
-rw-r--r--services/strategy-engine/strategies/vwap_strategy.py2
-rw-r--r--services/strategy-engine/tests/test_bollinger_strategy.py42
-rw-r--r--services/strategy-engine/tests/test_ema_crossover_strategy.py60
-rw-r--r--services/strategy-engine/tests/test_macd_strategy.py4
8 files changed, 176 insertions, 111 deletions
diff --git a/services/strategy-engine/strategies/asian_session_rsi.py b/services/strategy-engine/strategies/asian_session_rsi.py
index f22c3eb..741cd63 100644
--- a/services/strategy-engine/strategies/asian_session_rsi.py
+++ b/services/strategy-engine/strategies/asian_session_rsi.py
@@ -2,9 +2,10 @@
규칙:
- SOL/USDT 5분봉
-- 매수: RSI(14) < 25 + 볼륨 > 평균
+- 매수: RSI(14) < 25 + 볼륨 > 평균 + 센티먼트 OK
- 익절: +1.5%, 손절: -0.7%, 시간청산: 11:00 KST (02:00 UTC)
- 하루 최대 3회, 2연패 시 중단
+- 센티먼트 필터: Fear & Greed > 80이면 매수 차단, 뉴스 극도 부정이면 차단
"""
from collections import deque
@@ -14,6 +15,7 @@ from datetime import datetime
import pandas as pd
from shared.models import Candle, Signal, OrderSide
+from shared.sentiment import SentimentData
from strategies.base import BaseStrategy
@@ -33,6 +35,9 @@ class AsianSessionRsiStrategy(BaseStrategy):
self._session_end_utc: int = 2
self._max_trades_per_day: int = 3
self._max_consecutive_losses: int = 2
+ self._use_sentiment: bool = True
+ # Sentiment (updated externally before each session)
+ self._sentiment: SentimentData | None = None
# State
self._closes: deque[float] = deque(maxlen=200)
self._volumes: deque[float] = deque(maxlen=50)
@@ -57,6 +62,7 @@ class AsianSessionRsiStrategy(BaseStrategy):
self._session_end_utc = int(params.get("session_end_utc", 2))
self._max_trades_per_day = int(params.get("max_trades_per_day", 3))
self._max_consecutive_losses = int(params.get("max_consecutive_losses", 2))
+ self._use_sentiment = bool(params.get("use_sentiment", True))
if self._quantity <= 0:
raise ValueError(f"Quantity must be positive, got {self._quantity}")
@@ -82,6 +88,17 @@ class AsianSessionRsiStrategy(BaseStrategy):
self._consecutive_losses = 0
self._in_position = False
self._entry_price = 0.0
+ self._sentiment = None
+
+ def update_sentiment(self, sentiment: SentimentData) -> None:
+ """Update sentiment data. Call before each trading session."""
+ self._sentiment = sentiment
+
+ def _check_sentiment(self) -> bool:
+ """Check if sentiment allows buying. Returns True if OK."""
+ if not self._use_sentiment or self._sentiment is None:
+ return True # No sentiment data, allow by default
+ return not self._sentiment.should_block
def _is_session_active(self, dt: datetime) -> bool:
"""Check if current time is within trading session."""
@@ -135,29 +152,33 @@ class AsianSessionRsiStrategy(BaseStrategy):
if pnl_pct >= self._take_profit_pct:
self._in_position = False
self._consecutive_losses = 0
- return self._apply_filters(Signal(
- strategy=self.name,
- symbol=candle.symbol,
- side=OrderSide.SELL,
- price=candle.close,
- quantity=self._quantity,
- conviction=0.9,
- reason=f"Take profit {pnl_pct:.2f}% >= {self._take_profit_pct}%",
- ))
+ return self._apply_filters(
+ Signal(
+ strategy=self.name,
+ symbol=candle.symbol,
+ side=OrderSide.SELL,
+ price=candle.close,
+ quantity=self._quantity,
+ conviction=0.9,
+ reason=f"Take profit {pnl_pct:.2f}% >= {self._take_profit_pct}%",
+ )
+ )
# Stop loss
if pnl_pct <= -self._stop_loss_pct:
self._in_position = False
self._consecutive_losses += 1
- return self._apply_filters(Signal(
- strategy=self.name,
- symbol=candle.symbol,
- side=OrderSide.SELL,
- price=candle.close,
- quantity=self._quantity,
- conviction=1.0,
- reason=f"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=self._quantity,
+ conviction=1.0,
+ reason=f"Stop loss {pnl_pct:.2f}% <= -{self._stop_loss_pct}%",
+ )
+ )
# Time exit: session ended while in position
if not self._is_session_active(candle.open_time):
@@ -166,15 +187,17 @@ class AsianSessionRsiStrategy(BaseStrategy):
self._consecutive_losses += 1
else:
self._consecutive_losses = 0
- return self._apply_filters(Signal(
- strategy=self.name,
- symbol=candle.symbol,
- side=OrderSide.SELL,
- price=candle.close,
- quantity=self._quantity,
- conviction=0.5,
- reason=f"Time exit (session ended), PnL {pnl_pct:.2f}%",
- ))
+ return self._apply_filters(
+ Signal(
+ strategy=self.name,
+ symbol=candle.symbol,
+ side=OrderSide.SELL,
+ price=candle.close,
+ quantity=self._quantity,
+ conviction=0.5,
+ reason=f"Time exit (session ended), PnL {pnl_pct:.2f}%",
+ )
+ )
return None # Still in position, no action
@@ -188,6 +211,9 @@ class AsianSessionRsiStrategy(BaseStrategy):
if self._consecutive_losses >= self._max_consecutive_losses:
return None # Consecutive loss limit
+ if not self._check_sentiment():
+ return None # Sentiment blocked (extreme greed or very negative news)
+
rsi = self._compute_rsi()
if rsi is None:
return None
@@ -204,16 +230,18 @@ class AsianSessionRsiStrategy(BaseStrategy):
sl = candle.close * (1 - Decimal(str(self._stop_loss_pct / 100)))
tp = candle.close * (1 + Decimal(str(self._take_profit_pct / 100)))
- return self._apply_filters(Signal(
- strategy=self.name,
- symbol=candle.symbol,
- side=OrderSide.BUY,
- price=candle.close,
- quantity=self._quantity,
- conviction=conv,
- stop_loss=sl,
- take_profit=tp,
- reason=f"RSI {rsi:.1f} < {self._rsi_oversold} (session active, vol OK)",
- ))
+ return self._apply_filters(
+ Signal(
+ strategy=self.name,
+ symbol=candle.symbol,
+ side=OrderSide.BUY,
+ price=candle.close,
+ quantity=self._quantity,
+ conviction=conv,
+ stop_loss=sl,
+ take_profit=tp,
+ reason=f"RSI {rsi:.1f} < {self._rsi_oversold} (session active, vol OK)",
+ )
+ )
return None
diff --git a/services/strategy-engine/strategies/bollinger_strategy.py b/services/strategy-engine/strategies/bollinger_strategy.py
index a195cb8..ebe7967 100644
--- a/services/strategy-engine/strategies/bollinger_strategy.py
+++ b/services/strategy-engine/strategies/bollinger_strategy.py
@@ -102,27 +102,31 @@ class BollingerStrategy(BaseStrategy):
if price > sma:
# Breakout upward
conv = min(0.5 + squeeze_duration * 0.1, 1.0)
- return self._apply_filters(Signal(
- strategy=self.name,
- symbol=candle.symbol,
- side=OrderSide.BUY,
- price=candle.close,
- quantity=self._quantity,
- conviction=conv,
- reason=f"Bollinger squeeze breakout UP after {squeeze_duration} bars",
- ))
+ return self._apply_filters(
+ Signal(
+ strategy=self.name,
+ symbol=candle.symbol,
+ side=OrderSide.BUY,
+ price=candle.close,
+ quantity=self._quantity,
+ conviction=conv,
+ reason=f"Bollinger squeeze breakout UP after {squeeze_duration} bars",
+ )
+ )
else:
# Breakout downward
conv = min(0.5 + squeeze_duration * 0.1, 1.0)
- return self._apply_filters(Signal(
- strategy=self.name,
- symbol=candle.symbol,
- side=OrderSide.SELL,
- price=candle.close,
- quantity=self._quantity,
- conviction=conv,
- reason=f"Bollinger squeeze breakout DOWN after {squeeze_duration} bars",
- ))
+ return self._apply_filters(
+ Signal(
+ strategy=self.name,
+ symbol=candle.symbol,
+ side=OrderSide.SELL,
+ price=candle.close,
+ quantity=self._quantity,
+ conviction=conv,
+ reason=f"Bollinger squeeze breakout DOWN after {squeeze_duration} bars",
+ )
+ )
# Bandwidth filter: skip sideways markets
if sma != 0 and bandwidth < self._min_bandwidth:
diff --git a/services/strategy-engine/strategies/combined_strategy.py b/services/strategy-engine/strategies/combined_strategy.py
index 907d9c5..ba92485 100644
--- a/services/strategy-engine/strategies/combined_strategy.py
+++ b/services/strategy-engine/strategies/combined_strategy.py
@@ -53,7 +53,9 @@ class CombinedStrategy(BaseStrategy):
self._trade_history[strategy_name].append(is_win)
# Keep only last N results
if len(self._trade_history[strategy_name]) > self._history_window:
- self._trade_history[strategy_name] = self._trade_history[strategy_name][-self._history_window:]
+ self._trade_history[strategy_name] = self._trade_history[strategy_name][
+ -self._history_window :
+ ]
def _get_adaptive_weight(self, strategy_name: str, base_weight: float) -> float:
"""Get weight adjusted by recent performance."""
@@ -90,10 +92,14 @@ class CombinedStrategy(BaseStrategy):
effective_weight = self._get_adaptive_weight(strategy.name, weight)
if signal.side == OrderSide.BUY:
score += effective_weight * signal.conviction
- reasons.append(f"{strategy.name}:BUY({effective_weight}*{signal.conviction:.2f})")
+ reasons.append(
+ f"{strategy.name}:BUY({effective_weight}*{signal.conviction:.2f})"
+ )
elif signal.side == OrderSide.SELL:
score -= effective_weight * signal.conviction
- reasons.append(f"{strategy.name}:SELL({effective_weight}*{signal.conviction:.2f})")
+ reasons.append(
+ f"{strategy.name}:SELL({effective_weight}*{signal.conviction:.2f})"
+ )
normalized = score / total_weight # Range: -1.0 to 1.0
diff --git a/services/strategy-engine/strategies/grid_strategy.py b/services/strategy-engine/strategies/grid_strategy.py
index 07ccaba..283bfe5 100644
--- a/services/strategy-engine/strategies/grid_strategy.py
+++ b/services/strategy-engine/strategies/grid_strategy.py
@@ -40,9 +40,7 @@ class GridStrategy(BaseStrategy):
f"got lower={self._lower_price}, upper={self._upper_price}"
)
if self._exit_threshold_pct <= 0:
- raise ValueError(
- f"exit_threshold_pct must be > 0, got {self._exit_threshold_pct}"
- )
+ raise ValueError(f"exit_threshold_pct must be > 0, got {self._exit_threshold_pct}")
if self._grid_count < 2:
raise ValueError(f"Grid grid_count must be >= 2, got {self._grid_count}")
if self._quantity <= 0:
@@ -90,12 +88,17 @@ class GridStrategy(BaseStrategy):
if not self._out_of_range:
self._out_of_range = True
# Exit signal — close positions
- return self._apply_filters(Signal(
- strategy=self.name, symbol=candle.symbol,
- side=OrderSide.SELL, price=candle.close,
- quantity=self._quantity, conviction=0.8,
- reason=f"Grid: price {price:.2f} broke out of range [{self._grid_levels[0]:.2f}, {self._grid_levels[-1]:.2f}]",
- ))
+ return self._apply_filters(
+ Signal(
+ strategy=self.name,
+ symbol=candle.symbol,
+ side=OrderSide.SELL,
+ price=candle.close,
+ quantity=self._quantity,
+ conviction=0.8,
+ reason=f"Grid: price {price:.2f} broke out of range [{self._grid_levels[0]:.2f}, {self._grid_levels[-1]:.2f}]",
+ )
+ )
return None # Already out of range, no more signals
else:
self._out_of_range = False
diff --git a/services/strategy-engine/strategies/vwap_strategy.py b/services/strategy-engine/strategies/vwap_strategy.py
index 0348752..d64950e 100644
--- a/services/strategy-engine/strategies/vwap_strategy.py
+++ b/services/strategy-engine/strategies/vwap_strategy.py
@@ -110,7 +110,7 @@ class VwapStrategy(BaseStrategy):
diffs = [tp - v for tp, v in zip(self._tp_values, self._vwap_values)]
mean_diff = sum(diffs) / len(diffs)
variance = sum((d - mean_diff) ** 2 for d in diffs) / len(diffs)
- std_dev = variance ** 0.5
+ std_dev = variance**0.5
deviation = (close - vwap) / vwap
diff --git a/services/strategy-engine/tests/test_bollinger_strategy.py b/services/strategy-engine/tests/test_bollinger_strategy.py
index 473d9b4..7761f2d 100644
--- a/services/strategy-engine/tests/test_bollinger_strategy.py
+++ b/services/strategy-engine/tests/test_bollinger_strategy.py
@@ -107,12 +107,14 @@ def test_bollinger_squeeze_detection():
"""Tight bandwidth → no signal during squeeze."""
# Use a strategy with a high squeeze threshold so constant prices trigger squeeze
s = BollingerStrategy()
- s.configure({
- "period": 5,
- "num_std": 2.0,
- "min_bandwidth": 0.0,
- "squeeze_threshold": 0.5, # Very high threshold to ensure squeeze triggers
- })
+ s.configure(
+ {
+ "period": 5,
+ "num_std": 2.0,
+ "min_bandwidth": 0.0,
+ "squeeze_threshold": 0.5, # Very high threshold to ensure squeeze triggers
+ }
+ )
# Feed identical prices → bandwidth = 0 (below any threshold)
for _ in range(6):
@@ -126,12 +128,14 @@ def test_bollinger_squeeze_detection():
def test_bollinger_squeeze_breakout_buy():
"""Squeeze ends with price above SMA → BUY signal."""
s = BollingerStrategy()
- s.configure({
- "period": 5,
- "num_std": 1.0,
- "min_bandwidth": 0.0,
- "squeeze_threshold": 0.01,
- })
+ s.configure(
+ {
+ "period": 5,
+ "num_std": 1.0,
+ "min_bandwidth": 0.0,
+ "squeeze_threshold": 0.01,
+ }
+ )
# Feed identical prices to create a squeeze (bandwidth = 0)
for _ in range(6):
@@ -149,12 +153,14 @@ def test_bollinger_squeeze_breakout_buy():
def test_bollinger_pct_b_conviction():
"""Signals near band extremes have higher conviction via %B."""
s = BollingerStrategy()
- s.configure({
- "period": 5,
- "num_std": 1.0,
- "min_bandwidth": 0.0,
- "squeeze_threshold": 0.0, # Disable squeeze for this test
- })
+ s.configure(
+ {
+ "period": 5,
+ "num_std": 1.0,
+ "min_bandwidth": 0.0,
+ "squeeze_threshold": 0.0, # Disable squeeze for this test
+ }
+ )
# Build up with stable prices
for _ in range(5):
diff --git a/services/strategy-engine/tests/test_ema_crossover_strategy.py b/services/strategy-engine/tests/test_ema_crossover_strategy.py
index 9e48478..67a20bf 100644
--- a/services/strategy-engine/tests/test_ema_crossover_strategy.py
+++ b/services/strategy-engine/tests/test_ema_crossover_strategy.py
@@ -21,9 +21,18 @@ def make_candle(close: float) -> Candle:
)
-def _make_strategy(short: int = 3, long: int = 6, pullback_enabled: bool = False) -> EmaCrossoverStrategy:
+def _make_strategy(
+ short: int = 3, long: int = 6, pullback_enabled: bool = False
+) -> EmaCrossoverStrategy:
s = EmaCrossoverStrategy()
- s.configure({"short_period": short, "long_period": long, "quantity": "0.01", "pullback_enabled": pullback_enabled})
+ s.configure(
+ {
+ "short_period": short,
+ "long_period": long,
+ "quantity": "0.01",
+ "pullback_enabled": pullback_enabled,
+ }
+ )
return s
@@ -103,13 +112,15 @@ def test_ema_reset_clears_state():
def test_ema_pullback_entry():
"""Crossover detected, then pullback to short EMA triggers signal."""
strategy = EmaCrossoverStrategy()
- strategy.configure({
- "short_period": 3,
- "long_period": 6,
- "quantity": "0.01",
- "pullback_enabled": True,
- "pullback_tolerance": 0.05, # 5% tolerance for test simplicity
- })
+ strategy.configure(
+ {
+ "short_period": 3,
+ "long_period": 6,
+ "quantity": "0.01",
+ "pullback_enabled": True,
+ "pullback_tolerance": 0.05, # 5% tolerance for test simplicity
+ }
+ )
# Declining prices so short EMA stays below long EMA
declining = [100, 98, 96, 94, 92, 90, 88, 86, 84, 82]
@@ -129,6 +140,7 @@ def test_ema_pullback_entry():
# The short EMA will be tracking recent prices; feed a price that pulls back
# toward it. We use a moderate price to get close to short EMA.
import pandas as pd
+
series = pd.Series(list(strategy._closes))
short_ema_val = series.ewm(span=3, adjust=False).mean().iloc[-1]
# Feed a candle at approximately the short EMA value
@@ -141,13 +153,15 @@ def test_ema_pullback_entry():
def test_ema_pullback_cancelled_on_reversal():
"""Crossover detected, then reversal cancels the pending signal."""
strategy = EmaCrossoverStrategy()
- strategy.configure({
- "short_period": 3,
- "long_period": 6,
- "quantity": "0.01",
- "pullback_enabled": True,
- "pullback_tolerance": 0.001, # Very tight tolerance — won't trigger easily
- })
+ strategy.configure(
+ {
+ "short_period": 3,
+ "long_period": 6,
+ "quantity": "0.01",
+ "pullback_enabled": True,
+ "pullback_tolerance": 0.001, # Very tight tolerance — won't trigger easily
+ }
+ )
# Declining prices
declining = [100, 98, 96, 94, 92, 90, 88, 86, 84, 82]
@@ -172,12 +186,14 @@ def test_ema_pullback_cancelled_on_reversal():
def test_ema_immediate_mode():
"""With pullback_enabled=False, original immediate entry works."""
strategy = EmaCrossoverStrategy()
- strategy.configure({
- "short_period": 3,
- "long_period": 6,
- "quantity": "0.01",
- "pullback_enabled": False,
- })
+ strategy.configure(
+ {
+ "short_period": 3,
+ "long_period": 6,
+ "quantity": "0.01",
+ "pullback_enabled": False,
+ }
+ )
# Declining prices so short EMA stays below long EMA
declining = [100, 98, 96, 94, 92, 90, 88, 86, 84, 82]
diff --git a/services/strategy-engine/tests/test_macd_strategy.py b/services/strategy-engine/tests/test_macd_strategy.py
index cd24ee0..17dd2cf 100644
--- a/services/strategy-engine/tests/test_macd_strategy.py
+++ b/services/strategy-engine/tests/test_macd_strategy.py
@@ -98,7 +98,9 @@ def test_macd_signal_line_crossover():
assert len(buy_signals) > 0, "Expected at least one BUY signal"
# Check that at least one is a signal-line crossover or histogram crossover
all_reasons = [sig.reason for sig in buy_signals]
- assert any("crossover" in r for r in all_reasons), f"Expected crossover signal, got: {all_reasons}"
+ assert any("crossover" in r for r in all_reasons), (
+ f"Expected crossover signal, got: {all_reasons}"
+ )
def test_macd_conviction_varies_with_distance():