Team Ai
Apppublic

diegobeyl/backtesting

sourceHugging Faceupdated 9mo agoView on Hugging Face
2likes
backtester.py459 linesDownload Raw Back to core
1"""2Motor de backtesting genérico3Ejecuta cualquier algoritmo que implemente BaseAlgorithm4"""5 6from dataclasses import dataclass, field7from typing import List, Optional, Dict, Any8from datetime import datetime9import pandas as pd10import numpy as np11import sys12from pathlib import Path13 14# Add parent directory for imports15sys.path.insert(0, str(Path(__file__).parent.parent.parent))16 17from backtesting_app.algorithms.base import BaseAlgorithm, AlgorithmResult, TradeSignal, TrendState18from utils.validators import InputValidator19from utils.exceptions import ValidationException, AlgorithmException20 21 22@dataclass23class Trade:24    """Representa una operación completada"""25    entry_date: datetime26    entry_price: float27    direction: str  # 'LONG' or 'SHORT'28    initial_sl: float29    position_size: float30    capital_at_entry: float31    exit_date: Optional[datetime] = None32    exit_price: Optional[float] = None33    pnl: float = 0.034    pnl_percent: float = 0.035    bars_held: int = 036    exit_reason: str = ""37    sl_updates: List[float] = field(default_factory=list)38 39 40@dataclass41class BacktestResult:42    """Resultado completo del backtest"""43    trades: List[Trade]44    total_return: float45    total_return_percent: float46    win_rate: float47    profit_factor: float48    max_drawdown: float49    max_drawdown_percent: float50    sharpe_ratio: float51    total_trades: int52    winning_trades: int53    losing_trades: int54    avg_win: float55    avg_loss: float56    best_trade: float57    worst_trade: float58    avg_bars_held: float59    equity_curve: pd.Series60    algorithm_result: AlgorithmResult61    symbol: str = ""62    timeframe: str = ""63 64 65class Backtester:66    """67    Motor de backtesting genérico68    69    Ejecuta cualquier algoritmo y simula las operaciones70    """71    72    def __init__(73        self,74        algorithm: BaseAlgorithm,75        initial_capital: float = 10000,76        risk_percent: float = 1.0,77        commission_pct: float = 0.1,78        position_sizing: str = 'risk',  # 'risk' or 'fixed'79        fixed_position_pct: float = 50.0,  # % del capital por trade si mode='fixed'80        trade_direction: Optional[str] = None  # 'LONG', 'SHORT', or None (ambos)81    ):82        self.algorithm = algorithm83        self.initial_capital = initial_capital84        self.risk_percent = risk_percent85        self.commission_pct = commission_pct86        self.position_sizing = position_sizing87        self.fixed_position_pct = fixed_position_pct88        self.trade_direction = trade_direction  # Filtro de dirección89        90        # Estado del backtest91        self.capital = initial_capital92        self.trades: List[Trade] = []93        self.equity_curve: List[float] = []94        self.current_position: Optional[Trade] = None95        self._current_sl: float = 096        self._entry_bar_index: int = 097    98    def run(99        self,100        df: pd.DataFrame,101        algo_params: Dict[str, Any],102        symbol: str = "",103        timeframe: str = ""104    ) -> BacktestResult:105        """106        Ejecuta el backtest completo107        108        Args:109            df: DataFrame con datos OHLCV110            algo_params: Parámetros para el algoritmo111            symbol: Símbolo del activo (para referencia)112            timeframe: Timeframe usado (para referencia)113            114        Returns:115            BacktestResult con métricas y trades116            117        Raises:118            ValidationException: If DataFrame validation fails119            AlgorithmException: If algorithm execution fails120        """121        try:122            # STEP 1: Validate input DataFrame123            InputValidator.validate_dataframe(df, symbol)124            125            # Reset estado126            self.capital = self.initial_capital127            self.trades = []128            self.equity_curve = [self.initial_capital]129            self.current_position = None130            131            # STEP 2: Execute algorithm132            try:133                algo_result = self.algorithm.run(df, algo_params)134            except Exception as e:135                raise AlgorithmException(136                    f"Algorithm execution failed: {str(e)}",137                    algorithm=self.algorithm.__class__.__name__,138                    details={"error": str(e)}139                )140            141            # STEP 3: Simulate trades based on signals142            for i, state in enumerate(algo_result.states):143                try:144                    current = df.iloc[i]145                    146                    # Procesar señal si existe147                    if state.signal:148                        self._process_signal(state.signal, current, i, algo_result)149                    150                    # Actualizar trailing stop si hay posición151                    if self.current_position:152                        self._update_position(current, i, algo_result, state)153                    154                    # Actualizar equity155                    self._update_equity(current)156                    157                except Exception as e:158                    raise AlgorithmException(159                        f"Error processing bar {i}/{len(df)}: {str(e)}",160                        algorithm=self.algorithm.__class__.__name__,161                        bar_index=i,162                        details={"error": str(e)}163                    )164            165            # Cerrar posición abierta al final166            if self.current_position:167                self._close_position(df.iloc[-1], len(df) - 1, "Fin de datos")168            169            # Calcular métricas170            return self._calculate_results(df, algo_result, symbol, timeframe)171            172        except ValidationException:173            # Re-raise validation errors as-is174            raise175        except AlgorithmException:176            # Re-raise algorithm errors as-is177            raise178        except Exception as e:179            # Catch any unexpected errors and wrap them180            raise AlgorithmException(181                f"Unexpected error during backtest execution: {str(e)}",182                algorithm=self.algorithm.__class__.__name__,183                details={"error": str(e), "symbol": symbol}184            )185    186    def _process_signal(187        self,188        signal: TradeSignal,189        current: pd.Series,190        bar_index: int,191        algo_result: AlgorithmResult192    ):193        """Procesa una señal de trading"""194        195        # Filtrar por dirección si está configurado196        if self.trade_direction is not None:197            if signal.signal_type != self.trade_direction:198                return  # Ignorar señal si no coincide con la dirección configurada199        200        # Cerrar posición opuesta si existe201        if self.current_position:202            if (signal.signal_type == 'LONG' and self.current_position.direction == 'SHORT') or \203               (signal.signal_type == 'SHORT' and self.current_position.direction == 'LONG'):204                self._close_position(current, bar_index, "Cambio de tendencia")205        206        # Abrir nueva posición207        if signal.signal_type in ['LONG', 'SHORT'] and not self.current_position:208            self._open_position(signal, current, bar_index)209    210    def _open_position(self, signal: TradeSignal, current: pd.Series, bar_index: int):211        """Abre una nueva posición"""212        entry_price = current['close']213        initial_sl = signal.stop_loss if signal.stop_loss else (214            current['low'] * 0.99 if signal.signal_type == 'LONG' else current['high'] * 1.01215        )216        217        # Calcular tamaño de posición según el modo218        if self.position_sizing == 'fixed':219            # MODO FIJO: Usar porcentaje fijo del capital220            position_value = self.capital * (self.fixed_position_pct / 100)221            position_size = position_value / entry_price222        else:223            # MODO RIESGO: Calcular basado en distancia al stop224            sl_distance = abs(entry_price - initial_sl)225            226            # PROTECCIÓN 1: Distancia mínima del 0.5% para evitar stops muy cercanos227            min_sl_distance = entry_price * 0.005  # 0.5%228            if sl_distance < min_sl_distance:229                sl_distance = min_sl_distance230                # Ajustar el stop loss al mínimo permitido231                if signal.signal_type == 'LONG':232                    initial_sl = entry_price - min_sl_distance233                else:234                    initial_sl = entry_price + min_sl_distance235            236            risk_amount = self.capital * (self.risk_percent / 100)237            position_size = risk_amount / sl_distance238            239            # PROTECCIÓN 2: Limitar el valor de la posición al 95% del capital disponible240            position_value = position_size * entry_price241            max_position_value = self.capital * 0.95242            243            if position_value > max_position_value:244                position_size = max_position_value / entry_price245        246        # Comisión de entrada247        commission = position_size * entry_price * (self.commission_pct / 100)248        self.capital -= commission249        250        self.current_position = Trade(251            entry_date=signal.timestamp,252            entry_price=entry_price,253            direction=signal.signal_type,254            initial_sl=initial_sl,255            position_size=position_size,256            capital_at_entry=self.capital257        )258        259        self._current_sl = initial_sl260        self._entry_bar_index = bar_index261    262    def _update_position(263        self,264        current: pd.Series,265        bar_index: int,266        algo_result: AlgorithmResult,267        state268    ):269        """Actualiza el trailing stop de la posición abierta"""270        pos = self.current_position271        272        # NO VERIFICAR STOP EN LA BARRA DE ENTRADA273        # En velas grandes, el low/high de la barra de entrada puede estar fuera del stop274        # Esperamos al menos 1 barra para verificar el stop275        if bar_index == self._entry_bar_index:276            return277        278        if pos.direction == 'LONG':279            # Verificar stop loss hit280            if current['low'] <= self._current_sl:281                self._close_position(current, bar_index, "Stop Loss")282                return283            284            # Actualizar SL si el soporte subió285            if state.support is not None:286                new_sl = state.support287                # Debug: contar cuántas veces se intenta actualizar288                if not hasattr(pos, '_debug_checks'):289                    pos._debug_checks = 0290                pos._debug_checks += 1291                292                if new_sl > self._current_sl:293                    pos.sl_updates.append(new_sl)294                    self._current_sl = new_sl295            else:296                # Debug: contar None297                if not hasattr(pos, '_debug_none_count'):298                    pos._debug_none_count = 0299                pos._debug_none_count += 1300                301        else:  # SHORT302            # Verificar stop loss hit303            if current['high'] >= self._current_sl:304                self._close_position(current, bar_index, "Stop Loss")305                return306            307            # Actualizar SL si la resistencia bajó308            if state.resistance is not None:309                new_sl = state.resistance310                # Debug: contar cuántas veces se intenta actualizar311                if not hasattr(pos, '_debug_checks'):312                    pos._debug_checks = 0313                pos._debug_checks += 1314                315                if new_sl < self._current_sl:316                    pos.sl_updates.append(new_sl)317                    self._current_sl = new_sl318            else:319                # Debug: contar None320                if not hasattr(pos, '_debug_none_count'):321                    pos._debug_none_count = 0322                pos._debug_none_count += 1323    324    def _close_position(self, bar: pd.Series, bar_index: int, exit_reason: str):325        """Cierra la posición actual"""326        pos = self.current_position327        exit_price = self._current_sl if exit_reason == "Stop Loss" else bar['close']328        329        # Calcular P&L330        if pos.direction == 'LONG':331            pnl = (exit_price - pos.entry_price) * pos.position_size332        else:333            pnl = (pos.entry_price - exit_price) * pos.position_size334        335        # Comisión de salida336        commission = pos.position_size * exit_price * (self.commission_pct / 100)337        pnl -= commission338        339        # Actualizar capital340        self.capital += pnl341        342        # Completar trade343        pos.exit_date = bar.name344        pos.exit_price = exit_price345        pos.pnl = pnl346        pos.pnl_percent = (pnl / pos.capital_at_entry) * 100347        pos.bars_held = bar_index - self._entry_bar_index348        pos.exit_reason = exit_reason349        350        self.trades.append(pos)351        self.current_position = None352    353    def _update_equity(self, bar: pd.Series):354        """Actualiza la curva de equity"""355        if self.current_position:356            pos = self.current_position357            if pos.direction == 'LONG':358                unrealized = (bar['close'] - pos.entry_price) * pos.position_size359            else:360                unrealized = (pos.entry_price - bar['close']) * pos.position_size361            self.equity_curve.append(self.capital + unrealized)362        else:363            self.equity_curve.append(self.capital)364    365    def _calculate_results(366        self,367        df: pd.DataFrame,368        algo_result: AlgorithmResult,369        symbol: str,370        timeframe: str371    ) -> BacktestResult:372        """Calcula las métricas finales del backtest"""373        374        if not self.trades:375            return BacktestResult(376                trades=[],377                total_return=0,378                total_return_percent=0,379                win_rate=0,380                profit_factor=0,381                max_drawdown=0,382                max_drawdown_percent=0,383                sharpe_ratio=0,384                total_trades=0,385                winning_trades=0,386                losing_trades=0,387                avg_win=0,388                avg_loss=0,389                best_trade=0,390                worst_trade=0,391                avg_bars_held=0,392                equity_curve=pd.Series(self.equity_curve),393                algorithm_result=algo_result,394                symbol=symbol,395                timeframe=timeframe396            )397        398        # Métricas básicas399        total_trades = len(self.trades)400        winning = [t for t in self.trades if t.pnl > 0]401        losing = [t for t in self.trades if t.pnl <= 0]402        403        win_rate = len(winning) / total_trades * 100 if total_trades > 0 else 0404        405        # P&L406        total_return = self.capital - self.initial_capital407        total_return_percent = (total_return / self.initial_capital) * 100408        409        # Promedios410        avg_win = np.mean([t.pnl for t in winning]) if winning else 0411        avg_loss = abs(np.mean([t.pnl for t in losing])) if losing else 0412        413        # Profit Factor414        gross_profit = sum([t.pnl for t in winning]) if winning else 0415        gross_loss = abs(sum([t.pnl for t in losing])) if losing else 1416        profit_factor = gross_profit / gross_loss if gross_loss > 0 else 0417        418        # Max Drawdown419        equity = pd.Series(self.equity_curve)420        rolling_max = equity.expanding().max()421        drawdown = equity - rolling_max422        max_drawdown = drawdown.min()423        max_dd_pct = (max_drawdown / rolling_max[drawdown.idxmin()]) * 100 if len(equity) > 0 else 0424        425        # Sharpe Ratio426        returns = equity.pct_change().dropna()427        sharpe = (returns.mean() / returns.std()) * np.sqrt(252) if returns.std() > 0 else 0428        429        # Best/Worst430        pnls = [t.pnl_percent for t in self.trades]431        best = max(pnls) if pnls else 0432        worst = min(pnls) if pnls else 0433        434        # Avg bars435        avg_bars = np.mean([t.bars_held for t in self.trades])436        437        return BacktestResult(438            trades=self.trades,439            total_return=total_return,440            total_return_percent=total_return_percent,441            win_rate=win_rate,442            profit_factor=profit_factor,443            max_drawdown=max_drawdown,444            max_drawdown_percent=max_dd_pct,445            sharpe_ratio=sharpe,446            total_trades=total_trades,447            winning_trades=len(winning),448            losing_trades=len(losing),449            avg_win=avg_win,450            avg_loss=avg_loss,451            best_trade=best,452            worst_trade=worst,453            avg_bars_held=avg_bars,454            equity_curve=equity,455            algorithm_result=algo_result,456            symbol=symbol,457            timeframe=timeframe458        )459