diff --git a/docs/振幅统计说明.md b/docs/振幅统计说明.md index a29538a..7a28989 100644 --- a/docs/振幅统计说明.md +++ b/docs/振幅统计说明.md @@ -52,7 +52,8 @@ 汇总必含:最大振幅(及日期)、开→高/开→低的最大与均值等。 K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD / BTC-USD),失败再降级永续标记。 -近期 K 线接口约仅 **1440** 根(1H≈60 天);更长周期自动续拉 `history-index-candles` / `history-candles`。 +近期 K 线接口约仅 **1440** 根(1H≈60 天);更长周期自动续拉 `history-index-candles` / `history-candles`。 +分页带间隔,遇 OKX **429** 会自动退避重试(长周期首次会慢一些)。 --- diff --git a/lib/hub/amp_stats_lib.py b/lib/hub/amp_stats_lib.py index 4b349d3..aecb68d 100644 --- a/lib/hub/amp_stats_lib.py +++ b/lib/hub/amp_stats_lib.py @@ -7,6 +7,7 @@ from __future__ import annotations import csv import io import statistics +import time from datetime import date, datetime, timedelta from typing import Any, Callable, Optional from zoneinfo import ZoneInfo @@ -401,6 +402,51 @@ def _parse_okx_candle_row(row: list) -> Optional[dict[str, Any]]: return {"ts": ts, "o": o, "h": h, "l": l, "c": c} +def _okx_get_json( + client: httpx.Client, + url: str, + params: dict[str, str], + *, + retries: int = 8, +) -> dict[str, Any]: + """GET OKX 公共行情;遇 429 指数退避重试.""" + last_err: Optional[BaseException] = None + for attempt in range(max(1, int(retries))): + try: + r = client.get(url, params=params) + if r.status_code == 429: + wait = min(12.0, 0.7 * (2**attempt)) + time.sleep(wait) + last_err = httpx.HTTPStatusError( + f"429 Too Many Requests for url '{r.url}'", + request=r.request, + response=r, + ) + continue + r.raise_for_status() + body = r.json() + if not isinstance(body, dict): + raise RuntimeError("OKX 返回非对象 JSON") + return body + except httpx.HTTPStatusError as exc: + status = exc.response.status_code if exc.response is not None else None + if status == 429 and attempt + 1 < retries: + wait = min(12.0, 0.7 * (2**attempt)) + time.sleep(wait) + last_err = exc + continue + raise + except httpx.TransportError as exc: + if attempt + 1 < retries: + time.sleep(min(8.0, 0.5 * (2**attempt))) + last_err = exc + continue + raise + if last_err is not None: + raise last_err + raise RuntimeError("OKX 请求失败") + + def fetch_okx_candles( *, url: str, @@ -412,10 +458,13 @@ def fetch_okx_candles( timeout: float = 30.0, history_url: Optional[str] = None, max_pages: int = 200, + page_pause_sec: float = 0.12, + history_page_pause_sec: float = 0.22, ) -> list[dict[str, Any]]: """拉取 [since_ms, until_ms] 覆盖的 K 线(含边界). OKX 近期接口约仅 1440 根;更早需 history_* 端点续拉. + 分页带间隔,429 自动退避重试. """ own = client is None client = client or httpx.Client( @@ -428,40 +477,33 @@ def fetch_okx_candles( after: Optional[str] = None active_url = url switched_history = False - empty_streak = 0 - for _ in range(max(20, int(max_pages))): + for page_i in range(max(20, int(max_pages))): + if page_i > 0: + pause = history_page_pause_sec if switched_history or "history" in active_url else page_pause_sec + if pause > 0: + time.sleep(pause) params: dict[str, str] = {"instId": inst_id, "bar": bar, "limit": "100"} if after: params["after"] = after - r = client.get(active_url, params=params) - r.raise_for_status() - body = r.json() + body = _okx_get_json(client, active_url, params) 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: - empty_streak += 1 # 近期接口到头 → 切历史端点再试 - if ( - history_url - and not switched_history - and after is not None - ): + if history_url and not switched_history and after is not None: active_url = history_url switched_history = True - empty_streak = 0 + time.sleep(max(history_page_pause_sec, 0.35)) continue break - empty_streak = 0 oldest_ts = None - newest_in_page = 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) - newest_in_page = ts if newest_in_page is None else max(newest_in_page, ts) if ts < since_ms - 3600 * 1000: continue if ts > until_ms + 3600 * 1000: @@ -476,6 +518,7 @@ def fetch_okx_candles( if history_url and not switched_history: active_url = history_url switched_history = True + time.sleep(max(history_page_pause_sec, 0.35)) continue break after = str(oldest_ts) @@ -488,6 +531,7 @@ def fetch_okx_candles( ): active_url = history_url switched_history = True + time.sleep(max(history_page_pause_sec, 0.35)) return [out[k] for k in sorted(out.keys())] finally: if own: @@ -508,6 +552,7 @@ def fetch_symbol_bars( 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"] + index_err: Optional[BaseException] = None try: bars = fetch_okx_candles( url=OKX_INDEX_CANDLES, @@ -518,18 +563,25 @@ def fetch_symbol_bars( ) if bars: return bars, f"okx_index:{meta['index_inst']}", meta["index_inst"] - except Exception: - bars = [] + except Exception as exc: + index_err = exc + # 指数侧已触发限频时先冷却,再降级永续,避免连环 429 + time.sleep(1.2) - bars = fetch_okx_candles( - url=OKX_SWAP_CANDLES, - history_url=OKX_HISTORY_SWAP_CANDLES, - inst_id=meta["swap_inst"], - since_ms=since_ms, - until_ms=until_ms, - ) + try: + bars = fetch_okx_candles( + url=OKX_SWAP_CANDLES, + history_url=OKX_HISTORY_SWAP_CANDLES, + inst_id=meta["swap_inst"], + since_ms=since_ms, + until_ms=until_ms, + ) + except Exception as exc: + detail = f"index={index_err}; swap={exc}" if index_err else str(exc) + raise RuntimeError(f"OKX K线拉取失败({detail})") from exc if not bars: - raise RuntimeError("OKX 指数与永续 K 线均无数据") + detail = f"index={index_err}" if index_err else "empty" + raise RuntimeError(f"OKX 指数与永续 K 线均无数据({detail})") return bars, f"okx_swap:{meta['swap_inst']}", meta["swap_inst"] diff --git a/manual_trading_hub/static/amp_stats.js b/manual_trading_hub/static/amp_stats.js index 2c7a179..e071ba6 100644 --- a/manual_trading_hub/static/amp_stats.js +++ b/manual_trading_hub/static/amp_stats.js @@ -274,7 +274,7 @@ const startHour = Number(el("amp-start-hour")?.value || 16); const period = el("amp-period")?.value || "2m"; const customDays = Number(el("amp-custom-days")?.value || 60); - setStatus("计算中…(首次拉取 OKX K 线可能需数十秒)"); + setStatus("计算中…(长周期会分页拉 OKX,遇限频会自动重试,请稍候)"); try { const data = await apiFetch("/api/amp-stats/compute", { method: "POST", diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index e9cbbe0..bfdbd52 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -1508,7 +1508,7 @@ - + diff --git a/tests/test_amp_stats_lib.py b/tests/test_amp_stats_lib.py index a9cdc37..8d7b38d 100644 --- a/tests/test_amp_stats_lib.py +++ b/tests/test_amp_stats_lib.py @@ -14,6 +14,8 @@ from lib.hub.amp_stats_lib import ( window_bounds_for_settlement, ) +import httpx + TZ = ZoneInfo("Asia/Shanghai") @@ -198,11 +200,17 @@ class AmpStatsLibTests(unittest.TestCase): calls: list[str] = [] class FakeResp: - def __init__(self, data): + def __init__(self, data, url="https://x", status_code=200): self._data = data + self.status_code = status_code + self.url = url + self.request = httpx.Request("GET", url) def raise_for_status(self): - return None + if self.status_code >= 400: + raise httpx.HTTPStatusError( + "err", request=self.request, response=self + ) def json(self): return {"code": "0", "data": self._data} @@ -214,14 +222,14 @@ class AmpStatsLibTests(unittest.TestCase): # recent: only 2 pages then empty; history continues if "history" not in url: if after is None: - return FakeResp([["2000", "1", "2", "0.5", "1.5"], ["1900", "1", "2", "0.5", "1.5"]]) + return FakeResp([["2000", "1", "2", "0.5", "1.5"], ["1900", "1", "2", "0.5", "1.5"]], url=url) if after == "1900": - return FakeResp([]) # recent exhausted - return FakeResp([]) + return FakeResp([], url=url) # recent exhausted + return FakeResp([], url=url) # history if after == "1900": - return FakeResp([["1800", "1", "2", "0.5", "1.5"], ["1000", "1", "2", "0.5", "1.5"]]) - return FakeResp([]) + return FakeResp([["1800", "1", "2", "0.5", "1.5"], ["1000", "1", "2", "0.5", "1.5"]], url=url) + return FakeResp([], url=url) def close(self): return None @@ -234,11 +242,57 @@ class AmpStatsLibTests(unittest.TestCase): until_ms=3000, client=FakeClient(), max_pages=10, + page_pause_sec=0, + history_page_pause_sec=0, ) self.assertTrue(any("history-index-candles" in u for u in calls)) self.assertGreaterEqual(len(bars), 3) self.assertEqual(bars[0]["ts"], 1000) + def test_fetch_retries_on_429(self): + from lib.hub.amp_stats_lib import fetch_okx_candles + import httpx as _httpx + + hits = {"n": 0} + + class FakeResp: + def __init__(self, status_code, data=None): + self.status_code = status_code + self.url = "https://www.okx.com/api/v5/market/history-candles" + self.request = _httpx.Request("GET", self.url) + self._data = data or [] + + def raise_for_status(self): + if self.status_code >= 400: + raise _httpx.HTTPStatusError("429", request=self.request, response=self) + + def json(self): + return {"code": "0", "data": self._data} + + class FakeClient: + def get(self, url, params=None): + hits["n"] += 1 + if hits["n"] < 3: + return FakeResp(429) + return FakeResp(200, [["1000", "1", "2", "0.5", "1.5"]]) + + def close(self): + return None + + bars = fetch_okx_candles( + url="https://www.okx.com/api/v5/market/candles", + history_url=None, + inst_id="ETH-USDT-SWAP", + since_ms=1000, + until_ms=2000, + client=FakeClient(), + max_pages=3, + page_pause_sec=0, + history_page_pause_sec=0, + ) + self.assertGreaterEqual(hits["n"], 3) + self.assertEqual(len(bars), 1) + def test_compute_with_mock_fetch(self): now = datetime(2026, 7, 22, 18, 0, tzinfo=TZ)