Files
stonks-oracle/tests/test_broker_adapter.py
T

418 lines
12 KiB
Python

"""Tests for the broker API adapter interface and Alpaca implementation.
Validates data structures, request building, response parsing, and fail-closed behavior.
"""
from services.adapters.broker_adapter import (
AccountInfo,
AlpacaBrokerAdapter,
BrokerDataAdapter,
OrderEventType,
OrderRequest,
OrderResponse,
OrderSide,
OrderStatus,
OrderType,
PositionInfo,
TradingMode,
)
# --- Fake Alpaca responses ---
ALPACA_ORDER_RESPONSE = {
"id": "order-abc-123",
"client_order_id": "client-001",
"status": "accepted",
"symbol": "AAPL",
"side": "buy",
"qty": "10",
"filled_qty": "0",
"filled_avg_price": None,
"type": "market",
"time_in_force": "day",
"created_at": "2026-04-11T14:00:00Z",
}
ALPACA_FILLED_ORDER = {
"id": "order-def-456",
"status": "filled",
"symbol": "AAPL",
"side": "buy",
"qty": "10",
"filled_qty": "10",
"filled_avg_price": "172.50",
"type": "market",
"time_in_force": "day",
}
ALPACA_REJECTED_ORDER = {
"id": "order-ghi-789",
"status": "rejected",
"symbol": "AAPL",
"side": "sell",
"qty": "100",
"filled_qty": "0",
"filled_avg_price": None,
}
ALPACA_POSITION = {
"symbol": "AAPL",
"qty": "10",
"avg_entry_price": "172.50",
"current_price": "175.00",
"unrealized_pl": "25.00",
"market_value": "1750.00",
"side": "long",
}
ALPACA_ACCOUNT = {
"id": "acct-001",
"buying_power": "50000.00",
"cash": "25000.00",
"portfolio_value": "75000.00",
"currency": "USD",
}
# --- Enum tests ---
class TestBrokerEnums:
def test_order_side_values(self):
assert OrderSide.BUY.value == "buy"
assert OrderSide.SELL.value == "sell"
def test_order_type_values(self):
assert OrderType.MARKET.value == "market"
assert OrderType.LIMIT.value == "limit"
assert OrderType.STOP.value == "stop"
assert OrderType.STOP_LIMIT.value == "stop_limit"
def test_order_status_values(self):
assert OrderStatus.PENDING.value == "pending"
assert OrderStatus.FILLED.value == "filled"
assert OrderStatus.REJECTED.value == "rejected"
def test_trading_mode_values(self):
assert TradingMode.PAPER.value == "paper"
assert TradingMode.LIVE.value == "live"
def test_order_event_type_values(self):
assert OrderEventType.SUBMITTED.value == "submitted"
assert OrderEventType.FILL.value == "fill"
assert OrderEventType.CANCELLED.value == "cancelled"
# --- OrderRequest tests ---
class TestOrderRequest:
def test_basic_market_order(self):
req = OrderRequest(
ticker="AAPL",
side=OrderSide.BUY,
quantity=10,
)
assert req.ticker == "AAPL"
assert req.side == OrderSide.BUY
assert req.quantity == 10
assert req.order_type == OrderType.MARKET
assert req.time_in_force == "day"
assert req.idempotency_key # auto-generated
def test_limit_order(self):
req = OrderRequest(
ticker="MSFT",
side=OrderSide.SELL,
quantity=5,
order_type=OrderType.LIMIT,
limit_price=400.0,
)
assert req.order_type == OrderType.LIMIT
assert req.limit_price == 400.0
def test_custom_idempotency_key(self):
req = OrderRequest(
ticker="AAPL",
side=OrderSide.BUY,
quantity=1,
idempotency_key="my-key-123",
)
assert req.idempotency_key == "my-key-123"
def test_to_dict(self):
req = OrderRequest(
ticker="AAPL",
side=OrderSide.BUY,
quantity=10,
order_type=OrderType.LIMIT,
limit_price=170.0,
idempotency_key="key-1",
)
d = req.to_dict()
assert d["ticker"] == "AAPL"
assert d["side"] == "buy"
assert d["quantity"] == 10
assert d["order_type"] == "limit"
assert d["limit_price"] == 170.0
assert d["idempotency_key"] == "key-1"
def test_to_dict_omits_none_prices(self):
req = OrderRequest(ticker="AAPL", side=OrderSide.BUY, quantity=1)
d = req.to_dict()
assert "limit_price" not in d
assert "stop_price" not in d
# --- OrderResponse tests ---
class TestOrderResponse:
def test_ok_when_accepted(self):
resp = OrderResponse(
broker_order_id="abc",
status=OrderStatus.ACCEPTED,
ticker="AAPL",
side=OrderSide.BUY,
quantity=10,
)
assert resp.ok is True
def test_not_ok_when_rejected(self):
resp = OrderResponse(
broker_order_id="abc",
status=OrderStatus.REJECTED,
ticker="AAPL",
side=OrderSide.BUY,
quantity=10,
error="insufficient funds",
)
assert resp.ok is False
def test_not_ok_when_error(self):
resp = OrderResponse(
broker_order_id="abc",
status=OrderStatus.SUBMITTED,
ticker="AAPL",
side=OrderSide.BUY,
quantity=10,
error="network failure",
)
assert resp.ok is False
def test_to_dict(self):
resp = OrderResponse(
broker_order_id="order-1",
status=OrderStatus.FILLED,
ticker="AAPL",
side=OrderSide.BUY,
quantity=10,
filled_quantity=10,
filled_avg_price=172.5,
)
d = resp.to_dict()
assert d["broker_order_id"] == "order-1"
assert d["status"] == "filled"
assert d["filled_avg_price"] == 172.5
# --- PositionInfo tests ---
class TestPositionInfo:
def test_basic_position(self):
pos = PositionInfo(
ticker="AAPL",
quantity=10,
avg_entry_price=172.5,
current_price=175.0,
unrealized_pnl=25.0,
market_value=1750.0,
)
assert pos.ticker == "AAPL"
assert pos.side == "long"
def test_to_dict(self):
pos = PositionInfo(
ticker="AAPL",
quantity=10,
avg_entry_price=172.5,
current_price=175.0,
unrealized_pnl=25.0,
market_value=1750.0,
side="short",
)
d = pos.to_dict()
assert d["side"] == "short"
assert d["unrealized_pnl"] == 25.0
# --- AccountInfo tests ---
class TestAccountInfo:
def test_basic_account(self):
acct = AccountInfo(
account_id="acct-1",
buying_power=50000,
cash=25000,
portfolio_value=75000,
)
assert acct.mode == TradingMode.PAPER
assert acct.currency == "USD"
def test_to_dict(self):
acct = AccountInfo(
account_id="acct-1",
buying_power=50000,
cash=25000,
portfolio_value=75000,
mode=TradingMode.LIVE,
)
d = acct.to_dict()
assert d["mode"] == "live"
assert d["portfolio_value"] == 75000
# --- AlpacaBrokerAdapter tests ---
class TestAlpacaSourceType:
def test_source_type(self):
adapter = AlpacaBrokerAdapter(api_key="k", api_secret="s")
assert adapter.source_type() == "broker"
def test_inherits_broker_data_adapter(self):
assert issubclass(AlpacaBrokerAdapter, BrokerDataAdapter)
def test_bucket_name(self):
adapter = AlpacaBrokerAdapter(api_key="k", api_secret="s")
assert adapter.bucket_name() == "stonks-raw-broker"
def test_default_mode_is_paper(self):
adapter = AlpacaBrokerAdapter(api_key="k", api_secret="s")
assert adapter.mode == TradingMode.PAPER
def test_paper_base_url(self):
adapter = AlpacaBrokerAdapter(api_key="k", api_secret="s", mode=TradingMode.PAPER)
assert "paper" in adapter.base_url
def test_live_base_url(self):
adapter = AlpacaBrokerAdapter(api_key="k", api_secret="s", mode=TradingMode.LIVE)
assert "paper" not in adapter.base_url
def test_custom_base_url(self):
adapter = AlpacaBrokerAdapter(api_key="k", api_secret="s", base_url="http://localhost:8080/")
assert adapter.base_url == "http://localhost:8080"
class TestAlpacaHeaders:
def test_headers_contain_api_keys(self):
adapter = AlpacaBrokerAdapter(api_key="my-key", api_secret="my-secret")
headers = adapter._headers()
assert headers["APCA-API-KEY-ID"] == "my-key"
assert headers["APCA-API-SECRET-KEY"] == "my-secret"
assert headers["Content-Type"] == "application/json"
class TestAlpacaBuildFetchUrl:
def setup_method(self):
self.adapter = AlpacaBrokerAdapter(
api_key="k", api_secret="s", base_url="https://paper-api.alpaca.markets"
)
def test_positions_url(self):
url = self.adapter._build_fetch_url("AAPL", "positions")
assert url == "https://paper-api.alpaca.markets/v2/positions/AAPL"
def test_orders_url(self):
url = self.adapter._build_fetch_url("AAPL", "orders")
assert "v2/orders" in url
assert "symbols=AAPL" in url
def test_account_url(self):
url = self.adapter._build_fetch_url("AAPL", "account")
assert url == "https://paper-api.alpaca.markets/v2/account"
def test_default_is_positions(self):
url = self.adapter._build_fetch_url("AAPL", "unknown")
assert "/v2/positions/AAPL" in url
class TestAlpacaParseOrderResponse:
def setup_method(self):
self.adapter = AlpacaBrokerAdapter(api_key="k", api_secret="s")
def test_parse_accepted_order(self):
resp = self.adapter._parse_order_response(ALPACA_ORDER_RESPONSE)
assert resp.broker_order_id == "order-abc-123"
assert resp.status == OrderStatus.ACCEPTED
assert resp.ticker == "AAPL"
assert resp.side == OrderSide.BUY
assert resp.quantity == 10
assert resp.filled_quantity == 0
assert resp.filled_avg_price is None
def test_parse_filled_order(self):
resp = self.adapter._parse_order_response(ALPACA_FILLED_ORDER)
assert resp.status == OrderStatus.FILLED
assert resp.filled_quantity == 10
assert resp.filled_avg_price == 172.5
def test_parse_rejected_order(self):
resp = self.adapter._parse_order_response(ALPACA_REJECTED_ORDER)
assert resp.status == OrderStatus.REJECTED
assert resp.ok is False
def test_parse_unknown_status_defaults_to_pending(self):
data = {**ALPACA_ORDER_RESPONSE, "status": "some_new_status"}
resp = self.adapter._parse_order_response(data)
assert resp.status == OrderStatus.PENDING
def test_parse_sell_side(self):
data = {**ALPACA_ORDER_RESPONSE, "side": "sell"}
resp = self.adapter._parse_order_response(data)
assert resp.side == OrderSide.SELL
class TestAlpacaParsePosition:
def setup_method(self):
self.adapter = AlpacaBrokerAdapter(api_key="k", api_secret="s")
def test_parse_position(self):
pos = self.adapter._parse_position(ALPACA_POSITION)
assert pos.ticker == "AAPL"
assert pos.quantity == 10
assert pos.avg_entry_price == 172.5
assert pos.current_price == 175.0
assert pos.unrealized_pnl == 25.0
assert pos.market_value == 1750.0
assert pos.side == "long"
def test_parse_position_missing_fields(self):
pos = self.adapter._parse_position({"symbol": "TSLA"})
assert pos.ticker == "TSLA"
assert pos.quantity == 0
assert pos.avg_entry_price == 0
class TestAlpacaErrorResult:
def test_error_result_fields(self):
adapter = AlpacaBrokerAdapter(api_key="k", api_secret="s")
result = adapter._error_result("AAPL", "rate limited", 150.0, http_status=429, raw=b"slow down")
assert not result.ok
assert result.error == "rate limited"
assert result.http_status == 429
assert result.response_time_ms == 150.0
assert result.raw_payload == b"slow down"
assert result.metadata["provider"] == "alpaca"
assert result.metadata["mode"] == "paper"
assert result.source_type == "broker"
def test_error_result_defaults(self):
adapter = AlpacaBrokerAdapter(api_key="k", api_secret="s")
result = adapter._error_result("MSFT", "timeout", 200.0)
assert result.http_status is None
assert result.raw_payload == b""
assert result.ticker == "MSFT"