"""Market data loading with a three-tier fallback. Order of preference: live Yahoo download -> on-disk cache -> a deterministic simulator. The fallback exists because a Hugging Face Space that shows a stack trace on the first click is a Space nobody shares. When the simulator is used, :class:`~algotrader.types.MarketData` says so and the UI shows it. """ from __future__ import annotations import hashlib import logging import os from dataclasses import dataclass from pathlib import Path from typing import Optional import numpy as np import pandas as pd from .types import OHLCV_COLUMNS, MarketData logger = logging.getLogger(__name__) CACHE_DIR = Path(os.environ.get("ALGOTRADER_CACHE", Path.home() / ".cache" / "algotrader")) NETWORK_ENABLED = os.environ.get("ALGOTRADER_OFFLINE", "").lower() not in ("1", "true", "yes") # Popular tickers get hand-set simulation parameters so the offline demo is at # least in the right postcode: annual drift, annual vol, and a starting price. @dataclass(frozen=True) class SimProfile: drift: float vol: float price: float SIM_PROFILES: dict[str, SimProfile] = { "AAPL": SimProfile(0.24, 0.29, 190.0), "MSFT": SimProfile(0.25, 0.27, 410.0), "NVDA": SimProfile(0.55, 0.52, 120.0), "TSLA": SimProfile(0.30, 0.58, 250.0), "AMZN": SimProfile(0.22, 0.33, 180.0), "GOOGL": SimProfile(0.20, 0.31, 170.0), "META": SimProfile(0.26, 0.40, 500.0), "SPY": SimProfile(0.10, 0.16, 550.0), "QQQ": SimProfile(0.14, 0.21, 480.0), "BTC-USD": SimProfile(0.45, 0.65, 65000.0), "ETH-USD": SimProfile(0.35, 0.75, 3000.0), "GLD": SimProfile(0.07, 0.14, 200.0), "TLT": SimProfile(0.01, 0.15, 95.0), } DEFAULT_UNIVERSE = ["SPY", "AAPL", "NVDA", "MSFT", "TSLA", "QQQ", "BTC-USD", "GLD"] def _seed_for(symbol: str) -> int: """Stable per-symbol seed so a given ticker always simulates identically.""" digest = hashlib.sha256(symbol.upper().encode()).digest() return int.from_bytes(digest[:4], "big") def _normalise(df: pd.DataFrame) -> pd.DataFrame: """Coerce any loader's output into a clean lowercase OHLCV frame.""" if isinstance(df.columns, pd.MultiIndex): df = df.copy() df.columns = [str(c[0]) for c in df.columns] df = df.rename(columns={c: str(c).strip().lower().replace(" ", "_") for c in df.columns}) if "adj_close" in df.columns and "close" not in df.columns: df = df.rename(columns={"adj_close": "close"}) missing = [c for c in OHLCV_COLUMNS if c not in df.columns] for col in missing: if col == "volume": df["volume"] = 0.0 elif "close" in df.columns: df[col] = df["close"] else: raise ValueError(f"Price data is missing required column: {col}") df = df.loc[:, list(OHLCV_COLUMNS)].astype(float) if not isinstance(df.index, pd.DatetimeIndex): df.index = pd.to_datetime(df.index) df.index = df.index.tz_localize(None) if df.index.tz is not None else df.index df = df[~df.index.duplicated(keep="last")].sort_index() df = df[df["close"] > 0].dropna(subset=["close"]) return df def _cache_path(symbol: str, interval: str) -> Path: safe = symbol.upper().replace("/", "_") return CACHE_DIR / f"{safe}_{interval}.csv" def _read_cache(symbol: str, interval: str) -> Optional[pd.DataFrame]: path = _cache_path(symbol, interval) if not path.exists(): return None try: return _normalise(pd.read_csv(path, index_col=0, parse_dates=True)) except Exception as exc: # pragma: no cover - corrupted cache is not worth failing over logger.warning("Ignoring unreadable cache %s: %s", path, exc) return None def _write_cache(symbol: str, interval: str, df: pd.DataFrame) -> None: try: CACHE_DIR.mkdir(parents=True, exist_ok=True) df.to_csv(_cache_path(symbol, interval)) except Exception as exc: # pragma: no cover - a read-only FS must not break the app logger.warning("Could not write cache for %s: %s", symbol, exc) def _download(symbol: str, start: str, end: str | None, interval: str) -> Optional[pd.DataFrame]: if not NETWORK_ENABLED: return None try: import yfinance as yf except ImportError: logger.info("yfinance not installed; using offline data") return None try: raw = yf.download( symbol, start=start, end=end, interval=interval, progress=False, auto_adjust=True, threads=False, ) except Exception as exc: logger.warning("Download failed for %s: %s", symbol, exc) return None if raw is None or len(raw) == 0: logger.warning("Download for %s returned no rows", symbol) return None try: return _normalise(raw) except Exception as exc: logger.warning("Could not normalise download for %s: %s", symbol, exc) return None def simulate_ohlcv( symbol: str = "SIM", start: str = "2015-01-01", end: str | None = None, interval: str = "1d", seed: Optional[int] = None, ) -> pd.DataFrame: """Generate a deterministic but realistic-looking OHLCV series. This is not geometric Brownian motion with a straight face: it uses a two-state (calm / stressed) regime switch, Student-t innovations and GARCH-ish vol persistence, so the resulting series has fat tails and volatility clustering. That matters, because a strategy tested against naive GBM looks far better than it deserves to. """ profile = SIM_PROFILES.get(symbol.upper(), SimProfile(0.08, 0.25, 100.0)) rng = np.random.default_rng(_seed_for(symbol) if seed is None else seed) freq = {"1d": "B", "1wk": "W-FRI", "1h": "h"}.get(interval, "B") index = pd.date_range(start=start, end=end or pd.Timestamp.today().normalize(), freq=freq) n = len(index) if n < 50: raise ValueError("Simulated range is too short to backtest") ppy = 252 if freq in ("B", "h") else 52 mu = profile.drift / ppy base_vol = profile.vol / np.sqrt(ppy) # Regime chain: calm state is sticky, stressed state is short and violent. p_calm_to_stress, p_stress_to_calm = 0.01, 0.06 regime = np.zeros(n, dtype=int) for i in range(1, n): flip = rng.random() if regime[i - 1] == 0: regime[i] = 1 if flip < p_calm_to_stress else 0 else: regime[i] = 0 if flip < p_stress_to_calm else 1 # Persistent vol around a regime-dependent level. vol = np.empty(n) level = np.where(regime == 1, base_vol * 2.4, base_vol * 0.9) vol[0] = level[0] for i in range(1, n): vol[i] = 0.92 * vol[i - 1] + 0.08 * level[i] shocks = rng.standard_t(df=4, size=n) / np.sqrt(2.0) # unit-ish variance, fat tails drift = np.where(regime == 1, mu - 3.0 * base_vol**2, mu) log_ret = drift + vol * shocks close = profile.price * np.exp(np.cumsum(log_ret)) close = close * (profile.price / close[-1]) # end near the quoted level intrabar = vol * rng.uniform(0.3, 1.1, size=n) open_ = close * np.exp(-log_ret * rng.uniform(0.2, 0.8, size=n)) high = np.maximum(open_, close) * np.exp(np.abs(intrabar)) low = np.minimum(open_, close) * np.exp(-np.abs(intrabar)) volume = rng.lognormal(mean=15.5, sigma=0.45, size=n) * (1.0 + 3.0 * regime) return _normalise( pd.DataFrame( {"open": open_, "high": high, "low": low, "close": close, "volume": volume}, index=index, ) ) def load_ohlcv( symbol: str = "SPY", start: str = "2015-01-01", end: str | None = None, interval: str = "1d", source: str = "yahoo", ) -> MarketData: """Load OHLCV for ``symbol``. Default ``source='yahoo'`` requires a Yahoo download. ``auto`` still falls back to cache then the simulator (Hugging Face Space). ``synthetic`` is tests only. """ symbol = (symbol or "SPY").strip().upper() if source == "synthetic": df = simulate_ohlcv(symbol, start, end, interval) return MarketData(symbol, df, "synthetic", interval, "Simulated prices (requested).") if source in ("yahoo", "live", "auto"): df = _download(symbol, start, end, interval) if df is not None and len(df) > 50: _write_cache(symbol, interval, df) return MarketData(symbol, df, "yfinance", interval, "Live data from Yahoo Finance.") if source in ("yahoo", "live"): raise RuntimeError( f"Yahoo returned no usable bars for {symbol}. " "Check the ticker, date range, and network. " "Pass source='synthetic' only for offline tests." ) cached = _read_cache(symbol, interval) if cached is not None and len(cached) > 50: window = cached.loc[str(start) : str(end)] if end else cached.loc[str(start) :] if len(window) > 50: return MarketData(symbol, window, "bundled", interval, "Cached data (network unavailable).") df = simulate_ohlcv(symbol, start, end, interval) return MarketData( symbol, df, "synthetic", interval, f"Live data for {symbol} was unavailable, so this run uses a deterministic " "market simulator with fat tails and volatility clustering. The statistics " "below are still valid — they are just measured on a simulated market.", )