262 lines
8.9 KiB
Python
262 lines
8.9 KiB
Python
"""Tests for the broker service - sandbox integration wiring.
|
|
|
|
Validates job parsing, risk evaluation integration, order building,
|
|
and the overall process_order_job flow using a mock Alpaca adapter.
|
|
"""
|
|
import pytest
|
|
|
|
from services.adapters.broker_adapter import (
|
|
AlpacaBrokerAdapter,
|
|
OrderRequest,
|
|
OrderResponse,
|
|
OrderSide,
|
|
OrderStatus,
|
|
OrderType,
|
|
TradingMode,
|
|
)
|
|
from services.adapters.broker_service import (
|
|
build_order_request,
|
|
build_proposed_order,
|
|
generate_idempotency_key,
|
|
)
|
|
from services.risk.engine import (
|
|
AccountRiskState,
|
|
PortfolioRiskConfig,
|
|
ProposedOrder,
|
|
TradingMode as RiskTradingMode,
|
|
evaluate_order,
|
|
)
|
|
from services.shared.redis_keys import QUEUE_BROKER
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# build_order_request tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBuildOrderRequest:
|
|
def test_basic_buy_market(self):
|
|
job = {
|
|
"ticker": "AAPL",
|
|
"side": "buy",
|
|
"quantity": 10,
|
|
"order_type": "market",
|
|
"idempotency_key": "key-1",
|
|
}
|
|
req = build_order_request(job)
|
|
assert req.ticker == "AAPL"
|
|
assert req.side == OrderSide.BUY
|
|
assert req.quantity == 10
|
|
assert req.order_type == OrderType.MARKET
|
|
assert req.idempotency_key == "key-1"
|
|
|
|
def test_sell_limit_order(self):
|
|
job = {
|
|
"ticker": "MSFT",
|
|
"side": "sell",
|
|
"quantity": 5,
|
|
"order_type": "limit",
|
|
"limit_price": 400.0,
|
|
}
|
|
req = build_order_request(job)
|
|
assert req.side == OrderSide.SELL
|
|
assert req.order_type == OrderType.LIMIT
|
|
assert req.limit_price == 400.0
|
|
|
|
def test_stop_order(self):
|
|
job = {
|
|
"ticker": "TSLA",
|
|
"side": "sell",
|
|
"quantity": 3,
|
|
"order_type": "stop",
|
|
"stop_price": 200.0,
|
|
}
|
|
req = build_order_request(job)
|
|
assert req.order_type == OrderType.STOP
|
|
assert req.stop_price == 200.0
|
|
|
|
def test_defaults(self):
|
|
job = {"ticker": "GOOG"}
|
|
req = build_order_request(job)
|
|
assert req.side == OrderSide.BUY
|
|
assert req.quantity == 0
|
|
assert req.order_type == OrderType.MARKET
|
|
assert req.time_in_force == "day"
|
|
assert req.idempotency_key # deterministic from job content
|
|
|
|
def test_deterministic_key_without_explicit(self):
|
|
"""Without an explicit key, the same job produces the same key."""
|
|
job = {"ticker": "AAPL", "side": "buy", "quantity": 10}
|
|
req1 = build_order_request(job)
|
|
req2 = build_order_request(job)
|
|
assert req1.idempotency_key == req2.idempotency_key
|
|
|
|
def test_custom_time_in_force(self):
|
|
job = {"ticker": "AAPL", "time_in_force": "gtc"}
|
|
req = build_order_request(job)
|
|
assert req.time_in_force == "gtc"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# build_proposed_order tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBuildProposedOrder:
|
|
def test_basic_proposed_order(self):
|
|
job = {
|
|
"ticker": "AAPL",
|
|
"side": "buy",
|
|
"quantity": 10,
|
|
"estimated_value": 1500.0,
|
|
"confidence": 0.85,
|
|
"sector": "technology",
|
|
"recommendation_id": "rec-123",
|
|
}
|
|
proposed = build_proposed_order(job)
|
|
assert proposed.ticker == "AAPL"
|
|
assert proposed.action == "buy"
|
|
assert proposed.quantity == 10
|
|
assert proposed.estimated_value == 1500.0
|
|
assert proposed.confidence == 0.85
|
|
assert proposed.sector == "technology"
|
|
assert proposed.recommendation_id == "rec-123"
|
|
|
|
def test_defaults(self):
|
|
job = {"ticker": "GOOG"}
|
|
proposed = build_proposed_order(job)
|
|
assert proposed.action == "buy"
|
|
assert proposed.quantity == 0
|
|
assert proposed.estimated_value == 0
|
|
assert proposed.sector == ""
|
|
assert proposed.recommendation_id is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Risk evaluation integration with broker service flow
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRiskEvaluationIntegration:
|
|
"""Verify that risk evaluation correctly gates order submission."""
|
|
|
|
def test_order_passes_risk_in_paper_mode(self):
|
|
config = PortfolioRiskConfig(trading_mode=RiskTradingMode.PAPER)
|
|
state = AccountRiskState(
|
|
portfolio_value=100_000.0,
|
|
cash=50_000.0,
|
|
buying_power=50_000.0,
|
|
)
|
|
proposed = ProposedOrder(
|
|
ticker="AAPL",
|
|
action="buy",
|
|
quantity=10,
|
|
estimated_value=1500.0,
|
|
sector="technology",
|
|
)
|
|
result = evaluate_order(proposed, config, state)
|
|
assert result.eligible
|
|
assert result.allowed_mode == RiskTradingMode.PAPER
|
|
|
|
def test_order_blocked_when_trading_disabled(self):
|
|
config = PortfolioRiskConfig(trading_mode=RiskTradingMode.DISABLED)
|
|
proposed = ProposedOrder(ticker="AAPL", quantity=10, estimated_value=1500.0)
|
|
result = evaluate_order(proposed, config)
|
|
assert not result.eligible
|
|
assert "disabled" in result.rejection_reasons[0].lower()
|
|
|
|
def test_order_blocked_by_position_size(self):
|
|
config = PortfolioRiskConfig(trading_mode=RiskTradingMode.PAPER)
|
|
config.position_limits.max_position_value = 1000.0
|
|
state = AccountRiskState(portfolio_value=100_000.0)
|
|
proposed = ProposedOrder(
|
|
ticker="AAPL",
|
|
quantity=100,
|
|
estimated_value=15_000.0,
|
|
)
|
|
result = evaluate_order(proposed, config, state)
|
|
assert not result.eligible
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Alpaca adapter sandbox mode verification
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAlpacaSandboxMode:
|
|
def test_paper_mode_uses_sandbox_url(self):
|
|
adapter = AlpacaBrokerAdapter(
|
|
api_key="test-key",
|
|
api_secret="test-secret",
|
|
mode=TradingMode.PAPER,
|
|
)
|
|
assert adapter.mode == TradingMode.PAPER
|
|
assert "paper" in adapter.base_url
|
|
|
|
def test_custom_sandbox_url(self):
|
|
adapter = AlpacaBrokerAdapter(
|
|
api_key="test-key",
|
|
api_secret="test-secret",
|
|
mode=TradingMode.PAPER,
|
|
base_url="https://paper-api.alpaca.markets",
|
|
)
|
|
assert adapter.base_url == "https://paper-api.alpaca.markets"
|
|
|
|
def test_headers_set_correctly(self):
|
|
adapter = AlpacaBrokerAdapter(
|
|
api_key="pk-test",
|
|
api_secret="sk-test",
|
|
)
|
|
headers = adapter._headers()
|
|
assert headers["APCA-API-KEY-ID"] == "pk-test"
|
|
assert headers["APCA-API-SECRET-KEY"] == "sk-test"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Queue name constant
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestQueueConstant:
|
|
def test_broker_queue_name(self):
|
|
assert QUEUE_BROKER == "broker_orders"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Idempotency key generation tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGenerateIdempotencyKey:
|
|
def test_explicit_key_passthrough(self):
|
|
job = {"ticker": "AAPL", "idempotency_key": "my-explicit-key"}
|
|
assert generate_idempotency_key(job) == "my-explicit-key"
|
|
|
|
def test_deterministic_without_explicit_key(self):
|
|
job = {"ticker": "AAPL", "side": "buy", "quantity": 10, "order_type": "market"}
|
|
key1 = generate_idempotency_key(job)
|
|
key2 = generate_idempotency_key(job)
|
|
assert key1 == key2
|
|
assert len(key1) == 40 # sha256 truncated to 40 chars
|
|
|
|
def test_different_jobs_produce_different_keys(self):
|
|
job_a = {"ticker": "AAPL", "side": "buy", "quantity": 10}
|
|
job_b = {"ticker": "AAPL", "side": "sell", "quantity": 10}
|
|
assert generate_idempotency_key(job_a) != generate_idempotency_key(job_b)
|
|
|
|
def test_quantity_difference_changes_key(self):
|
|
job_a = {"ticker": "AAPL", "side": "buy", "quantity": 10}
|
|
job_b = {"ticker": "AAPL", "side": "buy", "quantity": 20}
|
|
assert generate_idempotency_key(job_a) != generate_idempotency_key(job_b)
|
|
|
|
def test_recommendation_id_included(self):
|
|
job_a = {"ticker": "AAPL", "recommendation_id": "rec-1"}
|
|
job_b = {"ticker": "AAPL", "recommendation_id": "rec-2"}
|
|
assert generate_idempotency_key(job_a) != generate_idempotency_key(job_b)
|
|
|
|
def test_minimal_job_still_produces_key(self):
|
|
job = {"ticker": "AAPL"}
|
|
key = generate_idempotency_key(job)
|
|
assert key
|
|
assert len(key) == 40
|