676 lines
22 KiB
Python
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()
|