File size: 16,014 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
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
"""OHLCV acquisition: provider chain, rate limiting, validation, refresh.

Two hard rules enforced here:

1. **Only the batch refresh path touches an external provider.** User-facing
   requests read exclusively from the cached store. `allow_network()` gates
   every outbound call and is off unless a refresh explicitly opens it.
2. **Never silently return partial data.** Every fetch reports what it actually
   got versus what was asked for; short coverage becomes an honest boundary in
   the manifest, never a silent truncation and never an error.

Providers are declared in `config.PROVIDER_CHAIN`; adding one means adding a
spec plus a fetch function to `_FETCHERS`, not restructuring the chain.
"""

from __future__ import annotations

import contextlib
import io
import logging
import os
import random
import threading
import time
from dataclasses import dataclass, field

import pandas as pd

from . import config
from .config import Asset, ProviderSpec
from .store import PRICE_COLUMNS, SignalStore, _iso, _utc, validate_price_frame

log = logging.getLogger("bit.data")

# --------------------------------------------------------------------------
# Network gate
# --------------------------------------------------------------------------

_network = threading.local()


def network_allowed() -> bool:
    return getattr(_network, "allowed", False)


@contextlib.contextmanager
def allow_network():
    """Open the gate for a batch refresh. Scoped to the calling thread."""
    prev = getattr(_network, "allowed", False)
    _network.allowed = True
    try:
        yield
    finally:
        _network.allowed = prev


class ProviderError(RuntimeError):
    """A provider failed in a way that should advance the chain."""


class NetworkNotAllowed(RuntimeError):
    """A user-facing code path tried to reach an external provider."""


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


class RateLimiter:
    """Per-provider minimum spacing between outbound calls, process-wide."""

    _locks: dict[str, threading.Lock] = {}
    _last: dict[str, float] = {}
    _guard = threading.Lock()

    @classmethod
    def wait(cls, name: str, min_interval_s: float) -> None:
        with cls._guard:
            lock = cls._locks.setdefault(name, threading.Lock())
        with lock:
            last = cls._last.get(name, 0.0)
            delta = time.monotonic() - last
            if delta < min_interval_s:
                time.sleep(min_interval_s - delta)
            cls._last[name] = time.monotonic()


def with_backoff(spec: ProviderSpec, fn, *args, **kwargs):
    """Run `fn` under the provider's rate limit with exponential backoff."""
    last_err: Exception | None = None
    for attempt in range(spec.max_retries):
        RateLimiter.wait(spec.name, spec.min_interval_s)
        try:
            return fn(*args, **kwargs)
        except Exception as e:  # provider libs raise a wide variety
            last_err = e
            if attempt == spec.max_retries - 1:
                break
            sleep_s = (spec.backoff_base_s ** attempt) + random.uniform(0, 0.4)
            log.warning(
                "provider %s attempt %d/%d failed (%s); backing off %.1fs",
                spec.name, attempt + 1, spec.max_retries, type(e).__name__, sleep_s,
            )
            time.sleep(sleep_s)
    raise ProviderError(f"{spec.name} exhausted retries: {last_err}") from last_err


# --------------------------------------------------------------------------
# Fetch result
# --------------------------------------------------------------------------


@dataclass
class FetchResult:
    frame: pd.DataFrame
    source: str
    requested_start: pd.Timestamp
    requested_end: pd.Timestamp
    # True when the provider's own history depth, not our request, set the floor.
    truncated_by_provider: bool = False
    provider_max_days: int | None = None
    notes: list[str] = field(default_factory=list)

    @property
    def rows(self) -> int:
        return len(self.frame)

    @property
    def actual_start(self) -> pd.Timestamp | None:
        return None if self.frame.empty else _utc(self.frame["ts"].iloc[0])

    @property
    def actual_end(self) -> pd.Timestamp | None:
        return None if self.frame.empty else _utc(self.frame["ts"].iloc[-1])


def _frame(rows: list[dict], source: str) -> pd.DataFrame:
    df = pd.DataFrame(rows, columns=["ts", "open", "high", "low", "close", "volume"])
    df["source"] = source
    if not df.empty:
        df["ts"] = df["ts"].map(_utc)
        df = df.drop_duplicates(subset="ts", keep="last").sort_values("ts")
    return df.loc[:, PRICE_COLUMNS].reset_index(drop=True)


# --------------------------------------------------------------------------
# Providers
# --------------------------------------------------------------------------


def _ccxt_exchange(name: str):
    import ccxt

    klass = getattr(ccxt, name)
    ex = klass({"enableRateLimit": True, "timeout": 20000})
    return ex


def _fetch_ccxt(exchange_name: str, spec: ProviderSpec, asset: Asset,
                tf: str, start, end) -> pd.DataFrame:
    if not asset.ccxt_symbol:
        raise ProviderError(f"{asset.slug} has no ccxt symbol")
    ex = _ccxt_exchange(exchange_name)
    ccxt_tf = config.TIMEFRAMES[tf].ccxt_tf
    step_ms = config.TIMEFRAMES[tf].minutes * 60_000
    since = int(_utc(start).timestamp() * 1000)
    end_ms = int(_utc(end).timestamp() * 1000)
    rows: list[dict] = []
    guard = 0

    while since < end_ms and guard < 4000:
        guard += 1
        batch = with_backoff(
            spec, ex.fetch_ohlcv, asset.ccxt_symbol, ccxt_tf, since, 1000
        )
        if not batch:
            break
        for ts, o, h, l, c, v in batch:
            if ts > end_ms:
                break
            rows.append({"ts": pd.Timestamp(ts, unit="ms", tz="UTC"),
                         "open": o, "high": h, "low": l, "close": c, "volume": v})
        last = batch[-1][0]
        if last <= since:
            break
        since = last + step_ms

    with contextlib.suppress(Exception):
        ex.close()
    return _frame(rows, exchange_name)


def _fetch_binance(spec, asset, tf, start, end):
    return _fetch_ccxt("binance", spec, asset, tf, start, end)


def _fetch_coinbase(spec, asset, tf, start, end):
    return _fetch_ccxt("coinbase", spec, asset, tf, start, end)


def _fetch_yfinance(spec: ProviderSpec, asset: Asset, tf: str, start, end) -> pd.DataFrame:
    if not asset.yahoo_symbol:
        raise ProviderError(f"{asset.slug} has no Yahoo symbol")
    import yfinance as yf

    interval = config.TIMEFRAMES[tf].yahoo_interval
    max_days = config.TIMEFRAMES[tf].yahoo_max_days
    s, e = _utc(start), _utc(end)
    if max_days is not None:
        floor = pd.Timestamp.now(tz="UTC") - pd.Timedelta(days=max_days - 1)
        s = max(s, floor)  # honest boundary; see refresh() notes

    def _call():
        return yf.download(
            asset.yahoo_symbol, start=s.date(), end=(e + pd.Timedelta(days=1)).date(),
            interval=interval, auto_adjust=False, progress=False, threads=False,
        )

    raw = with_backoff(spec, _call)
    if raw is None or raw.empty:
        raise ProviderError("yfinance returned no rows")
    if isinstance(raw.columns, pd.MultiIndex):
        raw.columns = raw.columns.get_level_values(0)
    # reset_index first: the timestamp arrives as the index ("Date"/"Datetime")
    # and only becomes a column here, so lowercasing must happen afterwards.
    raw = raw.reset_index()
    raw.columns = [str(c).lower() for c in raw.columns]
    tcol = next((c for c in ("datetime", "date", "index") if c in raw.columns), None)
    if tcol is None:
        raise ProviderError(f"yfinance frame has no timestamp column: {list(raw.columns)}")
    rows = [
        {"ts": r[tcol], "open": r["open"], "high": r["high"],
         "low": r["low"], "close": r["close"], "volume": r.get("volume", 0.0)}
        for _, r in raw.iterrows()
    ]
    return _frame(rows, "yfinance")


def _fetch_stooq(spec: ProviderSpec, asset: Asset, tf: str, start, end) -> pd.DataFrame:
    """Stooq CSV endpoint -- no key, daily only."""
    if tf != "1d":
        raise ProviderError("stooq serves daily bars only")
    if not asset.stooq_symbol:
        raise ProviderError(f"{asset.slug} has no Stooq symbol")
    import requests

    url = f"https://stooq.com/q/d/l/?s={asset.stooq_symbol}&i=d"

    def _call():
        r = requests.get(url, timeout=20)
        r.raise_for_status()
        if "Date" not in r.text[:64]:
            raise ProviderError("stooq returned no CSV header (rate limited?)")
        return r.text

    text = with_backoff(spec, _call)
    raw = pd.read_csv(io.StringIO(text))
    raw.columns = [c.lower() for c in raw.columns]
    raw = raw.dropna(subset=["open", "high", "low", "close"])
    s, e = _utc(start), _utc(end)
    rows = []
    for _, r in raw.iterrows():
        ts = _utc(r["date"])
        if ts < s or ts > e:
            continue
        rows.append({"ts": ts, "open": r["open"], "high": r["high"],
                     "low": r["low"], "close": r["close"], "volume": r.get("volume", 0.0)})
    return _frame(rows, "stooq")


def _fetch_tiingo(spec: ProviderSpec, asset: Asset, tf: str, start, end) -> pd.DataFrame:
    if tf != "1d":
        raise ProviderError("tiingo adapter covers daily bars only")
    key = os.environ.get("TIINGO_KEY")
    if not key:
        raise ProviderError("TIINGO_KEY not set")
    import requests

    sym = asset.tiingo_symbol or asset.slug
    url = f"https://api.tiingo.com/tiingo/daily/{sym}/prices"
    params = {"startDate": _utc(start).date().isoformat(),
              "endDate": _utc(end).date().isoformat(), "token": key}

    def _call():
        r = requests.get(url, params=params, timeout=20)
        r.raise_for_status()
        return r.json()

    payload = with_backoff(spec, _call)
    rows = [
        {"ts": _utc(d["date"]), "open": d["open"], "high": d["high"],
         "low": d["low"], "close": d["close"], "volume": d.get("volume", 0.0)}
        for d in payload
    ]
    return _frame(rows, "tiingo")


_FETCHERS = {
    "binance": _fetch_binance,
    "coinbase": _fetch_coinbase,
    "yfinance": _fetch_yfinance,
    "stooq": _fetch_stooq,
    "tiingo": _fetch_tiingo,
}


# --------------------------------------------------------------------------
# Chain walk
# --------------------------------------------------------------------------


def fetch_ohlcv(asset_slug: str, timeframe: str, start, end) -> FetchResult:
    """Walk the provider chain for `asset_slug` until one returns rows."""
    if not network_allowed():
        raise NetworkNotAllowed(
            "external providers are reachable only from the batch refresh path; "
            "user-facing requests must read from the cached store"
        )
    asset = config.ASSETS.get(asset_slug)
    if asset is None:
        raise ProviderError(f"unknown asset {asset_slug!r}")
    if timeframe not in config.TIMEFRAMES:
        raise ProviderError(f"unknown timeframe {timeframe!r}")

    s, e = _utc(start), _utc(end)
    chain = config.providers_for(asset.kind)
    if not chain:
        raise ProviderError(f"no usable provider for {asset.kind}")

    notes: list[str] = []
    for spec in chain:
        fetcher = _FETCHERS.get(spec.name)
        if fetcher is None:
            continue
        try:
            frame = fetcher(spec, asset, timeframe, s, e)
        except Exception as exc:
            notes.append(f"{spec.name}: {type(exc).__name__}: {exc}")
            log.warning("provider %s failed for %s %s: %s", spec.name, asset_slug, timeframe, exc)
            continue
        if frame.empty:
            notes.append(f"{spec.name}: returned 0 rows")
            continue

        max_days = config.TIMEFRAMES[timeframe].yahoo_max_days if spec.name == "yfinance" else None
        actual_start = _utc(frame["ts"].iloc[0])
        truncated = max_days is not None and actual_start > s + pd.Timedelta(days=1)
        if truncated:
            notes.append(
                f"{spec.name} serves at most ~{max_days}d of {timeframe} bars; "
                f"coverage starts {_iso(actual_start)}"
            )
        return FetchResult(
            frame=frame, source=spec.name, requested_start=s, requested_end=e,
            truncated_by_provider=truncated, provider_max_days=max_days, notes=notes,
        )

    raise ProviderError(
        f"all providers failed for {asset_slug} {timeframe} "
        f"[{_iso(s)} .. {_iso(e)}]: " + " | ".join(notes)
    )


# --------------------------------------------------------------------------
# Refresh
# --------------------------------------------------------------------------


@dataclass
class RefreshReport:
    asset: str
    timeframe: str
    fetched_ranges: list[tuple[str, str]] = field(default_factory=list)
    rows_added: int = 0
    sources: list[str] = field(default_factory=list)
    skipped_cached: bool = False
    gaps: int = 0
    boundary_notes: list[str] = field(default_factory=list)
    errors: list[str] = field(default_factory=list)

    @property
    def ok(self) -> bool:
        return not self.errors

    def summary(self) -> str:
        if self.skipped_cached:
            return f"{self.asset} {self.timeframe}: already cached, nothing fetched"
        if self.errors:
            return f"{self.asset} {self.timeframe}: FAILED -- {'; '.join(self.errors)}"
        return (
            f"{self.asset} {self.timeframe}: +{self.rows_added} rows "
            f"from {','.join(self.sources) or 'n/a'} ({self.gaps} gaps)"
        )


def missing_price_ranges(
    store: SignalStore, asset: str, timeframe: str, start, end
) -> list[tuple[pd.Timestamp, pd.Timestamp]]:
    """Sub-ranges of [start, end] absent from the price cache."""
    s, e = _utc(start), _utc(end)
    if s > e:
        return []
    cov = store.load_manifest().prices.get(f"{asset}|{timeframe}")
    if cov is None or cov.rows == 0:
        return [(s, e)]
    cs, ce = _utc(cov.start_ts), _utc(cov.end_ts)
    step = pd.Timedelta(minutes=config.TIMEFRAMES[timeframe].minutes)
    out = []
    if s < cs:
        out.append((s, min(e, cs - step)))
    if e > ce:
        out.append((max(s, ce + step), e))
    return [(a, b) for a, b in out if a <= b]


def refresh(
    store: SignalStore, asset: str, timeframe: str, start, end, *, strict: bool = False
) -> RefreshReport:
    """Fetch only what the cache is missing, validate it, and write it.

    Never fetches a range the manifest already covers.
    """
    rep = RefreshReport(asset=asset, timeframe=timeframe)
    try:
        gaps = missing_price_ranges(store, asset, timeframe, start, end)
    except Exception as e:
        rep.errors.append(f"coverage lookup failed: {e}")
        return rep

    if not gaps:
        rep.skipped_cached = True
        return rep

    with allow_network():
        for gs, ge in gaps:
            try:
                res = fetch_ohlcv(asset, timeframe, gs, ge)
            except Exception as e:
                rep.errors.append(f"[{_iso(gs)}..{_iso(ge)}] {e}")
                continue

            _, report = validate_price_frame(res.frame, timeframe, strict=strict)
            if report.problems:
                # Partial or dirty data is surfaced, never silently accepted.
                rep.errors.append(
                    f"[{_iso(gs)}..{_iso(ge)}] validation: {'; '.join(report.problems)}"
                )
                continue

            cov = store.write_prices(asset, timeframe, res.frame, strict=strict)
            rep.fetched_ranges.append((_iso(gs), _iso(ge)))
            rep.rows_added += res.rows
            rep.gaps = max(rep.gaps, cov.gaps)
            if res.source not in rep.sources:
                rep.sources.append(res.source)
            rep.boundary_notes.extend(res.notes)
    return rep