| """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") |
|
|
| |
| |
| @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: |
| 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: |
| 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) |
|
|
| |
| 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 |
|
|
| |
| 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) |
| 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]) |
|
|
| 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.", |
| ) |
|
|