From b9d21e2e2f7ae096c2f8a01bb142a685683b5b90 Mon Sep 17 00:00:00 2001 From: TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:44:43 +0900 Subject: feat: add market sentiment filters (Fear & Greed, CryptoPanic, CryptoQuant) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 4 + .../src/order_executor/risk_manager.py | 30 ++- services/order-executor/tests/test_risk_manager.py | 108 ++++++++-- .../strategies/asian_session_rsi.py | 106 ++++++---- .../strategies/bollinger_strategy.py | 40 ++-- .../strategies/combined_strategy.py | 12 +- .../strategy-engine/strategies/grid_strategy.py | 21 +- .../strategy-engine/strategies/vwap_strategy.py | 2 +- .../tests/test_bollinger_strategy.py | 42 ++-- .../tests/test_ema_crossover_strategy.py | 60 +++--- .../strategy-engine/tests/test_macd_strategy.py | 4 +- shared/src/shared/config.py | 2 + shared/src/shared/sentiment.py | 219 +++++++++++++++++++++ shared/tests/test_sentiment.py | 144 ++++++++++++++ 14 files changed, 661 insertions(+), 133 deletions(-) create mode 100644 shared/src/shared/sentiment.py create mode 100644 shared/tests/test_sentiment.py diff --git a/.env.example b/.env.example index cd70aec..f428104 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,7 @@ HEALTH_PORT=8080 CIRCUIT_BREAKER_THRESHOLD=5 CIRCUIT_BREAKER_TIMEOUT=60 METRICS_AUTH_TOKEN= + +# Sentiment APIs (all optional, free) +CRYPTOPANIC_API_KEY= +CRYPTOQUANT_API_KEY= diff --git a/services/order-executor/src/order_executor/risk_manager.py b/services/order-executor/src/order_executor/risk_manager.py index 94d15c2..5a05746 100644 --- a/services/order-executor/src/order_executor/risk_manager.py +++ b/services/order-executor/src/order_executor/risk_manager.py @@ -212,8 +212,16 @@ class RiskManager: prices_a = prices_a[-min_len:] prices_b = prices_b[-min_len:] - returns_a = [(prices_a[i] - prices_a[i-1]) / prices_a[i-1] for i in range(1, len(prices_a)) if prices_a[i-1] != 0] - returns_b = [(prices_b[i] - prices_b[i-1]) / prices_b[i-1] for i in range(1, len(prices_b)) if prices_b[i-1] != 0] + returns_a = [ + (prices_a[i] - prices_a[i - 1]) / prices_a[i - 1] + for i in range(1, len(prices_a)) + if prices_a[i - 1] != 0 + ] + returns_b = [ + (prices_b[i] - prices_b[i - 1]) / prices_b[i - 1] + for i in range(1, len(prices_b)) + if prices_b[i - 1] != 0 + ] if len(returns_a) < 3 or len(returns_b) < 3: return None @@ -225,7 +233,9 @@ class RiskManager: mean_a = sum(returns_a) / len(returns_a) mean_b = sum(returns_b) / len(returns_b) - cov = sum((a - mean_a) * (b - mean_b) for a, b in zip(returns_a, returns_b)) / len(returns_a) + cov = sum((a - mean_a) * (b - mean_b) for a, b in zip(returns_a, returns_b)) / len( + returns_a + ) std_a = math.sqrt(sum((a - mean_a) ** 2 for a in returns_a) / len(returns_a)) std_b = math.sqrt(sum((b - mean_b) ** 2 for b in returns_b) / len(returns_b)) @@ -253,7 +263,11 @@ class RiskManager: if not hist or len(hist) < 5: continue prices = list(hist) - returns = [(prices[i] - prices[i-1]) / prices[i-1] for i in range(1, len(prices)) if prices[i-1] != 0] + returns = [ + (prices[i] - prices[i - 1]) / prices[i - 1] + for i in range(1, len(prices)) + if prices[i - 1] != 0 + ] if returns: all_returns.append(returns) weight = float(pos.quantity * pos.current_price / balance) @@ -280,15 +294,15 @@ class RiskManager: return abs(var_return) * 100 # As percentage - def check_portfolio_exposure(self, positions: dict[str, Position], balance: Decimal) -> RiskCheckResult: + def check_portfolio_exposure( + self, positions: dict[str, Position], balance: Decimal + ) -> RiskCheckResult: """Check total portfolio exposure.""" if balance <= 0: return RiskCheckResult(allowed=True, reason="OK") total_exposure = sum( - pos.quantity * pos.current_price - for pos in positions.values() - if pos.quantity > 0 + pos.quantity * pos.current_price for pos in positions.values() if pos.quantity > 0 ) exposure_ratio = total_exposure / balance diff --git a/services/order-executor/tests/test_risk_manager.py b/services/order-executor/tests/test_risk_manager.py index a8fe37f..00a9ab4 100644 --- a/services/order-executor/tests/test_risk_manager.py +++ b/services/order-executor/tests/test_risk_manager.py @@ -204,21 +204,49 @@ def test_position_size_without_scaling(): def test_portfolio_exposure_check_passes(): - rm = RiskManager(max_position_size=Decimal("0.5"), stop_loss_pct=Decimal("5"), daily_loss_limit_pct=Decimal("10"), max_portfolio_exposure=0.8) - positions = {"BTCUSDT": Position(symbol="BTCUSDT", quantity=Decimal("0.01"), avg_entry_price=Decimal("50000"), current_price=Decimal("50000"))} + rm = RiskManager( + max_position_size=Decimal("0.5"), + stop_loss_pct=Decimal("5"), + daily_loss_limit_pct=Decimal("10"), + max_portfolio_exposure=0.8, + ) + positions = { + "BTCUSDT": Position( + symbol="BTCUSDT", + quantity=Decimal("0.01"), + avg_entry_price=Decimal("50000"), + current_price=Decimal("50000"), + ) + } result = rm.check_portfolio_exposure(positions, Decimal("10000")) assert result.allowed # 500/10000 = 5% < 80% def test_portfolio_exposure_check_rejects(): - rm = RiskManager(max_position_size=Decimal("0.5"), stop_loss_pct=Decimal("5"), daily_loss_limit_pct=Decimal("10"), max_portfolio_exposure=0.3) - positions = {"BTCUSDT": Position(symbol="BTCUSDT", quantity=Decimal("1"), avg_entry_price=Decimal("50000"), current_price=Decimal("50000"))} + rm = RiskManager( + max_position_size=Decimal("0.5"), + stop_loss_pct=Decimal("5"), + daily_loss_limit_pct=Decimal("10"), + max_portfolio_exposure=0.3, + ) + positions = { + "BTCUSDT": Position( + symbol="BTCUSDT", + quantity=Decimal("1"), + avg_entry_price=Decimal("50000"), + current_price=Decimal("50000"), + ) + } result = rm.check_portfolio_exposure(positions, Decimal("10000")) assert not result.allowed # 50000/10000 = 500% > 30% def test_correlation_calculation(): - rm = RiskManager(max_position_size=Decimal("0.5"), stop_loss_pct=Decimal("5"), daily_loss_limit_pct=Decimal("10")) + rm = RiskManager( + max_position_size=Decimal("0.5"), + stop_loss_pct=Decimal("5"), + daily_loss_limit_pct=Decimal("10"), + ) # Feed identical price histories — correlation should be ~1.0 for i in range(20): rm.update_price("A", Decimal(str(100 + i))) @@ -229,10 +257,21 @@ def test_correlation_calculation(): def test_var_calculation(): - rm = RiskManager(max_position_size=Decimal("0.5"), stop_loss_pct=Decimal("5"), daily_loss_limit_pct=Decimal("10")) + rm = RiskManager( + max_position_size=Decimal("0.5"), + stop_loss_pct=Decimal("5"), + daily_loss_limit_pct=Decimal("10"), + ) for i in range(30): rm.update_price("BTCUSDT", Decimal(str(100 + (i % 5) - 2))) - positions = {"BTCUSDT": Position(symbol="BTCUSDT", quantity=Decimal("1"), avg_entry_price=Decimal("100"), current_price=Decimal("100"))} + positions = { + "BTCUSDT": Position( + symbol="BTCUSDT", + quantity=Decimal("1"), + avg_entry_price=Decimal("100"), + current_price=Decimal("100"), + ) + } var = rm.calculate_portfolio_var(positions, Decimal("10000")) assert var >= 0 # Non-negative @@ -241,28 +280,52 @@ def test_var_calculation(): def test_drawdown_position_scale_full(): - rm = RiskManager(max_position_size=Decimal("0.5"), stop_loss_pct=Decimal("5"), daily_loss_limit_pct=Decimal("10"), drawdown_reduction_threshold=0.1, drawdown_halt_threshold=0.2) + rm = RiskManager( + max_position_size=Decimal("0.5"), + stop_loss_pct=Decimal("5"), + daily_loss_limit_pct=Decimal("10"), + drawdown_reduction_threshold=0.1, + drawdown_halt_threshold=0.2, + ) rm.update_balance(Decimal("10000")) scale = rm.get_position_scale(Decimal("10000")) assert scale == 1.0 # No drawdown def test_drawdown_position_scale_reduced(): - rm = RiskManager(max_position_size=Decimal("0.5"), stop_loss_pct=Decimal("5"), daily_loss_limit_pct=Decimal("10"), drawdown_reduction_threshold=0.1, drawdown_halt_threshold=0.2) + rm = RiskManager( + max_position_size=Decimal("0.5"), + stop_loss_pct=Decimal("5"), + daily_loss_limit_pct=Decimal("10"), + drawdown_reduction_threshold=0.1, + drawdown_halt_threshold=0.2, + ) rm.update_balance(Decimal("10000")) scale = rm.get_position_scale(Decimal("8500")) # 15% drawdown (between 10% and 20%) assert 0.25 < scale < 1.0 def test_drawdown_halt(): - rm = RiskManager(max_position_size=Decimal("0.5"), stop_loss_pct=Decimal("5"), daily_loss_limit_pct=Decimal("10"), drawdown_reduction_threshold=0.1, drawdown_halt_threshold=0.2) + rm = RiskManager( + max_position_size=Decimal("0.5"), + stop_loss_pct=Decimal("5"), + daily_loss_limit_pct=Decimal("10"), + drawdown_reduction_threshold=0.1, + drawdown_halt_threshold=0.2, + ) rm.update_balance(Decimal("10000")) scale = rm.get_position_scale(Decimal("7500")) # 25% drawdown assert scale == 0.0 def test_consecutive_losses_pause(): - rm = RiskManager(max_position_size=Decimal("0.5"), stop_loss_pct=Decimal("5"), daily_loss_limit_pct=Decimal("10"), max_consecutive_losses=3, loss_pause_minutes=60) + rm = RiskManager( + max_position_size=Decimal("0.5"), + stop_loss_pct=Decimal("5"), + daily_loss_limit_pct=Decimal("10"), + max_consecutive_losses=3, + loss_pause_minutes=60, + ) rm.record_trade_result(False) rm.record_trade_result(False) assert not rm.is_paused() @@ -271,7 +334,12 @@ def test_consecutive_losses_pause(): def test_consecutive_losses_reset_on_win(): - rm = RiskManager(max_position_size=Decimal("0.5"), stop_loss_pct=Decimal("5"), daily_loss_limit_pct=Decimal("10"), max_consecutive_losses=3) + rm = RiskManager( + max_position_size=Decimal("0.5"), + stop_loss_pct=Decimal("5"), + daily_loss_limit_pct=Decimal("10"), + max_consecutive_losses=3, + ) rm.record_trade_result(False) rm.record_trade_result(False) rm.record_trade_result(True) # Win resets counter @@ -280,9 +348,21 @@ def test_consecutive_losses_reset_on_win(): def test_drawdown_check_rejects_in_check(): - rm = RiskManager(max_position_size=Decimal("0.5"), stop_loss_pct=Decimal("5"), daily_loss_limit_pct=Decimal("10"), drawdown_halt_threshold=0.15) + rm = RiskManager( + max_position_size=Decimal("0.5"), + stop_loss_pct=Decimal("5"), + daily_loss_limit_pct=Decimal("10"), + drawdown_halt_threshold=0.15, + ) rm.update_balance(Decimal("10000")) - signal = Signal(strategy="test", symbol="BTC/USDT", side=OrderSide.BUY, price=Decimal("50000"), quantity=Decimal("0.01"), reason="test") + signal = Signal( + strategy="test", + symbol="BTC/USDT", + side=OrderSide.BUY, + price=Decimal("50000"), + quantity=Decimal("0.01"), + reason="test", + ) result = rm.check(signal, Decimal("8000"), {}, Decimal("0")) # 20% dd > 15% assert not result.allowed assert "halted" in result.reason.lower() 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(): diff --git a/shared/src/shared/config.py b/shared/src/shared/config.py index 6023755..def70b9 100644 --- a/shared/src/shared/config.py +++ b/shared/src/shared/config.py @@ -36,5 +36,7 @@ class Settings(BaseSettings): circuit_breaker_threshold: int = 5 circuit_breaker_timeout: int = 60 metrics_auth_token: str = "" # If set, /health and /metrics require Bearer token + cryptopanic_api_key: str = "" # Free key from cryptopanic.com + cryptoquant_api_key: str = "" # Free key from cryptoquant.com model_config = {"env_file": ".env", "env_file_encoding": "utf-8"} diff --git a/shared/src/shared/sentiment.py b/shared/src/shared/sentiment.py new file mode 100644 index 0000000..bc62efe --- /dev/null +++ b/shared/src/shared/sentiment.py @@ -0,0 +1,219 @@ +"""Market sentiment data from free APIs. + +Supports: +- Fear & Greed Index (alternative.me) — no API key needed +- CryptoPanic news sentiment — free API key from cryptopanic.com +- CryptoQuant exchange netflow — free API key from cryptoquant.com + +All providers are optional. If API key is missing, the provider is disabled. +""" + +import logging +from dataclasses import dataclass, field +from datetime import datetime, timezone + +import aiohttp + +logger = logging.getLogger(__name__) + + +@dataclass +class SentimentData: + """Aggregated sentiment snapshot.""" + + fear_greed_value: int | None = None # 0-100 + fear_greed_label: str | None = ( + None # "Extreme Fear", "Fear", "Neutral", "Greed", "Extreme Greed" + ) + news_sentiment: float | None = None # -1.0 (bearish) to 1.0 (bullish) + news_count: int = 0 + exchange_netflow: float | None = ( + None # Positive = inflow (bearish), Negative = outflow (bullish) + ) + timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + @property + def should_buy(self) -> bool: + """Simple aggregated buy signal from sentiment.""" + score = 0 + checks = 0 + + if self.fear_greed_value is not None: + checks += 1 + if self.fear_greed_value < 30: + score += 1 # Fear = buy opportunity + elif self.fear_greed_value > 70: + score -= 1 # Greed = avoid buying + + if self.news_sentiment is not None: + checks += 1 + if self.news_sentiment > 0.1: + score += 1 # Positive news + elif self.news_sentiment < -0.3: + score -= 1 # Very negative news = avoid + + if self.exchange_netflow is not None: + checks += 1 + if self.exchange_netflow < 0: + score += 1 # Outflow = bullish (coins leaving exchanges) + elif self.exchange_netflow > 0: + score -= 1 # Inflow = bearish (coins entering exchanges to sell) + + if checks == 0: + return True # No data, allow by default + + return score >= 0 # Net neutral or positive = allow + + @property + def should_block(self) -> bool: + """Strong bearish signal — block all buying.""" + # Block on extreme greed + if self.fear_greed_value is not None and self.fear_greed_value > 80: + return True + # Block on very negative news + if self.news_sentiment is not None and self.news_sentiment < -0.5: + return True + return False + + +class SentimentProvider: + """Fetches sentiment data from multiple free APIs.""" + + def __init__( + self, + cryptopanic_api_key: str = "", + cryptoquant_api_key: str = "", + ) -> None: + self._cryptopanic_key = cryptopanic_api_key + self._cryptoquant_key = cryptoquant_api_key + self._session: aiohttp.ClientSession | None = None + self._cached: SentimentData | None = None + self._cache_ttl: int = 300 # 5 minutes cache + + async def _ensure_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession() + return self._session + + async def fetch_fear_greed(self) -> tuple[int | None, str | None]: + """Fetch Fear & Greed Index from alternative.me (no key needed).""" + try: + session = await self._ensure_session() + url = "https://api.alternative.me/fng/?limit=1" + async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp: + if resp.status != 200: + logger.warning("Fear & Greed API returned %d", resp.status) + return None, None + data = await resp.json() + entry = data.get("data", [{}])[0] + value = int(entry.get("value", 0)) + label = entry.get("value_classification", "") + return value, label + except Exception as exc: + logger.warning("Fear & Greed fetch failed: %s", exc) + return None, None + + async def fetch_news_sentiment(self, currency: str = "SOL") -> tuple[float | None, int]: + """Fetch news sentiment from CryptoPanic. + + Returns (sentiment_score, news_count). + Sentiment score: -1.0 (all bearish) to 1.0 (all bullish). + """ + if not self._cryptopanic_key: + return None, 0 + + try: + session = await self._ensure_session() + url = ( + f"https://cryptopanic.com/api/v1/posts/" + f"?auth_token={self._cryptopanic_key}" + f"¤cies={currency}" + f"&kind=news" + f"&filter=hot" + f"&limit=10" + ) + async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp: + if resp.status != 200: + logger.warning("CryptoPanic API returned %d", resp.status) + return None, 0 + data = await resp.json() + results = data.get("results", []) + if not results: + return None, 0 + + # CryptoPanic provides votes: positive, negative, important, etc. + total_positive = 0 + total_negative = 0 + count = 0 + for post in results: + votes = post.get("votes", {}) + pos = votes.get("positive", 0) + neg = votes.get("negative", 0) + total_positive += pos + total_negative += neg + count += 1 + + total_votes = total_positive + total_negative + if total_votes == 0: + return 0.0, count + + sentiment = (total_positive - total_negative) / total_votes + return sentiment, count + except Exception as exc: + logger.warning("CryptoPanic fetch failed: %s", exc) + return None, 0 + + async def fetch_exchange_netflow(self, symbol: str = "sol") -> float | None: + """Fetch exchange netflow from CryptoQuant. + + Returns netflow value. Positive = inflow (bearish), Negative = outflow (bullish). + """ + if not self._cryptoquant_key: + return None + + try: + session = await self._ensure_session() + url = ( + f"https://api.cryptoquant.com/v1/{symbol}/exchange-flows/netflow?window=day&limit=1" + ) + headers = {"Authorization": f"Bearer {self._cryptoquant_key}"} + async with session.get( + url, headers=headers, timeout=aiohttp.ClientTimeout(total=10) + ) as resp: + if resp.status != 200: + logger.warning("CryptoQuant API returned %d", resp.status) + return None + data = await resp.json() + result = data.get("result", {}).get("data", []) + if result: + return float(result[0].get("netflow", 0)) + return None + except Exception as exc: + logger.warning("CryptoQuant fetch failed: %s", exc) + return None + + async def get_sentiment(self, currency: str = "SOL") -> SentimentData: + """Fetch all sentiment data and return aggregated result.""" + fg_value, fg_label = await self.fetch_fear_greed() + news_score, news_count = await self.fetch_news_sentiment(currency) + netflow = await self.fetch_exchange_netflow(currency.lower()) + + sentiment = SentimentData( + fear_greed_value=fg_value, + fear_greed_label=fg_label, + news_sentiment=news_score, + news_count=news_count, + exchange_netflow=netflow, + ) + + self._cached = sentiment + return sentiment + + @property + def cached(self) -> SentimentData | None: + """Return last fetched sentiment data.""" + return self._cached + + async def close(self) -> None: + if self._session and not self._session.closed: + await self._session.close() diff --git a/shared/tests/test_sentiment.py b/shared/tests/test_sentiment.py new file mode 100644 index 0000000..2caa266 --- /dev/null +++ b/shared/tests/test_sentiment.py @@ -0,0 +1,144 @@ +"""Tests for market sentiment module.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock +from shared.sentiment import SentimentData, SentimentProvider + + +# --- SentimentData tests --- + + +def test_sentiment_should_buy_on_fear(): + s = SentimentData(fear_greed_value=15) # Extreme fear + assert s.should_buy is True + + +def test_sentiment_should_not_buy_on_greed(): + s = SentimentData(fear_greed_value=75) # Greed + assert s.should_buy is False + + +def test_sentiment_should_block_extreme_greed(): + s = SentimentData(fear_greed_value=85) + assert s.should_block is True + + +def test_sentiment_should_block_very_negative_news(): + s = SentimentData(news_sentiment=-0.6) + assert s.should_block is True + + +def test_sentiment_no_block_on_neutral(): + s = SentimentData(fear_greed_value=50, news_sentiment=0.0) + assert s.should_block is False + + +def test_sentiment_should_buy_default_no_data(): + s = SentimentData() + assert s.should_buy is True + assert s.should_block is False + + +def test_sentiment_positive_news_allows_buy(): + s = SentimentData(fear_greed_value=50, news_sentiment=0.3) + assert s.should_buy is True + + +def test_sentiment_outflow_bullish(): + s = SentimentData(exchange_netflow=-100.0) # Outflow = bullish + assert s.should_buy is True + + +def test_sentiment_inflow_bearish(): + s = SentimentData(fear_greed_value=50, exchange_netflow=100.0) # Inflow = bearish + assert s.should_buy is False + + +# --- SentimentProvider tests --- + + +@pytest.mark.asyncio +async def test_provider_fetch_fear_greed(): + provider = SentimentProvider() + + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.json = AsyncMock( + return_value={"data": [{"value": "25", "value_classification": "Extreme Fear"}]} + ) + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=False) + + mock_session = MagicMock() + mock_session.closed = False + mock_session.get = MagicMock(return_value=mock_response) + mock_session.close = AsyncMock() + provider._session = mock_session + + value, label = await provider.fetch_fear_greed() + assert value == 25 + assert label == "Extreme Fear" + + await provider.close() + + +@pytest.mark.asyncio +async def test_provider_fetch_fear_greed_failure(): + provider = SentimentProvider() + + mock_response = AsyncMock() + mock_response.status = 500 + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=False) + + mock_session = MagicMock() + mock_session.closed = False + mock_session.get = MagicMock(return_value=mock_response) + mock_session.close = AsyncMock() + provider._session = mock_session + + value, label = await provider.fetch_fear_greed() + assert value is None + + await provider.close() + + +@pytest.mark.asyncio +async def test_provider_news_disabled_without_key(): + provider = SentimentProvider(cryptopanic_api_key="") + score, count = await provider.fetch_news_sentiment() + assert score is None + assert count == 0 + + +@pytest.mark.asyncio +async def test_provider_netflow_disabled_without_key(): + provider = SentimentProvider(cryptoquant_api_key="") + result = await provider.fetch_exchange_netflow() + assert result is None + + +@pytest.mark.asyncio +async def test_provider_get_sentiment_aggregates(): + provider = SentimentProvider() + + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.json = AsyncMock( + return_value={"data": [{"value": "20", "value_classification": "Extreme Fear"}]} + ) + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=False) + + mock_session = MagicMock() + mock_session.closed = False + mock_session.get = MagicMock(return_value=mock_response) + mock_session.close = AsyncMock() + provider._session = mock_session + + sentiment = await provider.get_sentiment("SOL") + assert sentiment.fear_greed_value == 20 + assert sentiment.fear_greed_label == "Extreme Fear" + assert provider.cached is sentiment + + await provider.close() -- cgit v1.2.3