brighter-trading/tests/test_live_margin_broker.py

1405 lines
48 KiB
Python

"""
Tests for LiveMarginBroker (Phase 3: Read-Only Sync).
These tests verify the broker can derive positions from exchange account data
and correctly track sync state.
"""
import pytest
from datetime import datetime, timezone
from unittest.mock import Mock, patch
from brokers.live_margin_broker import (
LiveMarginBroker,
LiveMarginPosition,
SyncState,
)
class TestLiveMarginPosition:
"""Tests for LiveMarginPosition dataclass."""
def test_create_long_position(self):
"""Test creating a long position."""
pos = LiveMarginPosition(
symbol='BTC/USDT',
side='long',
size=0.01,
entry_price=50000.0,
borrowed=200.0,
borrowed_asset='USDT',
interest_owed=0.5,
liability_ratio=0.15,
collateral=100.0,
leverage=3.0,
current_price=51000.0,
unrealized_pnl=10.0,
liquidation_price=45000.0,
margin_ratio=85.0,
sync_state=SyncState.HEALTHY,
)
assert pos.symbol == 'BTC/USDT'
assert pos.side == 'long'
assert pos.borrowed_asset == 'USDT'
assert pos.sync_state == SyncState.HEALTHY
def test_create_short_position(self):
"""Test creating a short position."""
pos = LiveMarginPosition(
symbol='ETH/USDT',
side='short',
size=0.5,
entry_price=3000.0,
borrowed=0.5,
borrowed_asset='ETH',
interest_owed=0.001,
liability_ratio=0.20,
collateral=500.0,
leverage=3.0,
)
assert pos.side == 'short'
assert pos.borrowed_asset == 'ETH'
def test_to_dict(self):
"""Test serialization to dict."""
pos = LiveMarginPosition(
symbol='BTC/USDT',
side='long',
size=0.01,
entry_price=50000.0,
borrowed=200.0,
borrowed_asset='USDT',
interest_owed=0.5,
liability_ratio=0.15,
collateral=100.0,
leverage=3.0,
sync_state=SyncState.HEALTHY,
)
d = pos.to_dict()
assert d['symbol'] == 'BTC/USDT'
assert d['side'] == 'long'
assert d['sync_state'] == 'healthy'
assert 'last_synced' in d
class TestLiveMarginBrokerInit:
"""Tests for LiveMarginBroker initialization."""
def test_create_broker(self):
"""Test creating a broker."""
mock_exchange = Mock()
mock_exchange.is_isolated_margin_supported.return_value = True
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
assert broker.user_id == 1
assert broker.broker_key == 'kucoin_margin_isolated_production'
assert broker.exchange is mock_exchange
def test_initial_sync_state(self):
"""Test broker starts with not_synced state."""
mock_exchange = Mock()
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_testnet'
)
status = broker.get_sync_status()
assert status['state'] == 'not_synced'
assert status['can_trade'] is False
class TestLiveMarginBrokerSync:
"""Tests for sync functionality."""
def test_sync_no_positions(self):
"""Test syncing when there are no positions."""
mock_exchange = Mock()
mock_exchange.fetch_isolated_margin_balance.return_value = {
'accounts': []
}
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
result = broker.sync_positions()
assert result['positions'] == []
assert result['sync_state'] == 'healthy'
def test_sync_removes_stale_positions(self):
"""Test that positions not in snapshot are removed from cache."""
mock_exchange = Mock()
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
# First sync: BTC position exists
mock_exchange.fetch_isolated_margin_balance.return_value = {
'accounts': [{
'symbol': 'BTC/USDT',
'base_asset': 'BTC',
'quote_asset': 'USDT',
'base_available': 0.01,
'quote_borrowed': 200,
'quote_interest': 0.5,
'liability_ratio': 0.15,
}]
}
mock_exchange.fetch_margin_trades.return_value = [
{'side': 'buy', 'price': 50000.0, 'amount': 0.01, 'timestamp': 1000}
]
mock_exchange.get_price.return_value = 50000.0
broker.sync_positions()
assert len(broker.get_all_positions()) == 1
# Second sync: BTC position closed (no longer in snapshot)
mock_exchange.fetch_isolated_margin_balance.return_value = {
'accounts': []
}
result = broker.sync_positions()
# Stale position should be removed
assert len(broker.get_all_positions()) == 0
assert result['positions'] == []
def test_sync_long_position(self):
"""Test syncing a long position from exchange data."""
mock_exchange = Mock()
mock_exchange.fetch_isolated_margin_balance.return_value = {
'accounts': [{
'symbol': 'BTC/USDT',
'base_asset': 'BTC',
'quote_asset': 'USDT',
'base_available': 0.01,
'base_hold': 0,
'base_borrowed': 0,
'base_interest': 0,
'quote_available': 50,
'quote_hold': 0,
'quote_borrowed': 200,
'quote_interest': 0.5,
'liability_ratio': 0.15,
}]
}
mock_exchange.fetch_margin_trades.return_value = [
{'side': 'buy', 'price': 50000.0, 'amount': 0.01}
]
mock_exchange.get_price.return_value = 51000.0
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
result = broker.sync_positions()
assert len(result['positions']) == 1
pos = result['positions'][0]
assert pos['symbol'] == 'BTC/USDT'
assert pos['side'] == 'long'
assert pos['borrowed_asset'] == 'USDT'
assert pos['entry_price'] == 50000.0
def test_sync_short_position(self):
"""Test syncing a short position from exchange data."""
mock_exchange = Mock()
mock_exchange.fetch_isolated_margin_balance.return_value = {
'accounts': [{
'symbol': 'ETH/USDT',
'base_asset': 'ETH',
'quote_asset': 'USDT',
'base_available': 0,
'base_hold': 0,
'base_borrowed': 0.5,
'base_interest': 0.001,
'quote_available': 1600,
'quote_hold': 0,
'quote_borrowed': 0,
'quote_interest': 0,
'liability_ratio': 0.20,
}]
}
mock_exchange.fetch_margin_trades.return_value = [
{'side': 'sell', 'price': 3000.0, 'amount': 0.5}
]
mock_exchange.get_price.return_value = 2900.0
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
result = broker.sync_positions()
assert len(result['positions']) == 1
pos = result['positions'][0]
assert pos['symbol'] == 'ETH/USDT'
assert pos['side'] == 'short'
assert pos['borrowed_asset'] == 'ETH'
assert pos['entry_price'] == 3000.0
def test_sync_degraded_missing_fills(self):
"""Test sync state is degraded when fills not found."""
mock_exchange = Mock()
mock_exchange.fetch_isolated_margin_balance.return_value = {
'accounts': [{
'symbol': 'BTC/USDT',
'base_asset': 'BTC',
'quote_asset': 'USDT',
'base_available': 0.01,
'base_borrowed': 0,
'quote_borrowed': 200,
'quote_interest': 0.5,
'liability_ratio': 0.15,
}]
}
# No fills found
mock_exchange.fetch_margin_trades.return_value = []
mock_exchange.get_price.return_value = 51000.0
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
result = broker.sync_positions()
assert len(result['positions']) == 1
pos = result['positions'][0]
assert pos['sync_state'] == 'degraded_missing_fills'
def test_entry_price_uses_only_current_position_fills(self):
"""Test that entry price only uses fills for current position, not old closed ones."""
mock_exchange = Mock()
mock_exchange.fetch_isolated_margin_balance.return_value = {
'accounts': [{
'symbol': 'BTC/USDT',
'base_asset': 'BTC',
'quote_asset': 'USDT',
'base_available': 0.01, # Current position size
'quote_borrowed': 500,
'quote_interest': 0,
'liability_ratio': 0.15,
}]
}
# Fills include old closed position (should be excluded)
mock_exchange.fetch_margin_trades.return_value = [
# Current position (most recent) - should be used
{'side': 'buy', 'price': 50000.0, 'amount': 0.01, 'timestamp': 3000},
# Old closed position (older) - should NOT be used
{'side': 'buy', 'price': 40000.0, 'amount': 0.02, 'timestamp': 1000},
]
mock_exchange.get_price.return_value = 50000.0
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
result = broker.sync_positions()
assert len(result['positions']) == 1
pos = result['positions'][0]
# Entry price should be 50000 (from current position), not ~43333 (average of all)
assert pos['entry_price'] == 50000.0
assert pos['sync_state'] == 'healthy'
def test_entry_price_handles_ladder_buys(self):
"""Test entry price correctly averages multiple fills for same position."""
mock_exchange = Mock()
mock_exchange.fetch_isolated_margin_balance.return_value = {
'accounts': [{
'symbol': 'BTC/USDT',
'base_asset': 'BTC',
'quote_asset': 'USDT',
'base_available': 0.03, # Laddered in with 3 buys
'quote_borrowed': 1500,
'quote_interest': 0,
'liability_ratio': 0.15,
}]
}
# Three ladder buys for current position
mock_exchange.fetch_margin_trades.return_value = [
{'side': 'buy', 'price': 52000.0, 'amount': 0.01, 'timestamp': 3000},
{'side': 'buy', 'price': 51000.0, 'amount': 0.01, 'timestamp': 2000},
{'side': 'buy', 'price': 50000.0, 'amount': 0.01, 'timestamp': 1000},
]
mock_exchange.get_price.return_value = 51000.0
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
result = broker.sync_positions()
pos = result['positions'][0]
# Entry price should be weighted average: (52000 + 51000 + 50000) / 3 = 51000
assert pos['entry_price'] == 51000.0
assert pos['sync_state'] == 'healthy'
def test_sync_error_handling(self):
"""Test sync handles errors gracefully."""
mock_exchange = Mock()
mock_exchange.fetch_isolated_margin_balance.return_value = {
'error': 'API error'
}
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
result = broker.sync_positions()
assert result['sync_state'] == 'unhealthy_conflict'
assert 'error' in result
class TestLiveMarginBrokerPriceUpdates:
"""Tests for price update functionality."""
def test_update_price(self):
"""Test price updates are stored."""
mock_exchange = Mock()
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
broker.update_price('BTC/USDT', 52000.0)
broker.update_price('ETH/USDT', 3100.0, exchange='kucoin')
assert broker._current_prices['BTC/USDT'] == 52000.0
assert broker._current_prices['kucoin:ETH/USDT'] == 3100.0
class TestLiveMarginBrokerBalance:
"""Tests for balance retrieval."""
def test_get_margin_balance(self):
"""Test getting margin balance summary."""
mock_exchange = Mock()
mock_exchange.fetch_isolated_margin_balance.return_value = {
'accounts': [{
'symbol': 'BTC/USDT',
'quote_available': 500,
'quote_borrowed': 200,
'base_available': 0.01,
'base_borrowed': 0,
}]
}
mock_exchange.get_price.return_value = 50000.0
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
balance = broker.get_margin_balance()
assert 'total_equity' in balance
assert 'total_borrowed' in balance
assert balance['total_borrowed'] == 200
def test_get_borrow_rates(self):
"""Test getting borrow rates."""
mock_exchange = Mock()
mock_exchange.fetch_borrow_rate.return_value = {
'hourly_rate': 0.0001,
'daily_rate': 0.0024,
'source': 'exchange'
}
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
rates = broker.get_borrow_rates(['USDT', 'BTC'])
assert 'rates' in rates
assert 'USDT' in rates['rates']
assert 'BTC' in rates['rates']
class TestLiveMarginBrokerTrading:
"""Tests for live margin trading methods (Phase 3b)."""
def test_open_position_requires_healthy_sync(self):
"""Test that open_position fails when sync is unhealthy."""
mock_exchange = Mock()
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
# Never synced - should fail
result = broker.open_position(
symbol='BTC/USDT',
side='long',
collateral=100,
leverage=3
)
assert result['success'] is False
assert 'sync' in result['error'].lower()
def test_open_position_validates_side(self):
"""Test that open_position validates side parameter."""
mock_exchange = Mock()
mock_exchange.fetch_isolated_margin_balance.return_value = {'accounts': []}
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
broker.sync_positions() # Make sync healthy
result = broker.open_position(
symbol='BTC/USDT',
side='buy', # Invalid - should be 'long' or 'short'
collateral=100,
leverage=3
)
assert result['success'] is False
assert 'side' in result['error'].lower()
def test_open_position_validates_leverage(self):
"""Test that open_position validates leverage range."""
mock_exchange = Mock()
mock_exchange.fetch_isolated_margin_balance.return_value = {'accounts': []}
mock_exchange.get_price.return_value = 50000.0
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
broker.sync_positions()
result = broker.open_position(
symbol='BTC/USDT',
side='long',
collateral=100,
leverage=20 # Too high
)
assert result['success'] is False
assert 'leverage' in result['error'].lower()
def test_open_position_validates_collateral(self):
"""Test that open_position validates collateral > 0."""
mock_exchange = Mock()
mock_exchange.fetch_isolated_margin_balance.return_value = {'accounts': []}
mock_exchange.get_price.return_value = 50000.0
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
broker.sync_positions()
# Test zero collateral
result = broker.open_position(
symbol='BTC/USDT',
side='long',
collateral=0,
leverage=3
)
assert result['success'] is False
assert 'collateral' in result['error'].lower() or 'positive' in result['error'].lower()
# Test negative collateral
result = broker.open_position(
symbol='BTC/USDT',
side='long',
collateral=-100,
leverage=3
)
assert result['success'] is False
assert 'collateral' in result['error'].lower() or 'positive' in result['error'].lower()
def test_open_position_rejects_duplicate(self):
"""Test that open_position rejects when position exists."""
mock_exchange = Mock()
mock_exchange.fetch_isolated_margin_balance.return_value = {
'accounts': [{
'symbol': 'BTC/USDT',
'base_asset': 'BTC',
'quote_asset': 'USDT',
'base_available': 0.01,
'quote_borrowed': 200,
'quote_interest': 0,
'liability_ratio': 0.15,
}]
}
mock_exchange.fetch_margin_trades.return_value = [
{'side': 'buy', 'price': 50000.0, 'amount': 0.01, 'timestamp': 1000}
]
mock_exchange.get_price.return_value = 50000.0
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
broker.sync_positions()
# Try to open another position on same symbol
result = broker.open_position(
symbol='BTC/USDT',
side='long',
collateral=100,
leverage=3
)
assert result['success'] is False
assert 'already exists' in result['error'].lower()
def test_close_position_not_found(self):
"""Test close_position fails when no position exists."""
mock_exchange = Mock()
mock_exchange.fetch_isolated_margin_balance.return_value = {'accounts': []}
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
broker.sync_positions()
result = broker.close_position('BTC/USDT')
assert result['success'] is False
assert 'no position' in result['error'].lower()
def test_reduce_position_validates_size(self):
"""Test reduce_position validates reduce_size parameter."""
mock_exchange = Mock()
mock_exchange.fetch_isolated_margin_balance.return_value = {'accounts': []}
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
broker.sync_positions()
result = broker.reduce_position('BTC/USDT', -0.01)
assert result['success'] is False
assert 'positive' in result['error'].lower() or 'no position' in result['error'].lower()
def test_client_order_id_generation(self):
"""Test client order ID generation is unique."""
mock_exchange = Mock()
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
id1 = broker._generate_client_order_id('BTC/USDT', 'long')
id2 = broker._generate_client_order_id('BTC/USDT', 'long')
# IDs should be unique (different timestamps)
assert id1 != id2
assert 'margin_1_BTC_USDT_long_' in id1
def test_add_margin_requires_healthy_sync(self):
"""Test add_margin fails when sync is unhealthy."""
mock_exchange = Mock()
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
# Never synced
result = broker.add_margin('BTC/USDT', 50)
assert result['success'] is False
assert 'sync' in result['error'].lower()
def test_add_margin_position_not_found(self):
"""Test add_margin fails when no position exists."""
mock_exchange = Mock()
mock_exchange.fetch_isolated_margin_balance.return_value = {'accounts': []}
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
broker.sync_positions()
result = broker.add_margin('BTC/USDT', 50)
assert result['success'] is False
assert 'no position' in result['error'].lower()
def test_add_margin_validates_amount(self):
"""Test add_margin validates amount parameter."""
mock_exchange = Mock()
mock_exchange.fetch_isolated_margin_balance.return_value = {
'accounts': [{
'symbol': 'BTC/USDT',
'base_asset': 'BTC',
'quote_asset': 'USDT',
'base_available': 0.01,
'quote_borrowed': 200,
'quote_interest': 0,
'liability_ratio': 0.15,
}]
}
mock_exchange.fetch_margin_trades.return_value = [
{'side': 'buy', 'price': 50000.0, 'amount': 0.01, 'timestamp': 1000}
]
mock_exchange.get_price.return_value = 50000.0
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
broker.sync_positions()
result = broker.add_margin('BTC/USDT', -50)
assert result['success'] is False
assert 'positive' in result['error'].lower()
def test_open_position_rolls_back_collateral_on_order_failure(self):
"""Failed live opens should attempt to move collateral back to main."""
mock_exchange = Mock()
mock_exchange.fetch_isolated_margin_balance.return_value = {'accounts': []}
mock_exchange.get_price.return_value = 50000.0
mock_exchange.transfer_to_isolated_margin.return_value = ('Success', {'id': 'tx-1'})
mock_exchange.place_margin_order.return_value = ('Failure', 'order rejected')
mock_exchange.transfer_from_isolated_margin.return_value = ('Success', {'id': 'tx-rollback'})
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
broker.sync_positions()
result = broker.open_position(
symbol='BTC/USDT',
side='long',
collateral=100,
leverage=3
)
assert result['success'] is False
assert result['collateral_rollback']['success'] is True
mock_exchange.transfer_from_isolated_margin.assert_called_once_with(
symbol='BTC/USDT',
asset='USDT',
amount=100
)
def test_close_position_partial_fill_returns_partial_result(self):
"""Partial closes should not be reported as full success."""
mock_exchange = Mock()
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
broker._last_sync = datetime.now(timezone.utc)
position = LiveMarginPosition(
symbol='BTC/USDT',
side='long',
size=0.02,
entry_price=50000.0,
borrowed=200.0,
borrowed_asset='USDT',
interest_owed=2.0,
liability_ratio=0.15,
collateral=100.0,
leverage=3.0,
current_price=51000.0,
sync_state=SyncState.HEALTHY
)
broker._positions['BTC/USDT'] = position
broker.sync_positions = Mock(return_value={})
broker.get_position = Mock(side_effect=[position, position])
broker.exchange.place_margin_order.return_value = ('Success', {'id': 'close-1'})
broker._wait_for_order_fill = Mock(return_value={
'filled': True,
'partial': True,
'filled_amount': 0.01,
'average_price': 51000.0,
'status': 'open'
})
result = broker.close_position('BTC/USDT')
assert result['success'] is False
assert result['partial'] is True
assert result['filled_amount'] == 0.01
assert result['realized_pnl'] == pytest.approx(9.0)
def test_close_position_records_margin_history(self):
"""Successful closes should be persisted through the history writer."""
mock_exchange = Mock()
history_writer = Mock()
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production',
history_writer=history_writer
)
broker._last_sync = datetime.now(timezone.utc)
position = LiveMarginPosition(
symbol='BTC/USDT',
side='long',
size=0.02,
entry_price=50000.0,
borrowed=200.0,
borrowed_asset='USDT',
interest_owed=2.0,
liability_ratio=0.15,
collateral=100.0,
leverage=3.0,
current_price=51000.0,
sync_state=SyncState.HEALTHY,
opened_at=datetime.now(timezone.utc)
)
broker._positions['BTC/USDT'] = position
broker.sync_positions = Mock(return_value={})
broker.get_position = Mock(side_effect=[position, None])
broker.exchange.place_margin_order.return_value = ('Success', {'id': 'close-2'})
broker._wait_for_order_fill = Mock(return_value={
'filled': True,
'filled_amount': 0.02,
'average_price': 51000.0,
'status': 'closed'
})
result = broker.close_position('BTC/USDT')
assert result['success'] is True
history_writer.assert_called_once()
assert history_writer.call_args.kwargs['symbol'] == 'BTC/USDT'
assert history_writer.call_args.kwargs['close_reason'] == 'manual'
def test_add_margin_uses_symbol_quote_asset(self):
"""Quote-asset pairs should transfer their actual quote asset, not hardcoded USDT."""
mock_exchange = Mock()
mock_exchange.fetch_isolated_margin_balance.return_value = {
'accounts': [{
'symbol': 'ETH/BTC',
'base_asset': 'ETH',
'quote_asset': 'BTC',
'base_available': 1.0,
'quote_borrowed': 0.1,
'quote_interest': 0,
'liability_ratio': 0.15,
}]
}
mock_exchange.fetch_margin_trades.return_value = [
{'side': 'buy', 'price': 0.05, 'amount': 1.0, 'timestamp': 1000}
]
mock_exchange.get_price.return_value = 0.05
mock_exchange.transfer_to_isolated_margin.return_value = ('Success', {'id': 'tx-add'})
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
broker.sync_positions()
result = broker.add_margin('ETH/BTC', 0.01)
assert result['success'] is True
mock_exchange.transfer_to_isolated_margin.assert_called_once_with(
symbol='ETH/BTC',
asset='BTC',
amount=0.01
)
def test_remove_margin_refreshes_before_safety_check(self):
"""remove_margin should sync before computing max withdrawable and again after transfer."""
mock_exchange = Mock()
mock_exchange.transfer_from_isolated_margin.return_value = ('Success', {'id': 'tx-remove'})
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
broker._last_sync = datetime.now(timezone.utc)
position = LiveMarginPosition(
symbol='BTC/USDT',
side='long',
size=0.02,
entry_price=50000.0,
borrowed=400.0,
borrowed_asset='USDT',
interest_owed=0.0,
liability_ratio=0.15,
collateral=200.0,
leverage=5.0,
current_price=52000.0,
sync_state=SyncState.HEALTHY
)
broker._positions['BTC/USDT'] = position
broker.sync_positions = Mock(return_value={})
result = broker.remove_margin('BTC/USDT', 10.0)
assert result['success'] is True
assert broker.sync_positions.call_count == 2
mock_exchange.transfer_from_isolated_margin.assert_called_once_with(
symbol='BTC/USDT',
asset='USDT',
amount=10.0
)
def test_increase_position_uses_place_margin_order_and_waits_for_fill(self):
"""increase_position should use the exchange adapter contract and confirm the fill."""
mock_exchange = Mock()
mock_exchange.get_price.return_value = 50000.0
mock_exchange.transfer_to_isolated_margin.return_value = ('Success', {'id': 'tx-inc'})
mock_exchange.place_margin_order.return_value = ('Success', {'id': 'inc-1'})
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
broker._last_sync = datetime.now(timezone.utc)
position = LiveMarginPosition(
symbol='BTC/USDT',
side='long',
size=0.01,
entry_price=50000.0,
borrowed=200.0,
borrowed_asset='USDT',
interest_owed=0.5,
liability_ratio=0.15,
collateral=100.0,
leverage=3.0,
current_price=50000.0,
sync_state=SyncState.HEALTHY
)
updated_position = LiveMarginPosition(
symbol='BTC/USDT',
side='long',
size=0.016,
entry_price=50000.0,
borrowed=300.0,
borrowed_asset='USDT',
interest_owed=0.5,
liability_ratio=0.15,
collateral=200.0,
leverage=3.0,
current_price=50000.0,
sync_state=SyncState.HEALTHY
)
broker._positions['BTC/USDT'] = position
broker.sync_positions = Mock(return_value={})
broker.get_position = Mock(side_effect=[position, updated_position])
broker._wait_for_order_fill = Mock(return_value={
'filled': True,
'filled_amount': 0.006,
'average_price': 50000.0,
'status': 'closed'
})
result = broker.increase_position(
symbol='BTC/USDT',
additional_collateral=100.0,
execution_leverage=3.0
)
assert result['success'] is True
assert result['added_size'] == pytest.approx(0.006)
assert broker.sync_positions.call_count == 2
broker._wait_for_order_fill.assert_called_once_with('inc-1', 'BTC/USDT')
kwargs = mock_exchange.place_margin_order.call_args.kwargs
assert kwargs['symbol'] == 'BTC/USDT'
assert kwargs['side'] == 'buy'
assert kwargs['order_type'] == 'market'
assert kwargs['funds'] == pytest.approx(300.0)
assert kwargs['auto_borrow'] is True
assert kwargs['client_order_id'] == result['client_order_id']
def test_increase_position_partial_fill_returns_partial_result(self):
"""Partial live increases should not be reported as full success."""
mock_exchange = Mock()
mock_exchange.get_price.return_value = 50000.0
mock_exchange.transfer_to_isolated_margin.return_value = ('Success', {'id': 'tx-inc'})
mock_exchange.place_margin_order.return_value = ('Success', {'id': 'inc-2'})
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
broker._last_sync = datetime.now(timezone.utc)
position = LiveMarginPosition(
symbol='BTC/USDT',
side='long',
size=0.01,
entry_price=50000.0,
borrowed=200.0,
borrowed_asset='USDT',
interest_owed=0.5,
liability_ratio=0.15,
collateral=100.0,
leverage=3.0,
current_price=50000.0,
sync_state=SyncState.HEALTHY
)
broker._positions['BTC/USDT'] = position
broker.sync_positions = Mock(return_value={})
broker.get_position = Mock(side_effect=[position, position])
broker._wait_for_order_fill = Mock(return_value={
'filled': True,
'partial': True,
'filled_amount': 0.003,
'average_price': 50050.0,
'status': 'open'
})
result = broker.increase_position(
symbol='BTC/USDT',
additional_collateral=100.0,
execution_leverage=3.0
)
assert result['success'] is False
assert result['partial'] is True
assert result['added_size'] == pytest.approx(0.003)
broker._wait_for_order_fill.assert_called_once_with('inc-2', 'BTC/USDT')
class TestLiveMarginBrokerSyncStatus:
"""Tests for sync status reporting."""
def test_sync_status_healthy_after_sync(self):
"""Test sync status is healthy after successful sync."""
mock_exchange = Mock()
mock_exchange.fetch_isolated_margin_balance.return_value = {
'accounts': []
}
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
broker.sync_positions()
status = broker.get_sync_status()
assert status['state'] == 'healthy'
assert status['can_trade'] is True
assert status['last_synced'] is not None
def test_sync_status_stale(self):
"""Test sync status becomes stale after threshold."""
mock_exchange = Mock()
mock_exchange.fetch_isolated_margin_balance.return_value = {
'accounts': []
}
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
broker.sync_positions()
# Manually set last_sync to old time
from datetime import timedelta
broker._last_sync = datetime.now(timezone.utc) - timedelta(minutes=10)
status = broker.get_sync_status()
assert status['state'] == 'degraded_stale'
assert status['is_stale'] is True
def test_trading_disabled_when_unhealthy(self):
"""Test trading is disabled when sync is unhealthy."""
mock_exchange = Mock()
mock_exchange.fetch_isolated_margin_balance.return_value = {
'error': 'Connection failed'
}
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
broker.sync_positions()
assert broker.is_trading_enabled() is False
class TestLiveMarginBrokerMinOrderSize:
"""Tests for minimum order size validation (Phase 8 edge cases)."""
def _make_broker_with_position(self, mock_exchange, position_size=0.01):
"""Helper to create broker with mocked long position."""
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
broker._last_sync = datetime.now(timezone.utc)
# Inject position directly
position = LiveMarginPosition(
symbol='BTC/USDT',
side='long',
size=position_size,
entry_price=50000.0,
borrowed=200.0,
borrowed_asset='USDT',
interest_owed=0.5,
liability_ratio=0.15,
collateral=100.0,
leverage=3.0,
current_price=50000.0,
sync_state=SyncState.HEALTHY
)
broker._positions['BTC/USDT'] = position
# Mock sync_positions to return immediately and keep position
broker.sync_positions = Mock(return_value={})
return broker
def test_increase_rejects_below_min_qty(self):
"""Increase fails if added size is below exchange minimum."""
mock_exchange = Mock()
mock_exchange.get_min_qty.return_value = 0.001 # min 0.001 BTC
mock_exchange.get_min_notional_qty.return_value = 10.0 # min $10
mock_exchange.get_price.return_value = 50000.0
broker = self._make_broker_with_position(mock_exchange)
# Try to increase by tiny amount (below min qty)
result = broker.increase_position(
symbol='BTC/USDT',
additional_collateral=1.0, # Would add ~0.00006 BTC at 50000 price
execution_leverage=3.0
)
assert result['success'] is False
assert 'below minimum' in result['error']
def test_increase_rejects_below_min_notional(self):
"""Increase fails if order value is below minimum notional."""
mock_exchange = Mock()
mock_exchange.get_min_qty.return_value = 0.0001 # very small min qty
mock_exchange.get_min_notional_qty.return_value = 10.0 # min $10
mock_exchange.get_price.return_value = 50000.0
broker = self._make_broker_with_position(mock_exchange)
# Try to increase by small amount (above min qty but below min notional)
result = broker.increase_position(
symbol='BTC/USDT',
additional_collateral=2.0, # Would add $6 worth at 3x
execution_leverage=3.0
)
assert result['success'] is False
assert 'minimum notional' in result['error']
def test_reduce_rejects_below_min_qty(self):
"""Reduce fails if reduce size is below exchange minimum."""
mock_exchange = Mock()
mock_exchange.get_min_qty.return_value = 0.001 # min 0.001 BTC
mock_exchange.get_min_notional_qty.return_value = 10.0
mock_exchange.get_price.return_value = 50000.0
broker = self._make_broker_with_position(mock_exchange, position_size=0.01)
# Try to reduce by tiny amount
result = broker.reduce_position(symbol='BTC/USDT', reduce_size=0.0001)
assert result['success'] is False
assert 'below minimum' in result['error']
def test_reduce_rejects_dust_remainder(self):
"""Reduce fails if it would leave a dust position."""
mock_exchange = Mock()
mock_exchange.get_min_qty.return_value = 0.001 # min 0.001 BTC
mock_exchange.get_min_notional_qty.return_value = 10.0
mock_exchange.get_price.return_value = 50000.0
broker = self._make_broker_with_position(mock_exchange, position_size=0.01)
# Try to reduce leaving 0.0001 BTC (below min qty)
result = broker.reduce_position(symbol='BTC/USDT', reduce_size=0.0099)
assert result['success'] is False
assert 'dust' in result['error'].lower()
def test_reduce_allows_full_close_near_total(self):
"""Reducing to exactly full position redirects to close."""
mock_exchange = Mock()
mock_exchange.get_min_qty.return_value = 0.001
mock_exchange.get_min_notional_qty.return_value = 10.0
mock_exchange.get_price.return_value = 50000.0
mock_exchange.place_margin_order.return_value = ('Success', {'id': 'close-1'})
broker = self._make_broker_with_position(mock_exchange, position_size=0.01)
broker._wait_for_order_fill = Mock(return_value={
'filled': True, 'filled_amount': 0.01, 'average_price': 50000.0
})
# Reducing by full size should redirect to close_position
result = broker.reduce_position(symbol='BTC/USDT', reduce_size=0.01)
# Should call close_position internally
assert mock_exchange.place_margin_order.called
class TestLiveMarginBrokerPreview:
"""Tests for live margin broker preview methods."""
def _make_broker_with_position(self, mock_exchange, position_size=0.01, side='long'):
"""Helper to create broker with mocked position."""
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
broker._last_sync = datetime.now(timezone.utc)
position = LiveMarginPosition(
symbol='BTC/USDT',
side=side,
size=position_size,
entry_price=50000.0,
borrowed=200.0,
borrowed_asset='USDT' if side == 'long' else 'BTC',
interest_owed=0.5,
liability_ratio=0.15,
collateral=100.0,
leverage=3.0,
current_price=50000.0,
sync_state=SyncState.HEALTHY
)
broker._positions['BTC/USDT'] = position
return broker
def test_preview_increase_returns_projected_values(self):
"""Preview increase returns valid projected values."""
mock_exchange = Mock()
mock_exchange.get_price.return_value = 50000.0
mock_exchange.get_min_qty.return_value = 0.001
mock_exchange.get_min_notional_qty.return_value = 10.0
broker = self._make_broker_with_position(mock_exchange)
result = broker.preview_increase(
symbol='BTC/USDT',
additional_collateral=100.0,
execution_leverage=3.0
)
assert result['valid'] is True
assert result['preview_type'] == 'increase'
assert result['current'] is not None
assert result['current']['size'] == 0.01
assert result['projected'] is not None
assert result['projected']['total_size'] > 0.01
assert result['projected']['total_collateral'] == pytest.approx(200.0)
assert result['projected']['added_size'] > 0
assert result['projected']['margin_ratio'] == pytest.approx(24.9375)
def test_preview_increase_rejects_below_min_order_constraints(self):
"""Preview increase rejects orders that live execution would reject."""
mock_exchange = Mock()
mock_exchange.get_price.return_value = 50000.0
mock_exchange.get_min_qty.return_value = 0.0001
mock_exchange.get_min_notional_qty.return_value = 10.0
broker = self._make_broker_with_position(mock_exchange)
result = broker.preview_increase(
symbol='BTC/USDT',
additional_collateral=5.0,
execution_leverage=1.0
)
assert result['valid'] is False
assert 'below minimum notional' in result['errors'][0]
def test_preview_increase_rejects_missing_position(self):
"""Preview increase fails if no position exists."""
mock_exchange = Mock()
mock_exchange.get_price.return_value = 50000.0
broker = LiveMarginBroker(
user_id=1,
exchange=mock_exchange,
broker_key='kucoin_margin_isolated_production'
)
broker._last_sync = datetime.now(timezone.utc)
result = broker.preview_increase(
symbol='ETH/USDT',
additional_collateral=100.0,
execution_leverage=3.0
)
assert result['valid'] is False
assert 'No position' in result['errors'][0]
def test_preview_reduce_calculates_realized_pnl(self):
"""Preview reduce calculates realized P/L correctly."""
mock_exchange = Mock()
mock_exchange.get_price.return_value = 55000.0 # Price moved up
mock_exchange.get_min_qty.return_value = 0.001
mock_exchange.get_min_notional_qty.return_value = 10.0
broker = self._make_broker_with_position(mock_exchange)
result = broker.preview_reduce(
symbol='BTC/USDT',
reduce_size=0.005 # Reduce half
)
assert result['valid'] is True
assert result['preview_type'] == 'reduce'
assert result['projected']['realized_pnl'] > 0 # Profit since price went up
assert result['projected']['remaining_size'] == pytest.approx(0.005)
assert result['projected']['margin_ratio'] == pytest.approx(27.1818, rel=0.001)
def test_preview_reduce_rejects_dust_remainder(self):
"""Preview reduce rejects dust remainders the same way execution does."""
mock_exchange = Mock()
mock_exchange.get_price.return_value = 50000.0
mock_exchange.get_min_qty.return_value = 0.001
mock_exchange.get_min_notional_qty.return_value = 10.0
broker = self._make_broker_with_position(mock_exchange, position_size=0.01)
result = broker.preview_reduce(
symbol='BTC/USDT',
reduce_size=0.0099
)
assert result['valid'] is False
assert 'dust position' in result['errors'][0].lower()
def test_preview_add_margin_shows_improved_health(self):
"""Preview add margin shows improved margin ratio."""
mock_exchange = Mock()
mock_exchange.get_price.return_value = 50000.0
broker = self._make_broker_with_position(mock_exchange)
# Original position: size=0.01, entry=50000, collateral=100
# Position value = 0.01 * 50000 = 500
# Original effective leverage = 500 / 100 = 5x
result = broker.preview_add_margin(symbol='BTC/USDT', amount=50.0)
assert result['valid'] is True
assert result['preview_type'] == 'add_margin'
# New collateral should be higher
assert result['projected']['total_collateral'] == pytest.approx(150.0)
assert result['projected']['new_collateral'] == pytest.approx(150.0)
# New leverage = 500 / 150 = 3.33x (lower than original 5x)
assert result['projected']['effective_leverage'] == pytest.approx(3.33, rel=0.01)
assert result['projected']['margin_ratio'] == pytest.approx(29.9)
def test_preview_remove_margin_rejects_excessive_withdrawal(self):
"""Preview remove margin rejects if it would risk liquidation."""
mock_exchange = Mock()
mock_exchange.get_price.return_value = 50000.0
broker = self._make_broker_with_position(mock_exchange)
# Try to remove almost all collateral
result = broker.preview_remove_margin(symbol='BTC/USDT', amount=95.0)
assert result['valid'] is False
assert 'Cannot withdraw' in result['errors'][0]
def test_preview_remove_margin_shows_max_withdrawable(self):
"""Preview remove margin includes max withdrawable info."""
mock_exchange = Mock()
mock_exchange.get_price.return_value = 50000.0
broker = self._make_broker_with_position(mock_exchange)
# Small withdrawal should be valid
result = broker.preview_remove_margin(symbol='BTC/USDT', amount=10.0)
assert result['valid'] is True
assert result['projected']['max_withdrawable'] > 0
assert result['projected']['total_collateral'] == pytest.approx(90.0)
assert result['projected']['withdrawn_amount'] == 10.0
assert result['projected']['margin_ratio'] == pytest.approx(17.9)