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
|
|
@ -401,3 +401,119 @@ class TestExecutorFlowRejected:
|
|||
|
||||
# Rejection counter should have been incremented
|
||||
counters["rejections"].add.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Executor flow — DB persistence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_mock_db_session_factory(session=None):
|
||||
"""Create a mock async_sessionmaker that yields a mock session."""
|
||||
if session is None:
|
||||
session = AsyncMock()
|
||||
session.add = MagicMock()
|
||||
session.commit = AsyncMock()
|
||||
|
||||
factory = MagicMock()
|
||||
ctx = AsyncMock()
|
||||
ctx.__aenter__ = AsyncMock(return_value=session)
|
||||
ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
factory.return_value = ctx
|
||||
return factory
|
||||
|
||||
|
||||
class TestExecutorDBPersistence:
|
||||
"""Verify that trades are persisted to the DB when db_session_factory is provided."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trade_persisted_with_signal_id(self):
|
||||
"""When db_session_factory is provided, a Trade row should be created."""
|
||||
config = _make_config()
|
||||
broker = _mock_broker(positions=[], account=_make_account(100_000))
|
||||
publisher = AsyncMock()
|
||||
publisher.publish = AsyncMock(return_value=b"1-0")
|
||||
counters = {
|
||||
"trades_executed": MagicMock(),
|
||||
"rejections": MagicMock(),
|
||||
"fill_latency": MagicMock(),
|
||||
}
|
||||
|
||||
signal = _make_signal(ticker="AAPL", strength=0.8, current_price=150.0)
|
||||
mock_session = AsyncMock()
|
||||
mock_session.add = MagicMock()
|
||||
mock_session.commit = AsyncMock()
|
||||
db_factory = _make_mock_db_session_factory(mock_session)
|
||||
|
||||
with patch.object(RiskManager, "check_risk", return_value=(True, "approved")):
|
||||
await process_signal(
|
||||
signal, RiskManager(config, broker), broker, publisher, counters, db_factory
|
||||
)
|
||||
|
||||
# Trade should be persisted
|
||||
mock_session.add.assert_called_once()
|
||||
mock_session.commit.assert_awaited_once()
|
||||
|
||||
# Verify the trade object
|
||||
trade_obj = mock_session.add.call_args[0][0]
|
||||
assert trade_obj.ticker == "AAPL"
|
||||
assert trade_obj.signal_id == signal.signal_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trade_not_persisted_without_db(self):
|
||||
"""When db_session_factory is None, no DB write should happen."""
|
||||
config = _make_config()
|
||||
broker = _mock_broker(positions=[], account=_make_account(100_000))
|
||||
publisher = AsyncMock()
|
||||
publisher.publish = AsyncMock(return_value=b"1-0")
|
||||
counters = {
|
||||
"trades_executed": MagicMock(),
|
||||
"rejections": MagicMock(),
|
||||
"fill_latency": MagicMock(),
|
||||
}
|
||||
|
||||
signal = _make_signal(ticker="AAPL", strength=0.8, current_price=150.0)
|
||||
|
||||
with patch.object(RiskManager, "check_risk", return_value=(True, "approved")):
|
||||
await process_signal(
|
||||
signal, RiskManager(config, broker), broker, publisher, counters, None
|
||||
)
|
||||
|
||||
# Should still publish
|
||||
publisher.publish.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_error_does_not_block_publishing(self):
|
||||
"""A DB error should not prevent the trade from being published."""
|
||||
config = _make_config()
|
||||
broker = _mock_broker(positions=[], account=_make_account(100_000))
|
||||
publisher = AsyncMock()
|
||||
publisher.publish = AsyncMock(return_value=b"1-0")
|
||||
counters = {
|
||||
"trades_executed": MagicMock(),
|
||||
"rejections": MagicMock(),
|
||||
"fill_latency": MagicMock(),
|
||||
}
|
||||
|
||||
signal = _make_signal(ticker="AAPL", strength=0.8, current_price=150.0)
|
||||
mock_session = AsyncMock()
|
||||
mock_session.add = MagicMock()
|
||||
mock_session.commit = AsyncMock(side_effect=RuntimeError("DB connection lost"))
|
||||
db_factory = _make_mock_db_session_factory(mock_session)
|
||||
|
||||
with patch.object(RiskManager, "check_risk", return_value=(True, "approved")):
|
||||
await process_signal(
|
||||
signal, RiskManager(config, broker), broker, publisher, counters, db_factory
|
||||
)
|
||||
|
||||
# Trade should still be published despite DB error
|
||||
publisher.publish.assert_called_once()
|
||||
counters["trades_executed"].add.assert_called_once_with(1)
|
||||
|
||||
def test_signal_id_flows_through_execution(self):
|
||||
"""signal_id from TradeSignal should appear in the published TradeExecution."""
|
||||
signal = _make_signal(ticker="AAPL", strength=0.8, current_price=150.0)
|
||||
assert signal.signal_id is not None
|
||||
# Verify signal_id is a UUID
|
||||
from uuid import UUID
|
||||
assert isinstance(signal.signal_id, UUID)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue