289 lines
8.8 KiB
Python
289 lines
8.8 KiB
Python
"""
|
|
Tests for the broker abstraction layer.
|
|
"""
|
|
import pytest
|
|
from brokers import (
|
|
BaseBroker, BacktestBroker, PaperBroker,
|
|
OrderSide, OrderType, OrderStatus, OrderResult, Position,
|
|
create_broker, TradingMode
|
|
)
|
|
|
|
|
|
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
|