diegobeyl/backtesting
2
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 