feat: real data pipeline — market data, DB persistence, portfolio sync, signal-trade linkage
Wire the trading bot to real Alpaca market data and persist pipeline state to the database so the dashboard displays live information. - Add market-data service fetching OHLCV bars from Alpaca, publishing to market:bars Redis Stream; signal generator consumes bars and injects current_price into signals for position sizing - Sentiment analyzer now persists Article + ArticleSentiment rows to DB after scoring, with duplicate and error handling - API gateway runs a background portfolio sync task that snapshots Alpaca account state into PortfolioSnapshot/Position DB tables during market hours - TradeSignal carries a signal_id UUID; signal generator and trade executor both persist their records to DB with cross-references - 303 unit tests pass (57 new tests added)
This commit is contained in:
parent
5a6b20c8f1
commit
e2a3bd456d
19 changed files with 2238 additions and 72 deletions
|
|
@ -1,7 +1,7 @@
|
|||
"""Tests for the sentiment analyzer service.
|
||||
|
||||
Covers FinBERT analyzer, Ollama analyzer, ticker extraction, and the main
|
||||
service flow.
|
||||
service flow including DB persistence.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -10,6 +10,7 @@ from datetime import datetime, timezone
|
|||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from services.sentiment_analyzer.analyzers.finbert import FinBERTAnalyzer
|
||||
from services.sentiment_analyzer.analyzers.ollama_analyzer import OllamaAnalyzer
|
||||
|
|
@ -409,3 +410,229 @@ class TestMainFlow:
|
|||
publisher.publish.assert_not_called()
|
||||
# Still counted as scored
|
||||
counters["articles_scored"].add.assert_called_once_with(1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers for DB persistence tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_counters() -> dict:
|
||||
"""Create a dict of mock OpenTelemetry counter/histogram instruments."""
|
||||
return {
|
||||
"articles_scored": MagicMock(),
|
||||
"finbert_count": MagicMock(),
|
||||
"ollama_count": MagicMock(),
|
||||
"inference_latency": MagicMock(),
|
||||
}
|
||||
|
||||
|
||||
def _make_mock_db_session_factory(session: AsyncMock | None = None) -> AsyncMock:
|
||||
"""Create a mock async_sessionmaker that yields a mock session.
|
||||
|
||||
The returned factory, when called, returns an async context manager
|
||||
that yields ``session`` (a mock AsyncSession).
|
||||
"""
|
||||
if session is None:
|
||||
session = AsyncMock()
|
||||
session.add = MagicMock()
|
||||
session.commit = AsyncMock()
|
||||
|
||||
factory = MagicMock()
|
||||
|
||||
# factory() should return an async context manager (the session)
|
||||
ctx = AsyncMock()
|
||||
ctx.__aenter__ = AsyncMock(return_value=session)
|
||||
ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
factory.return_value = ctx
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB Persistence Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDBPersistence:
|
||||
"""Tests for the DB write step in process_article."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_write_creates_article_and_sentiments(self):
|
||||
"""When db_session_factory is provided, Article and ArticleSentiment rows are created."""
|
||||
finbert = AsyncMock(spec=FinBERTAnalyzer)
|
||||
finbert.analyze = AsyncMock(return_value=(0.75, 0.88))
|
||||
|
||||
ollama = AsyncMock(spec=OllamaAnalyzer)
|
||||
|
||||
publisher = AsyncMock()
|
||||
publisher.publish = AsyncMock(return_value=b"1-0")
|
||||
|
||||
config = SentimentAnalyzerConfig(
|
||||
finbert_confidence_threshold=0.6,
|
||||
otel_metrics_port=0,
|
||||
)
|
||||
|
||||
counters = _make_counters()
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.add = MagicMock()
|
||||
mock_session.commit = AsyncMock()
|
||||
|
||||
db_factory = _make_mock_db_session_factory(mock_session)
|
||||
|
||||
article = _make_raw_article(
|
||||
title="$AAPL and $MSFT report strong earnings",
|
||||
content="Both Apple and Microsoft beat estimates.",
|
||||
)
|
||||
|
||||
await process_article(
|
||||
article, finbert, ollama, publisher, config, counters, db_factory
|
||||
)
|
||||
|
||||
# session.add should be called: 1 Article + 2 ArticleSentiments = 3 calls
|
||||
assert mock_session.add.call_count == 3
|
||||
|
||||
# Verify the types of objects added
|
||||
added_objects = [call.args[0] for call in mock_session.add.call_args_list]
|
||||
|
||||
from shared.models.news import Article, ArticleSentiment
|
||||
|
||||
articles = [o for o in added_objects if isinstance(o, Article)]
|
||||
sentiments = [o for o in added_objects if isinstance(o, ArticleSentiment)]
|
||||
|
||||
assert len(articles) == 1
|
||||
assert len(sentiments) == 2
|
||||
|
||||
# Verify article fields
|
||||
db_article = articles[0]
|
||||
assert db_article.source == "test"
|
||||
assert db_article.url == "https://example.com/article"
|
||||
assert db_article.content_hash == "abc123"
|
||||
|
||||
# Verify sentiment fields
|
||||
tickers_in_sentiments = {s.ticker for s in sentiments}
|
||||
assert "AAPL" in tickers_in_sentiments
|
||||
assert "MSFT" in tickers_in_sentiments
|
||||
for s in sentiments:
|
||||
assert s.score == 0.75
|
||||
assert s.confidence == 0.88
|
||||
assert s.model_used == "finbert"
|
||||
|
||||
# session.commit should be called once
|
||||
mock_session.commit.assert_awaited_once()
|
||||
|
||||
# Redis publishing should still happen
|
||||
assert publisher.publish.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_duplicate_article_handled_gracefully(self):
|
||||
"""Duplicate content_hash (IntegrityError) should be caught silently."""
|
||||
finbert = AsyncMock(spec=FinBERTAnalyzer)
|
||||
finbert.analyze = AsyncMock(return_value=(0.5, 0.9))
|
||||
|
||||
ollama = AsyncMock(spec=OllamaAnalyzer)
|
||||
|
||||
publisher = AsyncMock()
|
||||
publisher.publish = AsyncMock(return_value=b"1-0")
|
||||
|
||||
config = SentimentAnalyzerConfig(
|
||||
finbert_confidence_threshold=0.6,
|
||||
otel_metrics_port=0,
|
||||
)
|
||||
|
||||
counters = _make_counters()
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.add = MagicMock()
|
||||
# Simulate IntegrityError on commit (duplicate content_hash)
|
||||
mock_session.commit = AsyncMock(
|
||||
side_effect=IntegrityError("duplicate", {}, Exception())
|
||||
)
|
||||
|
||||
db_factory = _make_mock_db_session_factory(mock_session)
|
||||
|
||||
article = _make_raw_article(
|
||||
title="$AAPL news",
|
||||
content="Apple earnings report.",
|
||||
)
|
||||
|
||||
# Should NOT raise — IntegrityError is caught
|
||||
await process_article(
|
||||
article, finbert, ollama, publisher, config, counters, db_factory
|
||||
)
|
||||
|
||||
# Redis publishing should still have happened before the DB write
|
||||
assert publisher.publish.call_count >= 1
|
||||
counters["articles_scored"].add.assert_called_once_with(1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_none_backward_compatible(self):
|
||||
"""When db_session_factory is None, process_article works without DB writes."""
|
||||
finbert = AsyncMock(spec=FinBERTAnalyzer)
|
||||
finbert.analyze = AsyncMock(return_value=(0.6, 0.85))
|
||||
|
||||
ollama = AsyncMock(spec=OllamaAnalyzer)
|
||||
|
||||
publisher = AsyncMock()
|
||||
publisher.publish = AsyncMock(return_value=b"1-0")
|
||||
|
||||
config = SentimentAnalyzerConfig(
|
||||
finbert_confidence_threshold=0.6,
|
||||
otel_metrics_port=0,
|
||||
)
|
||||
|
||||
counters = _make_counters()
|
||||
|
||||
article = _make_raw_article(
|
||||
title="$GOOG quarterly results",
|
||||
content="Google reports revenue growth.",
|
||||
)
|
||||
|
||||
# Pass db_session_factory=None explicitly (the default)
|
||||
await process_article(
|
||||
article, finbert, ollama, publisher, config, counters, db_session_factory=None
|
||||
)
|
||||
|
||||
# Redis publishing should work as before
|
||||
assert publisher.publish.call_count >= 1
|
||||
counters["articles_scored"].add.assert_called_once_with(1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_error_does_not_break_processing(self):
|
||||
"""A DB error should be logged but not prevent Redis publishing."""
|
||||
finbert = AsyncMock(spec=FinBERTAnalyzer)
|
||||
finbert.analyze = AsyncMock(return_value=(0.3, 0.7))
|
||||
|
||||
ollama = AsyncMock(spec=OllamaAnalyzer)
|
||||
|
||||
publisher = AsyncMock()
|
||||
publisher.publish = AsyncMock(return_value=b"1-0")
|
||||
|
||||
config = SentimentAnalyzerConfig(
|
||||
finbert_confidence_threshold=0.6,
|
||||
otel_metrics_port=0,
|
||||
)
|
||||
|
||||
counters = _make_counters()
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.add = MagicMock()
|
||||
# Simulate a generic DB error
|
||||
mock_session.commit = AsyncMock(
|
||||
side_effect=RuntimeError("connection lost")
|
||||
)
|
||||
|
||||
db_factory = _make_mock_db_session_factory(mock_session)
|
||||
|
||||
article = _make_raw_article(
|
||||
title="$TSLA stock update",
|
||||
content="Tesla announces new factory.",
|
||||
)
|
||||
|
||||
# Should NOT raise — generic exceptions in DB write are caught
|
||||
await process_article(
|
||||
article, finbert, ollama, publisher, config, counters, db_factory
|
||||
)
|
||||
|
||||
# Redis publishing should still have happened
|
||||
assert publisher.publish.call_count >= 1
|
||||
counters["articles_scored"].add.assert_called_once_with(1)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue