Team Ai
Apppublic

diegobeyl/backtesting

sourceHugging Faceupdated 9mo agoView on Hugging Face
2likes
TESTS_RECOMENDADOS.py496 linesDownload Raw Back to tests
1"""
2Suite de Tests Recomendados para V2
3Pruebas unitarias, integración y carga
4"""
5
6# ============================================================================
7# tests/test_validators.py
8# ============================================================================
9"""Tests para validadores de entrada"""
10
11import pytest
12import pandas as pd
13import numpy as np
14from datetime import datetime, timedelta
15from utils.validators import InputValidator
16from utils.exceptions import ValidationException
17
18
19class TestInputValidator:
20    """Tests para InputValidator"""
21    
22    @pytest.fixture
23    def valid_dataframe(self):
24        """DataFrame válido para backtesting"""
25        return pd.DataFrame({
26            'open': [100, 101, 102, 103, 104],
27            'high': [101, 102, 103, 104, 105],
28            'low': [99, 100, 101, 102, 103],
29            'close': [100.5, 101.5, 102.5, 103.5, 104.5],
30            'volume': [1000, 1100, 1200, 1300, 1400]
31        }, index=pd.date_range('2024-01-01', periods=5))
32    
33    def test_valid_dataframe_passes(self, valid_dataframe):
34        """DataFrame válido debe pasar validación"""
35        assert InputValidator.validate_dataframe(valid_dataframe) is True
36    
37    def test_none_dataframe_raises(self):
38        """None debe lanzar excepción"""
39        with pytest.raises(ValidationException) as exc:
40            InputValidator.validate_dataframe(None)
41        assert "None" in str(exc.value)
42    
43    def test_empty_dataframe_raises(self):
44        """DataFrame vacío debe lanzar excepción"""
45        df = pd.DataFrame()
46        with pytest.raises(ValidationException) as exc:
47            InputValidator.validate_dataframe(df)
48        assert "empty" in str(exc.value)
49    
50    def test_insufficient_data_raises(self):
51        """Menos de 10 candles debe lanzar excepción"""
52        df = pd.DataFrame({
53            'open': [100, 101],
54            'high': [101, 102],
55            'low': [99, 100],
56            'close': [100.5, 101.5],
57            'volume': [1000, 1100]
58        })
59        with pytest.raises(ValidationException) as exc:
60            InputValidator.validate_dataframe(df)
61        assert "candles" in str(exc.value)
62    
63    def test_missing_columns_raises(self, valid_dataframe):
64        """DataFrame sin columnas OHLCV debe lanzar excepción"""
65        df = valid_dataframe[['open', 'close']]  # Faltan high, low, volume
66        with pytest.raises(ValidationException) as exc:
67            InputValidator.validate_dataframe(df)
68        assert "Missing required columns" in str(exc.value)
69    
70    def test_nan_values_raises(self, valid_dataframe):
71        """DataFrame con NaN debe lanzar excepción"""
72        df = valid_dataframe.copy()
73        df.loc[2, 'close'] = np.nan
74        with pytest.raises(ValidationException) as exc:
75            InputValidator.validate_dataframe(df)
76        assert "NaN" in str(exc.value)
77    
78    def test_negative_prices_raises(self, valid_dataframe):
79        """Precios negativos o cero deben lanzar excepción"""
80        df = valid_dataframe.copy()
81        df.loc[0, 'close'] = -100
82        with pytest.raises(ValidationException) as exc:
83            InputValidator.validate_dataframe(df)
84        assert "positive" in str(exc.value)
85    
86    def test_high_less_than_low_raises(self, valid_dataframe):
87        """High < Low debe lanzar excepción"""
88        df = valid_dataframe.copy()
89        df.loc[0, 'high'] = 50
90        with pytest.raises(ValidationException) as exc:
91            InputValidator.validate_dataframe(df)
92        assert "High must be >= Low" in str(exc.value)
93    
94    def test_duplicate_timestamps_raises(self, valid_dataframe):
95        """Timestamps duplicados deben lanzar excepción"""
96        df = valid_dataframe.copy()
97        df.index = [pd.Timestamp('2024-01-01')] * 5
98        with pytest.raises(ValidationException) as exc:
99            InputValidator.validate_dataframe(df)
100        assert "Duplicate" in str(exc.value)
101    
102    def test_unordered_timestamps_raises(self, valid_dataframe):
103        """Timestamps desordenados deben lanzar excepción"""
104        df = valid_dataframe.copy()
105        df = df.iloc[[0, 2, 1, 3, 4]]
106        with pytest.raises(ValidationException) as exc:
107            InputValidator.validate_dataframe(df)
108        assert "ascending order" in str(exc.value)
109    
110    # Pruebas para validación de parámetros
111    def test_valid_backtest_params_pass(self):
112        """Parámetros válidos deben pasar"""
113        assert InputValidator.validate_backtest_params(
114            capital=10000,
115            risk_percent=2.0,
116            commission_pct=0.1,
117            position_sizing='risk'
118        ) is True
119    
120    def test_zero_capital_raises(self):
121        """Capital cero debe lanzar excepción"""
122        with pytest.raises(ValidationException) as exc:
123            InputValidator.validate_backtest_params(
124                capital=0,
125                risk_percent=2.0,
126                commission_pct=0.1,
127                position_sizing='risk'
128            )
129        assert "positive" in str(exc.value)
130    
131    def test_negative_capital_raises(self):
132        """Capital negativo debe lanzar excepción"""
133        with pytest.raises(ValidationException) as exc:
134            InputValidator.validate_backtest_params(
135                capital=-1000,
136                risk_percent=2.0,
137                commission_pct=0.1,
138                position_sizing='risk'
139            )
140        assert "positive" in str(exc.value)
141    
142    def test_invalid_risk_percent_raises(self):
143        """Risk percent fuera de rango debe lanzar excepción"""
144        with pytest.raises(ValidationException):
145            InputValidator.validate_backtest_params(
146                capital=10000,
147                risk_percent=0,  # Debe ser > 0
148                commission_pct=0.1,
149                position_sizing='risk'
150            )
151        
152        with pytest.raises(ValidationException):
153            InputValidator.validate_backtest_params(
154                capital=10000,
155                risk_percent=150,  # Debe ser <= 100
156                commission_pct=0.1,
157                position_sizing='risk'
158            )
159    
160    def test_invalid_position_sizing_raises(self):
161        """Position sizing inválido debe lanzar excepción"""
162        with pytest.raises(ValidationException) as exc:
163            InputValidator.validate_backtest_params(
164                capital=10000,
165                risk_percent=2.0,
166                commission_pct=0.1,
167                position_sizing='invalid'
168            )
169        assert "risk" in str(exc.value)
170    
171    # Pruebas para símbolo
172    def test_valid_symbol_passes(self):
173        """Símbolo válido debe pasar"""
174        assert InputValidator.validate_symbol("BTC-USD") is True
175        assert InputValidator.validate_symbol("AAPL") is True
176        assert InputValidator.validate_symbol("ETH/USDT") is True
177    
178    def test_empty_symbol_raises(self):
179        """Símbolo vacío debe lanzar excepción"""
180        with pytest.raises(ValidationException):
181            InputValidator.validate_symbol("")
182    
183    def test_long_symbol_raises(self):
184        """Símbolo muy largo debe lanzar excepción"""
185        with pytest.raises(ValidationException):
186            InputValidator.validate_symbol("A" * 50)
187    
188    def test_invalid_characters_in_symbol_raises(self):
189        """Caracteres inválidos en símbolo debe lanzar excepción"""
190        with pytest.raises(ValidationException):
191            InputValidator.validate_symbol("BTC@USD!")
192    
193    # Pruebas para rango de fechas
194    def test_valid_date_range_passes(self):
195        """Rango de fechas válido debe pasar"""
196        start = datetime(2024, 1, 1)
197        end = datetime(2024, 12, 31)
198        assert InputValidator.validate_date_range(start, end) is True
199    
200    def test_start_after_end_raises(self):
201        """Start > End debe lanzar excepción"""
202        start = datetime(2024, 12, 31)
203        end = datetime(2024, 1, 1)
204        with pytest.raises(ValidationException) as exc:
205            InputValidator.validate_date_range(start, end)
206        assert "before" in str(exc.value)
207    
208    def test_same_date_raises(self):
209        """Misma start y end debe lanzar excepción"""
210        date = datetime(2024, 1, 1)
211        with pytest.raises(ValidationException):
212            InputValidator.validate_date_range(date, date)
213    
214    def test_too_long_range_raises(self):
215        """Rango > 10 años debe lanzar excepción"""
216        start = datetime(2000, 1, 1)
217        end = datetime(2025, 1, 1)  # 25 años
218        with pytest.raises(ValidationException) as exc:
219            InputValidator.validate_date_range(start, end)
220        assert "10 years" in str(exc.value)
221
222
223# ============================================================================
224# tests/test_backtester.py
225# ============================================================================
226"""Tests para Backtester"""
227
228import pytest
229import pandas as pd
230from backtesting_app.core.backtester import Backtester, BacktestResult
231from backtesting_app.algorithms.base import BaseAlgorithm
232
233
234class MockAlgorithm(BaseAlgorithm):
235    """Algoritmo mock para tests"""
236    
237    def __init__(self):
238        super().__init__("Mock", "1.0")
239    
240    def get_default_params(self):
241        return {'threshold': 1.0}
242    
243    def get_param_descriptions(self):
244        return {'threshold': 'Mock threshold'}
245    
246    def validate_params(self, params):
247        return True
248    
249    def run(self, df, params):
250        # Mock: retorna resultado vacío
251        from backtesting_app.algorithms.base import AlgorithmResult, TrendState
252        return AlgorithmResult(
253            states=[],
254            signals=[],
255            support_history=[],
256            resistance_history=[],
257            trend_history=[]
258        )
259
260
261class TestBacktester:
262    """Tests para Backtester"""
263    
264    @pytest.fixture
265    def backtester(self):
266        return Backtester(
267            algorithm=MockAlgorithm(),
268            initial_capital=10000,
269            risk_percent=2.0,
270            commission_pct=0.1
271        )
272    
273    @pytest.fixture
274    def sample_data(self):
275        """Datos OHLCV de ejemplo"""
276        return pd.DataFrame({
277            'open': [100, 101, 102, 103, 104, 105, 106, 107, 108, 109],
278            'high': [101, 102, 103, 104, 105, 106, 107, 108, 109, 110],
279            'low': [99, 100, 101, 102, 103, 104, 105, 106, 107, 108],
280            'close': [100.5, 101.5, 102.5, 103.5, 104.5, 105.5, 106.5, 107.5, 108.5, 109.5],
281            'volume': [1000] * 10
282        }, index=pd.date_range('2024-01-01', periods=10))
283    
284    def test_backtest_runs_successfully(self, backtester, sample_data):
285        """Backtest debe completarse sin errores"""
286        result = backtester.run(sample_data, {'threshold': 1.0})
287        assert isinstance(result, BacktestResult)
288        assert result.symbol == ""
289        assert result.total_trades >= 0
290    
291    def test_backtest_with_symbol(self, backtester, sample_data):
292        """Backtest debe guardar símbolo"""
293        result = backtester.run(
294            sample_data,
295            {'threshold': 1.0},
296            symbol="BTC-USD"
297        )
298        assert result.symbol == "BTC-USD"
299    
300    def test_initial_equity_equals_capital(self, backtester, sample_data):
301        """Equity inicial debe ser igual al capital"""
302        result = backtester.run(sample_data, {'threshold': 1.0})
303        assert result.equity_curve[0] == 10000
304    
305    def test_equity_never_negative(self, backtester, sample_data):
306        """Equity nunca debe ser negativo"""
307        result = backtester.run(sample_data, {'threshold': 1.0})
308        assert (result.equity_curve >= 0).all()
309    
310    def test_equity_decreases_on_loss(self, backtester, sample_data):
311        """Equity debe disminuir en operaciones perdedoras"""
312        # Con datos iguales, debería haber al menos retención de capital
313        result = backtester.run(sample_data, {'threshold': 1.0})
314        # Solo validar que existe equity curve
315        assert len(result.equity_curve) > 0
316    
317    def test_win_rate_valid_range(self, backtester, sample_data):
318        """Win rate debe estar entre 0 y 1"""
319        result = backtester.run(sample_data, {'threshold': 1.0})
320        assert 0 <= result.win_rate <= 1
321    
322    def test_total_trades_matches_trades_list(self, backtester, sample_data):
323        """total_trades debe ser = len(trades)"""
324        result = backtester.run(sample_data, {'threshold': 1.0})
325        assert result.total_trades == len(result.trades)
326    
327    def test_max_drawdown_valid_range(self, backtester, sample_data):
328        """Max drawdown debe estar entre -1 y 0"""
329        result = backtester.run(sample_data, {'threshold': 1.0})
330        assert -1 <= result.max_drawdown <= 0
331    
332    def test_backtest_with_empty_data_raises(self, backtester):
333        """Backtest con datos vacíos debe fallar"""
334        from utils.exceptions import ValidationException
335        with pytest.raises(ValidationException):
336            backtester.run(pd.DataFrame(), {'threshold': 1.0})
337    
338    def test_backtest_with_nan_data_raises(self, backtester, sample_data):
339        """Backtest con NaN debe fallar"""
340        from utils.exceptions import ValidationException
341        df = sample_data.copy()
342        df.loc[0, 'close'] = float('nan')
343        with pytest.raises(ValidationException):
344            backtester.run(df, {'threshold': 1.0})
345
346
347# ============================================================================
348# tests/test_cache.py
349# ============================================================================
350"""Tests para caché optimizado"""
351
352import pytest
353from utils.cache_optimized import OptimizedCache
354
355
356class TestOptimizedCache:
357    """Tests para caché optimizado"""
358    
359    @pytest.fixture
360    def cache(self, tmp_path):
361        return OptimizedCache(str(tmp_path / "cache"))
362    
363    def test_cache_stores_and_retrieves(self, cache):
364        """Caché debe almacenar y recuperar valores"""
365        cache.set("key1", {"data": "value"}, expire_hours=1)
366        value = cache.get("key1")
367        assert value == {"data": "value"}
368    
369    def test_cache_returns_none_for_missing_key(self, cache):
370        """Caché debe retornar None para keys faltantes"""
371        value = cache.get("nonexistent")
372        assert value is None
373    
374    def test_cache_decorator(self, cache):
375        """Decorador de caché debe funcionar"""
376        call_count = 0
377        
378        @cache.cached(expire_hours=1)
379        def expensive_function(x):
380            nonlocal call_count
381            call_count += 1
382            return x * 2
383        
384        # Primera llamada - ejecuta función
385        result1 = expensive_function(5)
386        assert result1 == 10
387        assert call_count == 1
388        
389        # Segunda llamada - desde caché
390        result2 = expensive_function(5)
391        assert result2 == 10
392        assert call_count == 1  # No se incrementó
393    
394    def test_cache_different_args_different_keys(self, cache):
395        """Argumentos diferentes deben generar keys diferentes"""
396        call_count = 0
397        
398        @cache.cached(expire_hours=1)
399        def multiply(x, y):
400            nonlocal call_count
401            call_count += 1
402            return x * y
403        
404        result1 = multiply(2, 3)
405        result2 = multiply(3, 4)
406        assert result1 == 6
407        assert result2 == 12
408        assert call_count == 2  # Dos llamadas, no desde caché
409    
410    def test_cache_stats_tracking(self, cache):
411        """Caché debe rastrear hits y misses"""
412        @cache.cached(expire_hours=1)
413        def get_value(x):
414            return x * 2
415        
416        # Miss
417        get_value(1)
418        stats = cache.get_stats()
419        assert stats['misses'] == 1
420        
421        # Hit
422        get_value(1)
423        stats = cache.get_stats()
424        assert stats['hits'] == 1
425        assert stats['hit_rate'].startswith("50")  # 50%
426
427
428# ============================================================================
429# tests/test_integration.py
430# ============================================================================
431"""Tests de integración"""
432
433import pytest
434import pandas as pd
435from datetime import datetime
436
437
438class TestIntegration:
439    """Tests de integración"""
440    
441    @pytest.fixture
442    def backtest_request(self):
443        """Request de backtest válido"""
444        return {
445            'symbol': 'BTC-USD',
446            'start_date': datetime(2024, 1, 1),
447            'end_date': datetime(2024, 3, 31),
448            'timeframe': 'D1',
449            'capital': 10000,
450            'risk_percent': 2.0,
451            'commission_pct': 0.1,
452            'algorithm': 'FAST',
453            'params': {}
454        }
455    
456    @pytest.mark.asyncio
457    async def test_full_backtest_workflow(self, backtest_request):
458        """Test del flujo completo: cargar datos -> validar -> ejecutar"""
459        # Este test requeriría datos reales o mocks complejos
460        # Aquí es pseudocódigo
461        pass
462    
463    @pytest.mark.asyncio
464    async def test_backtest_queue_workflow(self):
465        """Test de la fila de backtests"""
466        # Pseudocódigo
467        pass
468
469
470# ============================================================================
471# Cómo ejecutar los tests
472# ============================================================================
473"""
474# Instalar pytest
475pip install pytest pytest-asyncio pytest-cov
476
477# Ejecutar todos los tests
478pytest
479
480# Ejecutar tests específicos
481pytest tests/test_validators.py
482pytest tests/test_backtester.py::TestBacktester::test_backtest_runs_successfully
483
484# Con cobertura
485pytest --cov=. --cov-report=html
486
487# Con verbosidad
488pytest -v
489
490# Solo tests rápidos (excluir lentos)
491pytest -m "not slow"
492
493# En paralelo (instalar pytest-xdist)
494pytest -n auto
495"""
496