""" fetchers/breadth.py — Fetches TOP50 OHLCV and computes market breadth metrics. Multi-tier: Top20 / Top30 / Top50 for advance/decline, EMA20%, new highs, BTC.D. """ from datetime import date as Date, datetime from typing import Optional import logging import pandas as pd import numpy as np import requests from .base import BaseFetcher from config import config class BreadthFetcher(BaseFetcher): """Fetches TOP50 coin OHLCV data and computes breadth metrics.""" def __init__(self, provider_url: Optional[str] = None): super().__init__(timeout=60, max_retries=3) self.provider_url = provider_url or config.provider_url self.symbols = config.top50_symbols self.ema_period = config.breadth_ema_period self.new_high_window = config.breadth_new_high_window self.logger = logging.getLogger(__name__) def fetch(self, target_date: Optional[Date] = None) -> dict: """ Fetch daily OHLCV for all TOP50 symbols and compute breadth. Returns a dict suitable for storing in breadth_daily table. """ if target_date is None: target_date = Date.today() # Fetch last 60 days of daily data for each symbol to compute EMAs and new highs all_data = {} for symbol in self.symbols: try: df = self._fetch_symbol(symbol) if df is not None and not df.empty: all_data[symbol] = df except Exception as e: self.logger.debug(f"Failed to fetch {symbol}: {e}") if not all_data: self.logger.error("No symbol data fetched for breadth") return {} # Compute breadth metrics for the target date breadth = self._compute_breadth(all_data, target_date) return breadth def _fetch_symbol(self, symbol: str) -> Optional[pd.DataFrame]: """Fetch daily OHLCV for a single symbol.""" url = f"{self.provider_url}/api/candles" params = { "symbol": symbol, "tf": "1d", "limit": 100, } try: resp = requests.get(url, params=params, timeout=15) resp.raise_for_status() data = resp.json() if not data: return None df = pd.DataFrame(data) df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True) df["date"] = df["timestamp"].dt.date df = df.drop_duplicates(subset="date").sort_values("date").reset_index(drop=True) df["close"] = df["close"].astype(float) df["ema20"] = df["close"].ewm(span=self.ema_period, adjust=False).mean() return df except Exception: return None def _compute_breadth(self, all_data: dict, target_date: Date) -> dict: """Compute breadth metrics for a specific date across all symbols.""" total = len(all_data) advances_50 = declines_50 = 0 above_ema20_50 = 0 new_highs_50 = 0 advances_30 = declines_30 = 0 above_ema20_30 = 0 new_highs_30 = 0 advances_20 = declines_20 = 0 above_ema20_20 = 0 new_highs_20 = 0 for i, (symbol, df) in enumerate(all_data.items()): # Get data for target date df["date_str"] = df["date"].astype(str) target_str = str(target_date) idx = df[df["date_str"] == target_str].index if len(idx) == 0: continue row_idx = idx[0] if row_idx < 1: continue current_close = df.loc[row_idx, "close"] prev_close = df.loc[row_idx - 1, "close"] # Advance/Decline if current_close > prev_close: if i < 50: advances_50 += 1 if i < 30: advances_30 += 1 if i < 20: advances_20 += 1 elif current_close < prev_close: if i < 50: declines_50 += 1 if i < 30: declines_30 += 1 if i < 20: declines_20 += 1 # Above EMA20 ema20_val = df.loc[row_idx, "ema20"] if not pd.isna(ema20_val) and current_close > ema20_val: if i < 50: above_ema20_50 += 1 if i < 30: above_ema20_30 += 1 if i < 20: above_ema20_20 += 1 # New 20-day highs lookback_start = max(0, row_idx - self.new_high_window) recent_highs = df.loc[lookback_start:row_idx - 1, "high"].astype(float) current_high = df.loc[row_idx, "high"] if len(recent_highs) > 0 and float(current_high) > recent_highs.max(): if i < 50: new_highs_50 += 1 if i < 30: new_highs_30 += 1 if i < 20: new_highs_20 += 1 return { "date": str(target_date), "total_tracked": total, "advance_top50": advances_50, "decline_top50": declines_50, "above_ema20_top50": above_ema20_50, "new_highs_20d_top50": new_highs_50, "advance_top30": advances_30, "advance_top20": advances_20, "above_ema20_top30": above_ema20_30, "above_ema20_top20": above_ema20_20, "new_highs_20d_top30": new_highs_30, "new_highs_20d_top20": new_highs_20, "btc_dominance": None, # Reserved for Coinglass API integration } def store(self, db_path: Optional[str] = None, record: Optional[dict] = None) -> int: """Store a breadth record into SQLite. Returns 1 if inserted/updated.""" import sqlite3 db_path = db_path or config.db_path conn = sqlite3.connect(db_path) if record is None: conn.close() return 0 try: conn.execute(""" INSERT OR REPLACE INTO breadth_daily (date, total_tracked, advance_top50, decline_top50, above_ema20_top50, new_highs_20d_top50, advance_top30, advance_top20, above_ema20_top30, above_ema20_top20, new_highs_20d_top30, new_highs_20d_top20, btc_dominance) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( record["date"], record.get("total_tracked", 50), record.get("advance_top50", 0), record.get("decline_top50", 0), record.get("above_ema20_top50", 0), record.get("new_highs_20d_top50", 0), record.get("advance_top30", 0), record.get("advance_top20", 0), record.get("above_ema20_top30", 0), record.get("above_ema20_top20", 0), record.get("new_highs_20d_top30", 0), record.get("new_highs_20d_top20", 0), record.get("btc_dominance"), )) conn.commit() return 1 except Exception as e: self.logger.error(f"Failed to store breadth: {e}") return 0 finally: conn.close()