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,
|
StopOrderRequest,
|
||||||
TakeProfitRequest,
|
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.broker.base import BaseBroker
|
||||||
from shared.schemas.trading import (
|
from shared.schemas.trading import (
|
||||||
|
|
@ -109,6 +112,13 @@ class AlpacaBroker(BaseBroker):
|
||||||
secret_key=secret_key,
|
secret_key=secret_key,
|
||||||
paper=paper,
|
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 ----------------------------------------------------
|
# -- internal helpers ----------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -324,18 +334,30 @@ class AlpacaBroker(BaseBroker):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def get_latest_price(self, symbol: str) -> Decimal:
|
async def get_latest_price(self, symbol: str) -> Decimal:
|
||||||
"""Last trade price for *symbol* as Decimal. Returns Decimal('0')
|
"""Latest trade price for *symbol* via the Alpaca market-data API,
|
||||||
on lookup failure — callers should treat 0 as 'no price available'.
|
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:
|
try:
|
||||||
# alpaca-py uses MarketDataClient for prices; the TradingClient
|
trade_req = StockLatestTradeRequest(symbol_or_symbols=symbol)
|
||||||
# does not expose latest_trade. Use the get_assets fallback
|
resp = await asyncio.to_thread(
|
||||||
# (returns last_close) when present.
|
self._data_client.get_stock_latest_trade, trade_req
|
||||||
asset = await asyncio.to_thread(self._client.get_asset, symbol)
|
)
|
||||||
for attr in ("last_trade_price", "close", "last_close"):
|
price = getattr(resp.get(symbol), "price", None)
|
||||||
v = getattr(asset, attr, None)
|
if price:
|
||||||
if v is not None:
|
return Decimal(str(price))
|
||||||
return Decimal(str(v))
|
|
||||||
except Exception:
|
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")
|
return Decimal("0")
|
||||||
|
|
|
||||||
|
|
@ -164,13 +164,71 @@ def mock_client() -> MagicMock:
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def broker(mock_client: MagicMock) -> AlpacaBroker:
|
def mock_data_client() -> MagicMock:
|
||||||
"""Return an ``AlpacaBroker`` whose internal client is mocked."""
|
"""Return a mocked ``StockHistoricalDataClient`` (market-data API)."""
|
||||||
with patch("shared.broker.alpaca_broker.TradingClient", return_value=mock_client):
|
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)
|
b = AlpacaBroker(api_key="test-key", secret_key="test-secret", paper=True)
|
||||||
return b
|
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
|
# Order submission
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue