Files
QuantumTerminal/backend/data_manager/data_manager.py
T

333 lines
14 KiB
Python

"""
DataManager — Main entry point for the Data Management Module.
Callers use this class exclusively. It coordinates:
- DataCatalog (knows what's cached)
- DataStore (reads/writes Parquet)
- Fetchers (fill gaps from live sources)
All paths are derived relative to a configurable library root.
Default root is <project_dir>/data_library/ for full portability.
Usage
-----
from data_manager import dm # pre-wired singleton
df = dm.get_bars("EURUSD", "D1", 1500) # last 1500 daily bars
df = dm.get_bars("XAUUSD", "M15", # date range fetch
date_from=datetime(2024,1,1),
date_to=datetime(2025,1,1))
"""
import time
import pandas as pd
from pathlib import Path
from datetime import datetime, timedelta, timezone
from typing import Optional, List, Union
from .data_catalog import DataCatalog, CoverageResult
from .data_store import DataStore
from .fetchers.base_fetcher import BaseFetcher
from .fetchers.mt5_fetcher import MT5Fetcher, DEFAULT_MT5_PATH
import logging
log = logging.getLogger("dmm.manager")
class DataManager:
"""
Unified data access layer.
Parameters
----------
library_root : Path or str or None
Where Parquet files and catalog.db live.
Default: <directory of this file>/../data_library/
mt5_terminal_path : str or None
Path to MT5 terminal64.exe. If None, uses DEFAULT_MT5_PATH
from mt5_fetcher.py (single source of truth for the terminal path).
auto_init_mt5 : bool
If True, DataManager will call mt5.initialize() automatically.
If False (default), the caller is responsible for MT5 init —
this is the typical pattern since the forecaster already inits MT5.
cache_enabled : bool
If False, always fetch fresh from source (passthrough mode for testing).
"""
def __init__(
self,
library_root: Union[str, Path, None] = None,
mt5_terminal_path: Optional[str] = None,
auto_init_mt5: bool = False,
cache_enabled: bool = True,
):
# Resolve library root relative to this file (portable)
if library_root is None:
library_root = Path(__file__).resolve().parent.parent / "data_library"
self.library_root = Path(library_root).resolve()
self.cache_enabled = cache_enabled
self.store = DataStore(self.library_root)
self.catalog = DataCatalog(self.library_root / "catalog.db")
# Primary fetcher: MT5
self._fetcher = MT5Fetcher(
terminal_path=mt5_terminal_path,
auto_init=auto_init_mt5,
)
# Clean up any interrupted writes from previous runs
self.store.cleanup_temp()
log.info(f"DataManager ready | library: {self.library_root} | cache: {self.cache_enabled}")
# ═══════════════════════════════════════════════════════════════════════════
# PUBLIC API — what callers use
# ═══════════════════════════════════════════════════════════════════════════
def get_bars(
self,
symbol: str,
timeframe: str,
n_bars: Optional[int] = None,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None,
data_type: str = "ohlcv",
) -> Optional[pd.DataFrame]:
"""
Get OHLCV bars — the main workhorse method.
Two calling modes:
1. n_bars mode: dm.get_bars("EURUSD", "D1", n_bars=1500)
2. date range mode: dm.get_bars("EURUSD", "M15", date_from=..., date_to=...)
Returns a DataFrame with DatetimeIndex('time') and lowercase columns,
identical to what MT5 returns (drop-in compatible).
"""
symbol = symbol.upper()
timeframe = timeframe.upper()
# ── Mode 1: last N bars ──────────────────────────────────────────────
if n_bars is not None:
return self._get_bars_n(symbol, timeframe, n_bars, data_type)
# ── Mode 2: date range ───────────────────────────────────────────────
if date_from is None:
raise ValueError("Must provide either n_bars or date_from")
if date_to is None:
date_to = datetime.now(timezone.utc)
# Ensure UTC
if date_from.tzinfo is None:
date_from = date_from.replace(tzinfo=timezone.utc)
if date_to.tzinfo is None:
date_to = date_to.replace(tzinfo=timezone.utc)
# Clamp future dates to now
now = datetime.now(timezone.utc)
if date_to > now:
date_to = now
return self._get_bars_range(symbol, timeframe, date_from, date_to, data_type)
def get_bars_as_forecaster(
self,
symbol: str,
timeframe: str,
n_bars: Optional[int] = None,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None,
) -> Optional[pd.DataFrame]:
"""
Same as get_bars() but returns with capitalized column names
(Open, High, Low, Close, Volume) and 'time' as the index —
matching what the forecaster currently expects from fetch_daily_data()
and fetch_intraday_data().
"""
df = self.get_bars(symbol, timeframe, n_bars=n_bars,
date_from=date_from, date_to=date_to)
if df is None:
return None
# Rename to match forecaster convention
rename_map = {
"open": "Open", "high": "High", "low": "Low",
"close": "Close", "tick_volume": "Volume",
}
df.rename(columns=rename_map, inplace=True)
# Strip timezone for compat with existing code that uses tz-naive datetimes
if df.index.tz is not None:
df.index = df.index.tz_localize(None)
return df
def prefetch(
self,
symbols: List[str],
timeframes: List[str],
date_from: datetime,
date_to: datetime,
data_type: str = "ohlcv",
):
"""Batch warm-up: ensure all (symbol, tf) combos are cached."""
total = len(symbols) * len(timeframes)
done = 0
for sym in symbols:
for tf in timeframes:
done += 1
log.info(f"[{done}/{total}] Prefetching {sym} {tf}...")
self.get_bars(sym, tf, date_from=date_from, date_to=date_to, data_type=data_type)
def get_catalog_summary(self) -> list:
"""Return a summary of all cached datasets."""
return self.catalog.get_summary()
def get_fetch_history(self, symbol: Optional[str] = None, limit: int = 50) -> list:
return self.catalog.get_fetch_history(symbol, limit)
def purge(self, symbol: str, timeframe: str, data_type: str = "ohlcv"):
"""Remove cached data for a specific (symbol, tf, data_type) tuple."""
fpath = self.store._parquet_path(symbol, timeframe, data_type)
if fpath.exists():
fpath.unlink()
meta = self.store._meta_path(symbol, timeframe, data_type)
if meta.exists():
meta.unlink()
self.catalog.purge(symbol, timeframe, data_type)
log.info(f"Purged {symbol} {timeframe} {data_type}")
# ═══════════════════════════════════════════════════════════════════════════
# INTERNAL — fetch orchestration
# ═══════════════════════════════════════════════════════════════════════════
def _get_bars_n(self, symbol: str, timeframe: str, n_bars: int,
data_type: str) -> Optional[pd.DataFrame]:
"""
Get last N bars. Strategy:
1. If cache has enough rows and is recent enough, return from cache.
2. Otherwise, fetch from source, cache, return.
"""
if self.cache_enabled:
coverage = self.catalog.check_latest(symbol, timeframe, data_type)
if coverage.covered and coverage.row_count >= n_bars:
# Check if cache is reasonably fresh (within the timeframe interval)
# For D1, "fresh" means updated today; for M15, within the last hour
df = self.store.load(symbol, timeframe, data_type)
if df is not None and len(df) >= n_bars:
# Still fetch a small update to catch new bars
self._update_trailing(symbol, timeframe, data_type, coverage)
df = self.store.load(symbol, timeframe, data_type)
if df is not None:
return df.tail(n_bars)
# Cache miss or disabled — fetch from source
t0 = time.time()
df = self._fetcher.fetch_bars_n(symbol, timeframe, n_bars)
elapsed_ms = int((time.time() - t0) * 1000)
if df is None or df.empty:
self.catalog.log_fetch(symbol, timeframe, self._fetcher.name,
datetime.now(timezone.utc), datetime.now(timezone.utc),
0, elapsed_ms, "failed", "No data returned")
return None
# Cache the result
if self.cache_enabled:
self.store.append(symbol, timeframe, df, data_type)
checksum = self.store.compute_checksum(symbol, timeframe, data_type)
file_path = str(self.store._parquet_path(symbol, timeframe, data_type).relative_to(self.library_root))
self.catalog.register_dataset(
symbol, timeframe, data_type, self._fetcher.name,
df.index.min(), df.index.max(), len(df),
file_path, checksum,
)
self.catalog.log_fetch(symbol, timeframe, self._fetcher.name,
df.index.min(), df.index.max(),
len(df), elapsed_ms, "success")
return df.tail(n_bars)
def _get_bars_range(self, symbol: str, timeframe: str,
date_from: datetime, date_to: datetime,
data_type: str) -> Optional[pd.DataFrame]:
"""
Get bars for a date range. Strategy:
1. Check catalog for coverage.
2. Fetch only gaps (smart gap filling).
3. Return full range from cache.
"""
if not self.cache_enabled:
return self._fetch_and_log(symbol, timeframe, date_from, date_to, data_type)
coverage = self.catalog.check_coverage(symbol, timeframe, date_from, date_to, data_type)
if coverage.needs_fetch:
for gap in coverage.gaps:
gap_from = datetime.fromisoformat(gap["from"])
gap_to = datetime.fromisoformat(gap["to"])
log.info(f"Filling gap: {symbol} {timeframe} {gap_from.date()}{gap_to.date()}")
fetched = self._fetch_and_log(symbol, timeframe, gap_from, gap_to, data_type)
if fetched is not None and not fetched.empty:
self.store.append(symbol, timeframe, fetched, data_type)
# Update catalog with new combined coverage
full_df = self.store.load(symbol, timeframe, data_type)
if full_df is not None and not full_df.empty:
checksum = self.store.compute_checksum(symbol, timeframe, data_type)
file_path = str(self.store._parquet_path(symbol, timeframe, data_type).relative_to(self.library_root))
self.catalog.register_dataset(
symbol, timeframe, data_type, self._fetcher.name,
full_df.index.min(), full_df.index.max(), len(full_df),
file_path, checksum,
)
# Return requested slice from cache
df = self.store.load(symbol, timeframe, data_type, date_from, date_to)
return df
def _update_trailing(self, symbol: str, timeframe: str, data_type: str,
coverage: CoverageResult):
"""Fetch only bars newer than what's already cached."""
if coverage.date_to is None:
return
# Start from last cached bar (will be deduped)
fetch_from = coverage.date_to - timedelta(hours=1) # small overlap for safety
fetch_to = datetime.now(timezone.utc)
if fetch_to <= fetch_from:
return
fetched = self._fetch_and_log(symbol, timeframe, fetch_from, fetch_to, data_type)
if fetched is not None and not fetched.empty:
self.store.append(symbol, timeframe, fetched, data_type)
# Re-read and update catalog
full_df = self.store.load(symbol, timeframe, data_type)
if full_df is not None and not full_df.empty:
checksum = self.store.compute_checksum(symbol, timeframe, data_type)
file_path = str(self.store._parquet_path(symbol, timeframe, data_type).relative_to(self.library_root))
self.catalog.register_dataset(
symbol, timeframe, data_type, self._fetcher.name,
full_df.index.min(), full_df.index.max(), len(full_df),
file_path, checksum,
)
def _fetch_and_log(self, symbol: str, timeframe: str,
date_from: datetime, date_to: datetime,
data_type: str) -> Optional[pd.DataFrame]:
"""Fetch from source with timing and audit logging."""
t0 = time.time()
df = self._fetcher.fetch_bars(symbol, timeframe, date_from, date_to)
elapsed_ms = int((time.time() - t0) * 1000)
if df is None or df.empty:
self.catalog.log_fetch(symbol, timeframe, self._fetcher.name,
date_from, date_to, 0, elapsed_ms,
"failed", "No data returned")
return None
self.catalog.log_fetch(symbol, timeframe, self._fetcher.name,
date_from, date_to, len(df), elapsed_ms, "success")
log.info(f"Fetched {len(df)} rows {symbol} {timeframe} ({elapsed_ms}ms)")
return df