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:
Viktor Barzin 2026-02-22 19:52:45 +00:00
parent 5a6b20c8f1
commit e2a3bd456d
No known key found for this signature in database
GPG key ID: 0EB088298288D958
19 changed files with 2238 additions and 72 deletions

View file

@ -15,10 +15,15 @@ import time
import uuid
from redis.asyncio import Redis
from sqlalchemy.ext.asyncio import async_sessionmaker
from services.trade_executor.config import TradeExecutorConfig
from services.trade_executor.risk_manager import RiskManager
from shared.broker.alpaca_broker import AlpacaBroker
from shared.db import create_db
from shared.models.trading import Trade as TradeModel
from shared.models.trading import TradeSide as TradeSideModel
from shared.models.trading import TradeStatus as TradeStatusModel
from shared.redis_streams import StreamConsumer, StreamPublisher
from shared.schemas.trading import (
OrderRequest,
@ -39,6 +44,7 @@ async def process_signal(
broker: AlpacaBroker,
publisher: StreamPublisher,
counters: dict,
db_session_factory: async_sessionmaker | None = None,
) -> None:
"""Process a single trade signal: risk check, order, record, publish.
@ -54,6 +60,8 @@ async def process_signal(
Publishes execution results to ``trades:executed``.
counters:
Dict of OpenTelemetry counter/histogram instruments.
db_session_factory:
Optional async session factory for persisting trades to the DB.
"""
# --- Step 1: risk check ---
approved, reason = await risk_manager.check_risk(signal)
@ -93,12 +101,42 @@ async def process_signal(
qty=result.qty,
price=result.filled_price or 0.0,
status=result.status,
signal_id=None,
signal_id=signal.signal_id,
strategy_id=None,
timestamp=result.timestamp,
)
# --- Step 6: publish to trades:executed ---
# --- Step 6: persist trade to DB ---
if db_session_factory is not None:
try:
side_map = {
OrderSide.BUY: TradeSideModel.BUY,
OrderSide.SELL: TradeSideModel.SELL,
}
status_map = {
OrderStatus.PENDING: TradeStatusModel.PENDING,
OrderStatus.FILLED: TradeStatusModel.FILLED,
OrderStatus.CANCELLED: TradeStatusModel.CANCELLED,
OrderStatus.REJECTED: TradeStatusModel.REJECTED,
}
async with db_session_factory() as session:
db_trade = TradeModel(
id=trade_id,
ticker=signal.ticker,
side=side_map[side],
qty=result.qty,
price=result.filled_price or 0.0,
timestamp=str(result.timestamp),
signal_id=signal.signal_id,
status=status_map.get(result.status, TradeStatusModel.PENDING),
)
session.add(db_trade)
await session.commit()
logger.debug("Persisted trade %s to DB (signal_id=%s)", trade_id, signal.signal_id)
except Exception:
logger.exception("Failed to persist trade to DB")
# --- Step 7: publish to trades:executed ---
await publisher.publish(execution.model_dump(mode="json"))
counters["trades_executed"].add(1)
logger.info(
@ -157,6 +195,14 @@ async def run(config: TradeExecutorConfig | None = None) -> None:
# --- Risk manager ---
risk_manager = RiskManager(config, broker)
# --- Database (for persisting trades) ---
db_session_factory = None
try:
_engine, db_session_factory = create_db(config)
logger.info("Database session factory initialised for trade persistence")
except Exception:
logger.exception("Failed to initialise DB — trades will NOT be persisted")
logger.info("Consuming from signals:generated, publishing to trades:executed")
# Graceful shutdown on SIGTERM/SIGINT
@ -172,7 +218,7 @@ async def run(config: TradeExecutorConfig | None = None) -> None:
break
try:
signal_msg = TradeSignal.model_validate(data)
await process_signal(signal_msg, risk_manager, broker, publisher, counters)
await process_signal(signal_msg, risk_manager, broker, publisher, counters, db_session_factory)
except Exception:
logger.exception("Error processing signal: %s", data)
finally: