190 lines
6.9 KiB
Python
190 lines
6.9 KiB
Python
"""
|
|
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()
|