1060 lines
35 KiB
Python
1060 lines
35 KiB
Python
"""Tests for the Trade and Trades classes."""
|
|
import pytest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import sys
|
|
import os
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
|
|
|
from trade import Trade, Trades
|
|
from manual_trading_broker import ManualTradingBrokerManager
|
|
|
|
|
|
class TestTrade:
|
|
"""Tests for the Trade class."""
|
|
|
|
def test_trade_creation(self):
|
|
"""Test basic trade creation."""
|
|
trade = Trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
side='BUY',
|
|
order_price=50000.0,
|
|
base_order_qty=0.1
|
|
)
|
|
|
|
assert trade.symbol == 'BTC/USDT'
|
|
assert trade.side == 'BUY'
|
|
assert trade.order_price == 50000.0
|
|
assert trade.base_order_qty == 0.1
|
|
assert trade.status == 'inactive'
|
|
assert trade.is_paper is False
|
|
assert trade.unique_id is not None
|
|
|
|
def test_trade_paper_flag(self):
|
|
"""Test is_paper flag."""
|
|
trade = Trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
side='BUY',
|
|
order_price=50000.0,
|
|
base_order_qty=0.1,
|
|
is_paper=True
|
|
)
|
|
|
|
assert trade.is_paper is True
|
|
|
|
def test_trade_to_json(self):
|
|
"""Test trade serialization."""
|
|
trade = Trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
side='BUY',
|
|
order_price=50000.0,
|
|
base_order_qty=0.1,
|
|
is_paper=True,
|
|
creator=1
|
|
)
|
|
|
|
json_data = trade.to_json()
|
|
|
|
assert json_data['symbol'] == 'BTC/USDT'
|
|
assert json_data['side'] == 'BUY'
|
|
assert json_data['is_paper'] is True
|
|
assert json_data['creator'] == 1
|
|
assert 'stats' in json_data
|
|
|
|
def test_trade_with_stop_loss_take_profit(self):
|
|
"""Test trade with SL/TP fields."""
|
|
trade = Trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
side='BUY',
|
|
order_price=50000.0,
|
|
base_order_qty=0.1,
|
|
stop_loss=45000.0,
|
|
take_profit=60000.0
|
|
)
|
|
|
|
assert trade.stop_loss == 45000.0
|
|
assert trade.take_profit == 60000.0
|
|
|
|
# Verify serialization
|
|
json_data = trade.to_json()
|
|
assert json_data['stop_loss'] == 45000.0
|
|
assert json_data['take_profit'] == 60000.0
|
|
|
|
def test_trade_sltp_defaults_to_none(self):
|
|
"""Test that SL/TP default to None when not provided."""
|
|
trade = Trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
side='BUY',
|
|
order_price=50000.0,
|
|
base_order_qty=0.1
|
|
)
|
|
|
|
assert trade.stop_loss is None
|
|
assert trade.take_profit is None
|
|
|
|
def test_trade_update_values(self):
|
|
"""Test P/L calculation."""
|
|
trade = Trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
side='BUY',
|
|
order_price=50000.0,
|
|
base_order_qty=0.1,
|
|
fee=0.001 # 0.1% fee
|
|
)
|
|
trade.status = 'filled'
|
|
trade.stats['qty_filled'] = 0.1
|
|
|
|
# Price goes up
|
|
trade.update_values(55000.0)
|
|
|
|
assert trade.stats['current_price'] == 55000.0
|
|
assert trade.stats['current_value'] == 5500.0
|
|
# Profit should be positive (minus fees)
|
|
assert trade.stats['profit'] > 0
|
|
|
|
def test_trade_sell_side_pl(self):
|
|
"""Test P/L calculation for sell side."""
|
|
trade = Trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
side='SELL',
|
|
order_price=50000.0,
|
|
base_order_qty=0.1,
|
|
fee=0.001
|
|
)
|
|
trade.status = 'filled'
|
|
trade.stats['qty_filled'] = 0.1
|
|
|
|
# Price goes down - should be profit for sell
|
|
trade.update_values(45000.0)
|
|
|
|
assert trade.stats['profit'] > 0
|
|
|
|
def test_trade_short_side_pl(self):
|
|
"""Margin SHORT trades should profit when price drops."""
|
|
trade = Trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
side='SHORT',
|
|
order_price=50000.0,
|
|
base_order_qty=0.1,
|
|
fee=0.001
|
|
)
|
|
trade.status = 'filled'
|
|
trade.stats['qty_filled'] = 0.1
|
|
|
|
trade.update_values(45000.0)
|
|
|
|
assert trade.stats['profit'] > 0
|
|
|
|
def test_trade_filled(self):
|
|
"""Test trade fill logic."""
|
|
trade = Trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
side='BUY',
|
|
order_price=50000.0,
|
|
base_order_qty=0.1
|
|
)
|
|
|
|
trade.trade_filled(qty=0.1, price=50000.0)
|
|
|
|
assert trade.status == 'filled'
|
|
assert trade.stats['qty_filled'] == 0.1
|
|
assert trade.stats['opening_price'] == 50000.0
|
|
|
|
def test_trade_partial_fill(self):
|
|
"""Test partial fill logic."""
|
|
trade = Trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
side='BUY',
|
|
order_price=50000.0,
|
|
base_order_qty=0.1
|
|
)
|
|
|
|
trade.trade_filled(qty=0.05, price=50000.0)
|
|
|
|
assert trade.status == 'part-filled'
|
|
assert trade.stats['qty_filled'] == 0.05
|
|
|
|
def test_trade_first_fill_from_open_order_uses_fill_price(self):
|
|
"""First broker fill should not average against the original unfilled order notional."""
|
|
trade = Trade(
|
|
target='kucoin',
|
|
symbol='BTC/USDT',
|
|
side='BUY',
|
|
order_price=69394.3,
|
|
base_order_qty=0.0001
|
|
)
|
|
trade.status = 'open'
|
|
|
|
trade.trade_filled(qty=5.28e-06, price=69340.8)
|
|
|
|
assert trade.status == 'part-filled'
|
|
assert trade.stats['qty_filled'] == pytest.approx(5.28e-06)
|
|
assert trade.stats['opening_price'] == pytest.approx(69340.8)
|
|
assert trade.stats['opening_value'] == pytest.approx(5.28e-06 * 69340.8)
|
|
|
|
def test_trade_update_values_uses_filled_quantity_for_pl(self):
|
|
"""Unrealized P/L should be based on filled exposure, not the original order size."""
|
|
trade = Trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
side='BUY',
|
|
order_price=50000.0,
|
|
base_order_qty=0.1,
|
|
fee=0.001
|
|
)
|
|
|
|
trade.trade_filled(qty=0.05, price=50000.0)
|
|
trade.update_values(55000.0)
|
|
|
|
assert trade.stats['current_value'] == pytest.approx(2750.0)
|
|
assert trade.stats['profit'] == pytest.approx(244.75)
|
|
assert trade.stats['profit_pct'] == pytest.approx(9.79)
|
|
|
|
def test_trade_settle(self):
|
|
"""Test trade settlement."""
|
|
trade = Trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
side='BUY',
|
|
order_price=50000.0,
|
|
base_order_qty=0.1
|
|
)
|
|
trade.trade_filled(qty=0.1, price=50000.0)
|
|
|
|
trade.settle(qty=0.1, price=55000.0)
|
|
|
|
assert trade.status == 'closed'
|
|
assert trade.stats['settled_price'] == 55000.0
|
|
assert trade.stats['profit'] == pytest.approx(489.5)
|
|
assert trade.stats['profit_pct'] == pytest.approx(9.79)
|
|
|
|
|
|
class TestTrades:
|
|
"""Tests for the Trades class."""
|
|
|
|
@pytest.fixture
|
|
def mock_users(self):
|
|
"""Create a mock Users object."""
|
|
users = MagicMock()
|
|
users.get_username.return_value = 'test_user'
|
|
return users
|
|
|
|
@pytest.fixture
|
|
def mock_data_cache(self):
|
|
"""Create a mock DataCache object."""
|
|
dc = MagicMock()
|
|
dc.db.table_exists.return_value = True
|
|
dc.get_all_rows_from_datacache.return_value = None
|
|
dc.get_rows_from_datacache.return_value = MagicMock(empty=True)
|
|
return dc
|
|
|
|
def test_trades_creation_no_cache(self, mock_users):
|
|
"""Test Trades creation without data cache."""
|
|
trades = Trades(mock_users)
|
|
|
|
assert trades.users == mock_users
|
|
assert trades.data_cache is None
|
|
assert len(trades.active_trades) == 0
|
|
|
|
def test_trades_creation_with_cache(self, mock_users, mock_data_cache):
|
|
"""Test Trades creation with data cache."""
|
|
trades = Trades(mock_users, data_cache=mock_data_cache)
|
|
|
|
assert trades.data_cache == mock_data_cache
|
|
|
|
def test_connect_exchanges(self, mock_users):
|
|
"""Test exchange connection."""
|
|
trades = Trades(mock_users)
|
|
mock_exchange = MagicMock()
|
|
|
|
trades.connect_exchanges(mock_exchange)
|
|
|
|
assert trades.exchange_interface == mock_exchange
|
|
assert trades.exchange_connected() is True
|
|
|
|
def test_recover_missing_paper_margin_state_rebuilds_latest_and_prunes_duplicates(self, mock_users):
|
|
"""Restart recovery should rebuild a missing paper margin position and prune duplicate ghost trades."""
|
|
trades = Trades(mock_users)
|
|
trades.manual_broker_manager = ManualTradingBrokerManager(data_cache=None, exchange_interface=None, users=mock_users)
|
|
|
|
def make_margin_trade(unique_id, created_at, collateral, leverage, qty, price):
|
|
trade = Trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
side='LONG',
|
|
order_price=price,
|
|
base_order_qty=qty,
|
|
unique_id=unique_id,
|
|
status='filled',
|
|
stats={
|
|
'opening_price': price,
|
|
'opening_value': qty * price,
|
|
'current_price': price,
|
|
'current_value': qty * price,
|
|
'settled_price': 0.0,
|
|
'settled_value': 0.0,
|
|
'qty_filled': qty,
|
|
'qty_settled': 0.0,
|
|
'profit': 0.0,
|
|
'profit_pct': 0.0,
|
|
'fee_paid': 0.0,
|
|
'realized_profit': 0.0,
|
|
'unrealized_profit': 0.0
|
|
},
|
|
is_paper=True,
|
|
creator=1,
|
|
created_at=created_at,
|
|
broker_kind='paper',
|
|
broker_mode='paper',
|
|
broker_order_id=f'margin_{unique_id}',
|
|
collateral=collateral,
|
|
leverage=leverage
|
|
)
|
|
trades.active_trades[unique_id] = trade
|
|
|
|
make_margin_trade('older', '2026-03-12T01:00:00+00:00', 100.0, 3.0, 0.004, 70000.0)
|
|
make_margin_trade('newer', '2026-03-12T02:00:00+00:00', 150.0, 5.0, 0.010, 71000.0)
|
|
|
|
recovered = trades.recover_brokers()
|
|
broker = trades.manual_broker_manager.get_paper_margin_broker(1)
|
|
position = broker.get_position('BTC/USDT')
|
|
|
|
assert recovered >= 1
|
|
assert position is not None
|
|
assert position.entry_price == 71000.0
|
|
assert position.collateral == 150.0
|
|
assert position.leverage == 5.0
|
|
assert set(trades.active_trades.keys()) == {'newer'}
|
|
assert 'older' in trades.settled_trades
|
|
assert trades.settled_trades['older'].status == 'cancelled'
|
|
assert trades.settled_trades['older'].stats['close_reason'] == 'paper_margin_recovery_pruned'
|
|
|
|
def test_close_all_paper_trades_closes_spot_and_margin(self, mock_users):
|
|
"""Paper reset cleanup should remove both spot and margin active trades."""
|
|
trades = Trades(mock_users)
|
|
|
|
spot_trade = Trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
side='BUY',
|
|
order_price=50000.0,
|
|
base_order_qty=0.1,
|
|
unique_id='spot_trade',
|
|
status='filled',
|
|
stats={'qty_filled': 0.1},
|
|
is_paper=True,
|
|
creator=1
|
|
)
|
|
margin_trade = Trade(
|
|
target='test_exchange',
|
|
symbol='ETH/USDT',
|
|
side='LONG',
|
|
order_price=2500.0,
|
|
base_order_qty=0.2,
|
|
unique_id='margin_trade',
|
|
status='filled',
|
|
stats={'qty_filled': 0.2},
|
|
is_paper=True,
|
|
creator=1,
|
|
broker_kind='paper',
|
|
broker_mode='paper',
|
|
broker_order_id='margin_margin_trade',
|
|
collateral=100.0,
|
|
leverage=3.0
|
|
)
|
|
trades.active_trades[spot_trade.unique_id] = spot_trade
|
|
trades.active_trades[margin_trade.unique_id] = margin_trade
|
|
|
|
closed_ids = trades.close_all_paper_trades(1)
|
|
|
|
assert set(closed_ids) == {'spot_trade', 'margin_trade'}
|
|
assert not trades.active_trades
|
|
assert trades.settled_trades['spot_trade'].status == 'cancelled'
|
|
assert trades.settled_trades['margin_trade'].status == 'cancelled'
|
|
|
|
def test_new_paper_trade(self, mock_users):
|
|
"""Test creating a paper trade."""
|
|
trades = Trades(mock_users)
|
|
|
|
status, trade_id = trades.new_trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
price=50000.0,
|
|
side='buy',
|
|
order_type='MARKET',
|
|
qty=0.1,
|
|
user_id=1
|
|
)
|
|
|
|
assert status == 'Success'
|
|
assert trade_id is not None
|
|
assert trade_id in trades.active_trades
|
|
|
|
# Check trade properties
|
|
trade = trades.get_trade_by_id(trade_id)
|
|
assert trade.is_paper is True
|
|
assert trade.status == 'filled'
|
|
assert trade.creator == 1
|
|
|
|
def test_new_paper_trade_persists_time_in_force(self, mock_users):
|
|
"""Manual trades should keep the selected time-in-force on the Trade object."""
|
|
trades = Trades(mock_users)
|
|
|
|
status, trade_id = trades.new_trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
price=50000.0,
|
|
side='buy',
|
|
order_type='LIMIT',
|
|
qty=0.1,
|
|
user_id=1,
|
|
time_in_force='IOC'
|
|
)
|
|
|
|
assert status == 'Success'
|
|
trade = trades.get_trade_by_id(trade_id)
|
|
assert trade.time_in_force == 'IOC'
|
|
|
|
def test_new_live_trade_no_exchange(self, mock_users):
|
|
"""Test creating a live trade without exchange connected."""
|
|
trades = Trades(mock_users)
|
|
|
|
# Use testnet=True to bypass production safety gate and test exchange check
|
|
status, msg = trades.new_trade(
|
|
target='binance',
|
|
symbol='BTC/USDT',
|
|
price=50000.0,
|
|
side='buy',
|
|
order_type='MARKET',
|
|
qty=0.1,
|
|
user_id=1,
|
|
testnet=True
|
|
)
|
|
|
|
assert status == 'Error'
|
|
assert 'No exchange' in msg.lower() or 'no exchange' in msg.lower()
|
|
|
|
def test_new_live_trade_rejects_manual_sltp(self, mock_users):
|
|
"""Manual live SL/TP should fail fast until exchange-native support exists."""
|
|
trades = Trades(mock_users)
|
|
mock_exchange_interface = MagicMock()
|
|
mock_exchange_interface.get_exchange.return_value = MagicMock(configured=True)
|
|
trades.connect_exchanges(mock_exchange_interface)
|
|
|
|
status, msg = trades.new_trade(
|
|
target='binance',
|
|
symbol='BTC/USDT',
|
|
price=50000.0,
|
|
side='buy',
|
|
order_type='MARKET',
|
|
qty=0.1,
|
|
user_id=1,
|
|
testnet=True,
|
|
stop_loss=45000.0
|
|
)
|
|
|
|
assert status == 'Error'
|
|
assert 'not supported yet' in msg.lower()
|
|
|
|
def test_new_production_trade_blocked_without_env_var(self, mock_users):
|
|
"""Test that production trades are blocked without ALLOW_LIVE_PRODUCTION."""
|
|
import config
|
|
original_value = getattr(config, 'ALLOW_LIVE_PRODUCTION', False)
|
|
|
|
try:
|
|
# Ensure production is NOT allowed
|
|
config.ALLOW_LIVE_PRODUCTION = False
|
|
|
|
trades = Trades(mock_users)
|
|
status, msg = trades.new_trade(
|
|
target='binance',
|
|
symbol='BTC/USDT',
|
|
price=50000.0,
|
|
side='buy',
|
|
order_type='MARKET',
|
|
qty=0.1,
|
|
user_id=1,
|
|
testnet=False # Production mode
|
|
)
|
|
|
|
assert status == 'Error'
|
|
assert 'production trading is disabled' in msg.lower()
|
|
|
|
finally:
|
|
# Restore original value
|
|
config.ALLOW_LIVE_PRODUCTION = original_value
|
|
|
|
def test_get_trades_json(self, mock_users):
|
|
"""Test getting trades in JSON format."""
|
|
trades = Trades(mock_users)
|
|
|
|
# Create a paper trade
|
|
trades.new_trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
price=50000.0,
|
|
side='buy',
|
|
order_type='MARKET',
|
|
qty=0.1
|
|
)
|
|
|
|
result = trades.get_trades('json')
|
|
|
|
assert len(result) == 1
|
|
assert result[0]['symbol'] == 'BTC/USDT'
|
|
|
|
def test_get_trades_for_user(self, mock_users):
|
|
"""Test filtering trades by user."""
|
|
trades = Trades(mock_users)
|
|
|
|
# Create trades for different users
|
|
trades.new_trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
price=50000.0,
|
|
side='buy',
|
|
order_type='MARKET',
|
|
qty=0.1,
|
|
user_id=1
|
|
)
|
|
trades.new_trade(
|
|
target='test_exchange',
|
|
symbol='ETH/USDT',
|
|
price=3000.0,
|
|
side='buy',
|
|
order_type='MARKET',
|
|
qty=1.0,
|
|
user_id=2
|
|
)
|
|
|
|
# Filter for user 1
|
|
user1_trades = trades.get_trades_for_user(1, 'json')
|
|
assert len(user1_trades) == 1
|
|
assert user1_trades[0]['symbol'] == 'BTC/USDT'
|
|
|
|
def test_close_paper_trade(self, mock_users):
|
|
"""Test closing a paper trade."""
|
|
trades = Trades(mock_users)
|
|
|
|
# Create a paper trade
|
|
status, trade_id = trades.new_trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
price=50000.0,
|
|
side='buy',
|
|
order_type='MARKET',
|
|
qty=0.1
|
|
)
|
|
|
|
assert trade_id in trades.active_trades
|
|
|
|
# Close the trade
|
|
result = trades.close_trade(trade_id, current_price=55000.0)
|
|
|
|
assert result['success'] is True
|
|
assert trade_id not in trades.active_trades
|
|
assert trade_id in trades.settled_trades
|
|
|
|
def test_close_trade_recomputes_final_pl_from_close_price(self, mock_users):
|
|
"""Closing should use the settlement price, not the last cached unrealized P/L."""
|
|
trades = Trades(mock_users)
|
|
status, trade_id = trades.new_trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
price=50000.0,
|
|
side='buy',
|
|
order_type='MARKET',
|
|
qty=0.1
|
|
)
|
|
|
|
assert status == 'Success'
|
|
trade = trades.get_trade_by_id(trade_id)
|
|
trade.fee = 0.001
|
|
|
|
trade.update_values(45000.0)
|
|
assert trade.stats['profit'] < 0
|
|
|
|
result = trades.close_trade(trade_id, current_price=55000.0)
|
|
|
|
assert result['success'] is True
|
|
assert result['final_pl'] == pytest.approx(489.5)
|
|
assert result['final_pl_pct'] == pytest.approx(9.79)
|
|
|
|
def test_close_nonexistent_trade(self, mock_users):
|
|
"""Test closing a trade that doesn't exist."""
|
|
trades = Trades(mock_users)
|
|
|
|
result = trades.close_trade('nonexistent_id')
|
|
|
|
assert result['success'] is False
|
|
assert 'not found' in result['message']
|
|
|
|
def test_settle_broker_closed_position_filters_by_user_and_status(self, mock_users):
|
|
"""Broker-side closes should only settle filled trades for the matching user/broker."""
|
|
trades = Trades(mock_users)
|
|
|
|
filled_trade = Trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
side='BUY',
|
|
order_price=50000.0,
|
|
base_order_qty=0.1,
|
|
is_paper=True,
|
|
creator=1,
|
|
broker_kind='paper',
|
|
broker_mode='paper'
|
|
)
|
|
filled_trade.trade_filled(qty=0.1, price=50000.0)
|
|
|
|
other_user_trade = Trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
side='BUY',
|
|
order_price=50000.0,
|
|
base_order_qty=0.1,
|
|
is_paper=True,
|
|
creator=2,
|
|
broker_kind='paper',
|
|
broker_mode='paper'
|
|
)
|
|
other_user_trade.trade_filled(qty=0.1, price=50000.0)
|
|
|
|
open_trade = Trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
side='BUY',
|
|
order_price=49000.0,
|
|
base_order_qty=0.1,
|
|
is_paper=True,
|
|
creator=1,
|
|
broker_kind='paper',
|
|
broker_mode='paper'
|
|
)
|
|
open_trade.status = 'open'
|
|
|
|
trades.active_trades[filled_trade.unique_id] = filled_trade
|
|
trades.active_trades[other_user_trade.unique_id] = other_user_trade
|
|
trades.active_trades[open_trade.unique_id] = open_trade
|
|
trades.stats['num_trades'] = 3
|
|
|
|
settled_ids = trades.settle_broker_closed_position(
|
|
user_id=1,
|
|
symbol='BTC/USDT',
|
|
broker_key='paper',
|
|
close_price=44000.0
|
|
)
|
|
|
|
assert settled_ids == [filled_trade.unique_id]
|
|
assert filled_trade.unique_id not in trades.active_trades
|
|
assert filled_trade.unique_id in trades.settled_trades
|
|
assert trades.settled_trades[filled_trade.unique_id].status == 'closed'
|
|
assert other_user_trade.unique_id in trades.active_trades
|
|
assert open_trade.unique_id in trades.active_trades
|
|
|
|
def test_close_position_returns_closed_trade_ids(self, mock_users):
|
|
"""Close-position API flow should report which trade IDs were removed locally."""
|
|
trades = Trades(mock_users)
|
|
trades.manual_broker_manager = MagicMock()
|
|
trades.manual_broker_manager.close_position.return_value = {
|
|
'success': True,
|
|
'status': 'filled',
|
|
'filled_price': 51000.0
|
|
}
|
|
|
|
trade = Trade(
|
|
target='kucoin',
|
|
symbol='BTC/USDT',
|
|
side='BUY',
|
|
order_price=50000.0,
|
|
base_order_qty=0.1,
|
|
creator=1,
|
|
broker_kind='live',
|
|
broker_mode='production',
|
|
broker_exchange='kucoin'
|
|
)
|
|
trade.trade_filled(qty=0.1, price=50000.0)
|
|
trades.active_trades[trade.unique_id] = trade
|
|
trades.stats['num_trades'] = 1
|
|
|
|
result = trades.close_position(1, 'BTC/USDT', 'kucoin_production')
|
|
|
|
assert result['success'] is True
|
|
assert result['trades_closed'] == 1
|
|
assert result['closed_trades'] == [trade.unique_id]
|
|
assert trade.unique_id not in trades.active_trades
|
|
assert trade.unique_id in trades.settled_trades
|
|
|
|
def test_close_position_leaves_trade_active_when_close_order_is_still_open(self, mock_users):
|
|
"""Live close-position should not settle/remove the local trade until the close order fills."""
|
|
trades = Trades(mock_users)
|
|
trades.manual_broker_manager = MagicMock()
|
|
trades.manual_broker_manager.close_position.return_value = {
|
|
'success': True,
|
|
'status': 'open',
|
|
'order_id': 'close123',
|
|
'filled_price': 0.0,
|
|
'message': 'Close order submitted'
|
|
}
|
|
|
|
trade = Trade(
|
|
target='kucoin',
|
|
symbol='BTC/USDT',
|
|
side='BUY',
|
|
order_price=50000.0,
|
|
base_order_qty=0.1,
|
|
creator=1,
|
|
broker_kind='live',
|
|
broker_mode='production',
|
|
broker_exchange='kucoin'
|
|
)
|
|
trade.trade_filled(qty=0.1, price=50000.0)
|
|
trades.active_trades[trade.unique_id] = trade
|
|
trades.stats['num_trades'] = 1
|
|
|
|
result = trades.close_position(1, 'BTC/USDT', 'kucoin_production')
|
|
|
|
assert result['success'] is True
|
|
assert result['trades_closed'] == 0
|
|
assert result['closed_trades'] == []
|
|
assert trade.unique_id in trades.active_trades
|
|
assert trade.unique_id not in trades.settled_trades
|
|
|
|
def test_paper_margin_trade_requires_risk_ack(self, mock_users):
|
|
"""Paper margin trades should honor the risk-ack gate before order placement."""
|
|
trades = Trades(mock_users)
|
|
|
|
with patch('Configuration.Configuration') as mock_config_cls:
|
|
mock_config = mock_config_cls.return_value
|
|
mock_config.is_margin_risk_ack_required.return_value = True
|
|
mock_config.can_trade_margin.return_value = (False, 'risk_not_acknowledged')
|
|
|
|
status, message = trades.new_trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
price=50000.0,
|
|
side='buy',
|
|
order_type='MARKET',
|
|
qty=0.1,
|
|
user_id=1,
|
|
broker_key='paper_margin_isolated'
|
|
)
|
|
|
|
assert status == 'Error'
|
|
assert 'acknowledge margin trading risks' in message
|
|
|
|
def test_paper_margin_trade_opens_long_position(self, mock_users):
|
|
"""Paper margin trades should open a position via PaperMarginBroker."""
|
|
from manual_trading_broker import ManualTradingBrokerManager
|
|
|
|
trades = Trades(mock_users)
|
|
# Set up real broker manager
|
|
trades.manual_broker_manager = ManualTradingBrokerManager()
|
|
|
|
with patch('Configuration.Configuration') as mock_config_cls:
|
|
mock_config = mock_config_cls.return_value
|
|
mock_config.is_margin_risk_ack_required.return_value = False
|
|
mock_config.can_trade_margin.return_value = (True, None)
|
|
|
|
# Open a LONG position with 100 USDT collateral at 3x leverage
|
|
status, result = trades.new_trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
price=50000.0,
|
|
side='LONG',
|
|
order_type='MARKET',
|
|
qty=0.1, # Ignored for margin, collateral is used
|
|
user_id=1,
|
|
broker_key='paper_margin_isolated',
|
|
collateral=100.0,
|
|
leverage=3.0
|
|
)
|
|
|
|
assert status == 'Success', f"Expected Success, got {status}: {result}"
|
|
assert result is not None
|
|
|
|
# Verify position was created in margin broker
|
|
margin_broker = trades.manual_broker_manager.get_paper_margin_broker(1)
|
|
position = margin_broker.get_position('BTC/USDT')
|
|
assert position is not None
|
|
assert position.side == 'long'
|
|
assert position.collateral == 100.0
|
|
assert position.leverage == 3.0
|
|
|
|
trade = trades.get_trade_by_id(result)
|
|
assert trade is not None
|
|
assert trade.collateral == 100.0
|
|
assert trade.leverage == 3.0
|
|
assert trade.base_order_qty == pytest.approx(position.size)
|
|
assert trade.side == 'LONG'
|
|
|
|
def test_paper_margin_trade_validates_side(self, mock_users):
|
|
"""Paper margin trades require LONG or SHORT side."""
|
|
trades = Trades(mock_users)
|
|
|
|
with patch('Configuration.Configuration') as mock_config_cls:
|
|
mock_config = mock_config_cls.return_value
|
|
mock_config.is_margin_risk_ack_required.return_value = False
|
|
mock_config.can_trade_margin.return_value = (True, None)
|
|
|
|
# Try to open with BUY side (invalid for margin)
|
|
status, message = trades.new_trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
price=50000.0,
|
|
side='BUY', # Should be LONG or SHORT
|
|
order_type='MARKET',
|
|
qty=0.1,
|
|
user_id=1,
|
|
broker_key='paper_margin_isolated',
|
|
collateral=100.0,
|
|
leverage=3.0
|
|
)
|
|
|
|
assert status == 'Error'
|
|
assert 'LONG' in message or 'SHORT' in message
|
|
|
|
def test_live_margin_trade_uses_live_margin_broker(self, mock_users):
|
|
"""Live margin trades should use the live margin broker path instead of the spot live broker."""
|
|
trades = Trades(mock_users)
|
|
trades.connect_exchanges(MagicMock())
|
|
trades.exchange_connected = MagicMock(return_value=True)
|
|
trades.exchange_interface.get_exchange.return_value = MagicMock(configured=True)
|
|
trades.exchange_interface.get_price.return_value = 50000.0
|
|
trades.exchange_interface.get_trading_fees.return_value = {'taker': 0.001, 'source': 'mock'}
|
|
|
|
mock_manager = MagicMock()
|
|
mock_live_margin_broker = MagicMock()
|
|
mock_live_margin_broker.open_position.return_value = {
|
|
'success': True,
|
|
'order_id': 'live-margin-1',
|
|
'filled_amount': 0.006,
|
|
'average_price': 50000.0,
|
|
'position': {
|
|
'symbol': 'BTC/USDT',
|
|
'size': 0.006,
|
|
'entry_price': 50000.0,
|
|
'collateral': 100.0,
|
|
'leverage': 3.0
|
|
}
|
|
}
|
|
mock_manager.get_live_margin_broker.return_value = mock_live_margin_broker
|
|
mock_manager.get_live_broker.return_value = None
|
|
trades.manual_broker_manager = mock_manager
|
|
|
|
with patch('Configuration.Configuration') as mock_config_cls:
|
|
mock_config = mock_config_cls.return_value
|
|
mock_config.is_margin_risk_ack_required.return_value = False
|
|
mock_config.can_trade_margin.return_value = (True, None)
|
|
|
|
status, trade_id = trades.new_trade(
|
|
target='kucoin',
|
|
symbol='BTC/USDT',
|
|
price=50000.0,
|
|
side='LONG',
|
|
order_type='MARKET',
|
|
qty=0.0,
|
|
user_id=1,
|
|
testnet=True,
|
|
broker_key='kucoin_margin_isolated_testnet',
|
|
collateral=100.0,
|
|
leverage=3.0
|
|
)
|
|
|
|
assert status == 'Success'
|
|
mock_manager.get_live_margin_broker.assert_called_once()
|
|
mock_live_margin_broker.open_position.assert_called_once()
|
|
|
|
trade = trades.get_trade_by_id(trade_id)
|
|
assert trade is not None
|
|
assert trade.side == 'LONG'
|
|
assert trade.base_order_qty == pytest.approx(0.006)
|
|
assert trade.collateral == pytest.approx(100.0)
|
|
assert trade.leverage == pytest.approx(3.0)
|
|
assert trades._trade_broker_key(trade) == 'kucoin_margin_isolated_testnet'
|
|
|
|
def test_settle_broker_closed_position_matches_paper_margin(self, mock_users):
|
|
"""Paper margin broker closes should reconcile only margin trades for that symbol."""
|
|
trades = Trades(mock_users)
|
|
trade = Trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
side='LONG',
|
|
order_price=50000.0,
|
|
base_order_qty=0.006,
|
|
is_paper=True,
|
|
creator=1,
|
|
broker_kind='paper',
|
|
broker_mode='paper',
|
|
collateral=100.0,
|
|
leverage=3.0
|
|
)
|
|
trade.trade_filled(qty=0.006, price=50000.0)
|
|
trades.active_trades[trade.unique_id] = trade
|
|
trades.stats['num_trades'] = 1
|
|
|
|
settled = trades.settle_broker_closed_position(
|
|
user_id=1,
|
|
symbol='BTC/USDT',
|
|
broker_key='paper_margin_isolated',
|
|
close_price=51000.0
|
|
)
|
|
|
|
assert settled == [trade.unique_id]
|
|
assert trade.unique_id not in trades.active_trades
|
|
assert trade.unique_id in trades.settled_trades
|
|
|
|
def test_is_valid_trade_id(self, mock_users):
|
|
"""Test trade ID validation."""
|
|
trades = Trades(mock_users)
|
|
|
|
_, trade_id = trades.new_trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
price=50000.0,
|
|
side='buy',
|
|
order_type='MARKET',
|
|
qty=0.1
|
|
)
|
|
|
|
assert trades.is_valid_trade_id(trade_id) is True
|
|
assert trades.is_valid_trade_id('invalid_id') is False
|
|
|
|
def test_update_trades(self, mock_users):
|
|
"""Test updating trade P/L with price changes."""
|
|
trades = Trades(mock_users)
|
|
|
|
# Create and fill a trade
|
|
_, trade_id = trades.new_trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
price=50000.0,
|
|
side='buy',
|
|
order_type='MARKET',
|
|
qty=0.1
|
|
)
|
|
|
|
# Update with new price
|
|
price_updates = {'BTC/USDT': 55000.0}
|
|
updates = trades.update(price_updates)
|
|
|
|
assert len(updates) > 0
|
|
# Find our trade in updates
|
|
trade_update = next((u for u in updates if u['id'] == trade_id), None)
|
|
assert trade_update is not None
|
|
assert trade_update['pl'] != 0 # Should have some P/L
|
|
|
|
def test_buy_method_paper(self, mock_users):
|
|
"""Test buy method creates a BUY paper trade using new_trade."""
|
|
trades = Trades(mock_users)
|
|
|
|
# Use new_trade for paper trades (buy/sell methods are for live trading)
|
|
status, trade_id = trades.new_trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
price=50000.0,
|
|
side='buy',
|
|
order_type='MARKET',
|
|
qty=0.1,
|
|
user_id=1
|
|
)
|
|
|
|
assert status == 'Success'
|
|
trade = trades.get_trade_by_id(trade_id)
|
|
assert trade.side == 'BUY'
|
|
|
|
def test_sell_method_paper(self, mock_users):
|
|
"""Test sell method creates a SELL paper trade using new_trade."""
|
|
trades = Trades(mock_users)
|
|
|
|
# Use new_trade for paper trades (buy/sell methods are for live trading)
|
|
status, trade_id = trades.new_trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
price=50000.0,
|
|
side='sell',
|
|
order_type='MARKET',
|
|
qty=0.1,
|
|
user_id=1
|
|
)
|
|
|
|
assert status == 'Success'
|
|
trade = trades.get_trade_by_id(trade_id)
|
|
assert trade.side == 'SELL'
|
|
|
|
|
|
class TestTradeIntegration:
|
|
"""Integration tests for Trade system."""
|
|
|
|
@pytest.fixture
|
|
def mock_users(self):
|
|
users = MagicMock()
|
|
users.get_username.return_value = 'test_user'
|
|
return users
|
|
|
|
def test_full_trade_lifecycle(self, mock_users):
|
|
"""Test complete lifecycle: create -> update -> close."""
|
|
trades = Trades(mock_users)
|
|
|
|
# Create trade
|
|
status, trade_id = trades.new_trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
price=50000.0,
|
|
side='buy',
|
|
order_type='MARKET',
|
|
qty=0.1,
|
|
user_id=1
|
|
)
|
|
assert status == 'Success'
|
|
|
|
trade = trades.get_trade_by_id(trade_id)
|
|
# Set a more realistic fee (0.1% instead of default 10%)
|
|
trade.fee = 0.001
|
|
trade.stats['fee_paid'] = trade.stats['opening_value'] * trade.fee
|
|
|
|
# Update with higher price (20% increase should exceed fees)
|
|
trades.update({'BTC/USDT': 60000.0})
|
|
|
|
trade = trades.get_trade_by_id(trade_id)
|
|
assert trade.stats['profit'] > 0 # Should be in profit
|
|
|
|
# Close trade
|
|
result = trades.close_trade(trade_id, current_price=60000.0)
|
|
assert result['success'] is True
|
|
assert result['final_pl'] > 0
|
|
|
|
def test_multiple_trades(self, mock_users):
|
|
"""Test managing multiple trades."""
|
|
trades = Trades(mock_users)
|
|
|
|
# Create multiple trades
|
|
trade_ids = []
|
|
for i in range(3):
|
|
_, trade_id = trades.new_trade(
|
|
target='test_exchange',
|
|
symbol='BTC/USDT',
|
|
price=50000.0 + (i * 100),
|
|
side='buy',
|
|
order_type='MARKET',
|
|
qty=0.1
|
|
)
|
|
trade_ids.append(trade_id)
|
|
|
|
assert len(trades.active_trades) == 3
|
|
|
|
# Close one trade
|
|
trades.close_trade(trade_ids[1])
|
|
|
|
assert len(trades.active_trades) == 2
|
|
assert trade_ids[1] not in trades.active_trades
|