180 lines
6.4 KiB
Python
180 lines
6.4 KiB
Python
|
|
"""Tests for SmartExecAlgorithm enhancements."""
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from unittest.mock import Mock, MagicMock
|
||
|
|
from nautilus_trader.model.enums import OrderSide
|
||
|
|
from nautilus_trader.model.objects import Price
|
||
|
|
|
||
|
|
from nautilus_dolphin.nautilus.smart_exec_algorithm import SmartExecAlgorithmForTesting
|
||
|
|
|
||
|
|
|
||
|
|
class TestSmartExecAlgorithmAbortLogic:
|
||
|
|
"""Tests for the abort logic when price moves against position."""
|
||
|
|
|
||
|
|
def test_should_abort_entry_buy_price_rises(self):
|
||
|
|
"""Test BUY entry aborted when ask rises 5bps above limit."""
|
||
|
|
algo = SmartExecAlgorithmForTesting({'entry_abort_threshold_bps': 5.0})
|
||
|
|
|
||
|
|
# Create mock order (BUY at 50000)
|
||
|
|
order = Mock()
|
||
|
|
order.side = OrderSide.BUY
|
||
|
|
|
||
|
|
# Create mock tick (ask moved up 1% - well above 5bps threshold)
|
||
|
|
tick = Mock()
|
||
|
|
tick.ask_price = Price(50500, 2)
|
||
|
|
tick.bid_price = Price(50499, 2)
|
||
|
|
|
||
|
|
metadata = {'limit_price': 50000.0}
|
||
|
|
|
||
|
|
# Should abort (price moved more than 5bps against)
|
||
|
|
assert algo._should_abort_entry(order, tick, metadata) is True
|
||
|
|
|
||
|
|
def test_should_not_abort_entry_buy_price_stable(self):
|
||
|
|
"""Test BUY entry not aborted when price hasn't moved much."""
|
||
|
|
algo = SmartExecAlgorithmForTesting({'entry_abort_threshold_bps': 5.0})
|
||
|
|
|
||
|
|
order = Mock()
|
||
|
|
order.side = OrderSide.BUY
|
||
|
|
|
||
|
|
# Ask only 2bps higher (below 5bps threshold)
|
||
|
|
tick = Mock()
|
||
|
|
tick.ask_price = Price(50010, 2)
|
||
|
|
tick.bid_price = Price(50009, 2)
|
||
|
|
|
||
|
|
metadata = {'limit_price': 50000.0}
|
||
|
|
|
||
|
|
assert algo._should_abort_entry(order, tick, metadata) is False
|
||
|
|
|
||
|
|
def test_should_abort_entry_sell_price_drops(self):
|
||
|
|
"""Test SELL entry aborted when bid drops 5bps below limit."""
|
||
|
|
algo = SmartExecAlgorithmForTesting({'entry_abort_threshold_bps': 5.0})
|
||
|
|
|
||
|
|
order = Mock()
|
||
|
|
order.side = OrderSide.SELL
|
||
|
|
|
||
|
|
# Bid moved down 1% - well above 5bps threshold
|
||
|
|
tick = Mock()
|
||
|
|
tick.ask_price = Price(49501, 2)
|
||
|
|
tick.bid_price = Price(49500, 2)
|
||
|
|
|
||
|
|
metadata = {'limit_price': 50000.0}
|
||
|
|
|
||
|
|
assert algo._should_abort_entry(order, tick, metadata) is True
|
||
|
|
|
||
|
|
def test_abort_entry_cancels_order(self):
|
||
|
|
"""Test abort_entry cancels the order and updates metrics."""
|
||
|
|
algo = SmartExecAlgorithmForTesting({'entry_abort_threshold_bps': 5.0})
|
||
|
|
|
||
|
|
# Setup pending entry
|
||
|
|
order = Mock()
|
||
|
|
order.is_closed = False
|
||
|
|
algo._pending_entries = {'order1': {'limit_price': 50000.0}}
|
||
|
|
|
||
|
|
algo._abort_entry('order1', {'limit_price': 50000.0})
|
||
|
|
|
||
|
|
assert algo.cancel_order.called
|
||
|
|
assert algo._metrics['entries_aborted'] == 1
|
||
|
|
assert 'order1' not in algo._pending_entries
|
||
|
|
|
||
|
|
|
||
|
|
class TestSmartExecAlgorithmFeeTracking:
|
||
|
|
"""Tests for fee and slippage tracking."""
|
||
|
|
|
||
|
|
def test_fee_calculation_maker(self):
|
||
|
|
"""Test maker fee calculation (0.02%)."""
|
||
|
|
algo = SmartExecAlgorithmForTesting({
|
||
|
|
'maker_fee_rate': 0.0002,
|
||
|
|
'taker_fee_rate': 0.0005,
|
||
|
|
})
|
||
|
|
|
||
|
|
# $50000 notional
|
||
|
|
fee = algo._calculate_fee(50000.0, 'maker')
|
||
|
|
assert fee == 10.0 # 50000 * 0.0002
|
||
|
|
|
||
|
|
def test_fee_calculation_taker(self):
|
||
|
|
"""Test taker fee calculation (0.05%)."""
|
||
|
|
algo = SmartExecAlgorithmForTesting({
|
||
|
|
'maker_fee_rate': 0.0002,
|
||
|
|
'taker_fee_rate': 0.0005,
|
||
|
|
})
|
||
|
|
|
||
|
|
fee = algo._calculate_fee(50000.0, 'taker')
|
||
|
|
assert fee == 25.0 # 50000 * 0.0005
|
||
|
|
|
||
|
|
def test_slippage_calculation(self):
|
||
|
|
"""Test slippage calculation in basis points."""
|
||
|
|
algo = SmartExecAlgorithmForTesting({})
|
||
|
|
|
||
|
|
# BUY: expected 50000, got 50050 (10bps worse)
|
||
|
|
slippage = algo._calculate_slippage_bps(50000.0, 50050.0, OrderSide.BUY)
|
||
|
|
assert slippage == 10.0
|
||
|
|
|
||
|
|
# SELL: expected 50000, got 49950 (10bps worse)
|
||
|
|
slippage = algo._calculate_slippage_bps(50000.0, 49950.0, OrderSide.SELL)
|
||
|
|
assert slippage == 10.0
|
||
|
|
|
||
|
|
def test_get_metrics_summary(self):
|
||
|
|
"""Test metrics summary generation."""
|
||
|
|
algo = SmartExecAlgorithmForTesting({})
|
||
|
|
|
||
|
|
# Add some metrics
|
||
|
|
algo._metrics['entries_maker'] = 8
|
||
|
|
algo._metrics['entries_taker'] = 2
|
||
|
|
algo._metrics['total_fees'] = 100.0
|
||
|
|
algo._metrics['fill_count'] = 10
|
||
|
|
|
||
|
|
summary = algo.get_metrics_summary()
|
||
|
|
|
||
|
|
assert summary['entries']['total'] == 10
|
||
|
|
assert summary['entries']['maker_pct'] == 80.0
|
||
|
|
assert summary['fees']['total'] == 100.0
|
||
|
|
|
||
|
|
def test_reset_metrics(self):
|
||
|
|
"""Test metrics reset functionality."""
|
||
|
|
algo = SmartExecAlgorithmForTesting({})
|
||
|
|
|
||
|
|
# Add some metrics
|
||
|
|
algo._metrics['entries_maker'] = 5
|
||
|
|
algo._metrics['total_fees'] = 50.0
|
||
|
|
|
||
|
|
algo.reset_metrics()
|
||
|
|
|
||
|
|
assert algo._metrics['entries_maker'] == 0
|
||
|
|
assert algo._metrics['total_fees'] == 0.0
|
||
|
|
|
||
|
|
|
||
|
|
class TestSmartExecAlgorithmIntegration:
|
||
|
|
"""Integration tests for fee tracking with fills."""
|
||
|
|
|
||
|
|
def test_entry_fill_tracked_correctly(self):
|
||
|
|
"""Test that entry fills are tracked with correct fee calculation."""
|
||
|
|
algo = SmartExecAlgorithmForTesting({
|
||
|
|
'maker_fee_rate': 0.0002,
|
||
|
|
})
|
||
|
|
|
||
|
|
order = Mock()
|
||
|
|
order.side = OrderSide.BUY
|
||
|
|
order.tags = {'type': 'entry', 'expected_price': 50000.0}
|
||
|
|
|
||
|
|
# Fill at expected price (no slippage)
|
||
|
|
algo._record_fill(order, 50000.0, 1.0, 'maker')
|
||
|
|
|
||
|
|
assert algo._metrics['entries_maker'] == 1
|
||
|
|
assert algo._metrics['total_fees'] == 10.0 # 50000 * 0.0002
|
||
|
|
assert algo._metrics['fill_count'] == 1
|
||
|
|
|
||
|
|
def test_exit_fill_tracked_correctly(self):
|
||
|
|
"""Test that exit fills are tracked separately."""
|
||
|
|
algo = SmartExecAlgorithmForTesting({
|
||
|
|
'maker_fee_rate': 0.0002,
|
||
|
|
})
|
||
|
|
|
||
|
|
order = Mock()
|
||
|
|
order.side = OrderSide.SELL
|
||
|
|
order.tags = {'type': 'exit'}
|
||
|
|
|
||
|
|
algo._record_fill(order, 51000.0, 1.0, 'maker')
|
||
|
|
|
||
|
|
assert algo._metrics['exits_maker'] == 1
|
||
|
|
assert algo._metrics['entries_maker'] == 0 # Entry not counted
|