"""中控振幅统计:OKX 指数(可降级永续)按时段切窗,点数口径. 仅只读行情;不触及下单链路. """ from __future__ import annotations import csv import io import statistics from datetime import date, datetime, timedelta from typing import Any, Callable, Optional from zoneinfo import ZoneInfo import httpx APP_TZ = ZoneInfo("Asia/Shanghai") END_HOUR = 16 EXCHANGE = "okx" TIMEFRAME = "1H" SYMBOLS: dict[str, dict[str, str]] = { "eth": { "label": "ETH", "index_inst": "ETH-USD", "swap_inst": "ETH-USDT-SWAP", }, "btc": { "label": "BTC", "index_inst": "BTC-USD", "swap_inst": "BTC-USDT-SWAP", }, } PERIOD_DAYS: dict[str, int] = { "1m": 30, "2m": 60, "3m": 90, "6m": 180, "1y": 365, } OKX_INDEX_CANDLES = "https://www.okx.com/api/v5/market/index-candles" OKX_SWAP_CANDLES = "https://www.okx.com/api/v5/market/candles" def normalize_symbol(raw: str) -> str: s = (raw or "").strip().lower() if s in ("eth", "ethereum"): return "eth" if s in ("btc", "bitcoin"): return "btc" raise ValueError("symbol 仅支持 eth / btc") def resolve_sample_days(period: str, custom_days: Any = None) -> int: p = (period or "2m").strip().lower() if p == "custom": try: n = int(custom_days) except (TypeError, ValueError): raise ValueError("自定义天数无效") from None return max(7, min(400, n)) if p not in PERIOD_DAYS: raise ValueError("周期无效") return PERIOD_DAYS[p] def window_bounds_for_settlement(settlement: date, start_hour: int) -> tuple[datetime, datetime]: """返回 [start, end) 的本地时刻;end 为结算日 16:00.""" if not (0 <= int(start_hour) <= 23): raise ValueError("起点须为 0-23 整点") end = datetime(settlement.year, settlement.month, settlement.day, END_HOUR, 0, 0, tzinfo=APP_TZ) sh = int(start_hour) if sh >= END_HOUR: prev = settlement - timedelta(days=1) start = datetime(prev.year, prev.month, prev.day, sh, 0, 0, tzinfo=APP_TZ) else: start = datetime(settlement.year, settlement.month, settlement.day, sh, 0, 0, tzinfo=APP_TZ) return start, end def list_settlement_dates(*, sample_days: int, now: Optional[datetime] = None) -> list[date]: """最近 sample_days 个已收窗结算日(不含进行中的今天未到 16:00).""" now = now or datetime.now(APP_TZ) if now.tzinfo is None: now = now.replace(tzinfo=APP_TZ) else: now = now.astimezone(APP_TZ) today = now.date() today_end = datetime(today.year, today.month, today.day, END_HOUR, 0, 0, tzinfo=APP_TZ) latest = today if now >= today_end else today - timedelta(days=1) return [latest - timedelta(days=i) for i in range(int(sample_days))] def _safe_float(v: Any) -> Optional[float]: try: if v is None or v == "": return None return float(v) except (TypeError, ValueError): return None def bars_to_map(bars: list[dict[str, Any]]) -> dict[int, dict[str, float]]: """open_time_ms -> {o,h,l,c}.""" m: dict[int, dict[str, float]] = {} for b in bars or []: if not isinstance(b, dict): continue ts = b.get("ts") if ts is None: ts = b.get("open_time_ms") try: ts_i = int(ts) except (TypeError, ValueError): continue o = _safe_float(b.get("o") if "o" in b else b.get("open")) h = _safe_float(b.get("h") if "h" in b else b.get("high")) l = _safe_float(b.get("l") if "l" in b else b.get("low")) c = _safe_float(b.get("c") if "c" in b else b.get("close")) if None in (o, h, l, c): continue m[ts_i] = {"o": float(o), "h": float(h), "l": float(l), "c": float(c)} return m def compute_day_row( settlement: date, start_hour: int, bar_map: dict[int, dict[str, float]], ) -> Optional[dict[str, Any]]: start, end = window_bounds_for_settlement(settlement, start_hour) start_ms = int(start.timestamp() * 1000) # 1H 棒覆盖 [T, T+1h);窗终点 16:00 用 15:00 棒的 close last_bar_ms = int((end - timedelta(hours=1)).timestamp() * 1000) if start_ms not in bar_map or last_bar_ms not in bar_map: return None opens = bar_map[start_ms]["o"] close = bar_map[last_bar_ms]["c"] hi = bar_map[start_ms]["h"] lo = bar_map[start_ms]["l"] t = start_ms while t <= last_bar_ms: b = bar_map.get(t) if b: hi = max(hi, b["h"]) lo = min(lo, b["l"]) t += 3600 * 1000 up = hi - opens down = opens - lo amp = hi - lo change = close - opens return { "settlement_day": settlement.isoformat(), "window_start": start.strftime("%Y-%m-%d %H:%M"), "window_end": end.strftime("%Y-%m-%d %H:%M"), "open": round(opens, 4), "high": round(hi, 4), "low": round(lo, 4), "close": round(close, 4), "up_points": round(up, 4), "down_points": round(down, 4), "amplitude": round(amp, 4), "change": round(change, 4), } def summarize_rows(rows: list[dict[str, Any]]) -> dict[str, Any]: if not rows: return { "sample_count": 0, "max_amplitude": None, "max_amplitude_day": None, "avg_amplitude": None, "median_amplitude": None, "max_up_points": None, "avg_up_points": None, "max_down_points": None, "avg_down_points": None, "up_day_ratio": None, "down_day_ratio": None, } amps = [float(r["amplitude"]) for r in rows] ups = [float(r["up_points"]) for r in rows] downs = [float(r["down_points"]) for r in rows] max_amp = max(amps) max_amp_day = next(r["settlement_day"] for r in rows if float(r["amplitude"]) == max_amp) up_days = sum(1 for r in rows if float(r["change"]) > 0) down_days = sum(1 for r in rows if float(r["change"]) < 0) n = len(rows) return { "sample_count": n, "max_amplitude": round(max_amp, 4), "max_amplitude_day": max_amp_day, "avg_amplitude": round(statistics.fmean(amps), 4), "median_amplitude": round(statistics.median(amps), 4), "max_up_points": round(max(ups), 4), "avg_up_points": round(statistics.fmean(ups), 4), "max_down_points": round(max(downs), 4), "avg_down_points": round(statistics.fmean(downs), 4), "up_day_ratio": round(up_days / n, 4), "down_day_ratio": round(down_days / n, 4), } def _parse_okx_candle_row(row: list) -> Optional[dict[str, Any]]: if not row or len(row) < 5: return None try: ts = int(row[0]) o, h, l, c = float(row[1]), float(row[2]), float(row[3]), float(row[4]) except (TypeError, ValueError, IndexError): return None return {"ts": ts, "o": o, "h": h, "l": l, "c": c} def fetch_okx_candles( *, url: str, inst_id: str, since_ms: int, until_ms: int, bar: str = "1H", client: Optional[httpx.Client] = None, timeout: float = 30.0, ) -> list[dict[str, Any]]: """拉取 [since_ms, until_ms] 覆盖的 K 线(含边界).OKX 返回新→旧.""" own = client is None client = client or httpx.Client( timeout=timeout, trust_env=False, headers={"User-Agent": "crypto_monitor-amp-stats/1.0"}, ) try: out: dict[int, dict[str, Any]] = {} after: Optional[str] = None for _ in range(80): params: dict[str, str] = {"instId": inst_id, "bar": bar, "limit": "100"} if after: params["after"] = after r = client.get(url, params=params) r.raise_for_status() body = r.json() if str(body.get("code") or "") not in ("0", "0.0", ""): raise RuntimeError(body.get("msg") or f"OKX error {body.get('code')}") data = body.get("data") or [] if not data: break oldest_ts = None for row in data: parsed = _parse_okx_candle_row(row) if not parsed: continue ts = int(parsed["ts"]) oldest_ts = ts if oldest_ts is None else min(oldest_ts, ts) if ts < since_ms - 3600 * 1000: continue if ts > until_ms + 3600 * 1000: continue out[ts] = parsed if oldest_ts is None: break if oldest_ts <= since_ms: break after = str(oldest_ts) return [out[k] for k in sorted(out.keys())] finally: if own: client.close() def fetch_symbol_bars( symbol: str, *, since_ms: int, until_ms: int, fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None, ) -> tuple[list[dict[str, Any]], str, str]: """返回 (bars, price_source_label, inst_id).""" key = normalize_symbol(symbol) meta = SYMBOLS[key] if fetch_fn: bars = fetch_fn(inst_id=meta["index_inst"], since_ms=since_ms, until_ms=until_ms) return bars, f"okx_index:{meta['index_inst']}", meta["index_inst"] try: bars = fetch_okx_candles( url=OKX_INDEX_CANDLES, inst_id=meta["index_inst"], since_ms=since_ms, until_ms=until_ms, ) if bars: return bars, f"okx_index:{meta['index_inst']}", meta["index_inst"] except Exception: bars = [] bars = fetch_okx_candles( url=OKX_SWAP_CANDLES, inst_id=meta["swap_inst"], since_ms=since_ms, until_ms=until_ms, ) if not bars: raise RuntimeError("OKX 指数与永续 K 线均无数据") return bars, f"okx_swap:{meta['swap_inst']}", meta["swap_inst"] def compute_amp_stats( *, symbol: str = "eth", start_hour: int = 16, period: str = "2m", custom_days: Any = None, now: Optional[datetime] = None, fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None, ) -> dict[str, Any]: key = normalize_symbol(symbol) sh = int(start_hour) if sh < 0 or sh > 23: raise ValueError("起点须为 0-23 整点") sample_days = resolve_sample_days(period, custom_days) settlements = list_settlement_dates(sample_days=sample_days, now=now) if not settlements: raise RuntimeError("无可用结算日") # 最远窗起点 oldest = settlements[-1] newest = settlements[0] start0, _ = window_bounds_for_settlement(oldest, sh) _, end1 = window_bounds_for_settlement(newest, sh) since_ms = int(start0.timestamp() * 1000) until_ms = int(end1.timestamp() * 1000) bars, price_source, inst_id = fetch_symbol_bars( key, since_ms=since_ms, until_ms=until_ms, fetch_fn=fetch_fn ) bar_map = bars_to_map(bars) rows: list[dict[str, Any]] = [] missing: list[str] = [] for d in settlements: row = compute_day_row(d, sh, bar_map) if row is None: missing.append(d.isoformat()) continue rows.append(row) summary = summarize_rows(rows) period_label = period if period != "custom" else f"custom:{sample_days}" return { "ok": True, "exchange": EXCHANGE, "symbol": key, "symbol_label": SYMBOLS[key]["label"], "start_hour": sh, "end_hour": END_HOUR, "period": period_label, "sample_days_requested": sample_days, "timeframe": TIMEFRAME, "price_source": price_source, "inst_id": inst_id, "timezone": "Asia/Shanghai", "rows": rows, "summary": summary, "missing_days": missing[:30], "missing_count": len(missing), } def rows_page(rows: list[dict[str, Any]], *, page: int = 1, page_size: int = 20) -> dict[str, Any]: page = max(1, int(page or 1)) page_size = max(5, min(100, int(page_size or 20))) total = len(rows) start = (page - 1) * page_size chunk = rows[start : start + page_size] return { "page": page, "page_size": page_size, "total": total, "total_pages": max(1, (total + page_size - 1) // page_size) if total else 1, "rows": chunk, } def build_export_csv(payload: dict[str, Any]) -> str: buf = io.StringIO() # Excel 友好 BOM buf.write("\ufeff") w = csv.writer(buf) s = payload.get("summary") or {} w.writerow(["【统计摘要】"]) w.writerow(["交易所", payload.get("exchange")]) w.writerow(["标的", payload.get("symbol_label")]) w.writerow(["价源", payload.get("price_source")]) w.writerow(["起点整点", f"{payload.get('start_hour')}:00"]) w.writerow(["终点", f"{payload.get('end_hour')}:00"]) w.writerow(["周期", payload.get("period")]) w.writerow(["样本数", s.get("sample_count")]) w.writerow(["最大振幅", s.get("max_amplitude"), "日期", s.get("max_amplitude_day")]) w.writerow(["振幅均值", s.get("avg_amplitude"), "中位数", s.get("median_amplitude")]) w.writerow(["开→高最大", s.get("max_up_points"), "均值", s.get("avg_up_points")]) w.writerow(["开→低最大", s.get("max_down_points"), "均值", s.get("avg_down_points")]) w.writerow(["上涨窗占比", s.get("up_day_ratio"), "下跌窗占比", s.get("down_day_ratio")]) w.writerow([]) w.writerow(["【日表明细】"]) w.writerow( [ "结算日", "窗起点", "窗终点", "开盘", "最高", "最低", "收盘", "开→高", "开→低", "振幅", "涨跌值", ] ) for r in payload.get("rows") or []: w.writerow( [ r.get("settlement_day"), r.get("window_start"), r.get("window_end"), r.get("open"), r.get("high"), r.get("low"), r.get("close"), r.get("up_points"), r.get("down_points"), r.get("amplitude"), r.get("change"), ] ) return buf.getvalue() def export_filename(payload: dict[str, Any]) -> str: sym = (payload.get("symbol") or "eth").lower() sh = int(payload.get("start_hour") or 16) period = str(payload.get("period") or "2m").replace(":", "") day = datetime.now(APP_TZ).strftime("%Y%m%d") return f"okx_{sym}_amp_{sh}to16_{period}_{day}.csv"