diff options
| author | TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> | 2026-04-02 10:26:52 +0900 |
|---|---|---|
| committer | TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> | 2026-04-02 10:26:52 +0900 |
| commit | 53cadcf7e34f05f77082e84f0696b56bcbcbae36 (patch) | |
| tree | e02650e10c4d5727bc1e32e27788c17327fa46f7 /shared | |
| parent | f5521da2876a2c19afc24f370b3258f2be95f81a (diff) | |
refactor: remove all crypto/Binance code, update to US stock symbols
Diffstat (limited to 'shared')
| -rw-r--r-- | shared/src/shared/sentiment.py | 204 | ||||
| -rw-r--r-- | shared/tests/test_db.py | 12 | ||||
| -rw-r--r-- | shared/tests/test_events.py | 10 | ||||
| -rw-r--r-- | shared/tests/test_models.py | 16 | ||||
| -rw-r--r-- | shared/tests/test_sentiment.py | 136 |
5 files changed, 47 insertions, 331 deletions
diff --git a/shared/src/shared/sentiment.py b/shared/src/shared/sentiment.py index bc62efe..8213b47 100644 --- a/shared/src/shared/sentiment.py +++ b/shared/src/shared/sentiment.py @@ -1,19 +1,9 @@ -"""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. -""" +"""Market sentiment data.""" import logging from dataclasses import dataclass, field from datetime import datetime, timezone -import aiohttp - logger = logging.getLogger(__name__) @@ -21,199 +11,25 @@ logger = logging.getLogger(__name__) 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) + fear_greed_value: int | None = None + fear_greed_label: str | None = None + news_sentiment: float | None = None news_count: int = 0 - exchange_netflow: float | None = ( - None # Positive = inflow (bearish), Negative = outflow (bullish) - ) + exchange_netflow: float | None = None 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 + if self.fear_greed_value is not None and self.fear_greed_value > 70: + return False + if self.news_sentiment is not None and self.news_sentiment < -0.3: + return False + return True @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_db.py b/shared/tests/test_db.py index d33dfe1..239ee64 100644 --- a/shared/tests/test_db.py +++ b/shared/tests/test_db.py @@ -10,7 +10,7 @@ def make_candle(): from shared.models import Candle return Candle( - symbol="BTCUSDT", + symbol="AAPL", timeframe="1m", open_time=datetime(2024, 1, 1, tzinfo=timezone.utc), open=Decimal("50000"), @@ -27,7 +27,7 @@ def make_signal(): return Signal( id="sig-1", strategy="ma_cross", - symbol="BTCUSDT", + symbol="AAPL", side=OrderSide.BUY, price=Decimal("50000"), quantity=Decimal("0.1"), @@ -42,7 +42,7 @@ def make_order(): return Order( id="ord-1", signal_id="sig-1", - symbol="BTCUSDT", + symbol="AAPL", side=OrderSide.BUY, type=OrderType.LIMIT, price=Decimal("50000"), @@ -228,7 +228,7 @@ class TestGetCandles: # Create a mock row that behaves like a SA result row mock_row = MagicMock() mock_row._mapping = { - "symbol": "BTCUSDT", + "symbol": "AAPL", "timeframe": "1m", "open_time": datetime(2024, 1, 1, tzinfo=timezone.utc), "open": Decimal("50000"), @@ -248,11 +248,11 @@ class TestGetCandles: db._session_factory = MagicMock(return_value=mock_session) - result = await db.get_candles("BTCUSDT", "1m", 500) + result = await db.get_candles("AAPL", "1m", 500) assert isinstance(result, list) assert len(result) == 1 - assert result[0]["symbol"] == "BTCUSDT" + assert result[0]["symbol"] == "AAPL" mock_session.execute.assert_awaited_once() diff --git a/shared/tests/test_events.py b/shared/tests/test_events.py index ab7792b..6077d93 100644 --- a/shared/tests/test_events.py +++ b/shared/tests/test_events.py @@ -8,7 +8,7 @@ def make_candle(): from shared.models import Candle return Candle( - symbol="BTCUSDT", + symbol="AAPL", timeframe="1m", open_time=datetime(2024, 1, 1, tzinfo=timezone.utc), open=Decimal("50000"), @@ -24,7 +24,7 @@ def make_signal(): return Signal( strategy="test", - symbol="BTCUSDT", + symbol="AAPL", side=OrderSide.BUY, price=Decimal("50000"), quantity=Decimal("0.01"), @@ -40,7 +40,7 @@ def test_candle_event_serialize(): event = CandleEvent(data=candle) d = event.to_dict() assert d["type"] == EventType.CANDLE - assert d["data"]["symbol"] == "BTCUSDT" + assert d["data"]["symbol"] == "AAPL" assert d["data"]["timeframe"] == "1m" @@ -53,7 +53,7 @@ def test_candle_event_deserialize(): d = event.to_dict() restored = CandleEvent.from_raw(d) assert restored.type == EventType.CANDLE - assert restored.data.symbol == "BTCUSDT" + assert restored.data.symbol == "AAPL" assert restored.data.close == Decimal("50500") @@ -65,7 +65,7 @@ def test_signal_event_serialize(): event = SignalEvent(data=signal) d = event.to_dict() assert d["type"] == EventType.SIGNAL - assert d["data"]["symbol"] == "BTCUSDT" + assert d["data"]["symbol"] == "AAPL" assert d["data"]["strategy"] == "test" diff --git a/shared/tests/test_models.py b/shared/tests/test_models.py index 2b8cd5e..04098ce 100644 --- a/shared/tests/test_models.py +++ b/shared/tests/test_models.py @@ -27,7 +27,7 @@ def test_candle_creation(): now = datetime.now(timezone.utc) candle = Candle( - symbol="BTCUSDT", + symbol="AAPL", timeframe="1m", open_time=now, open=Decimal("50000.00"), @@ -36,7 +36,7 @@ def test_candle_creation(): close=Decimal("50500.00"), volume=Decimal("100.5"), ) - assert candle.symbol == "BTCUSDT" + assert candle.symbol == "AAPL" assert candle.timeframe == "1m" assert candle.open == Decimal("50000.00") assert candle.high == Decimal("51000.00") @@ -51,14 +51,14 @@ def test_signal_creation(): signal = Signal( strategy="rsi_strategy", - symbol="BTCUSDT", + symbol="AAPL", side=OrderSide.BUY, price=Decimal("50000.00"), quantity=Decimal("0.01"), reason="RSI oversold", ) assert signal.strategy == "rsi_strategy" - assert signal.symbol == "BTCUSDT" + assert signal.symbol == "AAPL" assert signal.side == OrderSide.BUY assert signal.price == Decimal("50000.00") assert signal.quantity == Decimal("0.01") @@ -75,7 +75,7 @@ def test_order_creation(): signal_id = str(uuid.uuid4()) order = Order( signal_id=signal_id, - symbol="BTCUSDT", + symbol="AAPL", side=OrderSide.BUY, type=OrderType.MARKET, price=Decimal("50000.00"), @@ -94,7 +94,7 @@ def test_signal_conviction_default(): signal = Signal( strategy="rsi", - symbol="BTCUSDT", + symbol="AAPL", side=OrderSide.BUY, price=Decimal("50000"), quantity=Decimal("0.01"), @@ -111,7 +111,7 @@ def test_signal_with_stops(): signal = Signal( strategy="rsi", - symbol="BTCUSDT", + symbol="AAPL", side=OrderSide.BUY, price=Decimal("50000"), quantity=Decimal("0.01"), @@ -130,7 +130,7 @@ def test_position_unrealized_pnl(): from shared.models import Position position = Position( - symbol="BTCUSDT", + symbol="AAPL", quantity=Decimal("0.1"), avg_entry_price=Decimal("50000"), current_price=Decimal("51000"), diff --git a/shared/tests/test_sentiment.py b/shared/tests/test_sentiment.py index 2caa266..9bd8ea3 100644 --- a/shared/tests/test_sentiment.py +++ b/shared/tests/test_sentiment.py @@ -1,23 +1,34 @@ """Tests for market sentiment module.""" -import pytest -from unittest.mock import AsyncMock, MagicMock -from shared.sentiment import SentimentData, SentimentProvider +from shared.sentiment import SentimentData -# --- SentimentData tests --- +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_should_buy_on_fear(): - s = SentimentData(fear_greed_value=15) # Extreme fear +def test_sentiment_should_buy_low_fear_greed(): + s = SentimentData(fear_greed_value=15) assert s.should_buy is True def test_sentiment_should_not_buy_on_greed(): - s = SentimentData(fear_greed_value=75) # Greed + s = SentimentData(fear_greed_value=75) + assert s.should_buy is False + + +def test_sentiment_should_not_buy_negative_news(): + s = SentimentData(news_sentiment=-0.4) assert s.should_buy is False +def test_sentiment_should_buy_positive_news(): + s = SentimentData(fear_greed_value=50, news_sentiment=0.3) + assert s.should_buy is True + + def test_sentiment_should_block_extreme_greed(): s = SentimentData(fear_greed_value=85) assert s.should_block is True @@ -31,114 +42,3 @@ def test_sentiment_should_block_very_negative_news(): 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() |
