1405 lines
48 KiB
Python
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)
|