675 lines
25 KiB
Python
675 lines
25 KiB
Python
"""
|
|
Tests for LiveStrategyInstance.
|
|
|
|
These tests verify circuit breaker, position limits, and live trading
|
|
integration using mocked exchange and broker instances.
|
|
"""
|
|
import pytest
|
|
from unittest.mock import Mock, MagicMock, patch
|
|
from datetime import datetime, timezone
|
|
|
|
import sys
|
|
import os
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
|
|
|
from brokers.base_broker import OrderSide, OrderType, OrderStatus, OrderResult, Position
|
|
from brokers.live_margin_broker import LiveMarginPosition, SyncState
|
|
from live_strategy_instance import LiveStrategyInstance
|
|
|
|
|
|
class MockLiveBroker:
|
|
"""Mock LiveBroker for testing LiveStrategyInstance."""
|
|
|
|
def __init__(self, initial_balance=10000):
|
|
self._connected = False
|
|
self._balances = {'USDT': initial_balance}
|
|
self._locked_balances = {}
|
|
self._positions = {}
|
|
self._orders = {}
|
|
self._current_prices = {'BTC/USDT': 50000}
|
|
self._testnet = True
|
|
self.commission = 0.001
|
|
|
|
def connect(self):
|
|
self._connected = True
|
|
return True
|
|
|
|
def disconnect(self):
|
|
self._connected = False
|
|
|
|
def sync_balance(self):
|
|
return self._balances
|
|
|
|
def get_balance(self, asset=None):
|
|
if asset:
|
|
return self._balances.get(asset, 0)
|
|
return self._balances.get('USDT', 0)
|
|
|
|
def get_available_balance(self, asset=None):
|
|
total = self.get_balance(asset)
|
|
locked = self._locked_balances.get(asset or 'USDT', 0)
|
|
return total - locked
|
|
|
|
def get_current_price(self, symbol):
|
|
return self._current_prices.get(symbol, 0)
|
|
|
|
def get_position(self, symbol):
|
|
return self._positions.get(symbol)
|
|
|
|
def get_all_positions(self):
|
|
return list(self._positions.values())
|
|
|
|
def get_open_orders(self, symbol=None):
|
|
return []
|
|
|
|
def place_order(self, symbol, side, order_type, size, price=None, **kwargs):
|
|
return OrderResult(
|
|
success=True,
|
|
order_id='TEST123',
|
|
status=OrderStatus.FILLED,
|
|
filled_qty=size,
|
|
filled_price=price or self._current_prices.get(symbol, 50000),
|
|
commission=size * (price or 50000) * self.commission
|
|
)
|
|
|
|
def update(self):
|
|
return []
|
|
|
|
def save_state(self, strategy_instance_id):
|
|
return True
|
|
|
|
def load_state(self, strategy_instance_id):
|
|
return False
|
|
|
|
def reconcile_with_exchange(self):
|
|
return {'success': True}
|
|
|
|
|
|
class TestLiveStrategyInstanceCircuitBreaker:
|
|
"""Tests for circuit breaker functionality."""
|
|
|
|
def _create_mock_instance(self, starting_balance=10000, current_balance=10000,
|
|
circuit_breaker_pct=-0.10):
|
|
"""Create a mock instance with circuit breaker."""
|
|
# We'll test the circuit breaker logic directly
|
|
class MockInstance:
|
|
def __init__(self):
|
|
self.starting_balance = starting_balance
|
|
self.current_balance = current_balance
|
|
self._circuit_breaker_pct = circuit_breaker_pct
|
|
self._circuit_breaker_tripped = False
|
|
self._circuit_breaker_reason = ""
|
|
|
|
def _check_circuit_breaker(self):
|
|
if self._circuit_breaker_tripped:
|
|
return True
|
|
|
|
if self.starting_balance <= 0:
|
|
return False
|
|
|
|
drawdown_pct = (self.current_balance - self.starting_balance) / self.starting_balance
|
|
|
|
if drawdown_pct < self._circuit_breaker_pct:
|
|
self._circuit_breaker_tripped = True
|
|
self._circuit_breaker_reason = (
|
|
f"Drawdown {drawdown_pct:.2%} exceeded threshold {self._circuit_breaker_pct:.2%}"
|
|
)
|
|
return True
|
|
|
|
return False
|
|
|
|
return MockInstance()
|
|
|
|
def test_circuit_breaker_not_tripped_within_threshold(self):
|
|
"""Test that circuit breaker doesn't trip within threshold."""
|
|
# 5% loss is within -10% threshold
|
|
instance = self._create_mock_instance(
|
|
starting_balance=10000,
|
|
current_balance=9500
|
|
)
|
|
|
|
result = instance._check_circuit_breaker()
|
|
|
|
assert result is False
|
|
assert instance._circuit_breaker_tripped is False
|
|
|
|
def test_circuit_breaker_trips_on_excessive_drawdown(self):
|
|
"""Test that circuit breaker trips on excessive drawdown."""
|
|
# 15% loss exceeds -10% threshold
|
|
instance = self._create_mock_instance(
|
|
starting_balance=10000,
|
|
current_balance=8500
|
|
)
|
|
|
|
result = instance._check_circuit_breaker()
|
|
|
|
assert result is True
|
|
assert instance._circuit_breaker_tripped is True
|
|
assert "-15" in instance._circuit_breaker_reason or "15" in instance._circuit_breaker_reason
|
|
|
|
def test_circuit_breaker_stays_tripped(self):
|
|
"""Test that circuit breaker stays tripped once triggered."""
|
|
instance = self._create_mock_instance(
|
|
starting_balance=10000,
|
|
current_balance=8500
|
|
)
|
|
|
|
# Trip the breaker
|
|
instance._check_circuit_breaker()
|
|
|
|
# Even if balance recovers, breaker stays tripped
|
|
instance.current_balance = 11000
|
|
result = instance._check_circuit_breaker()
|
|
|
|
assert result is True # Still tripped
|
|
|
|
def test_circuit_breaker_custom_threshold(self):
|
|
"""Test circuit breaker with custom threshold."""
|
|
# 3% loss with -5% threshold should not trip
|
|
instance = self._create_mock_instance(
|
|
starting_balance=10000,
|
|
current_balance=9700,
|
|
circuit_breaker_pct=-0.05
|
|
)
|
|
|
|
result = instance._check_circuit_breaker()
|
|
assert result is False
|
|
|
|
# 6% loss should trip with -5% threshold
|
|
instance.current_balance = 9400
|
|
result = instance._check_circuit_breaker()
|
|
assert result is True
|
|
|
|
|
|
class TestLiveStrategyInstancePositionLimits:
|
|
"""Tests for position limit functionality."""
|
|
|
|
def _create_mock_instance(self, starting_balance=10000, max_position_pct=0.5):
|
|
"""Create a mock instance with position limits."""
|
|
class MockInstance:
|
|
def __init__(self):
|
|
self.starting_balance = starting_balance
|
|
self._max_position_pct = max_position_pct
|
|
self.live_broker = Mock()
|
|
self.live_broker.get_available_balance.return_value = starting_balance
|
|
|
|
def _check_position_limit(self, size, price, symbol):
|
|
if self.starting_balance <= 0:
|
|
return True
|
|
|
|
order_value = size * price
|
|
max_order_value = self.starting_balance * self._max_position_pct
|
|
|
|
if order_value > max_order_value:
|
|
return False
|
|
|
|
available = self.live_broker.get_available_balance()
|
|
if order_value > available:
|
|
return False
|
|
|
|
return True
|
|
|
|
return MockInstance()
|
|
|
|
def test_position_limit_allows_valid_order(self):
|
|
"""Test that orders within limit are allowed."""
|
|
instance = self._create_mock_instance(
|
|
starting_balance=10000,
|
|
max_position_pct=0.5
|
|
)
|
|
|
|
# Order value = 0.05 * 50000 = 2500, which is < 5000 (50% of 10000)
|
|
result = instance._check_position_limit(0.05, 50000, 'BTC/USDT')
|
|
|
|
assert result is True
|
|
|
|
def test_position_limit_rejects_oversized_order(self):
|
|
"""Test that oversized orders are rejected."""
|
|
instance = self._create_mock_instance(
|
|
starting_balance=10000,
|
|
max_position_pct=0.5
|
|
)
|
|
|
|
# Order value = 0.15 * 50000 = 7500, which is > 5000 (50% of 10000)
|
|
result = instance._check_position_limit(0.15, 50000, 'BTC/USDT')
|
|
|
|
assert result is False
|
|
|
|
def test_position_limit_rejects_insufficient_balance(self):
|
|
"""Test that orders exceeding available balance are rejected."""
|
|
instance = self._create_mock_instance(
|
|
starting_balance=10000,
|
|
max_position_pct=0.5
|
|
)
|
|
|
|
# Available balance is less than order value
|
|
instance.live_broker.get_available_balance.return_value = 2000
|
|
|
|
# Order value = 0.05 * 50000 = 2500, but only 2000 available
|
|
result = instance._check_position_limit(0.05, 50000, 'BTC/USDT')
|
|
|
|
assert result is False
|
|
|
|
|
|
class TestLiveStrategyInstanceTick:
|
|
"""Tests for tick processing."""
|
|
|
|
def test_tick_returns_circuit_breaker_event(self):
|
|
"""Test that tick returns circuit breaker event when tripped."""
|
|
# Simulating the tick behavior
|
|
class MockInstance:
|
|
def __init__(self):
|
|
self.strategy_id = 'test-strategy'
|
|
self._circuit_breaker_tripped = True
|
|
self._circuit_breaker_reason = "Test reason"
|
|
|
|
def tick(self, candle_data=None):
|
|
if self._circuit_breaker_tripped:
|
|
return [{
|
|
'type': 'circuit_breaker',
|
|
'strategy_id': self.strategy_id,
|
|
'reason': self._circuit_breaker_reason
|
|
}]
|
|
return []
|
|
|
|
instance = MockInstance()
|
|
events = instance.tick()
|
|
|
|
assert len(events) == 1
|
|
assert events[0]['type'] == 'circuit_breaker'
|
|
assert events[0]['strategy_id'] == 'test-strategy'
|
|
|
|
def test_tick_processes_broker_fill_events(self):
|
|
"""Test that tick processes fill events from broker."""
|
|
fill_event = {
|
|
'type': 'fill',
|
|
'order_id': 'TEST123',
|
|
'symbol': 'BTC/USDT',
|
|
'side': 'buy',
|
|
'filled_qty': 0.1,
|
|
'filled_price': 50000
|
|
}
|
|
|
|
broker = Mock()
|
|
broker.update.return_value = [fill_event]
|
|
|
|
# The tick method should capture fill events
|
|
assert fill_event['type'] == 'fill'
|
|
|
|
|
|
class TestLiveStrategyInstanceSafetyFeatures:
|
|
"""Tests for overall safety features."""
|
|
|
|
def test_testnet_mode_default(self):
|
|
"""Test that testnet mode is default."""
|
|
broker = MockLiveBroker()
|
|
assert broker._testnet is True
|
|
|
|
def test_circuit_breaker_status_property(self):
|
|
"""Test circuit breaker status property."""
|
|
class MockInstance:
|
|
def __init__(self):
|
|
self._circuit_breaker_tripped = False
|
|
self._circuit_breaker_reason = ""
|
|
self._circuit_breaker_pct = -0.10
|
|
self.starting_balance = 10000
|
|
self.current_balance = 9500
|
|
|
|
@property
|
|
def circuit_breaker_status(self):
|
|
return {
|
|
'tripped': self._circuit_breaker_tripped,
|
|
'reason': self._circuit_breaker_reason,
|
|
'threshold': self._circuit_breaker_pct,
|
|
'current_drawdown': (
|
|
(self.current_balance - self.starting_balance) / self.starting_balance
|
|
if self.starting_balance > 0 else 0
|
|
)
|
|
}
|
|
|
|
instance = MockInstance()
|
|
status = instance.circuit_breaker_status
|
|
|
|
assert status['tripped'] is False
|
|
assert status['threshold'] == -0.10
|
|
assert status['current_drawdown'] == pytest.approx(-0.05)
|
|
|
|
def test_reset_circuit_breaker(self):
|
|
"""Test that circuit breaker can be manually reset."""
|
|
class MockInstance:
|
|
def __init__(self):
|
|
self._circuit_breaker_tripped = True
|
|
self._circuit_breaker_reason = "Previous issue"
|
|
|
|
def reset_circuit_breaker(self):
|
|
self._circuit_breaker_tripped = False
|
|
self._circuit_breaker_reason = ""
|
|
|
|
instance = MockInstance()
|
|
assert instance._circuit_breaker_tripped is True
|
|
|
|
instance.reset_circuit_breaker()
|
|
|
|
assert instance._circuit_breaker_tripped is False
|
|
assert instance._circuit_breaker_reason == ""
|
|
|
|
|
|
class TestLiveStrategyInstanceIntegration:
|
|
"""Integration tests for LiveStrategyInstance behavior."""
|
|
|
|
def test_order_rejected_when_circuit_breaker_tripped(self):
|
|
"""Test that orders are rejected when circuit breaker is tripped."""
|
|
class MockInstance:
|
|
def __init__(self):
|
|
self._circuit_breaker_tripped = True
|
|
self._max_position_pct = 0.5
|
|
self.starting_balance = 10000
|
|
self.live_broker = MockLiveBroker()
|
|
|
|
def _check_circuit_breaker(self):
|
|
return self._circuit_breaker_tripped
|
|
|
|
def trade_order(self, trade_type, size, order_type, **kwargs):
|
|
if self._check_circuit_breaker():
|
|
return None
|
|
return self.live_broker.place_order(
|
|
symbol='BTC/USDT',
|
|
side=OrderSide.BUY if trade_type.lower() == 'buy' else OrderSide.SELL,
|
|
order_type=OrderType.MARKET,
|
|
size=size
|
|
)
|
|
|
|
instance = MockInstance()
|
|
result = instance.trade_order('buy', 0.1, 'MARKET')
|
|
|
|
assert result is None
|
|
|
|
def test_order_succeeds_when_safety_checks_pass(self):
|
|
"""Test that orders succeed when all safety checks pass."""
|
|
class MockInstance:
|
|
def __init__(self):
|
|
self._circuit_breaker_tripped = False
|
|
self._max_position_pct = 0.5
|
|
self.starting_balance = 10000
|
|
self.live_broker = MockLiveBroker()
|
|
|
|
def _check_circuit_breaker(self):
|
|
return self._circuit_breaker_tripped
|
|
|
|
def _check_position_limit(self, size, price, symbol):
|
|
return True
|
|
|
|
def get_current_price(self, **kwargs):
|
|
return 50000
|
|
|
|
def trade_order(self, trade_type, size, order_type, **kwargs):
|
|
if self._check_circuit_breaker():
|
|
return None
|
|
|
|
price = self.get_current_price()
|
|
if not self._check_position_limit(size, price, 'BTC/USDT'):
|
|
return None
|
|
|
|
return self.live_broker.place_order(
|
|
symbol='BTC/USDT',
|
|
side=OrderSide.BUY if trade_type.lower() == 'buy' else OrderSide.SELL,
|
|
order_type=OrderType.MARKET,
|
|
size=size
|
|
)
|
|
|
|
instance = MockInstance()
|
|
result = instance.trade_order('buy', 0.05, 'MARKET')
|
|
|
|
assert result is not None
|
|
assert result.success is True
|
|
|
|
|
|
class TestLiveStrategyInstanceState:
|
|
"""Tests for state management."""
|
|
|
|
def test_save_context_includes_broker_state(self):
|
|
"""Test that save_context saves broker state."""
|
|
broker = Mock()
|
|
broker.save_state = Mock(return_value=True)
|
|
|
|
# Simulate save_context behavior
|
|
broker.save_state('test-strategy-123')
|
|
|
|
broker.save_state.assert_called_once_with('test-strategy-123')
|
|
|
|
def test_testnet_property(self):
|
|
"""Test is_testnet property."""
|
|
class MockInstance:
|
|
def __init__(self, testnet=True):
|
|
self._testnet = testnet
|
|
|
|
@property
|
|
def is_testnet(self):
|
|
return self._testnet
|
|
|
|
testnet_instance = MockInstance(testnet=True)
|
|
assert testnet_instance.is_testnet is True
|
|
|
|
production_instance = MockInstance(testnet=False)
|
|
assert production_instance.is_testnet is False
|
|
|
|
|
|
class TestLiveStrategyInstanceMarginAccounting:
|
|
"""Tests for live margin-aware balance reporting and SL/TP filtering."""
|
|
|
|
@staticmethod
|
|
def _make_margin_position(unrealized_pnl=50.0):
|
|
return 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=200.0,
|
|
leverage=3.0,
|
|
current_price=50500.0,
|
|
unrealized_pnl=unrealized_pnl,
|
|
liquidation_price=45000.0,
|
|
margin_ratio=30.0,
|
|
sync_state=SyncState.HEALTHY,
|
|
)
|
|
|
|
def test_update_balances_includes_owned_margin_equity(self):
|
|
"""Current balance/equity should include this strategy's margin positions."""
|
|
instance = LiveStrategyInstance.__new__(LiveStrategyInstance)
|
|
instance.strategy_id = 'strategy-a'
|
|
instance.exec_context = {}
|
|
instance.live_broker = Mock()
|
|
instance.live_broker.get_balance.return_value = 1000.0
|
|
instance.live_broker.get_available_balance.return_value = 800.0
|
|
instance.live_broker.get_total_equity.return_value = 1100.0
|
|
instance.live_margin_broker = Mock()
|
|
instance.live_margin_broker.get_owned_symbols.return_value = ['BTC/USDT']
|
|
instance.live_margin_broker.get_position.return_value = self._make_margin_position()
|
|
|
|
LiveStrategyInstance._update_balances(instance)
|
|
|
|
assert instance.current_balance == pytest.approx(1250.0)
|
|
assert instance.available_balance == pytest.approx(800.0)
|
|
assert instance.current_equity == pytest.approx(1350.0)
|
|
assert instance.exec_context['current_balance'] == pytest.approx(1250.0)
|
|
assert instance.exec_context['current_equity'] == pytest.approx(1350.0)
|
|
|
|
def test_get_current_balance_includes_owned_margin_equity(self):
|
|
"""Public balance getter should match the unified live balance model."""
|
|
instance = LiveStrategyInstance.__new__(LiveStrategyInstance)
|
|
instance.strategy_id = 'strategy-a'
|
|
instance.exec_context = {}
|
|
instance.live_broker = Mock()
|
|
instance.live_broker.get_balance.return_value = 900.0
|
|
instance.live_margin_broker = Mock()
|
|
instance.live_margin_broker.get_owned_symbols.return_value = ['BTC/USDT']
|
|
instance.live_margin_broker.get_position.return_value = self._make_margin_position(unrealized_pnl=25.0)
|
|
|
|
balance = LiveStrategyInstance.get_current_balance(instance)
|
|
|
|
assert balance == pytest.approx(1125.0)
|
|
assert instance.exec_context['current_balance'] == pytest.approx(1125.0)
|
|
|
|
def test_tick_ignores_sltp_triggers_owned_by_other_strategy(self):
|
|
"""A strategy instance must not close another strategy's SL/TP trigger."""
|
|
instance = LiveStrategyInstance.__new__(LiveStrategyInstance)
|
|
instance.strategy_id = 'strategy-a'
|
|
instance.strategy_instance_id = 'inst-1'
|
|
instance.paused = False
|
|
instance.exit = False
|
|
instance.exec_context = {}
|
|
instance.trade_history = []
|
|
instance.orders = []
|
|
instance._testnet = True
|
|
instance._circuit_breaker_reason = ''
|
|
instance._check_circuit_breaker = Mock(return_value=False)
|
|
instance.execute = Mock(return_value={'success': True})
|
|
instance._update_balances = Mock()
|
|
instance.notify_user = Mock()
|
|
instance.live_broker = Mock()
|
|
instance.live_broker.update.return_value = []
|
|
instance.live_broker.get_all_positions.return_value = []
|
|
instance.live_broker.get_open_orders.return_value = []
|
|
instance.live_margin_broker = Mock()
|
|
instance.live_margin_broker.sync_positions.return_value = None
|
|
instance.live_margin_broker.check_sltp_triggers.return_value = [{
|
|
'type': 'sltp_trigger',
|
|
'symbol': 'BTC/USDT',
|
|
'trigger': 'stop_loss',
|
|
'trigger_price': 40000.0,
|
|
'current_price': 39900.0,
|
|
'strategy_id': 'strategy-b',
|
|
'unrealized_pnl': -20.0,
|
|
}]
|
|
|
|
events = LiveStrategyInstance.tick(instance, {'symbol': 'BTC/USDT', 'close': 39900.0})
|
|
|
|
instance.live_margin_broker.close_position.assert_not_called()
|
|
assert not any(event.get('type') == 'sltp_triggered' for event in events)
|
|
|
|
|
|
class TestLiveStrategyInstanceMarginSharedOptions:
|
|
"""Tests for shared trade-option support on live margin entries."""
|
|
|
|
@staticmethod
|
|
def _build_instance():
|
|
instance = LiveStrategyInstance.__new__(LiveStrategyInstance)
|
|
instance.strategy_id = 'strategy-a'
|
|
instance.strategy_instance_id = 'inst-1'
|
|
instance.exec_context = {'current_symbol': 'BTC/USDT'}
|
|
instance.variables = {}
|
|
instance.notify_user = Mock()
|
|
instance.set_paused = Mock()
|
|
instance._check_circuit_breaker = Mock(return_value=False)
|
|
instance.get_margin_leverage = Mock(return_value=3)
|
|
instance.live_broker = Mock()
|
|
instance.live_broker.get_current_price.return_value = 50000.0
|
|
instance.live_margin_broker = Mock()
|
|
instance.live_margin_broker.can_trade.return_value = True
|
|
instance.live_margin_broker.register_strategy_ownership.return_value = {'success': True}
|
|
instance.live_margin_broker.sync_positions.return_value = None
|
|
instance.live_margin_broker.get_position.return_value = None
|
|
instance.live_margin_broker.release_strategy_ownership = Mock()
|
|
instance.live_margin_broker.clear_sltp = Mock()
|
|
instance.live_margin_broker.get_symbol_owner.return_value = None
|
|
return instance
|
|
|
|
def test_open_margin_position_passes_shared_limit_options_to_live_broker(self):
|
|
"""Live margin open should forward shared limit/target/name options correctly."""
|
|
instance = self._build_instance()
|
|
instance.live_margin_broker.open_position.return_value = {'success': True, 'order_id': 'open-1'}
|
|
instance._finalize_live_margin_entry = Mock(return_value={
|
|
'entry_price': 3050.0,
|
|
'size': 0.2,
|
|
'collateral': 200.0,
|
|
'leverage': 3.0,
|
|
'liquidation_price': 2500.0,
|
|
})
|
|
|
|
result = LiveStrategyInstance.open_margin_position(
|
|
instance,
|
|
side='long',
|
|
collateral=200.0,
|
|
limit={'limit': 3050.0},
|
|
tif='ioc',
|
|
stop_loss={'value': 2900.0},
|
|
take_profit={'value': 3400.0},
|
|
target_market={'exchange': 'binance', 'symbol': 'ETH/USDT', 'time_frame': '15m'},
|
|
name_order={'order_name': 'ETH breakout'},
|
|
)
|
|
|
|
instance.live_margin_broker.open_position.assert_called_once_with(
|
|
symbol='ETH/USDT',
|
|
side='long',
|
|
collateral=200.0,
|
|
leverage=3,
|
|
order_type='limit',
|
|
limit_price=3050.0,
|
|
time_in_force='IOC',
|
|
)
|
|
instance._finalize_live_margin_entry.assert_called_once_with(
|
|
symbol='ETH/USDT',
|
|
side='long',
|
|
stop_loss=2900.0,
|
|
take_profit=3400.0,
|
|
trailing_stop=None,
|
|
order_name='ETH breakout',
|
|
)
|
|
assert result['success'] is True
|
|
assert result['symbol'] == 'ETH/USDT'
|
|
assert result['order_name'] == 'ETH breakout'
|
|
|
|
def test_open_margin_position_trailing_limit_persists_pending_entry(self):
|
|
"""Trailing-limit live entries should persist shared option state until triggered."""
|
|
instance = self._build_instance()
|
|
|
|
result = LiveStrategyInstance.open_margin_position(
|
|
instance,
|
|
side='long',
|
|
collateral=150.0,
|
|
leverage=4.0,
|
|
tif='gtc',
|
|
trailing_stop={'trail_distance': 50.0},
|
|
trailing_limit={'trail_limit_distance': 100.0},
|
|
target_market={'symbol': 'BTC/USDT'},
|
|
name_order={'order_name': 'Trail Entry'},
|
|
)
|
|
|
|
pending_entry = instance.variables['_pending_margin_entries']['BTC/USDT']
|
|
|
|
instance.live_margin_broker.open_position.assert_not_called()
|
|
assert result['success'] is True
|
|
assert result['pending'] is True
|
|
assert result['pending_type'] == 'trailing_limit'
|
|
assert pending_entry['entry_type'] == 'trailing_limit'
|
|
assert pending_entry['tif'] == 'GTC'
|
|
assert pending_entry['trailing_limit_distance'] == pytest.approx(100.0)
|
|
assert pending_entry['trailing_stop'] == pytest.approx(50.0)
|
|
assert pending_entry['order_name'] == 'Trail Entry'
|
|
|
|
def test_close_margin_position_full_close_clears_margin_runtime_state(self):
|
|
"""Manual full closes should clear trailing-stop and pending-entry state."""
|
|
instance = self._build_instance()
|
|
instance.variables = {
|
|
'_pending_margin_entries': {'BTC/USDT': {'status': 'exchange_open'}},
|
|
'_margin_trailing_stops': {'BTC/USDT': {'trail_distance': 100.0, 'best_price': 50500.0}},
|
|
}
|
|
instance.live_margin_broker.get_position.return_value = Mock(size=0.01)
|
|
instance.live_margin_broker.close_position.return_value = {
|
|
'success': True,
|
|
'realized_pnl': 25.0,
|
|
'order_id': 'close-1',
|
|
}
|
|
|
|
result = LiveStrategyInstance.close_margin_position(instance, 'BTC/USDT', 100.0)
|
|
|
|
assert result['success'] is True
|
|
assert 'BTC/USDT' not in instance.variables['_pending_margin_entries']
|
|
assert 'BTC/USDT' not in instance.variables['_margin_trailing_stops']
|
|
instance.live_margin_broker.release_strategy_ownership.assert_called_once_with('strategy-a', 'BTC/USDT')
|
|
instance.live_margin_broker.clear_sltp.assert_called_once_with('BTC/USDT', 'strategy-a')
|