"""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