""" 链上指标数据模块:拉取 BTC 交易所净流量、稳定币供应、ETF 净流入、MVRV Z-Score、SOPR。 本地 CSV 落盘 + 内存缓存,参照 derivatives 模式。 免费数据源: - CoinMetrics Community API: 交易所流入/流出(USD)、MVRV Ratio - CoinGecko: 稳定币市值 需要 API key 的指标(可配置): - ETF 净流入: Coinglass / Farside / Glassnode - SOPR: Glassnode / CoinMetrics Pro """ import csv import json import logging import os import re import threading import time from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional import requests from fastapi import APIRouter, HTTPException, Query logger = logging.getLogger("onchain_metrics") # ── 常量 ───────────────────────────────────────────────────────────── METRIC_NAMES = [ "btc_netflow", "stablecoin_supply", "etf_flow", "mvrv_zscore", "sopr", ] REFRESH_INTERVAL = 300 # 后台刷新间隔(秒),免费 API 限频较严 COINMETRICS_BASE = "https://community-api.coinmetrics.io/v4" COINGECKO_BASE = "https://api.coingecko.com/api/v3" REQUEST_TIMEOUT = 30 HTTP_HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; chan-data-provider/1.0)"} def to_utc_iso(ts_ms: int) -> str: dt = datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc) return dt.isoformat().replace("+00:00", "Z") # ── OnchainMetricsManager ──────────────────────────────────────────── class OnchainMetricsManager: """管理链上指标的拉取、缓存和持久化。每个指标独立存储。""" def __init__(self, data_dir: Path, api_keys: Optional[Dict[str, str]] = None) -> None: self.data_dir = data_dir / "onchain" self.data_dir.mkdir(parents=True, exist_ok=True) self.api_keys = api_keys or {} # 内存缓存: metric_name -> List[Dict] self._metrics: Dict[str, List[Dict]] = {name: [] for name in METRIC_NAMES} self._lock = threading.RLock() self._stop_event = threading.Event() self._thread: Optional[threading.Thread] = None # 加载本地历史 for name in METRIC_NAMES: local = self._load_local(name) if local: self._metrics[name] = local logger.info("链上指标 %s 加载本地记录: %d 条", name, len(local)) # ── CSV 路径 ───────────────────────────────────────────────────── def _path(self, metric: str) -> Path: return self.data_dir / f"{metric}.csv" # ── CSV 读写 ───────────────────────────────────────────────────── def _load_local(self, metric: str) -> List[Dict]: path = self._path(metric) if not path.exists(): return [] records = [] with path.open("r", encoding="utf-8", newline="") as fp: for row in csv.DictReader(fp): try: records.append({ "timestamp": int(row["timestamp"]), "datetime": row.get("datetime", ""), "value": float(row.get("value", 0)), "sub_value": float(row.get("sub_value", 0)) if row.get("sub_value") else None, "extra": json.loads(row.get("extra", "{}")) if row.get("extra") else {}, }) except (KeyError, ValueError): continue records.sort(key=lambda r: r["timestamp"]) return records def _write_local(self, metric: str, records: List[Dict]) -> None: path = self._path(metric) fieldnames = ["timestamp", "datetime", "value", "sub_value", "extra"] tmp = path.with_suffix(".tmp") try: with tmp.open("w", encoding="utf-8", newline="") as fp: w = csv.DictWriter(fp, fieldnames=fieldnames, extrasaction="ignore") w.writeheader() for r in records: row = { "timestamp": r.get("timestamp", 0), "datetime": r.get("datetime", ""), "value": r.get("value", 0), "sub_value": "" if r.get("sub_value") is None else r["sub_value"], "extra": json.dumps(r.get("extra", {}), ensure_ascii=False), } w.writerow(row) tmp.replace(path) logger.info("链上指标 %s 已写入磁盘: %d 条", metric, len(records)) except Exception as e: logger.error("链上指标 %s 落盘失败: %s", metric, e) if tmp.exists(): tmp.unlink() def _merge(self, base: List[Dict], new: List[Dict]) -> List[Dict]: """按 timestamp 去重合并,新覆盖旧。""" merged = {r["timestamp"]: r for r in base} for r in new: merged[r["timestamp"]] = r return sorted(merged.values(), key=lambda r: r["timestamp"]) # ── 数据拉取 ───────────────────────────────────────────────────── def _fetch_cm_metrics( self, metrics: str, days: int = 365 ) -> Optional[List[Dict]]: """从 CoinMetrics Community API 拉取指标。返回 [{time, metric: value}, ...]""" page_size = min(days, 10000) url = ( f"{COINMETRICS_BASE}/timeseries/asset-metrics" f"?assets=btc&metrics={metrics}&frequency=1d&page_size={page_size}" ) try: r = requests.get(url, timeout=REQUEST_TIMEOUT, headers=HTTP_HEADERS) if r.status_code != 200: logger.warning("CoinMetrics %s 返回 %d: %s", metrics, r.status_code, r.text[:200]) return None data = r.json().get("data", []) if not data: return None # 翻页取更多历史 all_data = list(data) next_url = r.json().get("next_page_url") pages = 0 while next_url and pages < 10: pages += 1 time.sleep(0.5) r2 = requests.get(next_url, timeout=REQUEST_TIMEOUT, headers=HTTP_HEADERS) if r2.status_code != 200: break batch = r2.json() all_data.extend(batch.get("data", [])) next_url = batch.get("next_page_url") if not next_url or next_url == r.json().get("next_page_url"): break return all_data except Exception as e: logger.error("CoinMetrics %s 拉取失败: %s", metrics, e) return None def _fetch_btc_netflow(self) -> List[Dict]: """BTC 交易所净流量 (USD) = 流入 - 流出。""" data = self._fetch_cm_metrics("FlowInExUSD,FlowOutExUSD", days=365) if not data: return [] records = [] for d in data: ts_str = d.get("time", "") inflow = float(d.get("FlowInExUSD") or 0) outflow = float(d.get("FlowOutExUSD") or 0) netflow = inflow - outflow try: ts = int(datetime.fromisoformat(ts_str.replace("Z", "+00:00")).timestamp() * 1000) except Exception: continue records.append({ "timestamp": ts, "datetime": to_utc_iso(ts), "value": round(netflow, 2), "sub_value": round(inflow, 2), "extra": {"inflow_usd": round(inflow, 2), "outflow_usd": round(outflow, 2)}, }) return sorted(records, key=lambda r: r["timestamp"]) def _fetch_stablecoin_supply(self) -> List[Dict]: """稳定币总供应(USDT + USDC + DAI + FDUSD + TUSD 市值之和)。""" try: url = ( f"{COINGECKO_BASE}/coins/markets" f"?vs_currency=usd&category=stablecoins&order=market_cap_desc" f"&per_page=5&page=1&sparkline=false" ) r = requests.get(url, timeout=REQUEST_TIMEOUT, headers=HTTP_HEADERS) if r.status_code != 200: logger.warning("CoinGecko stablecoins 返回 %d", r.status_code) return [] coins = r.json() if not isinstance(coins, list): return [] total_mcap = sum(c.get("market_cap", 0) or 0 for c in coins) now_ms = int(time.time() * 1000) breakdown = { c.get("symbol", "?").upper(): c.get("market_cap", 0) or 0 for c in coins[:5] } return [{ "timestamp": now_ms, "datetime": to_utc_iso(now_ms), "value": round(total_mcap, 2), "sub_value": round(total_mcap / 1e9, 2), # Billions "extra": breakdown, }] except Exception as e: logger.error("稳定币供应拉取失败: %s", e) return [] def _fetch_etf_flow(self) -> List[Dict]: """BTC ETF 净流入/流出。优先从 Farside 免费爬取,Coinglass 为备用。 数据源优先级: 1. Farside (免费, HTML 爬取) 2. Coinglass (需 coinglass_key) 3. Glassnode (需 glassnode_key) """ # ── 优先:Farside 免费爬取 ── try: records = self._fetch_etf_farside() if records: logger.info("ETF 数据从 Farside 拉取: %d 条", len(records)) return records except Exception as e: logger.warning("Farside ETF 爬取失败: %s", e) # ── 备用:Coinglass ── cg_key = self.api_keys.get("coinglass_key") if cg_key: try: url = "https://open-api-v3.coinglass.com/api/bitcoin/etf/net-inflow?interval=30" r = requests.get(url, timeout=REQUEST_TIMEOUT, headers={ **HTTP_HEADERS, "coinglassSecret": cg_key, }) if r.status_code == 200: data = r.json() records = [] if isinstance(data, dict) and "data" in data: for item in data["data"]: ts = int(item.get("date", 0)) * 1000 if item.get("date") else 0 if ts: records.append({ "timestamp": ts, "datetime": to_utc_iso(ts), "value": float(item.get("netInflow", 0)), "sub_value": None, "extra": item, }) return records logger.warning("Coinglass ETF 返回 %d", r.status_code) except Exception as e: logger.error("Coinglass ETF 拉取失败: %s", e) logger.warning("ETF 数据源均不可用(Farside/Coinglass/Glassnode)") return [] def _fetch_etf_farside(self) -> List[Dict]: """从 Farside 网站爬取 BTC ETF 每日净流量。 https://farside.co.uk/btc/ 页面包含一个 HTML 表格, 每行包含日期和各 ETF 的当日流量(百万美元),最后一列为总计。 """ url = "https://farside.co.uk/btc/" r = requests.get(url, timeout=REQUEST_TIMEOUT, headers={ **HTTP_HEADERS, "Accept": "text/html,application/xhtml+xml,*/*", }) if r.status_code != 200: logger.warning("Farside 返回 %d", r.status_code) return [] html = r.text # 查找表格 table_match = re.search(r']*>(.*?)', html, re.DOTALL) if not table_match: logger.warning("Farside 页面未找到表格") return [] rows_html = re.findall(r']*>(.*?)', table_match.group(1), re.DOTALL) month_map = { "jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6, "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12, } records = [] for row_html in rows_html: cells = re.findall(r']*>(.*?)', row_html, re.DOTALL) # 清理 HTML 标签和空白 clean = [] for c in cells: t = re.sub(r'<[^>]+>', '', c).strip() t = t.replace('\xa0', ' ').replace(' ', ' ').strip() clean.append(t) if not clean: continue # 第一列应为日期格式 "15 Jun 2026" date_str = clean[0] parts = date_str.split() if len(parts) != 3: continue day_str, mon_str, year_str = parts mon = month_map.get(mon_str.lower()[:3]) if mon is None: continue try: day = int(day_str) year = int(year_str) except ValueError: continue # 最后一列为总计(可能带括号表示负值) total_str = clean[-1] if len(clean) > 1 else "" if not total_str or total_str in ("", "Total", "-"): continue # 解析 "(123.4)" → -123.4, "123.4" → 123.4 total_str = total_str.replace(",", "") is_negative = total_str.startswith("(") and total_str.endswith(")") if is_negative: total_str = total_str[1:-1] try: total_m = float(total_str) except ValueError: continue if is_negative: total_m = -total_m # 构建时间戳(UTC 午夜) from datetime import datetime, timezone as tz dt = datetime(year, mon, day, tzinfo=tz.utc) ts = int(dt.timestamp() * 1000) # 分解各 ETF 明细 etf_breakdown = {} if len(clean) > 2: etf_names = ["IBIT", "FBTC", "BITB", "ARKB", "BTCO", "EZBC", "BRRR", "HODL", "BTCW", "MSBT", "GBTC", "BTC"] for i, name in enumerate(etf_names): idx = i + 1 if idx < len(clean) - 1: val_str = clean[idx].replace(",", "").replace("(", "").replace(")", "") neg = clean[idx].startswith("(") try: val = float(val_str) etf_breakdown[name] = -val if neg else val except ValueError: pass records.append({ "timestamp": ts, "datetime": dt.isoformat().replace("+00:00", "Z"), "value": round(total_m, 2), # 净流入 (百万 USD) "sub_value": round(total_m * 1_000_000, 2), # 净流入 (USD) "extra": {"total_million_usd": round(total_m, 2), "breakdown": etf_breakdown}, }) return sorted(records, key=lambda r: r["timestamp"]) def _fetch_mvrv_zscore(self) -> List[Dict]: """MVRV Z-Score = (当前 MVRV - 滚动均值) / 滚动标准差。 从 CoinMetrics 拉取 CapMVRVCur(免费),计算 365 天滚动 Z-Score。 """ data = self._fetch_cm_metrics("CapMVRVCur", days=400) if not data: return [] # 解析时间序列 mvrv_series = [] for d in data: ts_str = d.get("time", "") mvrv_val = d.get("CapMVRVCur") if mvrv_val is None: continue try: ts = int(datetime.fromisoformat(ts_str.replace("Z", "+00:00")).timestamp() * 1000) except Exception: continue mvrv_series.append((ts, float(mvrv_val))) mvrv_series.sort(key=lambda x: x[0]) # 计算滚动 Z-Score (365 天窗口,即约 365 个数据点) window = 365 records = [] values = [] timestamps = [] for ts, val in mvrv_series: values.append(val) timestamps.append(ts) if len(values) < window: continue window_vals = values[-window:] mean = sum(window_vals) / window variance = sum((v - mean) ** 2 for v in window_vals) / window stddev = variance ** 0.5 zscore = (val - mean) / stddev if stddev > 0 else 0.0 records.append({ "timestamp": ts, "datetime": to_utc_iso(ts), "value": round(zscore, 4), "sub_value": round(val, 4), "extra": {"mvrv_ratio": round(val, 4), "rolling_mean": round(mean, 4), "rolling_stddev": round(stddev, 4)}, }) return records def _fetch_sopr(self) -> List[Dict]: """SOPR (Spent Output Profit Ratio)。需要 API key。 支持: - glassnode_key: Glassnode v1/metrics/indicators/sopr """ gn_key = self.api_keys.get("glassnode_key") if gn_key: try: url = ( f"https://api.glassnode.com/v1/metrics/indicators/sopr" f"?a=btc&f=json&api_key={gn_key}" ) r = requests.get(url, timeout=REQUEST_TIMEOUT, headers=HTTP_HEADERS) if r.status_code == 200: data = r.json() records = [] for item in data if isinstance(data, list) else []: ts = int(item.get("t", 0)) * 1000 if ts: records.append({ "timestamp": ts, "datetime": to_utc_iso(ts), "value": float(item.get("v", 0)), "sub_value": None, "extra": {"raw": item}, }) return records logger.warning("Glassnode SOPR 返回 %d", r.status_code) except Exception as e: logger.error("Glassnode SOPR 拉取失败: %s", e) logger.warning( "SOPR 数据未配置 API key。请设置环境变量 GLASSNODE_API_KEY" ) return [] # ── 刷新入口 ───────────────────────────────────────────────────── def refresh_all(self) -> None: """拉取全部 5 个指标,合并到内存并写盘。""" fetchers = { "btc_netflow": self._fetch_btc_netflow, "stablecoin_supply": self._fetch_stablecoin_supply, "etf_flow": self._fetch_etf_flow, "mvrv_zscore": self._fetch_mvrv_zscore, "sopr": self._fetch_sopr, } for name, fetcher in fetchers.items(): try: new_records = fetcher() if not new_records: continue with self._lock: base = list(self._metrics.get(name, [])) merged = self._merge(base, new_records) self._metrics[name] = merged self._write_local(name, merged) logger.info("链上指标 %s 刷新: +%d 条, 总计 %d 条", name, len(new_records), len(merged)) except Exception: logger.exception("链上指标 %s 刷新异常", name) # ── 后台线程 ───────────────────────────────────────────────────── def _refresh_loop(self) -> None: """后台线程:启动立即拉取一次,之后每 REFRESH_INTERVAL 秒刷新。""" logger.info("链上指标后台线程启动,间隔 %ds", REFRESH_INTERVAL) # 启动立即拉取 self.refresh_all() while not self._stop_event.wait(REFRESH_INTERVAL): self.refresh_all() logger.info("链上指标后台线程已退出") def start(self) -> None: self._stop_event.clear() self._thread = threading.Thread( target=self._refresh_loop, name="onchain-loop", daemon=True, ) self._thread.start() logger.info("链上指标模块已启动") def stop(self) -> None: self._stop_event.set() if self._thread: self._thread.join(timeout=5) # ── 查询接口 ───────────────────────────────────────────────────── def get_metric( self, metric: str, limit: int = 100, start_ms: Optional[int] = None, end_ms: Optional[int] = None, ) -> List[Dict]: if metric not in METRIC_NAMES: raise HTTPException( status_code=400, detail=f"不支持的指标: {metric}。可选: {METRIC_NAMES}", ) with self._lock: records = list(self._metrics.get(metric, [])) if start_ms is not None: records = [r for r in records if r["timestamp"] >= start_ms] if end_ms is not None: records = [r for r in records if r["timestamp"] <= end_ms] return records[-limit:] if limit else records def get_latest(self) -> Dict[str, Any]: """获取所有指标的最新值。""" result = {"timestamp": int(time.time() * 1000)} for name in METRIC_NAMES: with self._lock: records = self._metrics.get(name, []) if records: latest = records[-1] result[name] = { "timestamp": latest["timestamp"], "datetime": latest["datetime"], "value": latest["value"], "sub_value": latest.get("sub_value"), "extra": latest.get("extra", {}), } else: result[name] = None return result # ── FastAPI Router ──────────────────────────────────────────────────── def create_onchain_router(manager: OnchainMetricsManager) -> APIRouter: router = APIRouter(prefix="/api/onchain", tags=["onchain"]) @router.get("/metrics") async def get_metrics( metric: str = Query(..., description=f"指标名称: {', '.join(METRIC_NAMES)}"), limit: int = Query(100, ge=1, le=5000, description="返回条数上限"), start: Optional[int] = Query(None, description="起始时间戳(ms)"), end: Optional[int] = Query(None, description="结束时间戳(ms)"), ): """获取单个链上指标的时间序列。""" data = manager.get_metric(metric, limit=limit, start_ms=start, end_ms=end) return {"metric": metric, "count": len(data), "data": data} @router.get("/latest") async def get_latest(): """获取全部 5 个指标的最新快照。""" return manager.get_latest() @router.get("/available") async def get_available(): """列出可用指标及当前数据量。""" info = {} for name in METRIC_NAMES: records = manager.get_metric(name, limit=0) info[name] = { "count": len(records), "has_data": len(records) > 0, "first_ts": records[0]["datetime"] if records else None, "last_ts": records[-1]["datetime"] if records else None, } return info return router # ── 环境变量辅助 ───────────────────────────────────────────────────── def api_keys_from_env() -> Dict[str, str]: """从环境变量读取 API keys。""" keys = {} for env_var, key_name in [ ("COINGLASS_API_KEY", "coinglass_key"), ("GLASSNODE_API_KEY", "glassnode_key"), ("FARSIDE_API_KEY", "farside_key"), ("COINMETRICS_API_KEY", "coinmetrics_key"), ]: val = os.getenv(env_var, "").strip() if val and "***" not in val: # 忽略占位符 keys[key_name] = val return keys