brighter-trading/tests/test_brokers.py

356 lines
11 KiB
Python

"""
Tests for the broker abstraction layer.
"""
import pytest
from brokers import (
BaseBroker, BacktestBroker, PaperBroker,
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_falls_back_to_paper(self):
"""Test that live broker falls back to paper broker with warning."""
broker = create_broker(mode=TradingMode.LIVE, initial_balance=5000)
# Should return a PaperBroker (fallback)
assert isinstance(broker, PaperBroker)
assert broker.get_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)