trading/tests/test_broker.py
Viktor Barzin c6ad39310c
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fix(broker): get_latest_price uses market-data API (was always 0)
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>
2026-06-05 20:10:00 +00:00

753 lines
26 KiB
Python

"""Tests for the brokerage abstraction layer with a mocked Alpaca TradingClient."""
import uuid
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from alpaca.common.exceptions import APIError
from alpaca.trading.enums import OrderSide as AlpacaOrderSide
from alpaca.trading.enums import OrderStatus as AlpacaOrderStatus
from alpaca.trading.enums import OrderType as AlpacaOrderType
from alpaca.trading.enums import AssetClass, AssetExchange, OrderClass, PositionSide, TimeInForce
from alpaca.trading.models import Order as AlpacaOrder
from alpaca.trading.models import Position as AlpacaPosition
from alpaca.trading.models import TradeAccount
from alpaca.trading.requests import LimitOrderRequest, MarketOrderRequest, StopOrderRequest
from shared.broker.alpaca_broker import AlpacaBroker
from shared.schemas.trading import (
AccountInfo,
OrderRequest,
OrderResult,
OrderSide,
OrderStatus,
OrderType,
PositionInfo,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_alpaca_order(
*,
order_id: str | None = None,
symbol: str = "AAPL",
side: AlpacaOrderSide = AlpacaOrderSide.BUY,
qty: str = "10",
filled_avg_price: str | None = None,
status: AlpacaOrderStatus = AlpacaOrderStatus.NEW,
submitted_at: datetime | None = None,
order_class: OrderClass = OrderClass.SIMPLE,
legs: list[AlpacaOrder] | None = None,
) -> AlpacaOrder:
"""Build a minimal Alpaca ``Order`` model for testing."""
oid = uuid.UUID(order_id) if order_id else uuid.uuid4()
now = submitted_at or datetime.now(timezone.utc)
return AlpacaOrder(
id=oid,
client_order_id=str(uuid.uuid4()),
created_at=now,
updated_at=now,
submitted_at=now,
filled_at=None,
expired_at=None,
canceled_at=None,
failed_at=None,
replaced_at=None,
replaced_by=None,
replaces=None,
asset_id=uuid.uuid4(),
symbol=symbol,
asset_class=AssetClass.US_EQUITY,
notional=None,
qty=qty,
filled_qty="0",
filled_avg_price=filled_avg_price,
order_class=order_class,
order_type=AlpacaOrderType.MARKET,
type=AlpacaOrderType.MARKET,
side=side,
time_in_force=TimeInForce.DAY,
limit_price=None,
stop_price=None,
status=status,
extended_hours=False,
legs=legs,
trail_percent=None,
trail_price=None,
hwm=None,
)
def _make_alpaca_position(
*,
symbol: str = "AAPL",
qty: str = "10",
avg_entry_price: str = "150.00",
current_price: str = "155.00",
unrealized_pl: str = "50.00",
market_value: str = "1550.00",
) -> AlpacaPosition:
"""Build a minimal Alpaca ``Position`` model for testing."""
return AlpacaPosition(
asset_id=uuid.uuid4(),
symbol=symbol,
exchange=AssetExchange.NASDAQ,
asset_class=AssetClass.US_EQUITY,
asset_marginable=True,
avg_entry_price=avg_entry_price,
qty=qty,
side=PositionSide.LONG,
market_value=market_value,
cost_basis="1500.00",
unrealized_pl=unrealized_pl,
unrealized_plpc="0.0333",
unrealized_intraday_pl="50.00",
unrealized_intraday_plpc="0.0333",
current_price=current_price,
lastday_price="150.00",
change_today="0.0333",
)
def _make_alpaca_account(
*,
equity: str = "100000.00",
cash: str = "50000.00",
buying_power: str = "200000.00",
portfolio_value: str = "100000.00",
) -> TradeAccount:
"""Build a minimal Alpaca ``TradeAccount`` model for testing."""
return TradeAccount(
id=uuid.uuid4(),
account_number="test-123",
status="ACTIVE",
crypto_status=None,
currency="USD",
buying_power=buying_power,
regt_buying_power=buying_power,
daytrading_buying_power=buying_power,
non_marginable_buying_power=cash,
cash=cash,
accrued_fees="0",
pending_transfer_in=None,
portfolio_value=portfolio_value,
pattern_day_trader=False,
trading_blocked=False,
transfers_blocked=False,
account_blocked=False,
created_at=datetime.now(timezone.utc),
trade_suspended_by_user=False,
multiplier="2",
shorting_enabled=True,
equity=equity,
last_equity=equity,
long_market_value="50000.00",
short_market_value="0",
initial_margin="25000.00",
maintenance_margin="15000.00",
last_maintenance_margin="15000.00",
sma="100000.00",
daytrade_count=0,
)
@pytest.fixture
def mock_client() -> MagicMock:
"""Return a mocked ``TradingClient``."""
return MagicMock()
@pytest.fixture
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
# ---------------------------------------------------------------------------
class TestSubmitMarketOrder:
@pytest.mark.asyncio
async def test_submit_market_order(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""A market buy order should be converted and submitted to Alpaca."""
alpaca_order = _make_alpaca_order(
symbol="AAPL",
side=AlpacaOrderSide.BUY,
qty="10",
status=AlpacaOrderStatus.NEW,
)
mock_client.submit_order.return_value = alpaca_order
order = OrderRequest(ticker="AAPL", side=OrderSide.BUY, qty=10.0, order_type=OrderType.MARKET)
result = await broker.submit_order(order)
assert isinstance(result, OrderResult)
assert result.ticker == "AAPL"
assert result.side == OrderSide.BUY
assert result.qty == 10.0
assert result.status == OrderStatus.PENDING
assert result.order_id == str(alpaca_order.id)
assert result.filled_price is None
# Verify the Alpaca client received a MarketOrderRequest
submitted = mock_client.submit_order.call_args[0][0]
assert isinstance(submitted, MarketOrderRequest)
assert submitted.symbol == "AAPL"
assert submitted.qty == 10.0
assert submitted.side == AlpacaOrderSide.BUY
@pytest.mark.asyncio
async def test_submit_market_sell(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""A market sell order maps the side correctly."""
alpaca_order = _make_alpaca_order(
symbol="TSLA",
side=AlpacaOrderSide.SELL,
qty="5",
status=AlpacaOrderStatus.FILLED,
filled_avg_price="200.50",
)
mock_client.submit_order.return_value = alpaca_order
order = OrderRequest(ticker="TSLA", side=OrderSide.SELL, qty=5.0)
result = await broker.submit_order(order)
assert result.side == OrderSide.SELL
assert result.status == OrderStatus.FILLED
assert result.filled_price == 200.50
class TestSubmitLimitOrder:
@pytest.mark.asyncio
async def test_submit_limit_order(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""A limit order should include the limit price in the Alpaca request."""
alpaca_order = _make_alpaca_order(
symbol="MSFT",
side=AlpacaOrderSide.BUY,
qty="20",
status=AlpacaOrderStatus.ACCEPTED,
)
mock_client.submit_order.return_value = alpaca_order
order = OrderRequest(
ticker="MSFT",
side=OrderSide.BUY,
qty=20.0,
order_type=OrderType.LIMIT,
limit_price=350.00,
)
result = await broker.submit_order(order)
assert isinstance(result, OrderResult)
assert result.ticker == "MSFT"
assert result.status == OrderStatus.PENDING
submitted = mock_client.submit_order.call_args[0][0]
assert isinstance(submitted, LimitOrderRequest)
assert submitted.limit_price == 350.00
@pytest.mark.asyncio
async def test_limit_order_missing_price_raises(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""A limit order without limit_price should raise ValueError."""
order = OrderRequest(
ticker="MSFT",
side=OrderSide.BUY,
qty=20.0,
order_type=OrderType.LIMIT,
limit_price=None,
)
with pytest.raises(ValueError, match="limit_price is required"):
await broker.submit_order(order)
class TestSubmitStopOrder:
@pytest.mark.asyncio
async def test_submit_stop_order(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""A stop order should include the stop price in the Alpaca request."""
alpaca_order = _make_alpaca_order(
symbol="GOOG",
side=AlpacaOrderSide.SELL,
qty="15",
status=AlpacaOrderStatus.NEW,
)
mock_client.submit_order.return_value = alpaca_order
order = OrderRequest(
ticker="GOOG",
side=OrderSide.SELL,
qty=15.0,
order_type=OrderType.STOP,
stop_price=140.00,
)
result = await broker.submit_order(order)
assert isinstance(result, OrderResult)
assert result.ticker == "GOOG"
submitted = mock_client.submit_order.call_args[0][0]
assert isinstance(submitted, StopOrderRequest)
assert submitted.stop_price == 140.00
@pytest.mark.asyncio
async def test_stop_order_missing_price_raises(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""A stop order without stop_price should raise ValueError."""
order = OrderRequest(
ticker="GOOG",
side=OrderSide.SELL,
qty=15.0,
order_type=OrderType.STOP,
stop_price=None,
)
with pytest.raises(ValueError, match="stop_price is required"):
await broker.submit_order(order)
# ---------------------------------------------------------------------------
# Order rejection
# ---------------------------------------------------------------------------
class TestSubmitOrderRejected:
@pytest.mark.asyncio
async def test_api_error_returns_rejected_result(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""If Alpaca's API raises an error, submit_order returns REJECTED."""
mock_client.submit_order.side_effect = APIError("insufficient buying power")
order = OrderRequest(ticker="AAPL", side=OrderSide.BUY, qty=10000.0)
result = await broker.submit_order(order)
assert isinstance(result, OrderResult)
assert result.status == OrderStatus.REJECTED
assert result.ticker == "AAPL"
assert result.side == OrderSide.BUY
assert result.qty == 10000.0
assert result.filled_price is None
assert result.order_id == ""
# ---------------------------------------------------------------------------
# Cancel order
# ---------------------------------------------------------------------------
class TestCancelOrder:
@pytest.mark.asyncio
async def test_cancel_order_success(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""Successful cancellation returns True."""
mock_client.cancel_order_by_id.return_value = None # void method
result = await broker.cancel_order("some-order-id")
assert result is True
mock_client.cancel_order_by_id.assert_called_once_with("some-order-id")
@pytest.mark.asyncio
async def test_cancel_order_failure(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""If the API raises an error (e.g. order already filled), return False."""
mock_client.cancel_order_by_id.side_effect = APIError("order is not cancelable")
result = await broker.cancel_order("some-order-id")
assert result is False
# ---------------------------------------------------------------------------
# Positions
# ---------------------------------------------------------------------------
class TestGetPositions:
@pytest.mark.asyncio
async def test_get_positions_empty(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""When there are no open positions, return an empty list."""
mock_client.get_all_positions.return_value = []
positions = await broker.get_positions()
assert positions == []
@pytest.mark.asyncio
async def test_get_positions_with_data(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""Positions are correctly converted to PositionInfo."""
mock_client.get_all_positions.return_value = [
_make_alpaca_position(
symbol="AAPL",
qty="10",
avg_entry_price="150.00",
current_price="155.00",
unrealized_pl="50.00",
market_value="1550.00",
),
_make_alpaca_position(
symbol="TSLA",
qty="5",
avg_entry_price="200.00",
current_price="190.00",
unrealized_pl="-50.00",
market_value="950.00",
),
]
positions = await broker.get_positions()
assert len(positions) == 2
aapl = positions[0]
assert isinstance(aapl, PositionInfo)
assert aapl.ticker == "AAPL"
assert aapl.qty == 10.0
assert aapl.avg_entry == 150.0
assert aapl.current_price == 155.0
assert aapl.unrealized_pnl == 50.0
assert aapl.market_value == 1550.0
tsla = positions[1]
assert tsla.ticker == "TSLA"
assert tsla.qty == 5.0
assert tsla.unrealized_pnl == -50.0
# ---------------------------------------------------------------------------
# Account
# ---------------------------------------------------------------------------
class TestGetAccount:
@pytest.mark.asyncio
async def test_get_account(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""Account info is correctly converted from TradeAccount."""
mock_client.get_account.return_value = _make_alpaca_account(
equity="100000.00",
cash="50000.00",
buying_power="200000.00",
portfolio_value="100000.00",
)
account = await broker.get_account()
assert isinstance(account, AccountInfo)
assert account.equity == 100000.0
assert account.cash == 50000.0
assert account.buying_power == 200000.0
assert account.portfolio_value == 100000.0
# ---------------------------------------------------------------------------
# Order status
# ---------------------------------------------------------------------------
class TestGetOrderStatus:
@pytest.mark.asyncio
async def test_get_order_status_filled(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""A filled order returns FILLED status and the fill price."""
order_id = str(uuid.uuid4())
alpaca_order = _make_alpaca_order(
order_id=order_id,
symbol="AAPL",
side=AlpacaOrderSide.BUY,
qty="10",
status=AlpacaOrderStatus.FILLED,
filled_avg_price="152.30",
)
mock_client.get_order_by_id.return_value = alpaca_order
result = await broker.get_order_status(order_id)
assert isinstance(result, OrderResult)
assert result.order_id == order_id
assert result.status == OrderStatus.FILLED
assert result.filled_price == 152.30
assert result.ticker == "AAPL"
assert result.side == OrderSide.BUY
mock_client.get_order_by_id.assert_called_once_with(order_id)
@pytest.mark.asyncio
async def test_get_order_status_pending(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""A pending order returns PENDING status with no fill price."""
order_id = str(uuid.uuid4())
alpaca_order = _make_alpaca_order(
order_id=order_id,
symbol="MSFT",
status=AlpacaOrderStatus.PENDING_NEW,
)
mock_client.get_order_by_id.return_value = alpaca_order
result = await broker.get_order_status(order_id)
assert result.status == OrderStatus.PENDING
assert result.filled_price is None
@pytest.mark.asyncio
async def test_get_order_status_cancelled(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""A cancelled order returns CANCELLED status."""
order_id = str(uuid.uuid4())
alpaca_order = _make_alpaca_order(
order_id=order_id,
symbol="TSLA",
status=AlpacaOrderStatus.CANCELED,
)
mock_client.get_order_by_id.return_value = alpaca_order
result = await broker.get_order_status(order_id)
assert result.status == OrderStatus.CANCELLED
@pytest.mark.asyncio
async def test_get_order_status_rejected(self, broker: AlpacaBroker, mock_client: MagicMock) -> None:
"""A rejected order returns REJECTED status."""
order_id = str(uuid.uuid4())
alpaca_order = _make_alpaca_order(
order_id=order_id,
symbol="GOOG",
status=AlpacaOrderStatus.REJECTED,
)
mock_client.get_order_by_id.return_value = alpaca_order
result = await broker.get_order_status(order_id)
assert result.status == OrderStatus.REJECTED
# ---------------------------------------------------------------------------
# BaseBroker interface
# ---------------------------------------------------------------------------
class TestBaseBrokerInterface:
def test_alpaca_broker_is_subclass(self) -> None:
"""AlpacaBroker should be a proper subclass of BaseBroker."""
from shared.broker.base import BaseBroker
assert issubclass(AlpacaBroker, BaseBroker)
def test_package_exports(self) -> None:
"""The broker package should export BaseBroker and AlpacaBroker."""
from shared.broker import AlpacaBroker as AB
from shared.broker import BaseBroker as BB
assert AB is AlpacaBroker
from shared.broker.base import BaseBroker
assert BB is BaseBroker
# ---------------------------------------------------------------------------
# get_order — nested bracket parent + legs (Phase 4)
# ---------------------------------------------------------------------------
class TestGetOrderNested:
"""``get_order`` returns a ``BrokerOrder`` exposing the parent plus its
bracket child legs (stop-loss / take-profit) with status + fill price, so
reconciliation can tell which leg filled and at what price."""
@pytest.mark.asyncio
async def test_get_order_returns_broker_order(
self, broker: AlpacaBroker, mock_client: MagicMock
) -> None:
from shared.schemas.trading import BrokerOrder
parent_id = str(uuid.uuid4())
parent = _make_alpaca_order(
order_id=parent_id,
symbol="NVDA",
side=AlpacaOrderSide.BUY,
qty="10",
status=AlpacaOrderStatus.FILLED,
filled_avg_price="100.00",
order_class=OrderClass.BRACKET,
legs=[],
)
mock_client.get_order_by_id.return_value = parent
result = await broker.get_order(parent_id)
assert isinstance(result, BrokerOrder)
assert result.order_id == parent_id
assert result.ticker == "NVDA"
assert result.status == OrderStatus.FILLED
assert result.filled_price == 100.00
assert result.legs == []
@pytest.mark.asyncio
async def test_get_order_maps_take_profit_leg(
self, broker: AlpacaBroker, mock_client: MagicMock
) -> None:
"""A bracket parent with a FILLED take-profit leg exposes that leg with
its fill price; the stop-loss leg is reported still pending."""
parent_id = str(uuid.uuid4())
tp_id = str(uuid.uuid4())
sl_id = str(uuid.uuid4())
tp_leg = _make_alpaca_order(
order_id=tp_id,
symbol="NVDA",
side=AlpacaOrderSide.SELL,
qty="10",
status=AlpacaOrderStatus.FILLED,
filled_avg_price="120.00",
)
sl_leg = _make_alpaca_order(
order_id=sl_id,
symbol="NVDA",
side=AlpacaOrderSide.SELL,
qty="10",
status=AlpacaOrderStatus.HELD,
)
parent = _make_alpaca_order(
order_id=parent_id,
symbol="NVDA",
side=AlpacaOrderSide.BUY,
qty="10",
status=AlpacaOrderStatus.FILLED,
filled_avg_price="100.00",
order_class=OrderClass.BRACKET,
legs=[tp_leg, sl_leg],
)
mock_client.get_order_by_id.return_value = parent
result = await broker.get_order(parent_id)
assert len(result.legs) == 2
tp = next(leg for leg in result.legs if leg.order_id == tp_id)
sl = next(leg for leg in result.legs if leg.order_id == sl_id)
assert tp.status == OrderStatus.FILLED
assert tp.filled_price == 120.00
assert tp.side == OrderSide.SELL
assert sl.status == OrderStatus.PENDING
assert sl.filled_price is None
@pytest.mark.asyncio
async def test_get_order_passes_nested_flag(
self, broker: AlpacaBroker, mock_client: MagicMock
) -> None:
"""``nested=True`` must reach Alpaca as a GetOrderByIdRequest so the
legs come back populated."""
from alpaca.trading.requests import GetOrderByIdRequest
parent_id = str(uuid.uuid4())
mock_client.get_order_by_id.return_value = _make_alpaca_order(
order_id=parent_id, order_class=OrderClass.BRACKET, legs=[]
)
await broker.get_order(parent_id, nested=True)
call = mock_client.get_order_by_id.call_args
assert call.args[0] == parent_id
req = call.args[1] if len(call.args) > 1 else call.kwargs.get("filter")
assert isinstance(req, GetOrderByIdRequest)
assert req.nested is True
@pytest.mark.asyncio
async def test_get_order_returns_none_on_missing(
self, broker: AlpacaBroker, mock_client: MagicMock
) -> None:
"""A missing order (Alpaca 404 -> APIError) yields ``None`` rather than
raising, so reconciliation can log drift and move on."""
mock_client.get_order_by_id.side_effect = APIError("order not found")
result = await broker.get_order(str(uuid.uuid4()))
assert result is None
# ---------------------------------------------------------------------------
# list_orders (Phase 4)
# ---------------------------------------------------------------------------
class TestListOrders:
@pytest.mark.asyncio
async def test_list_orders_maps_results(
self, broker: AlpacaBroker, mock_client: MagicMock
) -> None:
o1 = _make_alpaca_order(symbol="AAPL", status=AlpacaOrderStatus.FILLED, filled_avg_price="1")
o2 = _make_alpaca_order(symbol="MSFT", status=AlpacaOrderStatus.NEW)
mock_client.get_orders.return_value = [o1, o2]
results = await broker.list_orders()
assert len(results) == 2
assert {r.ticker for r in results} == {"AAPL", "MSFT"}
assert all(isinstance(r, OrderResult) for r in results)
@pytest.mark.asyncio
async def test_list_orders_builds_request(
self, broker: AlpacaBroker, mock_client: MagicMock
) -> None:
from alpaca.trading.requests import GetOrdersRequest
mock_client.get_orders.return_value = []
await broker.list_orders(status="all", limit=25)
req = mock_client.get_orders.call_args.args[0]
assert isinstance(req, GetOrdersRequest)
assert req.limit == 25