|
| 1 | +"""Regression tests for P12-b — the ccxt loader must fail fast instead of |
| 2 | +hanging on a transient disconnect. |
| 3 | +
|
| 4 | +Pre-fix: `_fetch_one` called `exchange.fetch_ohlcv` with no per-call timeout, |
| 5 | +no retry, and no wall-clock budget, so a flaky connection hung |
| 6 | +`get_market_data` for 10+ minutes. Post-fix: bounded retry on the transient |
| 7 | +`ccxt.NetworkError` family + a hard budget that raises a clear `TimeoutError`; |
| 8 | +the happy path is unchanged (one call per page). |
| 9 | +""" |
| 10 | + |
| 11 | +from __future__ import annotations |
| 12 | + |
| 13 | +import pandas as pd |
| 14 | +import pytest |
| 15 | + |
| 16 | +import ccxt |
| 17 | + |
| 18 | +import backtest.loaders.ccxt_loader as cl |
| 19 | +from backtest.loaders.ccxt_loader import DataLoader |
| 20 | + |
| 21 | +SINCE = int(pd.Timestamp("2026-05-01").timestamp() * 1000) |
| 22 | +END = int((pd.Timestamp("2026-05-05") + pd.Timedelta(days=1)).timestamp() * 1000) |
| 23 | + |
| 24 | + |
| 25 | +def _bars(n: int = 4) -> list: |
| 26 | + base = int(pd.Timestamp("2026-05-01").timestamp() * 1000) |
| 27 | + day = 86_400_000 |
| 28 | + return [[base + i * day, 100 + i, 101 + i, 99 + i, 100 + i, 10 + i] for i in range(n)] |
| 29 | + |
| 30 | + |
| 31 | +class _FakeEx: |
| 32 | + """Scripted exchange: each fetch_ohlcv call consumes the next script item; |
| 33 | + an Exception item is raised, a list item is returned.""" |
| 34 | + |
| 35 | + def __init__(self, script: list) -> None: |
| 36 | + self.script = script |
| 37 | + self.calls = 0 |
| 38 | + |
| 39 | + def fetch_ohlcv(self, symbol, timeframe, since=None, limit=None): |
| 40 | + item = self.script[min(self.calls, len(self.script) - 1)] |
| 41 | + self.calls += 1 |
| 42 | + if isinstance(item, BaseException): |
| 43 | + raise item |
| 44 | + return item |
| 45 | + |
| 46 | + |
| 47 | +@pytest.fixture(autouse=True) |
| 48 | +def _no_sleep(monkeypatch): |
| 49 | + monkeypatch.setattr(cl.time, "sleep", lambda *_a, **_k: None) |
| 50 | + |
| 51 | + |
| 52 | +def test_transient_networkerror_retried_then_succeeds(): |
| 53 | + ex = _FakeEx([ccxt.NetworkError("blip"), ccxt.NetworkError("blip"), _bars(), []]) |
| 54 | + df = DataLoader._fetch_one(ex, "BTC/USDT", "1d", SINCE, END) |
| 55 | + assert ex.calls >= 3 |
| 56 | + assert df is not None and not df.empty |
| 57 | + |
| 58 | + |
| 59 | +def test_persistent_disconnect_is_bounded_not_a_hang(): |
| 60 | + """The old 10-min hang: now a bounded TimeoutError after a fixed budget.""" |
| 61 | + ex = _FakeEx([ccxt.NetworkError("down")]) # always fails |
| 62 | + with pytest.raises(TimeoutError): |
| 63 | + DataLoader._fetch_one(ex, "BTC/USDT", "1d", SINCE, END) |
| 64 | + assert ex.calls == cl._CCXT_MAX_RETRIES + 1 # bounded, not range(200)/forever |
| 65 | + |
| 66 | + |
| 67 | +def test_non_network_error_is_not_retried(): |
| 68 | + ex = _FakeEx([ccxt.ExchangeError("bad symbol")]) |
| 69 | + with pytest.raises(ccxt.ExchangeError): |
| 70 | + DataLoader._fetch_one(ex, "BTC/USDT", "1d", SINCE, END) |
| 71 | + assert ex.calls == 1 |
| 72 | + |
| 73 | + |
| 74 | +def test_happy_path_single_call_unchanged(): |
| 75 | + ex = _FakeEx([_bars(), []]) |
| 76 | + df = DataLoader._fetch_one(ex, "BTC/USDT", "1d", SINCE, END) |
| 77 | + assert ex.calls == 1 # short page (< limit) -> exactly one call, as before |
| 78 | + assert list(df.columns) == ["open", "high", "low", "close", "volume"] |
| 79 | + |
| 80 | + |
| 81 | +def test_wallclock_budget_enforced(monkeypatch): |
| 82 | + seq = iter([1000.0, 1000.0, 1_000_000.0]) # deadline blown by the retry check |
| 83 | + monkeypatch.setattr(cl.time, "monotonic", lambda: next(seq, 1_000_000.0)) |
| 84 | + ex = _FakeEx([ccxt.NetworkError("slow")]) |
| 85 | + with pytest.raises(TimeoutError): |
| 86 | + DataLoader._fetch_one(ex, "BTC/USDT", "1d", SINCE, END) |
| 87 | + |
| 88 | + |
| 89 | +def test_get_exchange_sets_explicit_timeout(): |
| 90 | + ex = DataLoader()._get_exchange() |
| 91 | + assert ex.timeout == cl._CCXT_TIMEOUT_MS |
0 commit comments