brighter-trading/tests/test_live_integration.py

676 lines
22 KiB
Python

"""
Integration tests for live trading against Binance testnet.
These tests require real testnet API keys set in environment variables:
BRIGHTER_BINANCE_TESTNET_API_KEY
BRIGHTER_BINANCE_TESTNET_API_SECRET
Run with:
pytest tests/test_live_integration.py -m live_testnet -v
Skip these tests in CI by using:
pytest -m "not live_testnet"
"""
import os
import sys
import time
import pytest
from unittest.mock import MagicMock, patch
from decimal import Decimal
# Add src to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
# Check if testnet credentials are available
TESTNET_API_KEY = os.environ.get('BRIGHTER_BINANCE_TESTNET_API_KEY', '')
TESTNET_API_SECRET = os.environ.get('BRIGHTER_BINANCE_TESTNET_API_SECRET', '')
HAS_TESTNET_CREDENTIALS = bool(TESTNET_API_KEY and TESTNET_API_SECRET)
# Skip reason for tests requiring credentials
SKIP_REASON = "Testnet API keys not configured. Set BRIGHTER_BINANCE_TESTNET_API_KEY and BRIGHTER_BINANCE_TESTNET_API_SECRET."
@pytest.fixture
def testnet_exchange():
"""Create a real Exchange instance connected to Binance testnet."""
if not HAS_TESTNET_CREDENTIALS:
pytest.skip(SKIP_REASON)
from Exchange import Exchange
api_keys = {
'key': TESTNET_API_KEY,
'secret': TESTNET_API_SECRET
}
exchange = Exchange(
name='binance',
api_keys=api_keys,
exchange_id='binance',
testnet=True
)
yield exchange
@pytest.fixture
def testnet_live_broker(testnet_exchange):
"""Create a LiveBroker connected to Binance testnet."""
from brokers import LiveBroker
from DataCache_v3 import DataCache
# Create a real data cache for persistence testing
data_cache = DataCache()
broker = LiveBroker(
exchange=testnet_exchange,
testnet=True,
initial_balance=0.0,
commission=0.001,
slippage=0.0,
data_cache=data_cache,
rate_limit=2.0
)
yield broker
# Cleanup: disconnect
if broker._connected:
broker.disconnect()
@pytest.fixture
def connected_broker(testnet_live_broker):
"""Provide a connected LiveBroker."""
testnet_live_broker.connect()
return testnet_live_broker
# =============================================================================
# Exchange Connection Tests
# =============================================================================
@pytest.mark.live_testnet
class TestExchangeConnection:
"""Tests for exchange connectivity."""
def test_exchange_connects_to_testnet(self, testnet_exchange):
"""Verify exchange initializes with sandbox mode enabled."""
assert testnet_exchange is not None
assert testnet_exchange.testnet is True
assert testnet_exchange.client is not None
# Verify testnet URLs are being used
assert 'testnet' in testnet_exchange.client.urls.get('api', {}).get('public', '')
def test_exchange_is_configured(self, testnet_exchange):
"""Verify exchange has valid API credentials."""
assert testnet_exchange.configured is True
assert testnet_exchange.api_key == TESTNET_API_KEY
def test_exchange_can_load_markets(self, testnet_exchange):
"""Verify exchange can load market data."""
# Markets should be loaded during initialization
assert testnet_exchange.exchange_info is not None
assert len(testnet_exchange.exchange_info) > 0
# BTC/USDT should be available
assert 'BTC/USDT' in testnet_exchange.exchange_info
# =============================================================================
# Balance and Price Tests
# =============================================================================
@pytest.mark.live_testnet
class TestBalanceAndPrice:
"""Tests for balance and price fetching."""
def test_balance_sync_returns_real_data(self, connected_broker):
"""Verify sync_balance() returns dict with actual testnet balances."""
balances = connected_broker.sync_balance()
assert isinstance(balances, dict)
# Testnet accounts typically have some balance
# At minimum, we should get a response without errors
# Check that balance tracking is working
assert connected_broker._balances is not None
def test_get_balance_returns_quote_currency(self, connected_broker):
"""Verify get_balance() returns a numeric value."""
balance = connected_broker.get_balance()
assert isinstance(balance, (int, float))
assert balance >= 0
def test_get_available_balance(self, connected_broker):
"""Verify get_available_balance() works."""
available = connected_broker.get_available_balance()
assert isinstance(available, (int, float))
assert available >= 0
def test_price_fetch_returns_valid_price(self, connected_broker):
"""Verify get_current_price() returns positive float."""
price = connected_broker.get_current_price('BTC/USDT')
assert isinstance(price, float)
assert price > 0
# BTC price should be in reasonable range (testnet may have different prices)
# Just verify it's a sensible number
assert price > 100 # BTC should be > $100
assert price < 1000000 # and < $1M
def test_price_cache_expires(self, connected_broker):
"""Verify price cache expires after TTL."""
symbol = 'BTC/USDT'
# First fetch
price1 = connected_broker.get_current_price(symbol)
# Immediate second fetch should use cache
price2 = connected_broker.get_current_price(symbol)
assert price1 == price2
# Wait for cache to expire (5 seconds + buffer)
time.sleep(6)
# Third fetch should get fresh price
price3 = connected_broker.get_current_price(symbol)
# Price may or may not have changed, but fetch should succeed
assert isinstance(price3, float)
assert price3 > 0
def test_total_equity_calculation(self, connected_broker):
"""Verify get_total_equity() works."""
equity = connected_broker.get_total_equity()
assert isinstance(equity, float)
assert equity >= 0
# =============================================================================
# Order Lifecycle Tests
# =============================================================================
@pytest.mark.live_testnet
class TestOrderLifecycle:
"""Tests for order placement, monitoring, and cancellation."""
def test_place_limit_order_appears_on_exchange(self, connected_broker):
"""Place limit order and verify it appears in open orders."""
from brokers import OrderSide, OrderType
# Get current price and place limit order below market
current_price = connected_broker.get_current_price('BTC/USDT')
limit_price = current_price * 0.9 # 10% below market
# Place small limit buy order
result = connected_broker.place_order(
symbol='BTC/USDT',
side=OrderSide.BUY,
order_type=OrderType.LIMIT,
size=0.001, # Minimum size
price=limit_price,
time_in_force='GTC'
)
assert result.success is True
assert result.order_id is not None
order_id = result.order_id
try:
# Verify order appears in open orders
open_orders = connected_broker.get_open_orders()
order_ids = [o['order_id'] for o in open_orders]
assert order_id in order_ids
# Verify order details from the list
order = next((o for o in open_orders if o['order_id'] == order_id), None)
assert order is not None
assert order['symbol'] == 'BTC/USDT'
assert order['side'] == 'buy'
finally:
# Cleanup: cancel the order
connected_broker.cancel_order(order_id)
def test_cancel_order_removes_from_exchange(self, connected_broker):
"""Cancel order and verify it's removed from open orders."""
from brokers import OrderSide, OrderType
# Place a limit order
current_price = connected_broker.get_current_price('BTC/USDT')
limit_price = current_price * 0.9
result = connected_broker.place_order(
symbol='BTC/USDT',
side=OrderSide.BUY,
order_type=OrderType.LIMIT,
size=0.001,
price=limit_price,
time_in_force='GTC'
)
assert result.success is True
order_id = result.order_id
# Cancel the order
cancel_result = connected_broker.cancel_order(order_id)
assert cancel_result is True
# Small delay for exchange to process
time.sleep(1)
# Sync open orders from exchange
connected_broker.sync_open_orders()
# Verify order is no longer in open orders
open_orders = connected_broker.get_open_orders()
order_ids = [o['order_id'] for o in open_orders]
assert order_id not in order_ids
def test_market_order_fills_immediately(self, connected_broker):
"""Place small market order and verify it fills."""
from brokers import OrderSide, OrderType, OrderStatus
# Check we have balance
balance = connected_broker.get_balance()
if balance < 20: # Need at least $20 for minimum BTC order
pytest.skip("Insufficient testnet balance for market order test")
# Place small market buy (no time_in_force for market orders)
result = connected_broker.place_order(
symbol='BTC/USDT',
side=OrderSide.BUY,
order_type=OrderType.MARKET,
size=0.001
)
assert result.success is True
# Market orders should fill immediately
assert result.status == OrderStatus.FILLED
assert result.filled_qty > 0
assert result.filled_price > 0
def test_order_fill_detected_in_update_cycle(self, connected_broker):
"""Place market order and verify fill event in update()."""
from brokers import OrderSide, OrderType
balance = connected_broker.get_balance()
if balance < 20:
pytest.skip("Insufficient testnet balance")
# Place market order (no time_in_force for market orders)
result = connected_broker.place_order(
symbol='BTC/USDT',
side=OrderSide.BUY,
order_type=OrderType.MARKET,
size=0.001
)
assert result.success is True
# Call update to process any events
events = connected_broker.update()
# For immediate fills, the fill may already be recorded
# The important thing is update() doesn't error
assert isinstance(events, list)
# =============================================================================
# Persistence Tests
# =============================================================================
@pytest.mark.live_testnet
class TestPersistence:
"""Tests for state persistence and recovery."""
def test_state_persistence_survives_restart(self, testnet_exchange):
"""Save state, create new broker, load state, verify orders match."""
from brokers import LiveBroker, OrderSide, OrderType
from DataCache_v3 import DataCache
data_cache = DataCache()
strategy_id = 'test-persistence-001'
# Create first broker and place order
broker1 = LiveBroker(
exchange=testnet_exchange,
testnet=True,
data_cache=data_cache,
rate_limit=2.0
)
broker1.connect()
# Place a limit order
current_price = broker1.get_current_price('BTC/USDT')
limit_price = current_price * 0.85 # Well below market
result = broker1.place_order(
symbol='BTC/USDT',
side=OrderSide.BUY,
order_type=OrderType.LIMIT,
size=0.001,
price=limit_price,
time_in_force='GTC'
)
assert result.success is True
order_id = result.order_id
try:
# Save state
broker1.save_state(strategy_id)
# Get state for comparison
original_state = broker1.to_state_dict()
original_order_count = len(broker1._orders)
# Disconnect first broker
broker1.disconnect()
# Create new broker instance
broker2 = LiveBroker(
exchange=testnet_exchange,
testnet=True,
data_cache=data_cache,
rate_limit=2.0
)
broker2.connect()
# Load state
load_success = broker2.load_state(strategy_id)
assert load_success is True
# Verify orders were restored
assert len(broker2._orders) == original_order_count
assert order_id in broker2._orders
restored_order = broker2._orders[order_id]
assert restored_order.symbol == 'BTC/USDT'
finally:
# Cleanup: cancel order using either broker
try:
if broker1._connected:
broker1.cancel_order(order_id)
elif broker2._connected:
broker2.cancel_order(order_id)
except Exception:
pass
def test_reconcile_detects_external_changes(self, connected_broker):
"""Place order via CCXT directly, reconcile, verify order appears."""
from brokers import OrderSide, OrderType
# Get current price
current_price = connected_broker.get_current_price('BTC/USDT')
limit_price = current_price * 0.8 # Well below market
# Place order directly via CCXT (simulating external action)
exchange = connected_broker._exchange
result, order_data = exchange.place_order(
symbol='BTC/USDT',
side='buy',
type='limit',
timeInForce='GTC',
quantity=0.001,
price=limit_price
)
assert result == 'Success'
external_order_id = order_data['id']
try:
# Before reconciliation, broker doesn't know about this order
# (unless it was already synced)
# Reconcile with exchange
reconcile_result = connected_broker.reconcile_with_exchange()
assert reconcile_result['success'] is True
# After reconciliation, the external order should be tracked
# Sync open orders to update local state
connected_broker.sync_open_orders()
# Check if external order is now in our tracking
exchange_ids = [o.exchange_order_id for o in connected_broker._orders.values()]
assert external_order_id in exchange_ids
finally:
# Cleanup: cancel order
try:
exchange.client.cancel_order(external_order_id, 'BTC/USDT')
except Exception:
pass
# =============================================================================
# Full Trade Lifecycle Test
# =============================================================================
@pytest.mark.live_testnet
class TestFullTradeLifecycle:
"""End-to-end trade lifecycle tests."""
def test_full_trade_lifecycle(self, connected_broker):
"""Open position, hold, close position, verify P&L calculated."""
from brokers import OrderSide, OrderType, OrderStatus
balance = connected_broker.get_balance()
if balance < 50:
pytest.skip("Insufficient testnet balance for full lifecycle test")
symbol = 'BTC/USDT'
size = 0.001
# Record starting balance
starting_balance = connected_broker.get_balance()
# Step 1: Open position (buy)
buy_result = connected_broker.place_order(
symbol=symbol,
side=OrderSide.BUY,
order_type=OrderType.MARKET,
size=size
)
assert buy_result.success is True
assert buy_result.status == OrderStatus.FILLED
entry_price = buy_result.filled_price
assert entry_price > 0
# Small delay
time.sleep(2)
# Step 2: Check position exists
connected_broker.sync_balance()
# Step 3: Close position (sell)
sell_result = connected_broker.place_order(
symbol=symbol,
side=OrderSide.SELL,
order_type=OrderType.MARKET,
size=size
)
assert sell_result.success is True
assert sell_result.status == OrderStatus.FILLED
exit_price = sell_result.filled_price
assert exit_price > 0
# Step 4: Verify P&L
# P&L = (exit - entry) * size - commissions
gross_pnl = (exit_price - entry_price) * size
# We can't verify exact P&L due to commissions, but the trade completed
print(f"Entry: {entry_price}, Exit: {exit_price}, Gross P&L: {gross_pnl}")
# Sync final balance
connected_broker.sync_balance()
final_balance = connected_broker.get_balance()
# Balance should have changed by approximately the P&L
balance_change = final_balance - starting_balance
print(f"Balance change: {balance_change} (includes commissions)")
# =============================================================================
# Rate Limiting Tests
# =============================================================================
@pytest.mark.live_testnet
class TestRateLimiting:
"""Tests for API rate limiting."""
def test_rapid_requests_dont_cause_errors(self, connected_broker):
"""Verify rapid API calls are properly throttled."""
# Make multiple rapid price requests
errors = []
for i in range(10):
try:
price = connected_broker.get_current_price('BTC/USDT')
assert price > 0
except Exception as e:
errors.append(str(e))
# Should have no rate limit errors
rate_limit_errors = [e for e in errors if 'rate' in e.lower() or 'limit' in e.lower()]
assert len(rate_limit_errors) == 0, f"Rate limit errors: {rate_limit_errors}"
# =============================================================================
# Error Handling Tests
# =============================================================================
@pytest.mark.live_testnet
class TestErrorHandling:
"""Tests for error handling and edge cases."""
def test_invalid_symbol_handled_gracefully(self, connected_broker):
"""Verify invalid symbol doesn't crash."""
price = connected_broker.get_current_price('INVALID/PAIR')
# Should return 0 or cached value, not crash
assert isinstance(price, (int, float))
def test_insufficient_balance_rejected(self, connected_broker):
"""Verify order with insufficient balance is rejected."""
from brokers import OrderSide, OrderType
# Try to buy way more than we have
result = connected_broker.place_order(
symbol='BTC/USDT',
side=OrderSide.BUY,
order_type=OrderType.MARKET,
size=1000.0 # 1000 BTC - definitely more than testnet balance
)
# Should fail gracefully (exchange may return various error messages)
assert result.success is False
# Binance returns different error codes - just verify it failed with a message
assert result.message is not None and len(result.message) > 0
def test_cancel_nonexistent_order(self, connected_broker):
"""Verify canceling nonexistent order is handled."""
result = connected_broker.cancel_order('nonexistent-order-id')
# Should fail gracefully (returns False for nonexistent orders)
assert result is False
# =============================================================================
# LiveStrategyInstance Integration Tests
# =============================================================================
@pytest.mark.live_testnet
@pytest.mark.skip(reason="LiveStrategyInstance integration tests require full DataCache setup - tested separately in test_live_strategy_instance.py")
class TestLiveStrategyInstanceIntegration:
"""Integration tests for LiveStrategyInstance with real exchange."""
def test_live_strategy_instance_creation(self, testnet_exchange):
"""Verify LiveStrategyInstance can be created with real exchange."""
from live_strategy_instance import LiveStrategyInstance
from unittest.mock import MagicMock
# Use mock data_cache to avoid database initialization hang
data_cache = MagicMock()
instance = LiveStrategyInstance(
strategy_instance_id='test-live-001',
strategy_id='test-strategy',
strategy_name='Test Strategy',
user_id=1,
generated_code='pass',
data_cache=data_cache,
indicators=None,
trades=None,
exchange=testnet_exchange,
testnet=True,
initial_balance=0.0,
max_position_pct=0.5,
circuit_breaker_pct=-0.10
)
assert instance is not None
assert instance.is_testnet is True
assert instance.live_broker._connected is True
# Cleanup
instance.disconnect()
def test_live_strategy_instance_tick(self, testnet_exchange):
"""Verify tick() works with real exchange data."""
from live_strategy_instance import LiveStrategyInstance
from unittest.mock import MagicMock
# Use mock data_cache to avoid database initialization hang
data_cache = MagicMock()
instance = LiveStrategyInstance(
strategy_instance_id='test-live-002',
strategy_id='test-strategy',
strategy_name='Test Strategy',
user_id=1,
generated_code='pass',
data_cache=data_cache,
indicators=None,
trades=None,
exchange=testnet_exchange,
testnet=True
)
try:
# Get current price for candle data
price = instance.get_current_price(symbol='BTC/USDT')
candle_data = {
'symbol': 'BTC/USDT',
'open': price,
'high': price * 1.01,
'low': price * 0.99,
'close': price,
'volume': 100.0
}
# Run tick
events = instance.tick(candle_data)
assert isinstance(events, list)
# Should complete without circuit breaker (we haven't lost money)
assert not any(e.get('type') == 'circuit_breaker' for e in events)
finally:
instance.disconnect()