717 lines
30 KiB
Python
717 lines
30 KiB
Python
import unittest
|
|
from flask import Flask
|
|
from src.app import app
|
|
import json
|
|
from unittest.mock import MagicMock, patch
|
|
import pandas as pd
|
|
|
|
|
|
class FlaskAppTests(unittest.TestCase):
|
|
def setUp(self):
|
|
"""
|
|
Set up the test client and any other test configuration.
|
|
"""
|
|
self.app = app.test_client()
|
|
self.app.testing = True
|
|
|
|
def test_index(self):
|
|
"""
|
|
Test the index route.
|
|
"""
|
|
response = self.app.get('/')
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertIn(b'Welcome', response.data) # Adjust this based on your actual landing page content
|
|
|
|
def test_login_redirect_on_invalid(self):
|
|
"""
|
|
Test that login redirects on invalid credentials.
|
|
"""
|
|
# Invalid credentials should redirect to login page
|
|
invalid_data = {'user_name': 'wrong_user', 'password': 'wrong_password'}
|
|
response = self.app.post('/login', data=invalid_data)
|
|
self.assertEqual(response.status_code, 302) # Redirects on failure
|
|
# Follow redirect to check error flash message
|
|
response = self.app.post('/login', data=invalid_data, follow_redirects=True)
|
|
# The page should contain login form or error message
|
|
self.assertIn(b'login', response.data.lower())
|
|
|
|
def test_login_with_valid_credentials(self):
|
|
"""
|
|
Test the login route with credentials that may or may not exist.
|
|
"""
|
|
# Valid credentials (test user may not exist)
|
|
valid_data = {'user_name': 'test_user', 'password': 'test_password'}
|
|
response = self.app.post('/login', data=valid_data)
|
|
# Should redirect regardless (to index if success, to login if failure)
|
|
self.assertEqual(response.status_code, 302)
|
|
|
|
def test_signup(self):
|
|
"""
|
|
Test the signup route.
|
|
"""
|
|
import random
|
|
username = f'test_user_{random.randint(10000, 99999)}'
|
|
data = {'email': f'{username}@example.com', 'user_name': username, 'password': 'new_password'}
|
|
response = self.app.post('/signup_submit', data=data)
|
|
self.assertEqual(response.status_code, 302) # Redirects on success
|
|
|
|
def test_signout(self):
|
|
"""
|
|
Test the signout route.
|
|
"""
|
|
response = self.app.get('/signout')
|
|
self.assertEqual(response.status_code, 302) # Redirects on signout
|
|
|
|
def test_indicator_init_requires_auth(self):
|
|
"""
|
|
Test that indicator_init requires authentication.
|
|
"""
|
|
data = {"user_name": "test_user"}
|
|
response = self.app.post('/api/indicator_init', data=json.dumps(data), content_type='application/json')
|
|
# Should return 401 without proper session
|
|
self.assertIn(response.status_code, [200, 401]) # Either authenticated or not
|
|
|
|
def test_login_page_loads(self):
|
|
"""
|
|
Test that login page loads.
|
|
"""
|
|
response = self.app.get('/login')
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
def test_signup_page_loads(self):
|
|
"""
|
|
Test that signup page loads.
|
|
"""
|
|
response = self.app.get('/signup')
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
@patch('src.app.is_margin_supported', return_value=True)
|
|
@patch('src.app._get_current_user_id', return_value=1)
|
|
def test_margin_capabilities_live_uses_margin_gate_signature(self, _mock_user_id, _mock_margin_supported):
|
|
"""Live margin capabilities should use the current can_trade_margin signature."""
|
|
with patch('src.app.brighter_trades') as mock_bt:
|
|
mock_bt.config.is_margin_risk_ack_required.return_value = False
|
|
mock_bt.config.can_trade_margin.return_value = (True, 'allowed')
|
|
mock_bt.users.get_username_by_id.return_value = 'rob'
|
|
|
|
exchange = MagicMock()
|
|
exchange.get_margin_info.return_value = {
|
|
'margin_enabled': True,
|
|
'margin_modes': ['isolated']
|
|
}
|
|
exchange.fetch_max_leverage.return_value = {
|
|
'max_leverage': 5,
|
|
'min_leverage': 1
|
|
}
|
|
exchange.fetch_borrow_rate.return_value = {
|
|
'hourly_rate': 0.0002
|
|
}
|
|
mock_bt.exchanges.get_exchange.return_value = exchange
|
|
|
|
response = self.app.post(
|
|
'/api/margin/capabilities',
|
|
json={
|
|
'broker_key': 'kucoin_margin_isolated_production',
|
|
'symbol': 'BTC/USDT'
|
|
}
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
data = response.get_json()
|
|
self.assertTrue(data['available'])
|
|
mock_bt.config.can_trade_margin.assert_called_with(1, True, None)
|
|
|
|
@patch('src.app._get_current_user_id', return_value=1)
|
|
def test_margin_capabilities_paper_respects_risk_ack_gate(self, _mock_user_id):
|
|
"""Paper margin capabilities should be gated until risk acknowledgment is recorded."""
|
|
with patch('src.app.brighter_trades') as mock_bt:
|
|
mock_bt.config.is_margin_risk_ack_required.return_value = True
|
|
mock_bt.data.db.has_margin_risk_ack.return_value = False
|
|
mock_bt.config.can_trade_margin.return_value = (False, 'risk_not_acknowledged')
|
|
|
|
response = self.app.post(
|
|
'/api/margin/capabilities',
|
|
json={
|
|
'broker_key': 'paper_margin_isolated',
|
|
'symbol': 'BTC/USDT'
|
|
}
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
data = response.get_json()
|
|
self.assertFalse(data['available'])
|
|
self.assertTrue(data['gated'])
|
|
self.assertEqual(data['reason'], 'Margin trading not available: risk_not_acknowledged')
|
|
self.assertTrue(data['risk_ack_required'])
|
|
self.assertFalse(data['risk_acknowledged'])
|
|
mock_bt.config.can_trade_margin.assert_called_with(1, False, False)
|
|
|
|
@patch('src.app._get_current_user_id', return_value=1)
|
|
def test_margin_history_returns_normalized_rows(self, _mock_user_id):
|
|
"""Margin history route should normalize DB rows for the frontend history panel."""
|
|
with patch('src.app.brighter_trades') as mock_bt:
|
|
mock_bt.data.db.get_margin_position_history.return_value = pd.DataFrame([
|
|
{
|
|
'id': 7,
|
|
'broker_key': 'kucoin_margin_isolated_production',
|
|
'symbol': 'BTC/USDT',
|
|
'side': 'long',
|
|
'size': 0.01,
|
|
'entry_price': 50000.0,
|
|
'close_price': 51000.0,
|
|
'pnl_quote': 9.5,
|
|
'collateral': 100.0,
|
|
'leverage': 3.0,
|
|
'close_reason': 'manual',
|
|
'opened_at': 1700000000,
|
|
'closed_at': 1700003600,
|
|
}
|
|
])
|
|
|
|
response = self.app.get('/api/margin/history?broker_key=kucoin_margin_isolated_production&limit=5')
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
data = response.get_json()
|
|
self.assertTrue(data['success'])
|
|
self.assertEqual(len(data['history']), 1)
|
|
row = data['history'][0]
|
|
self.assertEqual(row['unique_id'], 'mph_7')
|
|
self.assertEqual(row['symbol'], 'BTC/USDT')
|
|
self.assertEqual(row['side'], 'LONG')
|
|
self.assertEqual(row['broker_key'], 'kucoin_margin_isolated_production')
|
|
self.assertEqual(row['pnl_quote'], 9.5)
|
|
self.assertEqual(row['status'], 'closed')
|
|
|
|
@patch('src.app._get_current_user_id', return_value=1)
|
|
def test_margin_history_paper_uses_broker_history(self, _mock_user_id):
|
|
"""Paper margin history should come from the paper broker's persisted position history."""
|
|
with patch('src.app.brighter_trades') as mock_bt:
|
|
mock_broker = MagicMock()
|
|
mock_broker.get_position_history.return_value = [
|
|
{
|
|
'symbol': 'BTC/USDT',
|
|
'side': 'long',
|
|
'size': 0.01,
|
|
'entry_price': 50000.0,
|
|
'close_price': 51000.0,
|
|
'realized_pnl': 9.5,
|
|
'collateral': 100.0,
|
|
'leverage': 3.0,
|
|
'close_reason': 'manual',
|
|
'opened_at': '2023-11-14T22:13:20+00:00',
|
|
'closed_at': '2023-11-14T23:13:20+00:00',
|
|
}
|
|
]
|
|
mock_bt.manual_broker_manager.get_paper_margin_broker.return_value = mock_broker
|
|
|
|
response = self.app.get('/api/margin/history?broker_key=paper_margin_isolated&limit=5')
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
data = response.get_json()
|
|
self.assertTrue(data['success'])
|
|
self.assertEqual(len(data['history']), 1)
|
|
row = data['history'][0]
|
|
self.assertEqual(row['symbol'], 'BTC/USDT')
|
|
self.assertEqual(row['side'], 'LONG')
|
|
self.assertEqual(row['broker_key'], 'paper_margin_isolated')
|
|
self.assertTrue(row['is_paper'])
|
|
self.assertEqual(row['product'], 'margin')
|
|
self.assertEqual(row['pnl_quote'], 9.5)
|
|
self.assertEqual(row['opened_at'], 1700000000)
|
|
self.assertEqual(row['closed_at'], 1700003600)
|
|
mock_bt.data.db.get_margin_position_history.assert_not_called()
|
|
|
|
@patch('src.app._get_current_user_id', return_value=1)
|
|
def test_list_margin_positions_paper_normalizes_broker_metadata(self, _mock_user_id):
|
|
"""Paper margin position list should include broker metadata expected by the frontend."""
|
|
with patch('src.app.brighter_trades') as mock_bt:
|
|
mock_position = MagicMock()
|
|
mock_position.to_dict.return_value = {
|
|
'symbol': 'BTC/USDT',
|
|
'side': 'long',
|
|
'size': 0.1,
|
|
'collateral': 1000.0
|
|
}
|
|
|
|
mock_broker = MagicMock()
|
|
mock_broker.get_all_positions.return_value = [mock_position]
|
|
mock_bt.manual_broker_manager.get_paper_margin_broker.return_value = mock_broker
|
|
|
|
response = self.app.post(
|
|
'/api/margin/positions/list',
|
|
json={'broker_key': 'paper_margin_isolated'}
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
data = response.get_json()
|
|
self.assertTrue(data['success'])
|
|
self.assertEqual(len(data['positions']), 1)
|
|
position = data['positions'][0]
|
|
self.assertEqual(position['broker_key'], 'paper_margin_isolated')
|
|
self.assertEqual(position['broker_kind'], 'paper')
|
|
self.assertEqual(position['product'], 'margin')
|
|
|
|
@patch('src.app._get_current_user_id', return_value=1)
|
|
def test_preview_increase_paper_margin(self, _mock_user_id):
|
|
"""Preview increase endpoint should return valid preview for paper margin."""
|
|
with patch('src.app.brighter_trades') as mock_bt:
|
|
mock_broker = MagicMock()
|
|
mock_broker.preview_increase.return_value = {
|
|
'preview_type': 'increase',
|
|
'valid': True,
|
|
'current': {
|
|
'symbol': 'BTC/USDT',
|
|
'side': 'long',
|
|
'size': 0.1,
|
|
'entry_price': 60000,
|
|
'collateral': 1000,
|
|
},
|
|
'projected': {
|
|
'total_size': 0.15,
|
|
'average_entry': 61000,
|
|
'total_collateral': 1500,
|
|
'effective_leverage': 6.0,
|
|
'liquidation_price': 53000,
|
|
'margin_ratio': 85,
|
|
},
|
|
'warnings': [],
|
|
'errors': []
|
|
}
|
|
mock_bt.manual_broker_manager.get_paper_margin_broker.return_value = mock_broker
|
|
|
|
response = self.app.post(
|
|
'/api/margin/positions/preview-increase',
|
|
json={
|
|
'broker_key': 'paper_margin_isolated',
|
|
'symbol': 'BTC/USDT',
|
|
'mode': 'collateral_first',
|
|
'additional_collateral': 500,
|
|
'execution_leverage': 3
|
|
}
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
data = response.get_json()
|
|
self.assertEqual(data['preview_type'], 'increase')
|
|
self.assertTrue(data['valid'])
|
|
self.assertIsNotNone(data['projected'])
|
|
self.assertEqual(data['projected']['total_size'], 0.15)
|
|
|
|
@patch('src.app._get_current_user_id', return_value=1)
|
|
def test_preview_reduce_paper_margin(self, _mock_user_id):
|
|
"""Preview reduce endpoint should return valid preview for paper margin."""
|
|
with patch('src.app.brighter_trades') as mock_bt:
|
|
mock_broker = MagicMock()
|
|
mock_broker.preview_reduce.return_value = {
|
|
'preview_type': 'reduce',
|
|
'valid': True,
|
|
'current': {'size': 0.1},
|
|
'projected': {
|
|
'is_full_close': False,
|
|
'remaining_size': 0.05,
|
|
'realized_pnl': 100,
|
|
},
|
|
'warnings': [],
|
|
'errors': []
|
|
}
|
|
mock_bt.manual_broker_manager.get_paper_margin_broker.return_value = mock_broker
|
|
|
|
response = self.app.post(
|
|
'/api/margin/positions/preview-reduce',
|
|
json={
|
|
'broker_key': 'paper_margin_isolated',
|
|
'symbol': 'BTC/USDT',
|
|
'reduce_size': 0.05
|
|
}
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
data = response.get_json()
|
|
self.assertEqual(data['preview_type'], 'reduce')
|
|
self.assertTrue(data['valid'])
|
|
|
|
@patch('src.app._get_current_user_id', return_value=1)
|
|
def test_preview_add_margin_paper(self, _mock_user_id):
|
|
"""Preview add margin endpoint should return valid preview."""
|
|
with patch('src.app.brighter_trades') as mock_bt:
|
|
mock_broker = MagicMock()
|
|
mock_broker.preview_add_margin.return_value = {
|
|
'preview_type': 'add_margin',
|
|
'valid': True,
|
|
'current': {'collateral': 1000},
|
|
'projected': {
|
|
'total_collateral': 1500,
|
|
'effective_leverage': 4.0,
|
|
'margin_ratio': 95,
|
|
},
|
|
'warnings': [],
|
|
'errors': []
|
|
}
|
|
mock_bt.manual_broker_manager.get_paper_margin_broker.return_value = mock_broker
|
|
|
|
response = self.app.post(
|
|
'/api/margin/positions/preview-add-margin',
|
|
json={
|
|
'broker_key': 'paper_margin_isolated',
|
|
'symbol': 'BTC/USDT',
|
|
'amount': 500
|
|
}
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
data = response.get_json()
|
|
self.assertEqual(data['preview_type'], 'add_margin')
|
|
self.assertTrue(data['valid'])
|
|
|
|
@patch('src.app._get_current_user_id', return_value=1)
|
|
def test_preview_remove_margin_paper(self, _mock_user_id):
|
|
"""Preview remove margin endpoint should return valid preview."""
|
|
with patch('src.app.brighter_trades') as mock_bt:
|
|
mock_broker = MagicMock()
|
|
mock_broker.preview_remove_margin.return_value = {
|
|
'preview_type': 'remove_margin',
|
|
'valid': True,
|
|
'current': {'collateral': 1500},
|
|
'projected': {
|
|
'total_collateral': 1000,
|
|
'effective_leverage': 6.0,
|
|
'max_withdrawable': 600,
|
|
},
|
|
'warnings': ['Effective leverage will exceed 5x (6.0x)'],
|
|
'errors': []
|
|
}
|
|
mock_bt.manual_broker_manager.get_paper_margin_broker.return_value = mock_broker
|
|
|
|
response = self.app.post(
|
|
'/api/margin/positions/preview-remove-margin',
|
|
json={
|
|
'broker_key': 'paper_margin_isolated',
|
|
'symbol': 'BTC/USDT',
|
|
'amount': 500
|
|
}
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
data = response.get_json()
|
|
self.assertEqual(data['preview_type'], 'remove_margin')
|
|
self.assertTrue(data['valid'])
|
|
self.assertEqual(len(data['warnings']), 1)
|
|
|
|
@patch('src.app._get_current_user_id', return_value=1)
|
|
def test_increase_margin_position_paper(self, _mock_user_id):
|
|
"""Increase position endpoint should execute for paper margin."""
|
|
with patch('src.app.brighter_trades') as mock_bt:
|
|
mock_position = MagicMock()
|
|
mock_position.to_dict.return_value = {
|
|
'symbol': 'BTC/USDT',
|
|
'size': 0.15,
|
|
'collateral': 1500,
|
|
}
|
|
|
|
mock_broker = MagicMock()
|
|
mock_broker.increase_position.return_value = mock_position
|
|
mock_bt.manual_broker_manager.get_paper_margin_broker.return_value = mock_broker
|
|
|
|
response = self.app.post(
|
|
'/api/margin/positions/increase',
|
|
json={
|
|
'broker_key': 'paper_margin_isolated',
|
|
'symbol': 'BTC/USDT',
|
|
'mode': 'collateral_first',
|
|
'additional_collateral': 500,
|
|
'execution_leverage': 3
|
|
}
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
data = response.get_json()
|
|
self.assertTrue(data['success'])
|
|
self.assertIn('position', data)
|
|
|
|
@patch('src.app._get_current_user_id', return_value=1)
|
|
def test_increase_margin_position_live_executes_and_syncs_position(self, _mock_user_id):
|
|
"""Live increase route should call the live broker and sync the updated position."""
|
|
with patch('src.app.brighter_trades') as mock_bt:
|
|
live_position = {
|
|
'symbol': 'BTC/USDT',
|
|
'size': 0.02,
|
|
'collateral': 150.0,
|
|
}
|
|
mock_live_broker = MagicMock()
|
|
mock_live_broker.increase_position.return_value = {
|
|
'success': True,
|
|
'position': live_position
|
|
}
|
|
mock_bt.manual_broker_manager._get_or_create_live_margin_broker.return_value = mock_live_broker
|
|
mock_bt.data.db.has_margin_risk_ack.return_value = True
|
|
mock_bt.config.can_trade_margin.return_value = (True, 'allowed')
|
|
|
|
response = self.app.post(
|
|
'/api/margin/positions/increase',
|
|
json={
|
|
'broker_key': 'kucoin_margin_isolated_production',
|
|
'symbol': 'BTC/USDT',
|
|
'mode': 'collateral_first',
|
|
'additional_collateral': 50,
|
|
'execution_leverage': 3
|
|
}
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
data = response.get_json()
|
|
self.assertTrue(data['success'])
|
|
mock_bt.config.can_trade_margin.assert_called_with(1, True, True)
|
|
mock_live_broker.increase_position.assert_called_once_with(
|
|
symbol='BTC/USDT',
|
|
additional_collateral=50.0,
|
|
execution_leverage=3.0
|
|
)
|
|
mock_bt.trades.sync_margin_position.assert_called_once_with(
|
|
user_id=1,
|
|
symbol='BTC/USDT',
|
|
broker_key='kucoin_margin_isolated_production',
|
|
position=live_position
|
|
)
|
|
|
|
@patch('src.app._get_current_user_id', return_value=1)
|
|
@patch('src.app._record_margin_position_edit')
|
|
def test_reduce_margin_position_paper_records_preview_metadata(self, mock_record_edit, _mock_user_id):
|
|
"""Paper reduce route should persist preview-derived realized P/L and interest metadata."""
|
|
with patch('src.app.brighter_trades') as mock_bt:
|
|
existing_pos = MagicMock()
|
|
existing_pos.to_dict.return_value = {
|
|
'symbol': 'BTC/USDT',
|
|
'side': 'long',
|
|
'size': 0.1,
|
|
'entry_price': 60000.0,
|
|
'collateral': 1000.0,
|
|
}
|
|
|
|
reduced_pos = MagicMock()
|
|
reduced_pos.to_dict.return_value = {
|
|
'symbol': 'BTC/USDT',
|
|
'side': 'long',
|
|
'size': 0.05,
|
|
'entry_price': 60000.0,
|
|
'collateral': 500.0,
|
|
}
|
|
|
|
mock_broker = MagicMock()
|
|
mock_broker.get_position.return_value = existing_pos
|
|
mock_broker.get_current_price.return_value = 62000.0
|
|
mock_broker.preview_reduce.return_value = {
|
|
'valid': True,
|
|
'projected': {
|
|
'realized_pnl': 95.0,
|
|
'interest_paid': 5.0,
|
|
}
|
|
}
|
|
mock_broker.reduce_position.return_value = reduced_pos
|
|
mock_bt.manual_broker_manager.get_paper_margin_broker.return_value = mock_broker
|
|
|
|
response = self.app.post(
|
|
'/api/margin/positions/reduce',
|
|
json={
|
|
'broker_key': 'paper_margin_isolated',
|
|
'symbol': 'BTC/USDT',
|
|
'size': 0.05
|
|
}
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
data = response.get_json()
|
|
self.assertTrue(data['success'])
|
|
mock_broker.reduce_position.assert_called_with('BTC/USDT', 0.05, current_price=62000.0)
|
|
mock_record_edit.assert_called_once()
|
|
kwargs = mock_record_edit.call_args.kwargs
|
|
self.assertEqual(kwargs['action_type'], 'reduce')
|
|
self.assertEqual(kwargs['execution_price'], 62000.0)
|
|
self.assertEqual(kwargs['realized_pnl'], 95.0)
|
|
self.assertEqual(kwargs['interest_paid'], 5.0)
|
|
|
|
@patch('src.app._get_current_user_id', return_value=1)
|
|
@patch('src.app._record_margin_position_edit')
|
|
def test_close_margin_position_paper_records_preview_metadata(self, mock_record_edit, _mock_user_id):
|
|
"""Paper close route should persist preview-derived realized P/L and interest metadata."""
|
|
with patch('src.app.brighter_trades') as mock_bt:
|
|
existing_pos = MagicMock()
|
|
existing_pos.size = 0.1
|
|
existing_pos.to_dict.return_value = {
|
|
'symbol': 'BTC/USDT',
|
|
'side': 'long',
|
|
'size': 0.1,
|
|
'entry_price': 60000.0,
|
|
'collateral': 1000.0,
|
|
}
|
|
|
|
close_result = MagicMock()
|
|
close_result.success = True
|
|
close_result.close_price = 62000.0
|
|
close_result.realized_pnl = 190.0
|
|
close_result.message = None
|
|
|
|
mock_broker = MagicMock()
|
|
mock_broker.get_position.return_value = existing_pos
|
|
mock_broker.get_current_price.return_value = 62000.0
|
|
mock_broker.preview_reduce.return_value = {
|
|
'valid': True,
|
|
'projected': {
|
|
'realized_pnl': 190.0,
|
|
'interest_paid': 10.0,
|
|
}
|
|
}
|
|
mock_broker.close_position.return_value = close_result
|
|
mock_bt.manual_broker_manager.get_paper_margin_broker.return_value = mock_broker
|
|
|
|
response = self.app.post(
|
|
'/api/margin/positions/close',
|
|
json={
|
|
'broker_key': 'paper_margin_isolated',
|
|
'symbol': 'BTC/USDT',
|
|
}
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
data = response.get_json()
|
|
self.assertTrue(data['success'])
|
|
mock_broker.close_position.assert_called_with('BTC/USDT', current_price=62000.0)
|
|
mock_record_edit.assert_called_once()
|
|
kwargs = mock_record_edit.call_args.kwargs
|
|
self.assertEqual(kwargs['action_type'], 'close')
|
|
self.assertEqual(kwargs['execution_price'], 62000.0)
|
|
self.assertEqual(kwargs['realized_pnl'], 190.0)
|
|
self.assertEqual(kwargs['interest_paid'], 10.0)
|
|
|
|
@patch('src.app._get_current_user_id', return_value=1)
|
|
def test_remove_margin_from_position_paper(self, _mock_user_id):
|
|
"""Remove margin endpoint should execute for paper margin."""
|
|
with patch('src.app.brighter_trades') as mock_bt:
|
|
mock_position = MagicMock()
|
|
mock_position.to_dict.return_value = {
|
|
'symbol': 'BTC/USDT',
|
|
'collateral': 1000,
|
|
}
|
|
|
|
mock_broker = MagicMock()
|
|
mock_broker.remove_margin.return_value = mock_position
|
|
mock_bt.manual_broker_manager.get_paper_margin_broker.return_value = mock_broker
|
|
|
|
response = self.app.post(
|
|
'/api/margin/positions/remove-margin',
|
|
json={
|
|
'broker_key': 'paper_margin_isolated',
|
|
'symbol': 'BTC/USDT',
|
|
'amount': 500
|
|
}
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
data = response.get_json()
|
|
self.assertTrue(data['success'])
|
|
self.assertEqual(data['withdrawn_amount'], 500)
|
|
|
|
@patch('src.app._get_current_user_id', return_value=1)
|
|
def test_remove_margin_from_position_live_executes_and_syncs_position(self, _mock_user_id):
|
|
"""Live remove-margin route should call the live broker and sync the updated position."""
|
|
with patch('src.app.brighter_trades') as mock_bt:
|
|
live_position = {
|
|
'symbol': 'BTC/USDT',
|
|
'collateral': 120.0,
|
|
}
|
|
mock_live_broker = MagicMock()
|
|
mock_live_broker.remove_margin.return_value = {
|
|
'success': True,
|
|
'withdrawn_amount': 25.0,
|
|
'position': live_position
|
|
}
|
|
mock_bt.manual_broker_manager._get_or_create_live_margin_broker.return_value = mock_live_broker
|
|
mock_bt.data.db.has_margin_risk_ack.return_value = True
|
|
mock_bt.config.can_trade_margin.return_value = (True, 'allowed')
|
|
|
|
response = self.app.post(
|
|
'/api/margin/positions/remove-margin',
|
|
json={
|
|
'broker_key': 'kucoin_margin_isolated_production',
|
|
'symbol': 'BTC/USDT',
|
|
'amount': 25
|
|
}
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
data = response.get_json()
|
|
self.assertTrue(data['success'])
|
|
mock_bt.config.can_trade_margin.assert_called_with(1, True, True)
|
|
mock_live_broker.remove_margin.assert_called_once_with('BTC/USDT', 25.0)
|
|
mock_bt.trades.sync_margin_position.assert_called_once_with(
|
|
user_id=1,
|
|
symbol='BTC/USDT',
|
|
broker_key='kucoin_margin_isolated_production',
|
|
position=live_position
|
|
)
|
|
|
|
@patch('src.app._get_current_user_id', return_value=None)
|
|
def test_preview_endpoints_require_auth(self, _mock_user_id):
|
|
"""Preview endpoints should require authentication."""
|
|
endpoints = [
|
|
'/api/margin/positions/preview-increase',
|
|
'/api/margin/positions/preview-reduce',
|
|
'/api/margin/positions/preview-add-margin',
|
|
'/api/margin/positions/preview-remove-margin',
|
|
'/api/margin/positions/increase',
|
|
'/api/margin/positions/remove-margin',
|
|
]
|
|
|
|
for endpoint in endpoints:
|
|
response = self.app.post(
|
|
endpoint,
|
|
json={'broker_key': 'paper_margin_isolated', 'symbol': 'BTC/USDT'}
|
|
)
|
|
self.assertEqual(response.status_code, 401, f"{endpoint} should require auth")
|
|
|
|
@patch('src.app._get_current_user_id', return_value=1)
|
|
def test_margin_edits_endpoint_returns_edit_activity(self, _mock_user_id):
|
|
"""Margin edits endpoint should return edit activity records."""
|
|
with patch('src.app.brighter_trades') as mock_bt:
|
|
mock_bt.data.db.get_margin_position_edits.return_value = pd.DataFrame([
|
|
{
|
|
'id': 1,
|
|
'broker_key': 'paper_margin_isolated',
|
|
'symbol': 'BTC/USDT',
|
|
'action_type': 'increase',
|
|
'side': 'long',
|
|
'timestamp': 1700000000,
|
|
'size_delta': 0.05,
|
|
'collateral_delta': 500.0,
|
|
'size_before': 0.1,
|
|
'size_after': 0.15,
|
|
'entry_price_before': 60000.0,
|
|
'entry_price_after': 61333.0,
|
|
'collateral_before': 1000.0,
|
|
'collateral_after': 1500.0,
|
|
'margin_ratio_before': 85.0,
|
|
'margin_ratio_after': 82.0,
|
|
'execution_price': None,
|
|
'realized_pnl': None,
|
|
'interest_paid': None,
|
|
}
|
|
])
|
|
|
|
response = self.app.get('/api/margin/edits?broker_key=paper_margin_isolated&limit=10')
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
data = response.get_json()
|
|
self.assertTrue(data['success'])
|
|
self.assertEqual(len(data['edits']), 1)
|
|
edit = data['edits'][0]
|
|
self.assertEqual(edit['unique_id'], 'mpe_1')
|
|
self.assertEqual(edit['action_type'], 'increase')
|
|
self.assertEqual(edit['symbol'], 'BTC/USDT')
|
|
self.assertEqual(edit['side'], 'LONG')
|
|
self.assertEqual(edit['size_delta'], 0.05)
|
|
self.assertEqual(edit['collateral_delta'], 500.0)
|
|
self.assertIn('interest_paid', edit)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|