604 lines
19 KiB
Python
604 lines
19 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_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
|