""" Tests for the broker abstraction layer. """ import pytest from brokers import ( BaseBroker, BacktestBroker, PaperBroker, LiveBroker, OrderSide, OrderType, OrderStatus, OrderResult, Position, create_broker, TradingMode ) from ExchangeInterface import ExchangeInterface from trade import Trade, Trades class TestPaperBroker: """Tests for PaperBroker.""" def test_create_paper_broker(self): """Test creating a paper broker.""" broker = PaperBroker(initial_balance=10000) assert broker.get_balance() == 10000 assert broker.get_available_balance() == 10000 def test_paper_broker_market_buy(self): """Test market buy order.""" broker = PaperBroker(initial_balance=10000, commission=0.001) broker.update_price('BTC/USDT', 50000) result = broker.place_order( symbol='BTC/USDT', side=OrderSide.BUY, order_type=OrderType.MARKET, size=0.1 ) assert result.success assert result.status == OrderStatus.FILLED position = broker.get_position('BTC/USDT') assert position is not None assert position.size == 0.1 # Check balance deducted (price * size + commission) expected_cost = 50000 * 0.1 * (1 + 0.001) # with slippage assert broker.get_available_balance() < 10000 def test_paper_broker_market_sell(self): """Test market sell order.""" broker = PaperBroker(initial_balance=10000, commission=0.001) broker.update_price('BTC/USDT', 50000) # First buy broker.place_order( symbol='BTC/USDT', side=OrderSide.BUY, order_type=OrderType.MARKET, size=0.1 ) # Update price and sell broker.update_price('BTC/USDT', 55000) result = broker.place_order( symbol='BTC/USDT', side=OrderSide.SELL, order_type=OrderType.MARKET, size=0.1 ) assert result.success assert result.status == OrderStatus.FILLED # Position should be closed position = broker.get_position('BTC/USDT') assert position is None def test_paper_broker_insufficient_funds(self): """Test order rejection due to insufficient funds.""" broker = PaperBroker(initial_balance=1000) broker.update_price('BTC/USDT', 50000) result = broker.place_order( symbol='BTC/USDT', side=OrderSide.BUY, order_type=OrderType.MARKET, size=0.1 # Would cost ~5000 ) assert not result.success assert "Insufficient funds" in result.message def test_paper_broker_limit_order(self): """Test limit order placement and fill.""" broker = PaperBroker(initial_balance=10000, commission=0.001) broker.update_price('BTC/USDT', 50000) result = broker.place_order( symbol='BTC/USDT', side=OrderSide.BUY, order_type=OrderType.LIMIT, size=0.1, price=49000 ) assert result.success assert result.status == OrderStatus.OPEN # Order should be pending open_orders = broker.get_open_orders() assert len(open_orders) == 1 # Update price below limit - should fill broker.update_price('BTC/USDT', 48000) events = broker.update() assert len(events) == 1 assert events[0]['type'] == 'fill' # Now position should exist position = broker.get_position('BTC/USDT') assert position is not None assert position.size == 0.1 def test_paper_broker_cancel_order(self): """Test order cancellation.""" broker = PaperBroker(initial_balance=10000, commission=0, slippage=0) broker.update_price('BTC/USDT', 50000) initial_balance = broker.get_available_balance() result = broker.place_order( symbol='BTC/USDT', side=OrderSide.BUY, order_type=OrderType.LIMIT, size=0.1, price=49000 ) assert result.success assert broker.get_available_balance() < initial_balance # Funds locked # Cancel the order cancelled = broker.cancel_order(result.order_id) assert cancelled # Funds should be released assert broker.get_available_balance() == initial_balance def test_paper_broker_pnl_tracking(self): """Test P&L tracking.""" broker = PaperBroker(initial_balance=10000, commission=0, slippage=0) broker.update_price('BTC/USDT', 50000) # Buy at 50000 broker.place_order( symbol='BTC/USDT', side=OrderSide.BUY, order_type=OrderType.MARKET, size=0.1 ) # Price goes up broker.update_price('BTC/USDT', 52000) broker.update() position = broker.get_position('BTC/USDT') assert position is not None # Unrealized P&L: (52000 - 50000) * 0.1 = 200 assert position.unrealized_pnl == 200 def test_paper_broker_equity_calculation(self): """Test that equity = cash + position value (not just unrealized PnL).""" broker = PaperBroker(initial_balance=10000, commission=0, slippage=0) broker.update_price('BTC/USDT', 100) # Initial equity should equal initial balance assert broker.get_balance() == 10000 # Buy 1 unit at $100 = spend $100 broker.place_order( symbol='BTC/USDT', side=OrderSide.BUY, order_type=OrderType.MARKET, size=1.0 ) # Cash is now 9900, position value is 100 # Total equity should still be 10000 (9900 + 100) assert broker.get_available_balance() == 9900 # Cash assert broker.get_balance() == 10000 # Total equity # Price doubles broker.update_price('BTC/USDT', 200) broker.update() # Cash still 9900, position value now 200 # Total equity should be 10100 (9900 + 200) assert broker.get_available_balance() == 9900 assert broker.get_balance() == 10100 def test_paper_broker_reset(self): """Test broker reset.""" broker = PaperBroker(initial_balance=10000) broker.update_price('BTC/USDT', 50000) broker.place_order( symbol='BTC/USDT', side=OrderSide.BUY, order_type=OrderType.MARKET, size=0.1 ) broker.reset() assert broker.get_balance() == 10000 assert broker.get_all_positions() == [] assert broker.get_open_orders() == [] class TestBrokerFactory: """Tests for the broker factory.""" def test_create_paper_broker(self): """Test creating paper broker via factory.""" broker = create_broker( mode=TradingMode.PAPER, initial_balance=5000 ) assert isinstance(broker, PaperBroker) assert broker.get_balance() == 5000 def test_create_backtest_broker(self): """Test creating backtest broker via factory.""" broker = create_broker( mode=TradingMode.BACKTEST, initial_balance=10000 ) assert isinstance(broker, BacktestBroker) def test_create_live_broker_requires_exchange(self): """Test that live broker requires an exchange parameter.""" with pytest.raises(ValueError, match="exchange"): create_broker(mode=TradingMode.LIVE, initial_balance=5000) def test_invalid_mode(self): """Test that invalid mode raises ValueError.""" with pytest.raises(ValueError): create_broker(mode='invalid_mode') class TestOrderResult: """Tests for OrderResult dataclass.""" def test_order_result_success(self): """Test successful order result.""" result = OrderResult( success=True, order_id='12345', status=OrderStatus.FILLED, filled_qty=0.1, filled_price=50000 ) assert result.success assert result.order_id == '12345' assert result.status == OrderStatus.FILLED def test_order_result_failure(self): """Test failed order result.""" result = OrderResult( success=False, message="Insufficient funds" ) assert not result.success assert "Insufficient" in result.message class TestPosition: """Tests for Position dataclass.""" def test_position_creation(self): """Test position creation.""" position = Position( symbol='BTC/USDT', size=0.1, entry_price=50000, current_price=51000, unrealized_pnl=100 ) assert position.symbol == 'BTC/USDT' assert position.size == 0.1 assert position.unrealized_pnl == 100 class TestExecutionPriceFallbacks: """Tests for execution-price safety fallbacks.""" def test_exchange_interface_uses_caller_fallback_price(self): """When order_price is unavailable, caller fallback should be used.""" exchange_interface = ExchangeInterface.__new__(ExchangeInterface) # If this gets called, fallback was not used correctly. exchange_interface.get_price = lambda symbol: (_ for _ in ()).throw(RuntimeError("exchange unavailable")) trade = Trade( target='test_exchange', symbol='BTC/USDT', side='BUY', order_price=0.0, # Market order style base_order_qty=1.0, order_type='MARKET' ) trade.order = object() price = exchange_interface.get_trade_executed_price(trade, fallback_price=123.45) assert price == pytest.approx(123.45) def test_trades_update_fills_using_tick_price_fallback(self): """Trades.update should pass current tick price as execution fallback.""" class _DummyUsers: @staticmethod def get_username(user_id): return "test_user" class _MockExchangeInterface: @staticmethod def get_trade_status(trade): return 'FILLED' @staticmethod def get_trade_executed_qty(trade): return trade.base_order_qty @staticmethod def get_trade_executed_price(trade, fallback_price=None): return fallback_price trades = Trades(users=_DummyUsers()) trades.exchange_interface = _MockExchangeInterface() trade = Trade( target='test_exchange', symbol='BTC/USDT', side='BUY', order_price=0.0, # No explicit fill price available base_order_qty=1.0, order_type='MARKET' ) trade.order_placed(order=object()) trades.active_trades[trade.unique_id] = trade updates = trades.update({'BTC/USDT': 321.0}) assert updates assert trade.status == 'filled' assert trade.stats['opening_price'] == pytest.approx(321.0)