File size: 9,517 Bytes
46f1a78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
"""Phase 0 acceptance: provider chain, network gate, incremental refresh.

No test here touches the network; provider fetchers are substituted.
"""

from __future__ import annotations

import time

import pandas as pd
import pytest

from src import config, data
from src.data import (
    NetworkNotAllowed,
    ProviderError,
    RateLimiter,
    allow_network,
    fetch_ohlcv,
    missing_price_ranges,
    refresh,
)
from src.store import SignalStore


@pytest.fixture
def store(tmp_path) -> SignalStore:
    return SignalStore(repo_id=None, local_root=tmp_path / "store", offline=True)


def ohlcv(n=30, start="2024-01-01", freq="D", source="fake", base=100.0) -> pd.DataFrame:
    ts = pd.date_range(start, periods=n, freq=freq, tz="UTC")
    close = pd.Series([base + i for i in range(n)], dtype="float64")
    return pd.DataFrame({
        "ts": ts, "open": close * 0.99, "high": close * 1.02,
        "low": close * 0.98, "close": close, "volume": 1000.0, "source": source,
    })


@pytest.fixture
def fake_chain(monkeypatch):
    """Replace every provider fetcher with a recorder we control."""
    calls: list[str] = []

    def make(name, *, rows=30, fail=False, start="2024-01-01"):
        def _f(spec, asset, tf, s, e):
            calls.append(name)
            if fail:
                raise ProviderError(f"{name} is down")
            return ohlcv(n=rows, start=start, source=name)
        return _f

    monkeypatch.setattr(data, "_FETCHERS", {}, raising=False)
    return calls, make


# --------------------------------------------------------------------------
# Network gate — user-facing paths must never reach a provider
# --------------------------------------------------------------------------


def test_fetch_is_blocked_outside_the_refresh_path():
    with pytest.raises(NetworkNotAllowed, match="cached store"):
        fetch_ohlcv("BTC-USD", "1d", "2024-01-01", "2024-02-01")


def test_gate_closes_again_after_the_context_exits():
    assert not data.network_allowed()
    with allow_network():
        assert data.network_allowed()
    assert not data.network_allowed()


def test_gate_is_thread_local():
    import threading

    seen = {}

    def worker():
        seen["other"] = data.network_allowed()

    with allow_network():
        t = threading.Thread(target=worker)
        t.start()
        t.join()
    assert seen["other"] is False


# --------------------------------------------------------------------------
# Chain walk / fallback
# --------------------------------------------------------------------------


def test_primary_provider_wins(fake_chain, monkeypatch):
    calls, make = fake_chain
    monkeypatch.setitem(data._FETCHERS, "binance", make("binance"))
    monkeypatch.setitem(data._FETCHERS, "coinbase", make("coinbase"))
    with allow_network():
        res = fetch_ohlcv("BTC-USD", "1d", "2024-01-01", "2024-02-01")
    assert res.source == "binance"
    assert calls == ["binance"]


def test_falls_back_when_primary_fails(fake_chain, monkeypatch):
    calls, make = fake_chain
    monkeypatch.setitem(data._FETCHERS, "binance", make("binance", fail=True))
    monkeypatch.setitem(data._FETCHERS, "coinbase", make("coinbase"))
    with allow_network():
        res = fetch_ohlcv("BTC-USD", "1d", "2024-01-01", "2024-02-01")
    assert res.source == "coinbase"
    assert calls == ["binance", "coinbase"]


def test_exhausted_chain_raises_with_every_reason(fake_chain, monkeypatch):
    _, make = fake_chain
    monkeypatch.setitem(data._FETCHERS, "binance", make("binance", fail=True))
    monkeypatch.setitem(data._FETCHERS, "coinbase", make("coinbase", fail=True))
    with allow_network():
        with pytest.raises(ProviderError) as exc:
            fetch_ohlcv("BTC-USD", "1d", "2024-01-01", "2024-02-01")
    assert "binance is down" in str(exc.value)
    assert "coinbase is down" in str(exc.value)


def test_equity_and_crypto_use_different_chains():
    crypto = [p.name for p in config.providers_for("crypto")]
    equity = [p.name for p in config.providers_for("equity")]
    assert crypto[:2] == ["binance", "coinbase"]
    assert equity[:2] == ["yfinance", "stooq"]
    assert not set(crypto) & set(equity)


def test_tiingo_only_joins_the_chain_when_its_key_is_present(monkeypatch):
    monkeypatch.delenv("TIINGO_KEY", raising=False)
    assert "tiingo" not in [p.name for p in config.providers_for("equity")]
    monkeypatch.setenv("TIINGO_KEY", "x")
    assert "tiingo" in [p.name for p in config.providers_for("equity")]


def test_unknown_asset_is_rejected():
    with allow_network():
        with pytest.raises(ProviderError, match="unknown asset"):
            fetch_ohlcv("DOGE-USD", "1d", "2024-01-01", "2024-02-01")


# --------------------------------------------------------------------------
# Rate limiting + backoff
# --------------------------------------------------------------------------


def test_rate_limiter_spaces_calls():
    name = "unit-test-limiter"
    RateLimiter.wait(name, 0.0)
    t0 = time.monotonic()
    RateLimiter.wait(name, 0.15)
    assert time.monotonic() - t0 >= 0.14


def test_backoff_retries_then_succeeds(monkeypatch):
    monkeypatch.setattr(time, "sleep", lambda s: None)
    spec = config.ProviderSpec("retry-test", ("crypto",), min_interval_s=0.0,
                               max_retries=4, backoff_base_s=1.0)
    attempts = {"n": 0}

    def flaky():
        attempts["n"] += 1
        if attempts["n"] < 3:
            raise RuntimeError("transient")
        return "ok"

    assert data.with_backoff(spec, flaky) == "ok"
    assert attempts["n"] == 3


def test_backoff_gives_up_and_reports(monkeypatch):
    monkeypatch.setattr(time, "sleep", lambda s: None)
    spec = config.ProviderSpec("giveup-test", ("crypto",), min_interval_s=0.0,
                               max_retries=3, backoff_base_s=1.0)

    def always_fails():
        raise RuntimeError("nope")

    with pytest.raises(ProviderError, match="exhausted retries"):
        data.with_backoff(spec, always_fails)


# --------------------------------------------------------------------------
# Incremental refresh — only fetch what is missing
# --------------------------------------------------------------------------


def test_missing_ranges_on_empty_cache(store):
    gaps = missing_price_ranges(store, "BTC-USD", "1d", "2024-01-01", "2024-03-01")
    assert len(gaps) == 1


def test_missing_ranges_fully_cached(store):
    store.write_prices("BTC-USD", "1d", ohlcv(n=60, start="2024-01-01"))
    assert missing_price_ranges(store, "BTC-USD", "1d",
                                "2024-01-10", "2024-02-10") == []


def test_missing_ranges_finds_both_edges(store):
    store.write_prices("BTC-USD", "1d", ohlcv(n=30, start="2024-02-01"))
    gaps = missing_price_ranges(store, "BTC-USD", "1d", "2024-01-01", "2024-04-01")
    assert len(gaps) == 2
    assert gaps[0][0] < pd.Timestamp("2024-02-01", tz="UTC")
    assert gaps[1][1] > pd.Timestamp("2024-03-01", tz="UTC")


def test_refresh_skips_a_fully_cached_range(store, fake_chain, monkeypatch):
    calls, make = fake_chain
    monkeypatch.setitem(data._FETCHERS, "binance", make("binance", rows=60))
    store.write_prices("BTC-USD", "1d", ohlcv(n=60, start="2024-01-01"))

    rep = refresh(store, "BTC-USD", "1d", "2024-01-10", "2024-02-10")
    assert rep.skipped_cached and rep.rows_added == 0
    assert calls == []  # no provider was contacted


def test_refresh_fetches_and_writes(store, fake_chain, monkeypatch):
    calls, make = fake_chain
    monkeypatch.setitem(data._FETCHERS, "binance", make("binance", rows=30))
    rep = refresh(store, "BTC-USD", "1d", "2024-01-01", "2024-01-30")
    assert rep.ok and rep.rows_added == 30
    assert rep.sources == ["binance"]
    assert len(store.get_prices("BTC-USD", "1d")) == 30


def test_refresh_reports_failure_rather_than_returning_partial_data(
    store, fake_chain, monkeypatch
):
    _, make = fake_chain
    monkeypatch.setitem(data._FETCHERS, "binance", make("binance", fail=True))
    monkeypatch.setitem(data._FETCHERS, "coinbase", make("coinbase", fail=True))
    rep = refresh(store, "BTC-USD", "1d", "2024-01-01", "2024-01-30")
    assert not rep.ok
    assert rep.rows_added == 0
    assert "FAILED" in rep.summary()


def test_refresh_surfaces_dirty_data_instead_of_writing_it(
    store, fake_chain, monkeypatch
):
    def bad(spec, asset, tf, s, e):
        df = ohlcv(n=30, source="binance")
        df.loc[5, "close"] = -1.0
        return df

    monkeypatch.setitem(data._FETCHERS, "binance", bad)
    monkeypatch.setitem(data._FETCHERS, "coinbase", bad)
    rep = refresh(store, "BTC-USD", "1d", "2024-01-01", "2024-01-30")
    assert not rep.ok
    assert any("non-positive" in e for e in rep.errors)
    assert store.get_prices("BTC-USD", "1d").empty


def test_refresh_records_source_per_row(store, fake_chain, monkeypatch):
    _, make = fake_chain
    monkeypatch.setitem(data._FETCHERS, "binance", make("binance", rows=20))
    refresh(store, "BTC-USD", "1d", "2024-01-01", "2024-01-20")
    got = store.get_prices("BTC-USD", "1d")
    assert set(got["source"].unique()) == {"binance"}


def test_provider_depth_limit_is_recorded_as_a_boundary_not_an_error(store):
    """Yahoo serves ~730d of 1h bars; that is coverage truth, not a failure."""
    store.write_prices("SPY", "1h", ohlcv(n=48, start="2025-01-01", freq="h"))
    cov = store.load_manifest().prices["SPY|1h"]
    assert cov.provider_max_days == config.TIMEFRAMES["1h"].yahoo_max_days == 730