From 33b14aaa2344b0fd95d1629627c3d135b24ae102 Mon Sep 17 00:00:00 2001 From: TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> Date: Wed, 1 Apr 2026 15:56:35 +0900 Subject: feat: initial trading platform implementation Binance spot crypto trading platform with microservices architecture: - shared: Pydantic models, Redis Streams broker, asyncpg DB layer - data-collector: Binance WebSocket/REST market data collection - strategy-engine: Plugin-based strategy execution (RSI, Grid) - order-executor: Order execution with risk management - portfolio-manager: Position tracking and PnL calculation - backtester: Historical strategy testing with simulator - cli: Click-based CLI for all operations - Docker Compose orchestration with Redis and PostgreSQL - 24 test files covering all modules --- services/backtester/Dockerfile | 7 ++ services/backtester/pyproject.toml | 16 +++++ services/backtester/src/backtester/__init__.py | 0 services/backtester/src/backtester/config.py | 13 ++++ services/backtester/src/backtester/engine.py | 95 +++++++++++++++++++++++++ services/backtester/src/backtester/main.py | 60 ++++++++++++++++ services/backtester/src/backtester/reporter.py | 28 ++++++++ services/backtester/src/backtester/simulator.py | 54 ++++++++++++++ services/backtester/tests/__init__.py | 0 services/backtester/tests/test_engine.py | 74 +++++++++++++++++++ services/backtester/tests/test_reporter.py | 26 +++++++ services/backtester/tests/test_simulator.py | 73 +++++++++++++++++++ 12 files changed, 446 insertions(+) create mode 100644 services/backtester/Dockerfile create mode 100644 services/backtester/pyproject.toml create mode 100644 services/backtester/src/backtester/__init__.py create mode 100644 services/backtester/src/backtester/config.py create mode 100644 services/backtester/src/backtester/engine.py create mode 100644 services/backtester/src/backtester/main.py create mode 100644 services/backtester/src/backtester/reporter.py create mode 100644 services/backtester/src/backtester/simulator.py create mode 100644 services/backtester/tests/__init__.py create mode 100644 services/backtester/tests/test_engine.py create mode 100644 services/backtester/tests/test_reporter.py create mode 100644 services/backtester/tests/test_simulator.py (limited to 'services/backtester') diff --git a/services/backtester/Dockerfile b/services/backtester/Dockerfile new file mode 100644 index 0000000..77ec453 --- /dev/null +++ b/services/backtester/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.12-slim +WORKDIR /app +COPY shared/ shared/ +RUN pip install --no-cache-dir ./shared +COPY services/backtester/ services/backtester/ +RUN pip install --no-cache-dir ./services/backtester +CMD ["python", "-m", "backtester.main"] diff --git a/services/backtester/pyproject.toml b/services/backtester/pyproject.toml new file mode 100644 index 0000000..b51f913 --- /dev/null +++ b/services/backtester/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "backtester" +version = "0.1.0" +description = "Strategy backtesting engine" +requires-python = ">=3.12" +dependencies = ["pandas>=2.0", "trading-shared"] + +[project.optional-dependencies] +dev = ["pytest>=8.0", "pytest-asyncio>=0.23"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/backtester"] diff --git a/services/backtester/src/backtester/__init__.py b/services/backtester/src/backtester/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backtester/src/backtester/config.py b/services/backtester/src/backtester/config.py new file mode 100644 index 0000000..bfbc196 --- /dev/null +++ b/services/backtester/src/backtester/config.py @@ -0,0 +1,13 @@ +"""Configuration for the backtester service.""" +from pydantic_settings import BaseSettings + + +class BacktestConfig(BaseSettings): + backtest_initial_balance: float = 10000.0 + database_url: str = "postgresql://trading:trading@localhost:5432/trading" + symbol: str = "BTCUSDT" + timeframe: str = "1h" + strategy_name: str = "sma_crossover" + candle_limit: int = 500 + + model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"} diff --git a/services/backtester/src/backtester/engine.py b/services/backtester/src/backtester/engine.py new file mode 100644 index 0000000..b89d422 --- /dev/null +++ b/services/backtester/src/backtester/engine.py @@ -0,0 +1,95 @@ +"""Backtesting engine that runs strategies against historical candle data.""" +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Protocol + +from shared.models import Candle, Signal + +from backtester.simulator import OrderSimulator, SimulatedTrade + + +class StrategyProtocol(Protocol): + """Protocol matching BaseStrategy from strategy-engine.""" + + name: str + + def on_candle(self, candle: Candle) -> Signal | None: ... + + def configure(self, params: dict) -> None: ... + + def reset(self) -> None: ... + + +@dataclass +class BacktestResult: + strategy_name: str + symbol: str + total_trades: int + initial_balance: Decimal + final_balance: Decimal + profit: Decimal + profit_pct: Decimal + trades: list[SimulatedTrade] = field(default_factory=list) + + @property + def win_rate(self) -> float: + """Calculate win rate based on buy/sell pairs.""" + buy_prices: list[Decimal] = [] + wins = 0 + total_pairs = 0 + + for trade in self.trades: + if trade.side.value == "BUY": + buy_prices.append(trade.price) + else: + if buy_prices: + buy_price = buy_prices.pop(0) + total_pairs += 1 + if trade.price > buy_price: + wins += 1 + + if total_pairs == 0: + return 0.0 + return wins / total_pairs * 100 + + +class BacktestEngine: + """Runs a strategy against historical candles using a simulated order executor.""" + + def __init__(self, strategy: StrategyProtocol, initial_balance: Decimal) -> None: + self._strategy = strategy + self._initial_balance = initial_balance + + def run(self, candles: list[Candle]) -> BacktestResult: + """Run the backtest over a list of candles and return a result.""" + simulator = OrderSimulator(self._initial_balance) + + for candle in candles: + signal = self._strategy.on_candle(candle) + if signal is not None: + simulator.execute(signal) + + # Calculate final balance including open positions valued at last candle close + final_balance = simulator.balance + if candles: + last_price = candles[-1].close + for symbol, qty in simulator.positions.items(): + if qty > Decimal("0"): + final_balance += qty * last_price + + profit = final_balance - self._initial_balance + if self._initial_balance != Decimal("0"): + profit_pct = (profit / self._initial_balance) * Decimal("100") + else: + profit_pct = Decimal("0") + + return BacktestResult( + strategy_name=self._strategy.name, + symbol=candles[0].symbol if candles else "", + total_trades=len(simulator.trades), + initial_balance=self._initial_balance, + final_balance=final_balance, + profit=profit, + profit_pct=profit_pct, + trades=simulator.trades, + ) diff --git a/services/backtester/src/backtester/main.py b/services/backtester/src/backtester/main.py new file mode 100644 index 0000000..ab69ee1 --- /dev/null +++ b/services/backtester/src/backtester/main.py @@ -0,0 +1,60 @@ +"""Main entry point for the backtester service.""" +import sys +import os +from decimal import Decimal + +# Allow importing strategies from the strategy-engine service +_STRATEGY_ENGINE_PATH = os.path.join( + os.path.dirname(__file__), "../../../../strategy-engine" +) +if _STRATEGY_ENGINE_PATH not in sys.path: + sys.path.insert(0, _STRATEGY_ENGINE_PATH) + +from shared.db import Database +from shared.models import Candle + +from backtester.config import BacktestConfig +from backtester.engine import BacktestEngine +from backtester.reporter import format_report + + +async def run_backtest() -> str: + """Load strategy, fetch candles, run backtest, and return a formatted report.""" + config = BacktestConfig() + + # Import strategy dynamically (requires strategy-engine in sys.path) + try: + from strategies.base import BaseStrategy # noqa: F401 + + # Try to import concrete strategy by name + module_name = config.strategy_name + import importlib + + mod = importlib.import_module(f"strategies.{module_name}") + strategy_cls = getattr(mod, "Strategy") + strategy = strategy_cls() + strategy.configure({}) + except Exception as exc: + raise RuntimeError( + f"Failed to load strategy '{config.strategy_name}': {exc}" + ) from exc + + db = Database(config.database_url) + await db.connect() + try: + rows = await db.get_candles(config.symbol, config.timeframe, config.candle_limit) + candles = [Candle(**row) for row in rows] + candles = list(reversed(candles)) # oldest first for strategy processing + finally: + await db.close() + + engine = BacktestEngine(strategy, Decimal(str(config.backtest_initial_balance))) + result = engine.run(candles) + return format_report(result) + + +if __name__ == "__main__": + import asyncio + + report = asyncio.run(run_backtest()) + print(report) diff --git a/services/backtester/src/backtester/reporter.py b/services/backtester/src/backtester/reporter.py new file mode 100644 index 0000000..916d5d4 --- /dev/null +++ b/services/backtester/src/backtester/reporter.py @@ -0,0 +1,28 @@ +"""Report formatting for backtest results.""" +from backtester.engine import BacktestResult + + +def format_report(result: BacktestResult) -> str: + """Format a backtest result into a human-readable text report.""" + separator = "=" * 50 + lines = [ + separator, + "BACKTEST REPORT", + separator, + f"Strategy: {result.strategy_name}", + f"Symbol: {result.symbol}", + separator, + "PERFORMANCE SUMMARY", + separator, + f"Initial Balance: {result.initial_balance:.2f}", + f"Final Balance: {result.final_balance:.2f}", + f"Profit/Loss: {result.profit:.2f}", + f"Profit %: {result.profit_pct:.2f}%", + separator, + "TRADE STATISTICS", + separator, + f"Total Trades: {result.total_trades}", + f"Win Rate: {result.win_rate:.2f}%", + separator, + ] + return "\n".join(lines) diff --git a/services/backtester/src/backtester/simulator.py b/services/backtester/src/backtester/simulator.py new file mode 100644 index 0000000..081ea3b --- /dev/null +++ b/services/backtester/src/backtester/simulator.py @@ -0,0 +1,54 @@ +"""Simulated order executor for backtesting.""" +from dataclasses import dataclass, field +from decimal import Decimal + +from shared.models import OrderSide, Signal + + +@dataclass +class SimulatedTrade: + symbol: str + side: OrderSide + price: Decimal + quantity: Decimal + balance_after: Decimal + + +class OrderSimulator: + """Simulates order execution against a paper balance.""" + + def __init__(self, initial_balance: Decimal) -> None: + self.balance: Decimal = initial_balance + self.positions: dict[str, Decimal] = {} + self.trades: list[SimulatedTrade] = [] + + def execute(self, signal: Signal) -> bool: + """Execute a signal. Returns True if the trade was accepted, False otherwise.""" + if signal.side == OrderSide.BUY: + cost = signal.price * signal.quantity + if cost > self.balance: + return False + self.balance -= cost + self.positions[signal.symbol] = ( + self.positions.get(signal.symbol, Decimal("0")) + signal.quantity + ) + trade_quantity = signal.quantity + else: # SELL + current_position = self.positions.get(signal.symbol, Decimal("0")) + if current_position <= Decimal("0"): + return False + trade_quantity = min(signal.quantity, current_position) + proceeds = signal.price * trade_quantity + self.balance += proceeds + self.positions[signal.symbol] = current_position - trade_quantity + + self.trades.append( + SimulatedTrade( + symbol=signal.symbol, + side=signal.side, + price=signal.price, + quantity=trade_quantity, + balance_after=self.balance, + ) + ) + return True diff --git a/services/backtester/tests/__init__.py b/services/backtester/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backtester/tests/test_engine.py b/services/backtester/tests/test_engine.py new file mode 100644 index 0000000..1a25e1c --- /dev/null +++ b/services/backtester/tests/test_engine.py @@ -0,0 +1,74 @@ +"""Tests for the BacktestEngine.""" +from datetime import datetime, timezone +from decimal import Decimal +from unittest.mock import MagicMock + +import pytest + +from shared.models import Candle, Signal, OrderSide + +from backtester.engine import BacktestEngine, BacktestResult + + +def make_candle(symbol: str, price: float, timeframe: str = "1h") -> Candle: + return Candle( + symbol=symbol, + timeframe=timeframe, + open_time=datetime.now(timezone.utc), + open=Decimal(str(price)), + high=Decimal(str(price * 1.01)), + low=Decimal(str(price * 0.99)), + close=Decimal(str(price)), + volume=Decimal("100"), + ) + + +def make_candles(prices: list[float], symbol: str = "BTCUSDT") -> list[Candle]: + return [make_candle(symbol, p) for p in prices] + + +def make_signal(side: OrderSide, price: str, quantity: str = "0.1") -> Signal: + return Signal( + strategy="test", + symbol="BTCUSDT", + side=side, + price=Decimal(price), + quantity=Decimal(quantity), + reason="test", + ) + + +def test_backtest_engine_runs_strategy_over_candles(): + strategy = MagicMock() + strategy.name = "mock_strategy" + strategy.on_candle.return_value = None + + candles = make_candles([50000.0, 51000.0, 52000.0]) + engine = BacktestEngine(strategy, Decimal("10000")) + result = engine.run(candles) + + assert strategy.on_candle.call_count == 3 + assert result.total_trades == 0 + assert result.final_balance == Decimal("10000") + assert result.strategy_name == "mock_strategy" + + +def test_backtest_engine_executes_signals(): + buy_signal = make_signal(OrderSide.BUY, "50000", "0.1") + sell_signal = make_signal(OrderSide.SELL, "55000", "0.1") + + strategy = MagicMock() + strategy.name = "mock_strategy" + strategy.on_candle.side_effect = [buy_signal, None, sell_signal] + + candles = make_candles([50000.0, 52000.0, 55000.0]) + engine = BacktestEngine(strategy, Decimal("10000")) + result = engine.run(candles) + + assert result.total_trades == 2 + # Initial: 10000, bought 0.1 BTC @ 50000 (cost 5000) → balance 5000 + # Sold 0.1 BTC @ 55000 (proceeds 5500) → balance 10500 + expected_final = Decimal("10500") + assert result.final_balance == expected_final + expected_profit = Decimal("500") + assert result.profit == expected_profit diff --git a/services/backtester/tests/test_reporter.py b/services/backtester/tests/test_reporter.py new file mode 100644 index 0000000..f5c694c --- /dev/null +++ b/services/backtester/tests/test_reporter.py @@ -0,0 +1,26 @@ +"""Tests for the report formatter.""" +from decimal import Decimal + +from backtester.engine import BacktestResult +from backtester.reporter import format_report + + +def test_format_report_contains_key_metrics(): + result = BacktestResult( + strategy_name="sma_crossover", + symbol="BTCUSDT", + total_trades=10, + initial_balance=Decimal("10000"), + final_balance=Decimal("11500"), + profit=Decimal("1500"), + profit_pct=Decimal("15"), + trades=[], + ) + report = format_report(result) + + assert "sma_crossover" in report + assert "BTCUSDT" in report + assert "10000" in report + assert "11500" in report + assert "1500" in report + assert "15" in report diff --git a/services/backtester/tests/test_simulator.py b/services/backtester/tests/test_simulator.py new file mode 100644 index 0000000..9d8b23e --- /dev/null +++ b/services/backtester/tests/test_simulator.py @@ -0,0 +1,73 @@ +"""Tests for the OrderSimulator.""" +from decimal import Decimal + +import pytest + +from shared.models import Signal, OrderSide, OrderType +from backtester.simulator import OrderSimulator + + +def make_signal( + symbol: str, + side: OrderSide, + price: str, + quantity: str, + strategy: str = "test", +) -> Signal: + return Signal( + strategy=strategy, + symbol=symbol, + side=side, + price=Decimal(price), + quantity=Decimal(quantity), + reason="test", + ) + + +def test_simulator_initial_balance(): + sim = OrderSimulator(Decimal("10000")) + assert sim.balance == Decimal("10000") + + +def test_simulator_buy_reduces_balance(): + sim = OrderSimulator(Decimal("10000")) + signal = make_signal("BTCUSDT", OrderSide.BUY, "50000", "0.1") + result = sim.execute(signal) + assert result is True + assert sim.balance == Decimal("5000") + assert sim.positions["BTCUSDT"] == Decimal("0.1") + + +def test_simulator_sell_increases_balance(): + sim = OrderSimulator(Decimal("10000")) + buy_signal = make_signal("BTCUSDT", OrderSide.BUY, "50000", "0.1") + sim.execute(buy_signal) + balance_after_buy = sim.balance + + sell_signal = make_signal("BTCUSDT", OrderSide.SELL, "55000", "0.1") + result = sim.execute(sell_signal) + assert result is True + assert sim.balance > balance_after_buy + # Profit: sold at 55000, bought at 50000 → gain 500 + assert sim.balance == Decimal("10000") - Decimal("5000") + Decimal("5500") + + +def test_simulator_reject_buy_insufficient_balance(): + sim = OrderSimulator(Decimal("100")) + signal = make_signal("BTCUSDT", OrderSide.BUY, "50000", "0.1") + result = sim.execute(signal) + assert result is False + assert sim.balance == Decimal("100") + assert sim.positions.get("BTCUSDT", Decimal("0")) == Decimal("0") + + +def test_simulator_trade_history(): + sim = OrderSimulator(Decimal("10000")) + signal = make_signal("BTCUSDT", OrderSide.BUY, "50000", "0.1") + sim.execute(signal) + assert len(sim.trades) == 1 + trade = sim.trades[0] + assert trade.symbol == "BTCUSDT" + assert trade.side == OrderSide.BUY + assert trade.price == Decimal("50000") + assert trade.quantity == Decimal("0.1") -- cgit v1.2.3