summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--pyproject.toml2
-rw-r--r--tests/__init__.py0
-rw-r--r--tests/integration/__init__.py0
-rw-r--r--tests/integration/test_backtest_end_to_end.py66
-rw-r--r--tests/integration/test_order_execution_flow.py79
-rw-r--r--tests/integration/test_portfolio_tracking_flow.py59
-rw-r--r--tests/integration/test_strategy_signal_flow.py55
7 files changed, 260 insertions, 1 deletions
diff --git a/pyproject.toml b/pyproject.toml
index debb032..545eae1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ requires-python = ">=3.12"
[tool.pytest.ini_options]
asyncio_mode = "auto"
-testpaths = ["shared/tests", "services", "cli/tests"]
+testpaths = ["shared/tests", "services", "cli/tests", "tests"]
addopts = "--import-mode=importlib"
[tool.ruff]
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/__init__.py
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