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
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue