Files
AlphaMaster/download_okx_klines.py
T
2026-07-10 18:14:59 +08:00

318 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
download_okx_klines.py — 从 OKX 下载主流 USDT 永续合约 K 线
输出格式与 D:\\OKX_K线数据 一致:
{品种}_{周期}.parquet
列: time, open, high, low, close, tick_volume
time 为 Unix 秒(int64)
周期映射:
5m -> M5, 15m -> M15, 1H -> H1, 1D -> D1
主流币名单(20 只,按市值/流动性常见排序,不含稳定币):
BTC ETH XRP BNB SOL DOGE ADA LINK BCH XLM
LTC HBAR AVAX UNI DOT POL SHIB NEAR ATOM TRX
用法:
python download_okx_klines.py
python download_okx_klines.py --out D:\\OKX_K线数据
python download_okx_klines.py --resume
每个合约/周期会拉取 OKX 能提供的全部历史 K 线(不设条数上限)。
"""
from __future__ import annotations
import argparse
import json
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
import pandas as pd
from loguru import logger
sys.path.insert(0, str(Path(__file__).parent))
from config import Config
OKX_BASE = "https://www.okx.com"
DEFAULT_OUT = Path(r"D:\OKX_K线数据")
REQUEST_SLEEP = 0.21
LOG_EVERY_PAGES = 50
# 固定主流 20 币(参考 CoinMarketCap 市值前列,剔除 USDT/USDC 等稳定币)
MAINSTREAM_BASES: tuple[str, ...] = (
"BTC", "ETH", "XRP", "BNB", "SOL",
"DOGE", "ADA", "LINK", "BCH", "XLM",
"LTC", "HBAR", "AVAX", "UNI", "DOT",
"POL", "SHIB", "NEAR", "ATOM", "TRX",
)
TIMEFRAMES: dict[str, str] = {
"5m": "M5",
"15m": "M15",
"1H": "H1",
"1D": "D1",
}
_COLUMNS = ["time", "open", "high", "low", "close", "tick_volume"]
def okx_get(path: str, params: dict[str, str], retries: int = 5) -> list:
query = urllib.parse.urlencode(params)
url = f"{OKX_BASE}{path}?{query}"
last_err: Exception | None = None
for attempt in range(retries):
try:
req = urllib.request.Request(url, headers={"User-Agent": "AlphaMaster/1.0"})
with urllib.request.urlopen(req, timeout=30) as resp:
body = json.loads(resp.read().decode("utf-8"))
if body.get("code") != "0":
raise RuntimeError(f"OKX API {body.get('code')}: {body.get('msg')}")
return body.get("data") or []
except Exception as exc:
last_err = exc
wait = min(60, 2 ** attempt)
logger.warning(f"请求失败 ({attempt + 1}/{retries}): {exc},{wait}s 后重试")
time.sleep(wait)
raise RuntimeError(f"OKX 请求失败: {last_err}")
def inst_to_symbol(inst_id: str) -> str:
"""BTC-USDT-SWAP -> BTCUSDT"""
if not inst_id.endswith("-USDT-SWAP"):
raise ValueError(f"非 USDT 永续: {inst_id}")
base = inst_id[: -len("-USDT-SWAP")]
return f"{base}USDT"
def fetch_available_usdt_swaps() -> set[str]:
instruments = okx_get("/api/v5/public/instruments", {"instType": "SWAP"})
return {
str(x.get("instId", ""))
for x in instruments
if str(x.get("instId", "")).endswith("-USDT-SWAP")
}
def fetch_mainstream_swap_inst_ids() -> list[str]:
available = fetch_available_usdt_swaps()
inst_ids: list[str] = []
missing: list[str] = []
for base in MAINSTREAM_BASES:
inst_id = f"{base}-USDT-SWAP"
if inst_id in available:
inst_ids.append(inst_id)
else:
missing.append(base)
if missing:
logger.warning(f"以下主流币在 OKX 无 USDT 永续,已跳过: {', '.join(missing)}")
if not inst_ids:
raise RuntimeError("未找到任何可用的主流 USDT 永续合约")
logger.info(f"已选取固定主流币 {len(inst_ids)} 只 USDT 永续")
for i, inst in enumerate(inst_ids, 1):
logger.info(f" #{i:02d} {inst}")
return inst_ids
def download_history(inst_id: str, bar: str, max_bars: int | None = None) -> pd.DataFrame:
rows: list[list[str]] = []
after: str | None = None
stagnant = 0
page = 0
while max_bars is None or len(rows) < max_bars:
params: dict[str, str] = {"instId": inst_id, "bar": bar, "limit": "100"}
if after is not None:
params["after"] = after
batch = okx_get("/api/v5/market/history-candles", params)
if not batch:
break
prev_len = len(rows)
rows.extend(batch)
page += 1
if page % LOG_EVERY_PAGES == 0:
logger.info(f" {inst_id} {bar}: 已拉取 {len(rows):,} 根…")
if len(rows) == prev_len:
break
oldest_ts = batch[-1][0]
if after is not None and oldest_ts == after:
stagnant += 1
if stagnant >= 2:
break
else:
stagnant = 0
after = oldest_ts
if len(batch) < 100:
break
time.sleep(REQUEST_SLEEP)
if not rows:
return pd.DataFrame(columns=_COLUMNS)
records = []
for item in rows:
records.append(
{
"time": int(int(item[0]) // 1000),
"open": float(item[1]),
"high": float(item[2]),
"low": float(item[3]),
"close": float(item[4]),
"tick_volume": int(float(item[5])),
}
)
df = pd.DataFrame(records)
df = df.drop_duplicates("time").sort_values("time").reset_index(drop=True)
if max_bars is not None and len(df) > max_bars:
df = df.iloc[-max_bars:].reset_index(drop=True)
return df.astype(
{
"time": "int64",
"open": "float32",
"high": "float32",
"low": "float32",
"close": "float32",
"tick_volume": "int64",
}
)
def save_parquet(df: pd.DataFrame, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
df.to_parquet(path, index=False)
def load_manifest(path: Path) -> dict:
if not path.exists():
return {"done": []}
try:
return json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return {"done": []}
def manifest_key(symbol: str, tf_tag: str) -> str:
return f"{symbol}_{tf_tag}"
def fmt_range(df: pd.DataFrame) -> str:
if df.empty:
return "无数据"
t0 = datetime.fromtimestamp(int(df["time"].iloc[0]), tz=timezone.utc).strftime("%Y-%m-%d")
t1 = datetime.fromtimestamp(int(df["time"].iloc[-1]), tz=timezone.utc).strftime("%Y-%m-%d")
return f"{t0} ~ {t1}"
def run(
out_dir: Path,
resume: bool,
max_bars: int | None = None,
) -> None:
manifest_path = out_dir / "okx_download_manifest.json"
manifest = load_manifest(manifest_path)
done: set[str] = set(manifest.get("done") or [])
inst_ids = fetch_mainstream_swap_inst_ids()
total_tasks = len(inst_ids) * len(TIMEFRAMES)
finished = 0
ok = 0
skipped = 0
failed = 0
logger.info(f"输出目录: {out_dir}")
logger.info(
f"历史范围: {'全量(OKX 能提供的全部 K 线)' if max_bars is None else f'最多 {max_bars:,} 根'}"
)
logger.info(f"任务总数: {total_tasks}({len(inst_ids)} 品种 × {len(TIMEFRAMES)} 周期)")
for inst_id in inst_ids:
symbol = inst_to_symbol(inst_id)
for bar, tf_tag in TIMEFRAMES.items():
finished += 1
key = manifest_key(symbol, tf_tag)
out_path = out_dir / f"{symbol}_{tf_tag}.parquet"
if resume and key in done and out_path.exists():
skipped += 1
logger.info(f"[{finished}/{total_tasks}] 跳过已完成 {out_path.name}")
continue
logger.info(f"[{finished}/{total_tasks}] 下载 {inst_id} {bar} -> {out_path.name}")
t0 = time.time()
try:
df = download_history(inst_id, bar, max_bars)
if df.empty:
logger.warning(f" {out_path.name}: 无数据")
failed += 1
continue
save_parquet(df, out_path)
done.add(key)
manifest["done"] = sorted(done)
manifest["updated_at"] = datetime.now(timezone.utc).isoformat()
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
ok += 1
logger.success(
f" {out_path.name}: {len(df):,} bars {fmt_range(df)} ({time.time() - t0:.1f}s)"
)
if len(df) < Config.MIN_BARS:
logger.warning(
f" {out_path.name}: 仅 {len(df)} 根,低于训练最低要求 {Config.MIN_BARS}"
)
except Exception as exc:
failed += 1
logger.error(f" {out_path.name}: 失败 — {exc}")
logger.info(
f"完成。成功 {ok},跳过 {skipped},失败 {failed},总计 {total_tasks}。"
f"清单: {manifest_path}"
)
def main() -> None:
parser = argparse.ArgumentParser(description="下载 OKX 主流合约 K 线到 Parquet")
parser.add_argument("--out", type=str, default=str(DEFAULT_OUT), help="输出目录")
parser.add_argument(
"--max-bars",
type=int,
default=None,
help="可选:限制每个文件最多保留 K 线根数(默认不限制,拉全历史)",
)
parser.add_argument("--resume", action="store_true", help="跳过 manifest 中已完成的文件")
args = parser.parse_args()
log_dir = Path(__file__).parent / "logs"
log_dir.mkdir(exist_ok=True)
logger.add(log_dir / "okx_download.log", rotation="20 MB", encoding="utf-8")
print(f"{'=' * 62}")
print(" OKX 主流币 K 线下载")
print(f" 固定 {len(MAINSTREAM_BASES)} 只: {' '.join(MAINSTREAM_BASES)}")
print(f" 周期 {', '.join(TIMEFRAMES.values())}")
print(f" 历史: {'全量' if args.max_bars is None else f'最多 {args.max_bars:,} 根'}")
print(f" 保存至: {args.out}")
print(f"{'=' * 62}\n")
run(
out_dir=Path(args.out),
resume=args.resume,
max_bars=args.max_bars,
)
if __name__ == "__main__":
main()