brighter-trading/tests/test_brokers.py

685 lines
22 KiB
Python

"""
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_ioc_limit_cancels_if_not_marketable(self):
"""IOC limit orders should fail immediately if not marketable."""
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,
time_in_force='IOC'
)
assert not result.success
assert result.status == OrderStatus.CANCELLED
assert broker.get_open_orders() == []
assert broker.get_position('BTC/USDT') is None
def test_paper_broker_fok_limit_fills_immediately_if_marketable(self):
"""FOK limit orders should fill immediately when already marketable."""
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=51000,
time_in_force='FOK'
)
assert result.success
assert result.status == OrderStatus.FILLED
assert broker.get_open_orders() == []
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_pnl_includes_fees(self):
"""Test that P&L accurately reflects both entry and exit fees."""
broker = PaperBroker(initial_balance=10000, commission=0.001, slippage=0)
broker.update_price('BTC/USDT', 1000)
# Buy 1 unit at $1000, entry fee = $1
broker.place_order(
symbol='BTC/USDT',
side=OrderSide.BUY,
order_type=OrderType.MARKET,
size=1.0
)
# Immediately after buy, unrealized P&L should show -$1 (entry fee)
position = broker.get_position('BTC/USDT')
assert position is not None
assert position.entry_commission == 1.0 # 0.1% of $1000
assert position.unrealized_pnl == -1.0 # Entry fee already reflected
# Price hasn't moved, but we're down by entry fee
broker.update()
position = broker.get_position('BTC/USDT')
assert position.unrealized_pnl == -1.0
# Now sell at same price, exit fee = $1
result = broker.place_order(
symbol='BTC/USDT',
side=OrderSide.SELL,
order_type=OrderType.MARKET,
size=1.0
)
# Realized P&L should be -$2 (entry + exit fee)
assert result.success
# The realized_pnl on the order reflects both fees
# (price movement 0) - entry_fee ($1) - exit_fee ($1) = -$2
# Cash balance should reflect the loss
# Started with $10000, bought for $1001, sold for $999 = $9998
assert broker.get_available_balance() == 9998.0
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