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
|
|
@ -16,6 +16,12 @@ class ApiGatewayConfig(BaseConfig):
|
|||
access_token_expire_minutes: int = 15
|
||||
refresh_token_expire_days: int = 7
|
||||
|
||||
# Alpaca brokerage credentials (for portfolio sync)
|
||||
alpaca_api_key: str = ""
|
||||
alpaca_secret_key: str = ""
|
||||
paper_trading: bool = True
|
||||
snapshot_interval_seconds: int = 60
|
||||
|
||||
# CORS settings
|
||||
cors_origins: list[str] = ["http://localhost:5173"]
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncIterator
|
||||
|
|
@ -43,9 +44,23 @@ def create_app(config: ApiGatewayConfig | None = None) -> FastAPI:
|
|||
)
|
||||
app.state.config = config
|
||||
|
||||
# Start portfolio sync background task
|
||||
from services.api_gateway.tasks.portfolio_sync import portfolio_sync_loop
|
||||
|
||||
sync_task = asyncio.create_task(
|
||||
portfolio_sync_loop(config, session_factory)
|
||||
)
|
||||
|
||||
logger.info("API Gateway started")
|
||||
yield
|
||||
|
||||
# Cancel the sync task
|
||||
sync_task.cancel()
|
||||
try:
|
||||
await sync_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Cleanup
|
||||
await app.state.redis.aclose()
|
||||
await engine.dispose()
|
||||
|
|
|
|||
0
services/api_gateway/tasks/__init__.py
Normal file
0
services/api_gateway/tasks/__init__.py
Normal file
155
services/api_gateway/tasks/portfolio_sync.py
Normal file
155
services/api_gateway/tasks/portfolio_sync.py
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
"""Background task that periodically snapshots Alpaca account state into the DB.
|
||||
|
||||
Runs on a configurable interval (default 60s) during US market hours,
|
||||
creating ``PortfolioSnapshot`` rows and upserting ``Position`` rows so
|
||||
the dashboard portfolio page reflects real brokerage data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, time, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from services.api_gateway.config import ApiGatewayConfig
|
||||
from shared.broker.alpaca_broker import AlpacaBroker
|
||||
from shared.models.timeseries import PortfolioSnapshot
|
||||
from shared.models.trading import Position
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# US Eastern timezone for market hours check
|
||||
_ET = ZoneInfo("America/New_York")
|
||||
_MARKET_OPEN = time(9, 30)
|
||||
_MARKET_CLOSE = time(16, 0)
|
||||
|
||||
|
||||
def is_market_open(now_utc: datetime | None = None) -> bool:
|
||||
"""Return ``True`` if the US stock market is currently open.
|
||||
|
||||
Checks for weekday (Mon-Fri) and time between 9:30 AM and 4:00 PM ET.
|
||||
"""
|
||||
if now_utc is None:
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
now_et = now_utc.astimezone(_ET)
|
||||
# Weekday check: Monday=0 ... Friday=4
|
||||
if now_et.weekday() >= 5:
|
||||
return False
|
||||
return _MARKET_OPEN <= now_et.time() < _MARKET_CLOSE
|
||||
|
||||
|
||||
async def _sync_once(
|
||||
broker: AlpacaBroker,
|
||||
session_factory: async_sessionmaker,
|
||||
) -> None:
|
||||
"""Perform a single portfolio snapshot and position upsert cycle."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# 1. Snapshot account state
|
||||
account = await broker.get_account()
|
||||
|
||||
snapshot = PortfolioSnapshot(
|
||||
timestamp=now,
|
||||
total_value=account.portfolio_value,
|
||||
cash=account.cash,
|
||||
positions_value=account.portfolio_value - account.cash,
|
||||
daily_pnl=0.0,
|
||||
)
|
||||
|
||||
# 2. Fetch broker positions
|
||||
broker_positions = await broker.get_positions()
|
||||
broker_tickers = {p.ticker for p in broker_positions}
|
||||
|
||||
async with session_factory() as session:
|
||||
async with session.begin():
|
||||
# Insert portfolio snapshot
|
||||
session.add(snapshot)
|
||||
|
||||
# Upsert positions
|
||||
for pos_info in broker_positions:
|
||||
result = await session.execute(
|
||||
select(Position).where(Position.ticker == pos_info.ticker)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing is not None:
|
||||
existing.qty = pos_info.qty
|
||||
existing.avg_entry = pos_info.avg_entry
|
||||
existing.unrealized_pnl = pos_info.unrealized_pnl
|
||||
else:
|
||||
new_pos = Position(
|
||||
ticker=pos_info.ticker,
|
||||
qty=pos_info.qty,
|
||||
avg_entry=pos_info.avg_entry,
|
||||
unrealized_pnl=pos_info.unrealized_pnl,
|
||||
stop_loss=None,
|
||||
take_profit=None,
|
||||
)
|
||||
session.add(new_pos)
|
||||
|
||||
# 3. Remove positions that are no longer held at the broker
|
||||
if broker_tickers:
|
||||
await session.execute(
|
||||
delete(Position).where(Position.ticker.notin_(broker_tickers))
|
||||
)
|
||||
else:
|
||||
# No positions at broker — delete all local positions
|
||||
await session.execute(delete(Position))
|
||||
|
||||
logger.info(
|
||||
"Portfolio sync complete: value=%.2f, cash=%.2f, positions=%d",
|
||||
account.portfolio_value,
|
||||
account.cash,
|
||||
len(broker_positions),
|
||||
)
|
||||
|
||||
|
||||
async def portfolio_sync_loop(
|
||||
config: ApiGatewayConfig,
|
||||
session_factory: async_sessionmaker,
|
||||
) -> None:
|
||||
"""Run the portfolio sync loop until cancelled.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
config:
|
||||
API Gateway configuration containing Alpaca credentials and
|
||||
the snapshot interval.
|
||||
session_factory:
|
||||
SQLAlchemy async session factory for DB access.
|
||||
"""
|
||||
if not config.alpaca_api_key or not config.alpaca_secret_key:
|
||||
logger.warning(
|
||||
"Alpaca API credentials not configured — portfolio sync disabled"
|
||||
)
|
||||
return
|
||||
|
||||
broker = AlpacaBroker(
|
||||
api_key=config.alpaca_api_key,
|
||||
secret_key=config.alpaca_secret_key,
|
||||
paper=config.paper_trading,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Portfolio sync started (interval=%ds, paper=%s)",
|
||||
config.snapshot_interval_seconds,
|
||||
config.paper_trading,
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
if is_market_open():
|
||||
await _sync_once(broker, session_factory)
|
||||
else:
|
||||
logger.debug("Market closed — skipping portfolio snapshot")
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Portfolio sync task cancelled — shutting down")
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Portfolio sync error — will retry next interval")
|
||||
|
||||
await asyncio.sleep(config.snapshot_interval_seconds)
|
||||
Loading…
Add table
Add a link
Reference in a new issue