""" 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) class TestPaperBrokerSLTP: """Tests for Stop Loss / Take Profit functionality in PaperBroker.""" def test_sl_triggers_on_price_drop(self): """Test that stop loss triggers when price drops below threshold.""" broker = PaperBroker(initial_balance=10000, commission=0.001) broker.update_price('BTC/USDT', 50000) # Buy with SL at 45000 result = broker.place_order( symbol='BTC/USDT', side=OrderSide.BUY, order_type=OrderType.MARKET, size=0.1, stop_loss=45000 ) assert result.success # Position exists position = broker.get_position('BTC/USDT') assert position is not None assert position.size == 0.1 # SL is tracked assert 'BTC/USDT' in broker._position_sltp assert broker._position_sltp['BTC/USDT']['stop_loss'] == 45000 # Price drops below SL broker.update_price('BTC/USDT', 44000) events = broker.update() # SL should have triggered sltp_events = [e for e in events if e.get('type') == 'sltp_triggered'] assert len(sltp_events) == 1 assert sltp_events[0]['trigger'] == 'stop_loss' assert sltp_events[0]['symbol'] == 'BTC/USDT' # Position should be closed position = broker.get_position('BTC/USDT') assert position is None # SL tracking cleared assert 'BTC/USDT' not in broker._position_sltp def test_tp_triggers_on_price_rise(self): """Test that take profit triggers when price rises above threshold.""" broker = PaperBroker(initial_balance=10000, commission=0.001) broker.update_price('BTC/USDT', 50000) # Buy with TP at 55000 result = broker.place_order( symbol='BTC/USDT', side=OrderSide.BUY, order_type=OrderType.MARKET, size=0.1, take_profit=55000 ) assert result.success # TP is tracked assert 'BTC/USDT' in broker._position_sltp assert broker._position_sltp['BTC/USDT']['take_profit'] == 55000 # Price rises above TP broker.update_price('BTC/USDT', 56000) events = broker.update() # TP should have triggered sltp_events = [e for e in events if e.get('type') == 'sltp_triggered'] assert len(sltp_events) == 1 assert sltp_events[0]['trigger'] == 'take_profit' # Position should be closed position = broker.get_position('BTC/USDT') assert position is None def test_sltp_cleared_on_manual_close(self): """Test that SL/TP tracking is cleared when position is manually closed.""" broker = PaperBroker(initial_balance=10000, commission=0.001) broker.update_price('BTC/USDT', 50000) # Buy with SL and TP broker.place_order( symbol='BTC/USDT', side=OrderSide.BUY, order_type=OrderType.MARKET, size=0.1, stop_loss=45000, take_profit=55000 ) assert 'BTC/USDT' in broker._position_sltp # Manually close position broker.close_position('BTC/USDT') # SL/TP tracking should be cleared assert 'BTC/USDT' not in broker._position_sltp def test_sltp_persists_across_state_save_load(self): """Test that SL/TP tracking persists across state save/load.""" broker = PaperBroker(initial_balance=10000, commission=0.001) broker.update_price('BTC/USDT', 50000) # Buy with SL and TP broker.place_order( symbol='BTC/USDT', side=OrderSide.BUY, order_type=OrderType.MARKET, size=0.1, stop_loss=45000, take_profit=55000 ) # Save state state = broker.to_state_dict() assert 'position_sltp' in state assert 'BTC/USDT' in state['position_sltp'] # Create new broker and restore state broker2 = PaperBroker(initial_balance=10000) broker2.from_state_dict(state) # SL/TP should be restored assert 'BTC/USDT' in broker2._position_sltp assert broker2._position_sltp['BTC/USDT']['stop_loss'] == 45000 assert broker2._position_sltp['BTC/USDT']['take_profit'] == 55000 def test_no_sltp_trigger_when_price_within_range(self): """Test that no SL/TP triggers when price stays within range.""" broker = PaperBroker(initial_balance=10000, commission=0.001) broker.update_price('BTC/USDT', 50000) # Buy with SL at 45000 and TP at 55000 broker.place_order( symbol='BTC/USDT', side=OrderSide.BUY, order_type=OrderType.MARKET, size=0.1, stop_loss=45000, take_profit=55000 ) # Price moves but stays within range broker.update_price('BTC/USDT', 48000) events = broker.update() # No SL/TP triggers sltp_events = [e for e in events if e.get('type') == 'sltp_triggered'] assert len(sltp_events) == 0 # Position still exists position = broker.get_position('BTC/USDT') assert position is not None assert position.size == 0.1 class TestManualTradingSLTP: """Integration tests for SL/TP in manual trading path (Trades.new_trade -> broker).""" @pytest.fixture def mock_users(self): """Create a mock Users object.""" from unittest.mock import MagicMock users = MagicMock() users.get_username.return_value = 'test_user' return users def test_new_trade_passes_sltp_to_broker(self, mock_users): """Test that new_trade() passes SL/TP to broker.place_order().""" from manual_trading_broker import ManualTradingBrokerManager trades = Trades(mock_users) # Set up manual broker manager broker_manager = ManualTradingBrokerManager() trades.manual_broker_manager = broker_manager # Get the paper broker and set price broker = broker_manager.get_paper_broker(user_id=1) broker.update_price('BTC/USDT', 50000) # Create trade with SL/TP status, trade_id = trades.new_trade( target='test_exchange', symbol='BTC/USDT', price=50000.0, side='buy', order_type='MARKET', qty=0.1, user_id=1, stop_loss=45000.0, take_profit=60000.0 ) assert status == 'Success' # Verify trade has SL/TP trade = trades.get_trade_by_id(trade_id) assert trade.stop_loss == 45000.0 assert trade.take_profit == 60000.0 # Verify broker has SL/TP tracking assert 'BTC/USDT' in broker._position_sltp assert broker._position_sltp['BTC/USDT']['stop_loss'] == 45000.0 assert broker._position_sltp['BTC/USDT']['take_profit'] == 60000.0 def test_new_trade_sltp_triggers_on_price_drop(self, mock_users): """Test that SL/TP triggers work through the full manual trading path.""" from manual_trading_broker import ManualTradingBrokerManager trades = Trades(mock_users) # Set up manual broker manager broker_manager = ManualTradingBrokerManager() trades.manual_broker_manager = broker_manager # Get the paper broker and set price broker = broker_manager.get_paper_broker(user_id=1) broker.update_price('BTC/USDT', 50000) # Create trade with SL status, trade_id = trades.new_trade( target='test_exchange', symbol='BTC/USDT', price=50000.0, side='buy', order_type='MARKET', qty=0.1, user_id=1, stop_loss=45000.0 ) assert status == 'Success' assert trade_id in trades.active_trades # Price drops below SL broker.update_price('BTC/USDT', 44000) events = broker.update() # Verify SL triggered sltp_events = [e for e in events if e.get('type') == 'sltp_triggered'] assert len(sltp_events) == 1 assert sltp_events[0]['trigger'] == 'stop_loss' # Position should be closed at broker level position = broker.get_position('BTC/USDT') assert position is None