brighter-trading/tests/test_backtest_margin.py

681 lines
26 KiB
Python

"""
Tests for margin trading support in backtest mode.
"""
import pytest
from datetime import datetime, timezone, timedelta
from unittest.mock import MagicMock, patch
import sys
import os
# Add src to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
from brokers.paper_margin_broker import PaperMarginBroker
class TestPaperMarginBrokerTimeInjection:
"""Test that PaperMarginBroker accepts and uses time_provider."""
def test_time_provider_injection(self):
"""PaperMarginBroker should use injected time provider."""
simulated_time = datetime(2023, 6, 15, 12, 0, 0, tzinfo=timezone.utc)
broker = PaperMarginBroker(
time_provider=lambda: simulated_time,
initial_balance=10000.0
)
# Verify the injected time is used
assert broker._get_current_time() == simulated_time
def test_default_time_provider_uses_wall_clock(self):
"""Without time_provider, should use wall-clock time."""
broker = PaperMarginBroker(initial_balance=10000.0)
now = datetime.now(timezone.utc)
broker_time = broker._get_current_time()
# Should be within a few seconds of now
delta = abs((broker_time - now).total_seconds())
assert delta < 5.0
def test_interest_accrues_with_simulated_time(self):
"""Interest should accrue based on simulated time progression."""
times = [
datetime(2023, 6, 15, 12, 0, 0, tzinfo=timezone.utc),
datetime(2023, 6, 15, 13, 0, 0, tzinfo=timezone.utc), # +1 hour
]
time_index = [0]
def time_provider():
return times[time_index[0]]
broker = PaperMarginBroker(
time_provider=time_provider,
initial_balance=10000.0
)
# Open position at t=0
broker.update_price('BTC/USDT', 50000.0)
position = broker.open_position(
symbol='BTC/USDT',
side='long',
collateral=1000.0,
leverage=3.0,
current_price=50000.0
)
initial_interest = position.interest_accrued
# Advance simulated time by 1 hour
time_index[0] = 1
broker.update()
# Interest should have accrued
position = broker.get_position('BTC/USDT')
assert position.interest_accrued > initial_interest
def test_set_time_provider_method(self):
"""Can change time provider after initialization."""
broker = PaperMarginBroker(initial_balance=10000.0)
new_time = datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
broker.set_time_provider(lambda: new_time)
assert broker._get_current_time() == new_time
class TestBacktestMarginBasics:
"""Test basic margin operations in backtest context."""
def setup_method(self):
"""Set up mock backtrader strategy for tests."""
# Create mock backtrader strategy
self.mock_bt_strategy = MagicMock()
self.mock_bt_strategy.broker.getcash.return_value = 10000.0
self.mock_bt_strategy.broker.getvalue.return_value = 10000.0
self.mock_bt_strategy.data.close = [50000.0] # Current price
# Mock datetime to return simulated time
mock_datetime_attr = MagicMock()
mock_datetime_attr.datetime.return_value = datetime(2023, 6, 15, 12, 0, 0)
self.mock_bt_strategy.data.datetime = mock_datetime_attr
@patch('backtest_strategy_instance.PaperMarginBroker')
def test_margin_broker_initialized_with_time_provider(self, mock_broker_class):
"""BacktestStrategyInstance should initialize margin broker with time_provider."""
from backtest_strategy_instance import BacktestStrategyInstance
instance = BacktestStrategyInstance(
strategy_instance_id='test-123',
strategy_id='strat-1',
strategy_name='Test Strategy',
user_id=1,
generated_code='',
data_cache=MagicMock(),
indicators=None,
trades=None
)
# Set backtrader strategy which should initialize margin broker
instance.set_backtrader_strategy(self.mock_bt_strategy)
# Verify margin broker was created with time_provider
mock_broker_class.assert_called_once()
call_kwargs = mock_broker_class.call_args.kwargs
assert 'time_provider' in call_kwargs
assert call_kwargs['time_provider'] is not None
class TestBacktestMarginMethods:
"""Test margin trading methods in BacktestStrategyInstance."""
def setup_method(self):
"""Set up BacktestStrategyInstance for tests."""
from backtest_strategy_instance import BacktestStrategyInstance
# Create instance
self.instance = BacktestStrategyInstance(
strategy_instance_id='test-123',
strategy_id='strat-1',
strategy_name='Test Strategy',
user_id=1,
generated_code='',
data_cache=MagicMock(),
indicators=None,
trades=None
)
# Create mock backtrader strategy
self.mock_bt_strategy = MagicMock()
self.mock_bt_strategy.broker.getcash.return_value = 10000.0
self.mock_bt_strategy.broker.getvalue.return_value = 10000.0
self.mock_bt_strategy.broker.setcash = MagicMock()
self.mock_bt_strategy.data.close = [50000.0]
# Mock datetime
mock_datetime_attr = MagicMock()
mock_datetime_attr.datetime.return_value = datetime(2023, 6, 15, 12, 0, 0)
self.mock_bt_strategy.data.datetime = mock_datetime_attr
# Set backtrader strategy
self.instance.set_backtrader_strategy(self.mock_bt_strategy)
# Set up exec_context
self.instance.exec_context['current_symbol'] = 'BTC/USDT'
def test_has_margin_position_returns_false_when_no_position(self):
"""has_margin_position should return False when no position exists."""
assert self.instance.has_margin_position() is False
assert self.instance.has_margin_position('BTC/USDT') is False
def test_get_unrealized_pnl_returns_zero_when_no_position(self):
"""get_unrealized_pnl should return 0 when no position exists."""
assert self.instance.get_unrealized_pnl() == 0.0
def test_get_total_margin_used_returns_zero_when_no_position(self):
"""get_total_margin_used should return 0 when no position exists."""
assert self.instance.get_total_margin_used() == 0.0
def test_get_liquidation_buffer_pct_returns_100_when_no_position(self):
"""get_liquidation_buffer_pct should return 100 when no position exists."""
assert self.instance.get_liquidation_buffer_pct() == 100.0
def test_open_margin_position_clamps_leverage(self):
"""open_margin_position should clamp leverage to valid range (like paper/live)."""
# Leverage 15 should be clamped to 10 (max)
# This matches paper/live behavior
result = self.instance.open_margin_position(
side='long',
collateral=100.0,
leverage=15.0 # Too high - should be clamped to 10
)
assert result['success'] is True
assert result['leverage'] == 10 # Clamped
def test_open_margin_position_validates_collateral(self):
"""open_margin_position should reject invalid collateral."""
with pytest.raises(ValueError, match="Collateral must be positive"):
self.instance.open_margin_position(
side='long',
collateral=-100.0,
leverage=2.0
)
def test_margin_rejects_non_active_symbol(self):
"""Backtest margin should reject symbols that don't match active feed."""
with pytest.raises(ValueError, match="only supports active symbol"):
self.instance._get_margin_price('ETH/USDT') # Different from BTC/USDT
class TestBacktestMarginBlocklyPayloads:
"""Test margin trading with actual Blockly payload shapes."""
def setup_method(self):
"""Set up BacktestStrategyInstance for tests."""
from backtest_strategy_instance import BacktestStrategyInstance
self.instance = BacktestStrategyInstance(
strategy_instance_id='test-123',
strategy_id='strat-1',
strategy_name='Test Strategy',
user_id=1,
generated_code='',
data_cache=MagicMock(),
indicators=None,
trades=None
)
# Create mock backtrader strategy
self.mock_bt_strategy = MagicMock()
self.mock_bt_strategy.broker.getcash.return_value = 10000.0
self.mock_bt_strategy.broker.getvalue.return_value = 10000.0
self.mock_bt_strategy.broker.setcash = MagicMock()
self.mock_bt_strategy.data.close = [50000.0]
# Mock datetime
mock_datetime_attr = MagicMock()
mock_datetime_attr.datetime.return_value = datetime(2023, 6, 15, 12, 0, 0)
self.mock_bt_strategy.data.datetime = mock_datetime_attr
# Set backtrader strategy
self.instance.set_backtrader_strategy(self.mock_bt_strategy)
# Set up exec_context
self.instance.exec_context['current_symbol'] = 'BTC/USDT'
def test_sltp_with_dict_payloads(self):
"""SL/TP with Blockly dict payloads like {'value': 49000.0} should work."""
result = self.instance.open_margin_position(
side='long',
collateral=100.0,
leverage=2.0,
stop_loss={'value': 49000.0}, # Blockly payload shape
take_profit={'value': 55000.0} # Blockly payload shape
)
assert result['success'] is True
def test_limit_entry_with_blockly_payload(self):
"""Limit entry with Blockly payload {'limit': 48000.0} should work."""
result = self.instance.open_margin_position(
side='long',
collateral=100.0,
leverage=2.0,
limit={'limit': 48000.0} # Blockly payload shape
)
assert result['success'] is True
assert result['pending'] is True
assert result['entry_type'] == 'limit'
def test_trailing_limit_with_blockly_payload(self):
"""Trailing limit with Blockly payload {'trail_limit_distance': 500.0} should work."""
result = self.instance.open_margin_position(
side='long',
collateral=100.0,
leverage=2.0,
trailing_limit={'trail_limit_distance': 500.0} # Blockly payload shape
)
assert result['success'] is True
assert result['pending'] is True
assert result['entry_type'] == 'trailing_limit'
def test_trailing_stop_with_blockly_payload(self):
"""Trailing stop with Blockly payload {'trail_distance': 500.0} should work."""
result = self.instance.open_margin_position(
side='long',
collateral=100.0,
leverage=2.0,
trailing_stop={'trail_distance': 500.0} # Blockly payload shape
)
assert result['success'] is True
# Trailing stop is set up for the position
trailing_stops = self.instance._get_margin_trailing_stops()
assert 'BTC/USDT' in trailing_stops
def test_target_market_matching_active_symbol(self):
"""target_market matching active symbol should work."""
result = self.instance.open_margin_position(
side='long',
collateral=100.0,
leverage=2.0,
target_market={'symbol': 'BTC/USDT'} # Matches active symbol
)
assert result['success'] is True
def test_target_market_different_from_active_symbol_rejected(self):
"""target_market with different symbol should be rejected."""
with pytest.raises(ValueError, match="only supports active symbol"):
self.instance.open_margin_position(
side='long',
collateral=100.0,
leverage=2.0,
target_market={'symbol': 'ETH/USDT'} # Different from BTC/USDT
)
def test_default_leverage_uses_base_class_method(self):
"""Default leverage should use get_margin_leverage() from base class."""
# Set a specific default leverage using the correct variable name
self.instance.variables['_margin_leverage_default'] = 5
result = self.instance.open_margin_position(
side='long',
collateral=100.0,
leverage=None # Use default
)
assert result['success'] is True
assert result['leverage'] == 5 # Should use the variable
class TestBacktestMarginPendingEntries:
"""Regression tests for pending backtest margin entries."""
def setup_method(self):
"""Set up BacktestStrategyInstance with stateful cash for reservation checks."""
from backtest_strategy_instance import BacktestStrategyInstance
self.instance = BacktestStrategyInstance(
strategy_instance_id='test-123',
strategy_id='strat-1',
strategy_name='Test Strategy',
user_id=1,
generated_code='',
data_cache=MagicMock(),
indicators=None,
trades=None
)
self.cash_state = {'cash': 10000.0}
self.mock_bt_strategy = MagicMock()
self.mock_bt_strategy.broker.getcash.side_effect = lambda: self.cash_state['cash']
self.mock_bt_strategy.broker.getvalue.side_effect = lambda: self.cash_state['cash']
self.mock_bt_strategy.broker.setcash.side_effect = lambda value: self.cash_state.__setitem__('cash', value)
self.mock_bt_strategy.data.close = [50000.0]
mock_datetime_attr = MagicMock()
mock_datetime_attr.datetime.return_value = datetime(2023, 6, 15, 12, 0, 0)
self.mock_bt_strategy.data.datetime = mock_datetime_attr
self.instance.set_backtrader_strategy(self.mock_bt_strategy)
self.instance.exec_context['current_symbol'] = 'BTC/USDT'
def test_trailing_limit_pending_survives_flat_bar_without_refunding_collateral(self):
"""Trailing-limit entries should stay pending and keep collateral reserved until triggered."""
result = self.instance.open_margin_position(
side='long',
collateral=100.0,
leverage=2.0,
trailing_limit={'trail_limit_distance': 500.0}
)
assert result['pending'] is True
assert self.cash_state['cash'] == 9900.0
assert self.instance.paper_margin_broker.get_balance() == 100.0
events = self.instance.process_margin_tick()
assert events == []
assert self.instance.has_margin_position() is False
assert 'BTC/USDT' in self.instance._get_pending_margin_entries()
assert self.cash_state['cash'] == 9900.0
assert self.instance.paper_margin_broker.get_balance() == 100.0
def test_duplicate_pending_margin_entry_rejected_without_extra_reservation(self):
"""A second pending entry for the same symbol should fail without reserving more collateral."""
self.instance.open_margin_position(
side='long',
collateral=100.0,
leverage=2.0,
limit={'limit': 48000.0}
)
assert self.cash_state['cash'] == 9900.0
assert self.instance.paper_margin_broker.get_balance() == 100.0
with pytest.raises(ValueError, match='Pending margin entry already exists'):
self.instance.open_margin_position(
side='long',
collateral=200.0,
leverage=2.0,
limit={'limit': 47000.0}
)
pending = self.instance._get_pending_margin_entries()
assert len(pending) == 1
assert pending['BTC/USDT']['collateral'] == 100.0
assert self.cash_state['cash'] == 9900.0
assert self.instance.paper_margin_broker.get_balance() == 100.0
def test_ioc_non_marketable_limit_fails_without_pending_entry(self):
"""IOC/FOK limit orders should fail immediately when not marketable."""
with pytest.raises(RuntimeError, match='IOC margin limit order could not be filled immediately'):
self.instance.open_margin_position(
side='long',
collateral=100.0,
leverage=2.0,
tif='IOC',
limit={'limit': 48000.0}
)
assert self.instance._get_pending_margin_entries() == {}
assert self.cash_state['cash'] == 10000.0
assert self.instance.paper_margin_broker.get_balance() == 0.0
def test_pending_limit_fills_at_limit_price(self):
"""Pending limit entries should fill at the requested limit price, not the bar price."""
result = self.instance.open_margin_position(
side='long',
collateral=100.0,
leverage=2.0,
limit={'limit': 48000.0}
)
assert result['pending'] is True
assert self.cash_state['cash'] == 9900.0
self.mock_bt_strategy.data.close = [47000.0]
events = self.instance.process_margin_tick()
position = self.instance.paper_margin_broker.get_position('BTC/USDT')
assert position is not None
assert position.entry_price == 48000.0
assert events[0]['type'] == 'margin_entry_filled'
assert events[0]['entry_price'] == 48000.0
assert self.instance._get_pending_margin_entries() == {}
assert self.cash_state['cash'] == 9900.0
assert self.instance.paper_margin_broker.get_balance() == 0.0
class TestBacktestMarginIntegration:
"""Integration tests for margin trading in backtests."""
def test_process_margin_tick_returns_empty_when_no_broker(self):
"""process_margin_tick should return empty list when no margin broker."""
from backtest_strategy_instance import BacktestStrategyInstance
instance = BacktestStrategyInstance(
strategy_instance_id='test-123',
strategy_id='strat-1',
strategy_name='Test Strategy',
user_id=1,
generated_code='',
data_cache=MagicMock(),
indicators=None,
trades=None
)
# paper_margin_broker is None before set_backtrader_strategy
events = instance.process_margin_tick()
assert events == []
def test_get_current_time_utc_returns_aware_datetime(self):
"""_get_current_time_utc should return timezone-aware datetime."""
from backtest_strategy_instance import BacktestStrategyInstance
instance = BacktestStrategyInstance(
strategy_instance_id='test-123',
strategy_id='strat-1',
strategy_name='Test Strategy',
user_id=1,
generated_code='',
data_cache=MagicMock(),
indicators=None,
trades=None
)
# Mock backtrader strategy with naive datetime
mock_bt_strategy = MagicMock()
mock_datetime_attr = MagicMock()
mock_datetime_attr.datetime.return_value = datetime(2023, 6, 15, 12, 0, 0) # Naive
mock_bt_strategy.data.datetime = mock_datetime_attr
mock_bt_strategy.broker.getcash.return_value = 10000.0
mock_bt_strategy.broker.getvalue.return_value = 10000.0
instance.set_backtrader_strategy(mock_bt_strategy)
# Should return UTC-aware datetime
result = instance._get_current_time_utc()
assert result.tzinfo is not None
assert result.tzinfo == timezone.utc
class TestUndefinedValueBehavior:
"""Test that UndefinedValue prevents crashes and warns appropriately."""
def test_undefined_value_comparison_returns_false(self):
"""Comparisons with UndefinedValue should return False."""
from StrategyInstance import UndefinedValue
undef = UndefinedValue('test_var')
assert (100.0 < undef) is False
assert (100.0 > undef) is False
assert (100.0 <= undef) is False
assert (100.0 >= undef) is False
assert (undef < 100.0) is False
assert (undef > 100.0) is False
def test_undefined_value_math_returns_zero(self):
"""Math operations with UndefinedValue should return 0."""
from StrategyInstance import UndefinedValue
undef = UndefinedValue('test_var')
assert undef + 10 == 0
assert undef - 10 == 0
assert undef * 10 == 0
assert undef / 10 == 0
assert 10 + undef == 10 # 10 + undefined = 10
assert 10 - undef == 10 # 10 - undefined = 10
assert 10 * undef == 0 # 10 * undefined = 0
def test_undefined_value_equality_with_none(self):
"""UndefinedValue == None should be True for backwards compat."""
from StrategyInstance import UndefinedValue
undef = UndefinedValue('test_var')
assert (undef == None) is True
assert (undef != None) is False
def test_undefined_value_warns_via_callback(self):
"""UndefinedValue should call warn callback when used."""
from StrategyInstance import UndefinedValue
warnings = []
undef = UndefinedValue('my_var', warn_callback=warnings.append)
_ = undef < 100.0
assert len(warnings) == 1
assert 'my_var' in warnings[0]
assert 'comparison' in warnings[0]
def test_strategy_variables_returns_undefined_for_missing_key(self):
"""StrategyVariables.get() should return UndefinedValue for missing keys."""
from StrategyInstance import StrategyVariables, UndefinedValue
variables = StrategyVariables()
result = variables.get('nonexistent')
assert isinstance(result, UndefinedValue)
def test_strategy_variables_returns_real_value_for_existing_key(self):
"""StrategyVariables.get() should return actual value for existing keys."""
from StrategyInstance import StrategyVariables
variables = StrategyVariables()
variables['my_key'] = 42.5
result = variables.get('my_key')
assert result == 42.5
assert not isinstance(result, type(None))
def test_strategy_variables_honors_explicit_non_none_default(self):
"""Real dict.get(default) call sites should still receive their explicit fallback."""
from StrategyInstance import StrategyVariables
variables = StrategyVariables()
assert variables.get('missing', 3) == 3
def test_strategy_variables_keeps_none_default_undefined_safe_for_old_generated_code(self):
"""Existing generated code using get(..., None) should still receive UndefinedValue."""
from StrategyInstance import StrategyVariables, UndefinedValue
variables = StrategyVariables()
assert isinstance(variables.get('missing', None), UndefinedValue)
def test_undefined_warning_is_deduplicated_for_same_variable_in_one_tick(self):
"""Repeated reads of the same missing variable should only warn once per tick."""
from StrategyInstance import StrategyVariables
warnings = []
variables = StrategyVariables(warn_callback=warnings.append)
result = 100 < variables.get('sl_value') or 100 > variables.get('sl_value')
assert result is False
assert len(warnings) == 1
assert 'sl_value' in warnings[0]
def test_undefined_warning_resets_between_ticks(self):
"""Missing-variable warnings should be able to fire again on a later tick."""
from StrategyInstance import StrategyVariables
warnings = []
variables = StrategyVariables(warn_callback=warnings.append)
_ = 100 < variables.get('sl_value')
variables.reset_undefined_warnings()
_ = 100 < variables.get('sl_value')
assert len(warnings) == 2
def test_undefined_in_complex_expression_doesnt_crash(self):
"""Complex expressions with undefined variables should not crash."""
from StrategyInstance import StrategyVariables
variables = StrategyVariables()
price = 70000.0
# This would crash with None: price < variables.get('sl_value') or price > variables.get('tp_value')
# With UndefinedValue, it should return False without crashing
result = price < variables.get('sl_value') or price > variables.get('tp_value')
assert result is False
def test_undefined_value_format_string(self):
"""UndefinedValue should handle f-string formatting without crashing."""
from StrategyInstance import UndefinedValue
undef = UndefinedValue('trade_value')
# These would crash without __format__ method
formatted_float = f"{undef:.2f}"
formatted_int = f"{undef:d}"
formatted_plain = f"{undef}"
assert formatted_float == "0.00"
assert formatted_int == "0"
assert formatted_plain == ""
def test_default_margin_leverage_without_override_stays_three(self):
"""Backtest margin should still use the documented 3x default leverage."""
from backtest_strategy_instance import BacktestStrategyInstance
instance = BacktestStrategyInstance(
strategy_instance_id='test-123',
strategy_id='strat-1',
strategy_name='Test Strategy',
user_id=1,
generated_code='',
data_cache=MagicMock(),
indicators=None,
trades=None
)
mock_bt_strategy = MagicMock()
mock_bt_strategy.broker.getcash.return_value = 10000.0
mock_bt_strategy.broker.getvalue.return_value = 10000.0
mock_bt_strategy.broker.setcash = MagicMock()
mock_bt_strategy.data.close = [50000.0]
mock_datetime_attr = MagicMock()
mock_datetime_attr.datetime.return_value = datetime(2023, 6, 15, 12, 0, 0)
mock_bt_strategy.data.datetime = mock_datetime_attr
instance.set_backtrader_strategy(mock_bt_strategy)
instance.exec_context['current_symbol'] = 'BTC/USDT'
result = instance.open_margin_position(
side='long',
collateral=100.0,
leverage=None
)
assert result['success'] is True
assert result['leverage'] == 3
assert not any('_margin_leverage_default' in alert['message'] for alert in instance.collected_alerts)