brighter-trading/tests/test_app.py

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()