c85c0068a2
- Replace all datetime.utcnow() with datetime.now(tz=timezone.utc) across 8 files - Fix 12 failing tests to match current implementation behavior - Fix pytest_plugins in non-top-level conftest (moved to root conftest.py) - Auto-fix 189 lint issues (import sorting, unused imports) - Add CI/CD pipeline infrastructure (ARC, ArgoCD, Kargo manifests) - Add values-beta.yaml and values-paper.yaml for staged deployments - Update GitHub Actions workflow to use self-hosted-gremlin runners - Add integration-test job to CI pipeline Result: 1596 passed, 0 failed, 0 warnings
417 lines
12 KiB
Python
417 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"
|