summaryrefslogtreecommitdiff
path: root/shared/tests
diff options
context:
space:
mode:
Diffstat (limited to 'shared/tests')
-rw-r--r--shared/tests/test_broker.py6
-rw-r--r--shared/tests/test_db.py12
-rw-r--r--shared/tests/test_db_news.py78
-rw-r--r--shared/tests/test_events.py10
-rw-r--r--shared/tests/test_models.py16
-rw-r--r--shared/tests/test_news_events.py56
-rw-r--r--shared/tests/test_notifier.py12
-rw-r--r--shared/tests/test_resilience.py139
-rw-r--r--shared/tests/test_sa_models.py51
-rw-r--r--shared/tests/test_sa_news_models.py29
-rw-r--r--shared/tests/test_sentiment.py144
-rw-r--r--shared/tests/test_sentiment_aggregator.py77
-rw-r--r--shared/tests/test_sentiment_models.py113
13 files changed, 385 insertions, 358 deletions
diff --git a/shared/tests/test_broker.py b/shared/tests/test_broker.py
index 9be84b0..eb1582d 100644
--- a/shared/tests/test_broker.py
+++ b/shared/tests/test_broker.py
@@ -16,7 +16,7 @@ async def test_broker_publish():
from shared.broker import RedisBroker
broker = RedisBroker("redis://localhost:6379")
- data = {"type": "CANDLE", "symbol": "BTCUSDT"}
+ data = {"type": "CANDLE", "symbol": "AAPL"}
await broker.publish("candles", data)
mock_redis.xadd.assert_called_once()
@@ -35,7 +35,7 @@ async def test_broker_subscribe_returns_messages():
mock_redis = AsyncMock()
mock_from_url.return_value = mock_redis
- payload_data = {"type": "CANDLE", "symbol": "ETHUSDT"}
+ payload_data = {"type": "CANDLE", "symbol": "MSFT"}
mock_redis.xread.return_value = [
[
b"candles",
@@ -53,7 +53,7 @@ async def test_broker_subscribe_returns_messages():
mock_redis.xread.assert_called_once()
assert len(messages) == 1
assert messages[0]["type"] == "CANDLE"
- assert messages[0]["symbol"] == "ETHUSDT"
+ assert messages[0]["symbol"] == "MSFT"
@pytest.mark.asyncio
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_db_news.py b/shared/tests/test_db_news.py
new file mode 100644
index 0000000..a2c9140
--- /dev/null
+++ b/shared/tests/test_db_news.py
@@ -0,0 +1,78 @@
+"""Tests for database news/sentiment methods. Uses in-memory SQLite."""
+
+import pytest
+from datetime import datetime, date, timezone
+
+from shared.db import Database
+from shared.models import NewsItem, NewsCategory
+from shared.sentiment_models import SymbolScore, MarketSentiment
+
+
+@pytest.fixture
+async def db():
+ database = Database("sqlite+aiosqlite://")
+ await database.connect()
+ yield database
+ await database.close()
+
+
+async def test_insert_and_get_news_items(db):
+ item = NewsItem(
+ source="finnhub",
+ headline="AAPL earnings beat",
+ published_at=datetime(2026, 4, 2, 12, 0, tzinfo=timezone.utc),
+ sentiment=0.8,
+ category=NewsCategory.EARNINGS,
+ symbols=["AAPL"],
+ )
+ await db.insert_news_item(item)
+ items = await db.get_recent_news(hours=24)
+ assert len(items) == 1
+ assert items[0]["headline"] == "AAPL earnings beat"
+
+
+async def test_upsert_symbol_score(db):
+ score = SymbolScore(
+ symbol="AAPL",
+ news_score=0.5,
+ news_count=10,
+ social_score=0.3,
+ policy_score=0.0,
+ filing_score=0.2,
+ composite=0.3,
+ updated_at=datetime(2026, 4, 2, tzinfo=timezone.utc),
+ )
+ await db.upsert_symbol_score(score)
+ scores = await db.get_top_symbol_scores(limit=5)
+ assert len(scores) == 1
+ assert scores[0]["symbol"] == "AAPL"
+
+
+async def test_upsert_market_sentiment(db):
+ ms = MarketSentiment(
+ fear_greed=55,
+ fear_greed_label="Neutral",
+ vix=18.2,
+ fed_stance="neutral",
+ market_regime="neutral",
+ updated_at=datetime(2026, 4, 2, tzinfo=timezone.utc),
+ )
+ await db.upsert_market_sentiment(ms)
+ result = await db.get_latest_market_sentiment()
+ assert result is not None
+ assert result["fear_greed"] == 55
+
+
+async def test_insert_stock_selection(db):
+ await db.insert_stock_selection(
+ trade_date=date(2026, 4, 2),
+ symbol="NVDA",
+ side="BUY",
+ conviction=0.85,
+ reason="CHIPS Act",
+ key_news=["Trump signs CHIPS expansion"],
+ sentiment_snapshot={"composite": 0.8},
+ )
+ selections = await db.get_stock_selections(date(2026, 4, 2))
+ assert len(selections) == 1
+ assert selections[0]["symbol"] == "NVDA"
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_news_events.py b/shared/tests/test_news_events.py
new file mode 100644
index 0000000..384796a
--- /dev/null
+++ b/shared/tests/test_news_events.py
@@ -0,0 +1,56 @@
+"""Tests for NewsEvent."""
+
+from datetime import datetime, timezone
+
+from shared.models import NewsCategory, NewsItem
+from shared.events import NewsEvent, EventType, Event
+
+
+def test_news_event_to_dict():
+ item = NewsItem(
+ source="finnhub",
+ headline="Test",
+ published_at=datetime(2026, 4, 2, tzinfo=timezone.utc),
+ sentiment=0.5,
+ category=NewsCategory.MACRO,
+ )
+ event = NewsEvent(data=item)
+ d = event.to_dict()
+ assert d["type"] == EventType.NEWS
+ assert d["data"]["source"] == "finnhub"
+
+
+def test_news_event_from_raw():
+ raw = {
+ "type": "NEWS",
+ "data": {
+ "id": "abc",
+ "source": "rss",
+ "headline": "Test headline",
+ "published_at": "2026-04-02T00:00:00+00:00",
+ "sentiment": 0.3,
+ "category": "earnings",
+ "symbols": ["AAPL"],
+ "raw_data": {},
+ },
+ }
+ event = NewsEvent.from_raw(raw)
+ assert event.data.source == "rss"
+ assert event.data.symbols == ["AAPL"]
+
+
+def test_event_dispatcher_news():
+ raw = {
+ "type": "NEWS",
+ "data": {
+ "id": "abc",
+ "source": "finnhub",
+ "headline": "Test",
+ "published_at": "2026-04-02T00:00:00+00:00",
+ "sentiment": 0.0,
+ "category": "macro",
+ "raw_data": {},
+ },
+ }
+ event = Event.from_dict(raw)
+ assert isinstance(event, NewsEvent)
diff --git a/shared/tests/test_notifier.py b/shared/tests/test_notifier.py
index 3d29830..6c81369 100644
--- a/shared/tests/test_notifier.py
+++ b/shared/tests/test_notifier.py
@@ -86,7 +86,7 @@ class TestTelegramNotifierFormatters:
notifier = TelegramNotifier(bot_token="fake-token", chat_id="123")
signal = Signal(
strategy="rsi_strategy",
- symbol="BTCUSDT",
+ symbol="AAPL",
side=OrderSide.BUY,
price=Decimal("50000.00"),
quantity=Decimal("0.01"),
@@ -99,7 +99,7 @@ class TestTelegramNotifierFormatters:
msg = mock_send.call_args[0][0]
assert "BUY" in msg
assert "rsi_strategy" in msg
- assert "BTCUSDT" in msg
+ assert "AAPL" in msg
assert "50000.00" in msg
assert "0.01" in msg
assert "RSI oversold" in msg
@@ -109,7 +109,7 @@ class TestTelegramNotifierFormatters:
notifier = TelegramNotifier(bot_token="fake-token", chat_id="123")
order = Order(
signal_id=str(uuid.uuid4()),
- symbol="ETHUSDT",
+ symbol="MSFT",
side=OrderSide.SELL,
type=OrderType.LIMIT,
price=Decimal("3000.50"),
@@ -122,7 +122,7 @@ class TestTelegramNotifierFormatters:
mock_send.assert_called_once()
msg = mock_send.call_args[0][0]
assert "FILLED" in msg
- assert "ETHUSDT" in msg
+ assert "MSFT" in msg
assert "SELL" in msg
assert "3000.50" in msg
assert "1.5" in msg
@@ -143,7 +143,7 @@ class TestTelegramNotifierFormatters:
notifier = TelegramNotifier(bot_token="fake-token", chat_id="123")
positions = [
Position(
- symbol="BTCUSDT",
+ symbol="AAPL",
quantity=Decimal("0.1"),
avg_entry_price=Decimal("50000"),
current_price=Decimal("51000"),
@@ -158,7 +158,7 @@ class TestTelegramNotifierFormatters:
)
mock_send.assert_called_once()
msg = mock_send.call_args[0][0]
- assert "BTCUSDT" in msg
+ assert "AAPL" in msg
assert "5100.00" in msg
assert "100.00" in msg
diff --git a/shared/tests/test_resilience.py b/shared/tests/test_resilience.py
deleted file mode 100644
index e287777..0000000
--- a/shared/tests/test_resilience.py
+++ /dev/null
@@ -1,139 +0,0 @@
-"""Tests for retry with backoff and circuit breaker."""
-
-import time
-
-import pytest
-
-from shared.resilience import CircuitBreaker, CircuitState, retry_with_backoff
-
-
-# ---------------------------------------------------------------------------
-# retry_with_backoff tests
-# ---------------------------------------------------------------------------
-
-
-@pytest.mark.asyncio
-async def test_retry_succeeds_first_try():
- call_count = 0
-
- @retry_with_backoff(max_retries=3, base_delay=0.01)
- async def succeed():
- nonlocal call_count
- call_count += 1
- return "ok"
-
- result = await succeed()
- assert result == "ok"
- assert call_count == 1
-
-
-@pytest.mark.asyncio
-async def test_retry_succeeds_after_failures():
- call_count = 0
-
- @retry_with_backoff(max_retries=3, base_delay=0.01)
- async def flaky():
- nonlocal call_count
- call_count += 1
- if call_count < 3:
- raise ValueError("not yet")
- return "recovered"
-
- result = await flaky()
- assert result == "recovered"
- assert call_count == 3
-
-
-@pytest.mark.asyncio
-async def test_retry_raises_after_max_retries():
- call_count = 0
-
- @retry_with_backoff(max_retries=3, base_delay=0.01)
- async def always_fail():
- nonlocal call_count
- call_count += 1
- raise RuntimeError("permanent")
-
- with pytest.raises(RuntimeError, match="permanent"):
- await always_fail()
- # 1 initial + 3 retries = 4 calls
- assert call_count == 4
-
-
-@pytest.mark.asyncio
-async def test_retry_respects_max_delay():
- """Backoff should be capped at max_delay."""
-
- @retry_with_backoff(max_retries=2, base_delay=0.01, max_delay=0.02)
- async def always_fail():
- raise RuntimeError("fail")
-
- start = time.monotonic()
- with pytest.raises(RuntimeError):
- await always_fail()
- elapsed = time.monotonic() - start
- # With max_delay=0.02 and 2 retries, total delay should be small
- assert elapsed < 0.5
-
-
-# ---------------------------------------------------------------------------
-# CircuitBreaker tests
-# ---------------------------------------------------------------------------
-
-
-def test_circuit_starts_closed():
- cb = CircuitBreaker(failure_threshold=3, recovery_timeout=0.05)
- assert cb.state == CircuitState.CLOSED
- assert cb.allow_request() is True
-
-
-def test_circuit_opens_after_threshold():
- cb = CircuitBreaker(failure_threshold=3, recovery_timeout=60.0)
- for _ in range(3):
- cb.record_failure()
- assert cb.state == CircuitState.OPEN
- assert cb.allow_request() is False
-
-
-def test_circuit_rejects_when_open():
- cb = CircuitBreaker(failure_threshold=2, recovery_timeout=60.0)
- cb.record_failure()
- cb.record_failure()
- assert cb.state == CircuitState.OPEN
- assert cb.allow_request() is False
-
-
-def test_circuit_half_open_after_timeout():
- cb = CircuitBreaker(failure_threshold=2, recovery_timeout=0.05)
- cb.record_failure()
- cb.record_failure()
- assert cb.state == CircuitState.OPEN
-
- time.sleep(0.06)
- assert cb.allow_request() is True
- assert cb.state == CircuitState.HALF_OPEN
-
-
-def test_circuit_closes_on_success():
- cb = CircuitBreaker(failure_threshold=2, recovery_timeout=0.05)
- cb.record_failure()
- cb.record_failure()
- assert cb.state == CircuitState.OPEN
-
- time.sleep(0.06)
- cb.allow_request() # triggers HALF_OPEN
- assert cb.state == CircuitState.HALF_OPEN
-
- cb.record_success()
- assert cb.state == CircuitState.CLOSED
- assert cb.allow_request() is True
-
-
-def test_circuit_reopens_on_failure_in_half_open():
- cb = CircuitBreaker(failure_threshold=2, recovery_timeout=0.05)
- cb.record_failure()
- cb.record_failure()
- time.sleep(0.06)
- cb.allow_request() # HALF_OPEN
- cb.record_failure()
- assert cb.state == CircuitState.OPEN
diff --git a/shared/tests/test_sa_models.py b/shared/tests/test_sa_models.py
index 67c3c82..ae73833 100644
--- a/shared/tests/test_sa_models.py
+++ b/shared/tests/test_sa_models.py
@@ -11,9 +11,12 @@ def test_base_metadata_has_all_tables():
"candles",
"signals",
"orders",
- "trades",
"positions",
"portfolio_snapshots",
+ "news_items",
+ "symbol_scores",
+ "market_sentiment",
+ "stock_selections",
}
assert expected == table_names
@@ -120,44 +123,6 @@ class TestOrderRow:
assert fk_cols == {"signal_id": "signals.id"}
-class TestTradeRow:
- def test_table_name(self):
- from shared.sa_models import TradeRow
-
- assert TradeRow.__tablename__ == "trades"
-
- def test_columns(self):
- from shared.sa_models import TradeRow
-
- mapper = inspect(TradeRow)
- cols = {c.key for c in mapper.column_attrs}
- expected = {
- "id",
- "order_id",
- "symbol",
- "side",
- "price",
- "quantity",
- "fee",
- "traded_at",
- }
- assert expected == cols
-
- def test_primary_key(self):
- from shared.sa_models import TradeRow
-
- mapper = inspect(TradeRow)
- pk_cols = [c.name for c in mapper.mapper.primary_key]
- assert pk_cols == ["id"]
-
- def test_order_id_foreign_key(self):
- from shared.sa_models import TradeRow
-
- table = TradeRow.__table__
- fk_cols = {fk.parent.name: fk.target_fullname for fk in table.foreign_keys}
- assert fk_cols == {"order_id": "orders.id"}
-
-
class TestPositionRow:
def test_table_name(self):
from shared.sa_models import PositionRow
@@ -229,11 +194,3 @@ class TestStatusDefault:
status_col = table.c.status
assert status_col.server_default is not None
assert status_col.server_default.arg == "PENDING"
-
- def test_trade_fee_server_default(self):
- from shared.sa_models import TradeRow
-
- table = TradeRow.__table__
- fee_col = table.c.fee
- assert fee_col.server_default is not None
- assert fee_col.server_default.arg == "0"
diff --git a/shared/tests/test_sa_news_models.py b/shared/tests/test_sa_news_models.py
new file mode 100644
index 0000000..91e6d4a
--- /dev/null
+++ b/shared/tests/test_sa_news_models.py
@@ -0,0 +1,29 @@
+"""Tests for news-related SQLAlchemy models."""
+
+from shared.sa_models import NewsItemRow, SymbolScoreRow, MarketSentimentRow, StockSelectionRow
+
+
+def test_news_item_row_tablename():
+ assert NewsItemRow.__tablename__ == "news_items"
+
+
+def test_symbol_score_row_tablename():
+ assert SymbolScoreRow.__tablename__ == "symbol_scores"
+
+
+def test_market_sentiment_row_tablename():
+ assert MarketSentimentRow.__tablename__ == "market_sentiment"
+
+
+def test_stock_selection_row_tablename():
+ assert StockSelectionRow.__tablename__ == "stock_selections"
+
+
+def test_news_item_row_columns():
+ cols = {c.name for c in NewsItemRow.__table__.columns}
+ assert cols >= {"id", "source", "headline", "published_at", "sentiment", "category"}
+
+
+def test_symbol_score_row_columns():
+ cols = {c.name for c in SymbolScoreRow.__table__.columns}
+ assert cols >= {"id", "symbol", "news_score", "composite", "updated_at"}
diff --git a/shared/tests/test_sentiment.py b/shared/tests/test_sentiment.py
deleted file mode 100644
index 2caa266..0000000
--- a/shared/tests/test_sentiment.py
+++ /dev/null
@@ -1,144 +0,0 @@
-"""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()
diff --git a/shared/tests/test_sentiment_aggregator.py b/shared/tests/test_sentiment_aggregator.py
new file mode 100644
index 0000000..a99c711
--- /dev/null
+++ b/shared/tests/test_sentiment_aggregator.py
@@ -0,0 +1,77 @@
+"""Tests for sentiment aggregator."""
+
+import pytest
+from datetime import datetime, timezone, timedelta
+from shared.sentiment import SentimentAggregator
+
+
+@pytest.fixture
+def aggregator():
+ return SentimentAggregator()
+
+
+def test_freshness_decay_recent():
+ a = SentimentAggregator()
+ now = datetime.now(timezone.utc)
+ assert a._freshness_decay(now, now) == 1.0
+
+
+def test_freshness_decay_3_hours():
+ a = SentimentAggregator()
+ now = datetime.now(timezone.utc)
+ assert a._freshness_decay(now - timedelta(hours=3), now) == 0.7
+
+
+def test_freshness_decay_12_hours():
+ a = SentimentAggregator()
+ now = datetime.now(timezone.utc)
+ assert a._freshness_decay(now - timedelta(hours=12), now) == 0.3
+
+
+def test_freshness_decay_old():
+ a = SentimentAggregator()
+ now = datetime.now(timezone.utc)
+ assert a._freshness_decay(now - timedelta(days=2), now) == 0.0
+
+
+def test_compute_composite():
+ a = SentimentAggregator()
+ composite = a._compute_composite(
+ news_score=0.5, social_score=0.3, policy_score=0.8, filing_score=0.2
+ )
+ expected = 0.5 * 0.3 + 0.3 * 0.2 + 0.8 * 0.3 + 0.2 * 0.2
+ assert abs(composite - expected) < 0.001
+
+
+def test_aggregate_news_by_symbol(aggregator):
+ now = datetime.now(timezone.utc)
+ news_items = [
+ {"symbols": ["AAPL"], "sentiment": 0.8, "category": "earnings", "published_at": now},
+ {
+ "symbols": ["AAPL"],
+ "sentiment": 0.3,
+ "category": "macro",
+ "published_at": now - timedelta(hours=2),
+ },
+ {"symbols": ["MSFT"], "sentiment": -0.5, "category": "policy", "published_at": now},
+ ]
+ scores = aggregator.aggregate(news_items, now)
+ assert "AAPL" in scores
+ assert "MSFT" in scores
+ assert scores["AAPL"].news_count == 2
+ assert scores["AAPL"].news_score > 0
+ assert scores["MSFT"].policy_score < 0
+
+
+def test_aggregate_empty(aggregator):
+ now = datetime.now(timezone.utc)
+ assert aggregator.aggregate([], now) == {}
+
+
+def test_determine_regime():
+ a = SentimentAggregator()
+ assert a.determine_regime(15, None) == "risk_off"
+ assert a.determine_regime(15, 35.0) == "risk_off"
+ assert a.determine_regime(50, 35.0) == "risk_off"
+ assert a.determine_regime(70, 15.0) == "risk_on"
+ assert a.determine_regime(50, 20.0) == "neutral"
diff --git a/shared/tests/test_sentiment_models.py b/shared/tests/test_sentiment_models.py
new file mode 100644
index 0000000..25fc371
--- /dev/null
+++ b/shared/tests/test_sentiment_models.py
@@ -0,0 +1,113 @@
+"""Tests for news and sentiment models."""
+
+from datetime import datetime, timezone
+
+from shared.models import NewsCategory, NewsItem, OrderSide
+from shared.sentiment_models import SymbolScore, MarketSentiment, SelectedStock, Candidate
+
+
+def test_news_item_defaults():
+ item = NewsItem(
+ source="finnhub",
+ headline="Test headline",
+ published_at=datetime(2026, 4, 2, tzinfo=timezone.utc),
+ sentiment=0.5,
+ category=NewsCategory.MACRO,
+ )
+ assert item.id
+ assert item.symbols == []
+ assert item.summary is None
+ assert item.raw_data == {}
+ assert item.created_at is not None
+
+
+def test_news_item_with_symbols():
+ item = NewsItem(
+ source="rss",
+ headline="AAPL earnings beat",
+ published_at=datetime(2026, 4, 2, tzinfo=timezone.utc),
+ sentiment=0.8,
+ category=NewsCategory.EARNINGS,
+ symbols=["AAPL"],
+ )
+ assert item.symbols == ["AAPL"]
+ assert item.category == NewsCategory.EARNINGS
+
+
+def test_news_category_values():
+ assert NewsCategory.POLICY == "policy"
+ assert NewsCategory.EARNINGS == "earnings"
+ assert NewsCategory.MACRO == "macro"
+ assert NewsCategory.SOCIAL == "social"
+ assert NewsCategory.FILING == "filing"
+ assert NewsCategory.FED == "fed"
+
+
+def test_symbol_score():
+ score = SymbolScore(
+ symbol="AAPL",
+ news_score=0.5,
+ news_count=10,
+ social_score=0.3,
+ policy_score=0.0,
+ filing_score=0.2,
+ composite=0.3,
+ updated_at=datetime(2026, 4, 2, tzinfo=timezone.utc),
+ )
+ assert score.symbol == "AAPL"
+ assert score.composite == 0.3
+
+
+def test_market_sentiment():
+ ms = MarketSentiment(
+ fear_greed=25,
+ fear_greed_label="Extreme Fear",
+ vix=32.5,
+ fed_stance="hawkish",
+ market_regime="risk_off",
+ updated_at=datetime(2026, 4, 2, tzinfo=timezone.utc),
+ )
+ assert ms.market_regime == "risk_off"
+ assert ms.vix == 32.5
+
+
+def test_market_sentiment_no_vix():
+ ms = MarketSentiment(
+ fear_greed=50,
+ fear_greed_label="Neutral",
+ fed_stance="neutral",
+ market_regime="neutral",
+ updated_at=datetime(2026, 4, 2, tzinfo=timezone.utc),
+ )
+ assert ms.vix is None
+
+
+def test_selected_stock():
+ ss = SelectedStock(
+ symbol="NVDA",
+ side=OrderSide.BUY,
+ conviction=0.85,
+ reason="CHIPS Act expansion",
+ key_news=["Trump signs CHIPS Act expansion"],
+ )
+ assert ss.conviction == 0.85
+ assert len(ss.key_news) == 1
+
+
+def test_candidate():
+ c = Candidate(
+ symbol="TSLA",
+ source="sentiment",
+ direction=OrderSide.BUY,
+ score=0.75,
+ reason="High social buzz",
+ )
+ assert c.direction == OrderSide.BUY
+
+ c2 = Candidate(
+ symbol="XOM",
+ source="llm",
+ score=0.6,
+ reason="Oil price surge",
+ )
+ assert c2.direction is None