diff options
Diffstat (limited to 'tests/integration')
| -rw-r--r-- | tests/integration/__init__.py | 0 | ||||
| -rw-r--r-- | tests/integration/test_backtest_end_to_end.py | 66 | ||||
| -rw-r--r-- | tests/integration/test_order_execution_flow.py | 79 | ||||
| -rw-r--r-- | tests/integration/test_portfolio_tracking_flow.py | 59 | ||||
| -rw-r--r-- | tests/integration/test_strategy_signal_flow.py | 55 |
5 files changed, 259 insertions, 0 deletions
diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/integration/__init__.py diff --git a/tests/integration/test_backtest_end_to_end.py b/tests/integration/test_backtest_end_to_end.py new file mode 100644 index 0000000..4bdb5f3 --- /dev/null +++ b/tests/integration/test_backtest_end_to_end.py @@ -0,0 +1,66 @@ +"""Integration test: full backtest with real strategy on generated candles.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "services" / "strategy-engine" / "src")) +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "services" / "strategy-engine")) +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "services" / "backtester" / "src")) + +import pytest +from decimal import Decimal +from datetime import datetime, timedelta, timezone + +from shared.models import Candle +from backtester.engine import BacktestEngine + + +def _generate_candles(prices: list[float], symbol="BTCUSDT") -> list[Candle]: + return [ + Candle( + symbol=symbol, timeframe="1h", + open_time=datetime(2025, 1, 1, tzinfo=timezone.utc) + timedelta(hours=i), + open=Decimal(str(p)), high=Decimal(str(p + 100)), + low=Decimal(str(p - 100)), close=Decimal(str(p)), + volume=Decimal("100"), + ) + for i, p in enumerate(prices) + ] + + +def test_backtest_rsi_strategy_end_to_end(): + """Run RSI strategy through backtester and verify result structure.""" + from strategies.rsi_strategy import RsiStrategy + + strategy = RsiStrategy() + strategy.configure({"period": 5, "oversold": 30, "overbought": 70, "quantity": "0.1"}) + + # Generate price series: decline then rise + prices = [100 - i for i in range(15)] + [85 + i * 2 for i in range(15)] + candles = _generate_candles(prices) + + engine = BacktestEngine(strategy, Decimal("10000")) + result = engine.run(candles) + + assert result.strategy_name == "rsi" + assert result.symbol == "BTCUSDT" + assert result.initial_balance == Decimal("10000") + assert result.detailed is not None + assert result.detailed.total_trades >= 0 + + +def test_backtest_with_no_signals(): + """Strategy that generates no signals should return initial balance.""" + from strategies.rsi_strategy import RsiStrategy + + strategy = RsiStrategy() + strategy.configure({"period": 14, "oversold": 10, "overbought": 90, "quantity": "0.1"}) + + # Flat prices -- no RSI extremes + prices = [100.0] * 30 + candles = _generate_candles(prices) + + engine = BacktestEngine(strategy, Decimal("10000")) + result = engine.run(candles) + + assert result.total_trades == 0 + assert result.final_balance == Decimal("10000") diff --git a/tests/integration/test_order_execution_flow.py b/tests/integration/test_order_execution_flow.py new file mode 100644 index 0000000..1ea0485 --- /dev/null +++ b/tests/integration/test_order_execution_flow.py @@ -0,0 +1,79 @@ +"""Integration test: signal -> risk manager -> order executor -> order event.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "services" / "order-executor" / "src")) + +import pytest +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +from shared.models import Signal, OrderSide, OrderStatus +from order_executor.executor import OrderExecutor +from order_executor.risk_manager import RiskManager + + +@pytest.mark.asyncio +async def test_signal_to_order_flow(): + """A valid signal passes risk checks and produces a filled order.""" + signal = Signal( + strategy="rsi", symbol="BTC/USDT", side=OrderSide.BUY, + price=Decimal("50000"), quantity=Decimal("0.01"), reason="RSI oversold", + ) + + exchange = AsyncMock() + exchange.fetch_balance = AsyncMock(return_value={"free": {"USDT": 10000}}) + + risk_manager = RiskManager( + max_position_size=Decimal("0.5"), + stop_loss_pct=Decimal("5"), + daily_loss_limit_pct=Decimal("10"), + ) + + broker = AsyncMock() + db = AsyncMock() + notifier = AsyncMock() + + executor = OrderExecutor( + exchange=exchange, risk_manager=risk_manager, + broker=broker, db=db, notifier=notifier, dry_run=True, + ) + + order = await executor.execute(signal) + + assert order is not None + assert order.status == OrderStatus.FILLED + assert order.symbol == "BTC/USDT" + assert order.side == OrderSide.BUY + + # Verify order was persisted and published + db.insert_order.assert_called_once() + broker.publish.assert_called_once() + notifier.send_order.assert_called_once() + + +@pytest.mark.asyncio +async def test_signal_rejected_by_risk_manager(): + """A signal that exceeds position size is rejected.""" + signal = Signal( + strategy="rsi", symbol="BTC/USDT", side=OrderSide.BUY, + price=Decimal("50000"), quantity=Decimal("100"), # Way too large + reason="test", + ) + + exchange = AsyncMock() + exchange.fetch_balance = AsyncMock(return_value={"free": {"USDT": 1000}}) + + risk_manager = RiskManager( + max_position_size=Decimal("0.1"), + stop_loss_pct=Decimal("5"), + daily_loss_limit_pct=Decimal("10"), + ) + + executor = OrderExecutor( + exchange=exchange, risk_manager=risk_manager, + broker=AsyncMock(), db=AsyncMock(), notifier=AsyncMock(), dry_run=True, + ) + + order = await executor.execute(signal) + assert order is None # Rejected diff --git a/tests/integration/test_portfolio_tracking_flow.py b/tests/integration/test_portfolio_tracking_flow.py new file mode 100644 index 0000000..386e78f --- /dev/null +++ b/tests/integration/test_portfolio_tracking_flow.py @@ -0,0 +1,59 @@ +"""Integration test: order -> portfolio tracker -> position state.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "services" / "portfolio-manager" / "src")) + +import pytest +from decimal import Decimal +from datetime import datetime, timezone + +from shared.models import Order, OrderSide, OrderType, OrderStatus +from portfolio_manager.portfolio import PortfolioTracker + + +def test_portfolio_tracks_buy_sell_cycle(): + """Buy then sell should update position and reset on full sell.""" + tracker = PortfolioTracker() + + buy_order = Order( + signal_id="sig-1", symbol="BTCUSDT", side=OrderSide.BUY, + type=OrderType.MARKET, price=Decimal("50000"), + quantity=Decimal("0.1"), status=OrderStatus.FILLED, + ) + tracker.apply_order(buy_order) + + pos = tracker.get_position("BTCUSDT") + assert pos is not None + assert pos.quantity == Decimal("0.1") + assert pos.avg_entry_price == Decimal("50000") + + sell_order = Order( + signal_id="sig-2", symbol="BTCUSDT", side=OrderSide.SELL, + type=OrderType.MARKET, price=Decimal("55000"), + quantity=Decimal("0.1"), status=OrderStatus.FILLED, + ) + tracker.apply_order(sell_order) + + pos = tracker.get_position("BTCUSDT") + assert pos is None # Fully sold + + +def test_portfolio_weighted_average_on_multiple_buys(): + """Multiple buys at different prices should compute weighted average.""" + tracker = PortfolioTracker() + + tracker.apply_order(Order( + signal_id="s1", symbol="BTCUSDT", side=OrderSide.BUY, + type=OrderType.MARKET, price=Decimal("50000"), + quantity=Decimal("0.1"), status=OrderStatus.FILLED, + )) + tracker.apply_order(Order( + signal_id="s2", symbol="BTCUSDT", side=OrderSide.BUY, + type=OrderType.MARKET, price=Decimal("60000"), + quantity=Decimal("0.1"), status=OrderStatus.FILLED, + )) + + pos = tracker.get_position("BTCUSDT") + assert pos.quantity == Decimal("0.2") + assert pos.avg_entry_price == Decimal("55000") # weighted avg diff --git a/tests/integration/test_strategy_signal_flow.py b/tests/integration/test_strategy_signal_flow.py new file mode 100644 index 0000000..ee47f8e --- /dev/null +++ b/tests/integration/test_strategy_signal_flow.py @@ -0,0 +1,55 @@ +"""Integration test: candle -> strategy engine -> signal.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "services" / "strategy-engine" / "src")) +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "services" / "strategy-engine")) + +import pytest +from decimal import Decimal +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock + +from shared.models import Candle, OrderSide +from shared.events import CandleEvent, Event +from strategy_engine.engine import StrategyEngine + + +@pytest.fixture +def candles(): + """Generate a series of declining candles that should trigger RSI oversold.""" + base = [] + for i in range(20): + price = Decimal(str(100 - i * 2)) # 100, 98, 96... + base.append(Candle( + symbol="BTCUSDT", timeframe="1m", + open_time=datetime(2025, 1, 1, i, 0, tzinfo=timezone.utc), + open=price, high=price + 1, low=price - 1, + close=price, volume=Decimal("10"), + )) + return base + + +@pytest.mark.asyncio +async def test_strategy_engine_produces_signals_from_candles(candles): + """Feed candles into strategy engine and verify signals are published.""" + from strategies.rsi_strategy import RsiStrategy + + broker = AsyncMock() + # Mock broker.read to return candle events one at a time, then empty + events = [CandleEvent(data=c).to_dict() for c in candles] + broker.read = AsyncMock(side_effect=[events, []]) + + strategy = RsiStrategy() + strategy.configure({"period": 14, "oversold": 30, "overbought": 70, "quantity": "0.01"}) + + engine = StrategyEngine(broker=broker, strategies=[strategy]) + + await engine.process_once("candles.BTCUSDT", "$") + + # With 20 declining candles (100->62), RSI should be very low + # Check if broker.publish was called with a signal + if broker.publish.called: + call_args = broker.publish.call_args_list + for call in call_args: + assert call[0][0] == "signals" # published to signals stream |
