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/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 ++++++++++++++ 6 files changed, 250 insertions(+) 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 (limited to 'services/backtester/src') 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 -- cgit v1.2.3