fix(broker): get_latest_price uses market-data API (was always 0)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

get_latest_price queried TradingClient.get_asset (asset metadata — no price fields) so it ALWAYS returned Decimal('0'). Every Kevin signal was priced 0, so the executor skipped them ("No current price") even after the Phase 1 sizing fix; the market-closed deferral masked it until the market-open drain.

Use StockHistoricalDataClient.get_stock_latest_trade with a daily-bar close fallback — both return the last session's data, so they work when the market is closed (Kevin posts at all hours). Validated live: MRVL 263.16, AVGO 385.10 with the market closed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-06-05 20:10:00 +00:00
parent 44132e9961
commit c6ad39310c
2 changed files with 94 additions and 14 deletions

View file

@ -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")

View file

@ -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
# ---------------------------------------------------------------------------