diff --git a/shared/broker/alpaca_broker.py b/shared/broker/alpaca_broker.py index 68d7f59..5de7397 100644 --- a/shared/broker/alpaca_broker.py +++ b/shared/broker/alpaca_broker.py @@ -32,6 +32,9 @@ from alpaca.trading.requests import ( StopOrderRequest, TakeProfitRequest, ) +from alpaca.data.historical import StockHistoricalDataClient +from alpaca.data.requests import StockBarsRequest, StockLatestTradeRequest +from alpaca.data.timeframe import TimeFrame from shared.broker.base import BaseBroker from shared.schemas.trading import ( @@ -109,6 +112,13 @@ class AlpacaBroker(BaseBroker): secret_key=secret_key, paper=paper, ) + # Market-data API — separate from the trading API. Provides real + # prices even when the market is closed (latest trade / daily bar). + # Same API keys; no paper/live distinction for data. + self._data_client = StockHistoricalDataClient( + api_key=api_key, + secret_key=secret_key, + ) # -- internal helpers ---------------------------------------------------- @@ -324,18 +334,30 @@ class AlpacaBroker(BaseBroker): return False async def get_latest_price(self, symbol: str) -> Decimal: - """Last trade price for *symbol* as Decimal. Returns Decimal('0') - on lookup failure — callers should treat 0 as 'no price available'. + """Latest trade price for *symbol* via the Alpaca market-data API, + with a daily-bar close fallback. Both return the most recent + session's data, so they work when the market is closed. Returns + Decimal('0') only if every lookup fails — callers treat 0 as + 'no price available'. """ try: - # alpaca-py uses MarketDataClient for prices; the TradingClient - # does not expose latest_trade. Use the get_assets fallback - # (returns last_close) when present. - asset = await asyncio.to_thread(self._client.get_asset, symbol) - for attr in ("last_trade_price", "close", "last_close"): - v = getattr(asset, attr, None) - if v is not None: - return Decimal(str(v)) + trade_req = StockLatestTradeRequest(symbol_or_symbols=symbol) + resp = await asyncio.to_thread( + self._data_client.get_stock_latest_trade, trade_req + ) + price = getattr(resp.get(symbol), "price", None) + if price: + return Decimal(str(price)) except Exception: - pass + logger.warning("latest-trade lookup failed for %s; trying daily bar", symbol) + try: + bars_req = StockBarsRequest( + symbol_or_symbols=symbol, timeframe=TimeFrame.Day, limit=1 + ) + resp = await asyncio.to_thread(self._data_client.get_stock_bars, bars_req) + bars = resp.data.get(symbol) + if bars: + return Decimal(str(bars[-1].close)) + except Exception: + logger.warning("daily-bar lookup failed for %s", symbol) return Decimal("0") diff --git a/tests/test_broker.py b/tests/test_broker.py index 18ccbef..ebb456b 100644 --- a/tests/test_broker.py +++ b/tests/test_broker.py @@ -164,13 +164,71 @@ def mock_client() -> MagicMock: @pytest.fixture -def broker(mock_client: MagicMock) -> AlpacaBroker: - """Return an ``AlpacaBroker`` whose internal client is mocked.""" - with patch("shared.broker.alpaca_broker.TradingClient", return_value=mock_client): +def mock_data_client() -> MagicMock: + """Return a mocked ``StockHistoricalDataClient`` (market-data API).""" + return MagicMock() + + +@pytest.fixture +def broker(mock_client: MagicMock, mock_data_client: MagicMock) -> AlpacaBroker: + """Return an ``AlpacaBroker`` whose internal clients are mocked.""" + with ( + patch("shared.broker.alpaca_broker.TradingClient", return_value=mock_client), + patch( + "shared.broker.alpaca_broker.StockHistoricalDataClient", + return_value=mock_data_client, + ), + ): b = AlpacaBroker(api_key="test-key", secret_key="test-secret", paper=True) return b +# --------------------------------------------------------------------------- +# get_latest_price — market-data API (works when market closed) +# --------------------------------------------------------------------------- + + +class TestGetLatestPrice: + @pytest.mark.asyncio + async def test_returns_latest_trade_price( + self, broker: AlpacaBroker, mock_data_client: MagicMock + ) -> None: + from decimal import Decimal + + trade = MagicMock() + trade.price = 263.16 + mock_data_client.get_stock_latest_trade.return_value = {"MRVL": trade} + + price = await broker.get_latest_price("MRVL") + assert price == Decimal("263.16") + + @pytest.mark.asyncio + async def test_falls_back_to_daily_bar_close( + self, broker: AlpacaBroker, mock_data_client: MagicMock + ) -> None: + from decimal import Decimal + + mock_data_client.get_stock_latest_trade.side_effect = Exception("no quote") + bar = MagicMock() + bar.close = 263.47 + mock_data_client.get_stock_bars.return_value = MagicMock(data={"MRVL": [bar]}) + + price = await broker.get_latest_price("MRVL") + assert price == Decimal("263.47") + + @pytest.mark.asyncio + async def test_returns_zero_when_all_lookups_fail( + self, broker: AlpacaBroker, mock_data_client: MagicMock + ) -> None: + from decimal import Decimal + + mock_data_client.get_stock_latest_trade.side_effect = Exception("x") + mock_data_client.get_stock_bars.side_effect = Exception("y") + + price = await broker.get_latest_price("MRVL") + assert price == Decimal("0") + + # --------------------------------------------------------------------------- # Order submission # ---------------------------------------------------------------------------