fix(broker): get_latest_price uses market-data API (was always 0)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
parent
44132e9961
commit
c6ad39310c
2 changed files with 94 additions and 14 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue