SPB Git

Repository Public

hyperliquid.py 24.7 KB 506 lines python

1"""Hyperliquid perpetuals: candles and funding history from the PUBLIC info endpoint, into local Parquet files.23    uv run python -m spq.data.hyperliquid --symbols BTC,ETH,SOL,HYPE --interval 1h --out data45Read-only, keyless: three request types of `POST https://api.hyperliquid.xyz/info` and nothing else —6`candleSnapshot`, `fundingHistory` and `meta`. No account, no signature, no order.78What the venue serves (measured live on 2026-11-10, see docs/v4/quant-perps.md):910- `candleSnapshot {req: {coin, interval, startTime, endTime}}`: at most 5000 candles per answer and ONLY THE MOST11  RECENT 5000 of an interval exist at all (1h: about 208 days; 4h: about 833 days; 1d: everything). Rows12  `{t, T, o, h, l, c, v, n}`: `t` = bar-open time in ms, prices as decimal strings, `v` in base units. They are13  TRADE prices of the perpetual, not the venue's mark price and not its oracle. The bar in progress is included14  in the answer; it is dropped here (closed bars only).15- `fundingHistory {coin, startTime, endTime?}`: 500 rows per answer, oldest first, one row per hourly settlement16  since the listing (BTC: 2023-05-12). Rows `{coin, fundingRate, premium, time}`; `time` is the settlement instant17  in ms, a few tens of ms after the hour. `fundingRate` is the HOURLY rate that longs paid to shorts at that18  instant (negative: shorts paid longs).19- `meta`: `universe[i] = {name, szDecimals, maxLeverage, ...}`.2021Historical mark and oracle prices are NOT served by this API: no `mark` or `index` column is produced.2223Rate limit: the documentation gives 1200 weight per minute per IP; `candleSnapshot` costs 20 + 1 per 60 rows,24`fundingHistory` 20 + 1 per 20 rows, `meta` 20. `Budget` keeps this module under `weight_per_minute` (default 600)25and retries HTTP 429 / 5xx with a growing pause.2627Files: <out>/hyperliquid/<SYM>_<interval>.parquet   candles, the layout of spq.data.load_candles (source28       "hyperliquid"); <out>/hyperliquid/funding/<SYM>.parquet   columns ts (settlement hour, UTC), time_ms (the29       venue's instant), rate, premium; <out>/hyperliquid/meta.json.3031Funding alignment used everywhere in the lab: a settlement belongs to the bar that OPENS at its hour. The32`funding` column of bar i is the sum of the rates settled in (ts[i-1], ts[i]] (at 1h: the one settled at ts[i]).33A strategy deciding on the close of bar i therefore sees settlements up to ts[i] and not the one at ts[i] + 1h,34which the engine charges at the next open.35"""36from __future__ import annotations3738import argparse39import json40import sys41import time42import urllib.error43import urllib.request44from dataclasses import asdict, dataclass, field45from datetime import datetime, timezone46from pathlib import Path47from typing import Callable4849import numpy as np50import pandas as pd5152from .core import COLUMNS, DataError, Quality, align, interval_seconds, normalise, to_utc5354INFO_URL = "https://api.hyperliquid.xyz/info"55USER_AGENT = "spq-research/0.1"56SOURCE = "hyperliquid"57CANDLE_PAGE = 500058FUNDING_PAGE = 50059FUNDING_INTERVAL_S = 360060#: a settlement instant further than this from the hour is counted as off-grid (observed: 12-200 ms after)61FUNDING_TOLERANCE_MS = 60_00062ALLOWED_TYPES = ("candleSnapshot", "fundingHistory", "meta")63SPOT_SUFFIX = ".SPOT"646566class Budget:67    """Sliding one-minute window of request weight."""6869    def __init__(self, weight_per_minute: int = 600, sleep: Callable = time.sleep, clock: Callable = time.monotonic):70        self.limit, self.sleep, self.clock = weight_per_minute, sleep, clock71        self.spent: list[tuple[float, int]] = []7273    def take(self, weight: int):74        while True:75            now = self.clock()76            self.spent = [(t, w) for t, w in self.spent if now - t < 60.0]77            if not self.spent or sum(w for _, w in self.spent) + weight <= self.limit:78                self.spent.append((now, weight))79                return80            self.sleep(max(0.05, 60.0 - (now - self.spent[0][0])))8182    def add(self, weight: int):83        self.spent.append((self.clock(), weight))848586def post_info(body: dict, *, retries: int = 6, timeout: float = 30.0):87    """One read-only request to the public info endpoint (only the three types this module needs)."""88    if body.get("type") not in ALLOWED_TYPES:89        raise DataError(f"request type {body.get('type')!r} is not one of {ALLOWED_TYPES}")90    payload = json.dumps(body).encode()91    for attempt in range(retries):92        try:93            req = urllib.request.Request(INFO_URL, data=payload, method="POST", headers={94                "Content-Type": "application/json", "Accept": "application/json", "User-Agent": USER_AGENT})95            with urllib.request.urlopen(req, timeout=timeout) as resp:96                return json.loads(resp.read().decode("utf-8"))97        except urllib.error.HTTPError as e:98            if e.code not in (429, 500, 502, 503, 504) or attempt == retries - 1:99                raise DataError(f"HTTP {e.code} from {INFO_URL} for {body.get('type')}") from e100            time.sleep(10.0 * (attempt + 1) if e.code == 429 else 1.5 * (attempt + 1))101        except (urllib.error.URLError, TimeoutError) as e:102            if attempt == retries - 1:103                raise DataError(f"network error for {INFO_URL}: {e}") from e104            time.sleep(1.5 * (attempt + 1))105    raise DataError(f"no answer from {INFO_URL}")106107108def _now_ms() -> int:109    return int(datetime.now(timezone.utc).timestamp() * 1000)110111112def _ms(ts) -> int:113    return int(to_utc(ts).timestamp() * 1000)114115116def fetch_meta(*, post: Callable = post_info, budget: Budget | None = None) -> dict:117    """{symbol: {"max_leverage", "size_decimals", "delisted"}} of the validator-operated perp dex."""118    (budget or Budget()).take(20)119    out = post({"type": "meta"})120    if not isinstance(out, dict) or not isinstance(out.get("universe"), list):121        raise DataError(f"hyperliquid meta: unexpected answer {str(out)[:200]}")122    return {u["name"]: {"max_leverage": float(u["maxLeverage"]), "size_decimals": int(u["szDecimals"]),123                        "delisted": bool(u.get("isDelisted", False))} for u in out["universe"]}124125126def fetch_candles(symbol: str, interval: str, start=None, end=None, *, post: Callable = post_info,127                  budget: Budget | None = None, now_ms: int | None = None) -> pd.DataFrame:128    """Raw candles (ts in ms + OHLCV), paged forward from `start` (default: as far back as the venue goes).129130    The bar in progress is NOT removed here (normalise() does it); duplicates across pages are.131    """132    step_ms = interval_seconds(interval) * 1000133    budget = budget or Budget()134    end_ms = _ms(end) if end is not None else (now_ms or _now_ms())135    cur = 0 if start is None else _ms(start) // step_ms * step_ms136    rows: dict[int, tuple] = {}137    while cur < end_ms:138        budget.take(20)139        chunk = post({"type": "candleSnapshot",140                      "req": {"coin": symbol, "interval": interval, "startTime": cur, "endTime": end_ms}})141        if not isinstance(chunk, list):142            raise DataError(f"hyperliquid {symbol}: unexpected candle answer {str(chunk)[:200]}")143        budget.add(len(chunk) // 60)144        if not chunk:145            break146        for c in chunk:147            rows[int(c["t"])] = (float(c["o"]), float(c["h"]), float(c["l"]), float(c["c"]), float(c["v"]))148        last = max(int(c["t"]) for c in chunk)149        if len(chunk) < CANDLE_PAGE or last + step_ms <= cur:150            break151        cur = last + step_ms152    ts = sorted(rows)153    return pd.DataFrame([rows[t] for t in ts], columns=COLUMNS).assign(ts=ts)[["ts", *COLUMNS]]154155156def fetch_funding(symbol: str, start=None, end=None, *, post: Callable = post_info, budget: Budget | None = None,157                  now_ms: int | None = None) -> pd.DataFrame:158    """Raw funding settlements: time_ms, rate, premium; paged forward (500 rows per answer), oldest first."""159    budget = budget or Budget()160    end_ms = _ms(end) if end is not None else (now_ms or _now_ms())161    cur = 0 if start is None else _ms(start)162    rows: dict[int, tuple] = {}163    while cur <= end_ms:164        budget.take(20)165        chunk = post({"type": "fundingHistory", "coin": symbol, "startTime": cur, "endTime": end_ms})166        if not isinstance(chunk, list):167            raise DataError(f"hyperliquid {symbol}: unexpected funding answer {str(chunk)[:200]}")168        budget.add(len(chunk) // 20)169        if not chunk:170            break171        for f in chunk:172            premium = f.get("premium")173            rows[int(f["time"])] = (float(f["fundingRate"]), float(premium) if premium is not None else float("nan"))174        last = max(int(f["time"]) for f in chunk)175        if len(chunk) < FUNDING_PAGE or last < cur:176            break177        cur = last + 1178    t = sorted(rows)179    return pd.DataFrame({"time_ms": t, "rate": [rows[x][0] for x in t], "premium": [rows[x][1] for x in t]})180181182# ------------------------------------------------------------------------------------------------------ funding183184@dataclass185class FundingQuality:186    """Checks on one funding series (settlements indexed by their hour)."""187188    symbol: str189    source: str190    n: int = 0191    start: str | None = None192    end: str | None = None193    expected: int = 0194    missing: int = 0195    max_gap: int = 0196    gaps: list = field(default_factory=list)      # [[first missing hour, last missing hour, n], ...] (first 50)197    duplicates_dropped: int = 0198    off_grid: int = 0                             # settlement instants further than the tolerance from the hour199    max_offset_ms: int = 0                        # largest distance between an instant and its hour200    non_finite: int = 0201    unsettled_dropped: int = 0                    # rows dated after `as_of`202203    @property204    def missing_fraction(self) -> float:205        return self.missing / self.expected if self.expected else 0.0206207    def ok(self, max_missing_fraction: float = 0.005, max_gap: int = 24) -> bool:208        return (self.n > 0 and self.off_grid == 0 and self.non_finite == 0209                and self.missing_fraction <= max_missing_fraction and self.max_gap <= max_gap)210211    def to_dict(self) -> dict:212        d = asdict(self)213        d["missing_fraction"] = self.missing_fraction214        return d215216217def normalise_funding(raw: pd.DataFrame, *, symbol: str, source: str = SOURCE, start=None, end=None, as_of=None,218                      on_duplicate: str = "raise") -> tuple[pd.DataFrame, FundingQuality]:219    """Raw settlements (time_ms, rate[, premium]) -> frame indexed by settlement HOUR (UTC), oldest first.220221    Each instant is attached to the nearest hour; an instant further than FUNDING_TOLERANCE_MS from it is222    counted off-grid and dropped (it cannot be attributed to a settlement). Two rows on the same hour raise,223    unless on_duplicate='drop_identical' and they carry the same rate. Missing hours are counted, never filled.224    start is inclusive, end exclusive, on the settlement hour; rows dated after `as_of` (default: now) are dropped.225    """226    q = FundingQuality(symbol=symbol, source=source)227    if "time_ms" not in raw.columns or "rate" not in raw.columns:228        raise DataError(f"{symbol}: funding needs the columns time_ms and rate")229    df = raw[["time_ms", "rate"] + (["premium"] if "premium" in raw.columns else [])].copy()230    if "premium" not in df.columns:231        df["premium"] = np.nan232    t = df["time_ms"].to_numpy(dtype="int64")233    hour_ms = FUNDING_INTERVAL_S * 1000234    nearest = (t + hour_ms // 2) // hour_ms * hour_ms235    offset = np.abs(t - nearest)236    q.max_offset_ms = int(offset.max()) if len(offset) else 0237    bad = offset > FUNDING_TOLERANCE_MS238    q.off_grid = int(bad.sum())239    df = df.assign(ts=pd.to_datetime(nearest, unit="ms", utc=True).as_unit("ns"))[~bad]240    finite = np.isfinite(df["rate"].to_numpy(dtype="float64"))241    q.non_finite = int((~finite).sum())242    df = df[finite].sort_values("time_ms", kind="stable")243    dup = df["ts"].duplicated(keep=False)244    if dup.any():245        if on_duplicate != "drop_identical" or df[dup].groupby("ts")["rate"].nunique().max() > 1:246            raise DataError(f"{symbol}: {int(dup.sum())} funding rows share a settlement hour "247                            f"(first: {df.loc[dup, 'ts'].iloc[0]})")248        before = len(df)249        df = df[~df["ts"].duplicated(keep="first")]250        q.duplicates_dropped = before - len(df)251    df = df.set_index("ts")252    now = to_utc(as_of) if as_of is not None else pd.Timestamp(datetime.now(timezone.utc))253    settled = df.index <= now254    q.unsettled_dropped = int((~settled).sum())255    df = df[settled]256    if start is not None:257        df = df[df.index >= to_utc(start)]258    if end is not None:259        df = df[df.index < to_utc(end)]260    df = df.astype({"rate": "float64", "premium": "float64", "time_ms": "int64"})[["rate", "premium", "time_ms"]]261    q.n = len(df)262    if len(df):263        epoch = df.index.asi8 // 10**9264        q.start, q.end = df.index[0].isoformat(), df.index[-1].isoformat()265        q.expected = int((epoch[-1] - epoch[0]) // FUNDING_INTERVAL_S) + 1266        jumps = np.diff(epoch) // FUNDING_INTERVAL_S - 1267        where = np.nonzero(jumps > 0)[0]268        q.missing = int(jumps[where].sum())269        q.max_gap = int(jumps.max()) if len(jumps) else 0270        for i in where[:50]:271            q.gaps.append([(df.index[i] + pd.Timedelta(hours=1)).isoformat(),272                           (df.index[i + 1] - pd.Timedelta(hours=1)).isoformat(), int(jumps[i])])273    return df, q274275276def attach_funding(candles: pd.DataFrame, funding: pd.DataFrame) -> pd.DataFrame:277    """Candles + `funding` and `premium` columns, aligned on settlement time.278279    funding[i] = sum of the rates settled in (ts[i-1], ts[i]]; for the first bar, the rate settled at ts[0].280    NaN when no settlement of that window is in `funding` (the engine then charges nothing and counts it).281    premium[i] = the premium of the last settlement of the window. Nothing dated after ts[i] can enter row i.282    """283    out = candles.copy()284    idx = out.index285    if len(funding) == 0 or len(idx) == 0:286        return out.assign(funding=np.nan, premium=np.nan)287    f = funding[funding.index <= idx[-1]]288    pos = idx.searchsorted(f.index, side="left")          # first bar opening at or after the settlement289    keep = (pos < len(idx)) & ~(f.index < idx[0])         # settlements before the first bar belong to no window290    g =pd.DataFrame({"pos": pos[keep], "rate": f["rate"].to_numpy()[keep], "premium": f["premium"].to_numpy()[keep]})291    agg = g.groupby("pos").agg(rate=("rate", "sum"), premium=("premium", "last"))292    rate = np.full(len(idx), np.nan)293    prem = np.full(len(idx), np.nan)294    rate[agg.index.to_numpy()] = agg["rate"].to_numpy()295    prem[agg.index.to_numpy()] = agg["premium"].to_numpy()296    return out.assign(funding=rate, premium=prem)297298299def check_funding_alignment(frames: dict[str, pd.DataFrame], funding: dict[str, pd.DataFrame]) -> dict:300    """Rebuild the `funding` column of every perpetual from the settlement series and compare.301302    'pass' only if, for every symbol that has a settlement series, the column equals attach_funding() exactly303    (a column shifted by one bar — the classic look-ahead — fails), every settlement instant is within the304    tolerance of its hour, and no frame carries a funding value on a bar that has no settlement at or before it.305    """306    detail, ok = {}, True307    for sym, raw in funding.items():308        if sym not in frames:309            continue310        df = frames[sym]311        if "funding" not in df.columns:312            detail[sym] = "no funding column"313            ok = False314            continue315        want = attach_funding(df[COLUMNS], raw)["funding"].to_numpy()316        have = df["funding"].to_numpy(dtype="float64")317        same = bool(np.array_equal(np.isnan(want), np.isnan(have))318                    and np.allclose(want[~np.isnan(want)], have[~np.isnan(want)], rtol=0, atol=1e-15))319        offs = int(np.abs(raw["time_ms"].to_numpy() - raw.index.as_unit("ms").asi8).max()) if "time_ms" in raw and len(raw) else 0320        covered = int(np.isfinite(have).sum())321        detail[sym] = {"column_matches_settlements": same, "max_offset_ms": offs, "bars": int(len(df)),322                       "bars_with_funding": covered, "bars_without_funding": int(len(df) - covered)}323        ok &= same and offs <= FUNDING_TOLERANCE_MS324    if not detail:325        return {"status": "fail", "detail": "no funding series supplied"}326    return {"status": "pass" if ok else "fail", "detail": detail}327328329# -------------------------------------------------------------------------------------------------------- files330331def funding_path(root, symbol: str) -> Path:332    return Path(root) / SOURCE / "funding" / f"{symbol.upper()}.parquet"333334335def meta_path(root) -> Path:336    return Path(root) / SOURCE / "meta.json"337338339#: optional replacement for the file reader: callable(symbol, start, end) -> DataFrame[time_ms, rate, premium].340#: This is the hook for a database table if one exists later. No table is assumed here: the engine's PostgreSQL341#: schema has no funding table at the time of writing, and inventing one would be guessing.342FUNDING_LOADER: Callable | None = None343344345def load_funding(symbol: str, start=None, end=None, *, root=None, as_of=None,346                 loader: Callable | None = None) -> tuple[pd.DataFrame, FundingQuality]:347    """Funding settlements of one perpetual from the local store (or from `loader` / FUNDING_LOADER)."""348    loader = loader or FUNDING_LOADER349    if loader is not None:350        raw = loader(symbol, start, end)351    else:352        path = funding_path(root, symbol)353        if not path.exists():354            raise DataError(f"no funding file at {path}")355        raw = pd.read_parquet(path)356    return normalise_funding(raw, symbol=symbol.upper(), start=start, end=end, as_of=as_of)357358359def load_meta(root) -> dict:360    p = meta_path(root)361    return json.loads(p.read_text()) if p.exists() else {}362363364def load_perp_universe(symbols, interval: str, start=None, end=None, *, root, spot_source: str = "coinbase",365                       as_of=None, funding_loader: Callable | None = None):366    """Load perpetual candles + funding (and the candles of spot legs 'X.SPOT' from `spot_source`).367368    Returns (data aligned on common timestamps, {symbol: Quality}, {symbol: FundingQuality},369    {symbol: settlement frame}). Perpetual frames carry `funding` and `premium`.370    """371    from . import load_candles372373    data, quality, fq, raw = {}, {}, {}, {}374    for s in symbols:375        if s.endswith(SPOT_SUFFIX):376            data[s], quality[s] = load_candles(s[: -len(SPOT_SUFFIX)], interval, start, end, root=root,377                                               source=spot_source, as_of=as_of)378            quality[s].symbol = s379            continue380        data[s], quality[s] = load_candles(s, interval, start, end, root=root, source=SOURCE, as_of=as_of)381    data = align(data)382    for s in symbols:383        if s.endswith(SPOT_SUFFIX):384            continue385        raw[s], fq[s] = load_funding(s, None, end, root=root, as_of=as_of, loader=funding_loader)386        data[s] = attach_funding(data[s], raw[s])387        first = data[s].index[0] if len(data[s]) else None388        if first is not None:                       # quality of the part that the bars actually use389            _, fq[s] = normalise_funding(raw[s].reset_index(drop=True), symbol=s, start=first, end=end, as_of=as_of)390    return data, quality, fq, raw391392393def load_spot_reference(symbols, interval: str, index: pd.DatetimeIndex, *, root, source: str = "coinbase"):394    """Spot candles of the same assets on the perpetual bars (benchmark 'buy and hold spot').395396    Returns ({symbol: frame}, {symbol: Quality}) restricted to the timestamps present for every symbol AND in397    `index`; ({}, {}) when a spot file is missing.398    """399    from . import load_candles400401    data, quality = {}, {}402    try:403        for s in symbols:404            data[s], quality[s] = load_candles(s, interval, index[0], index[-1] + pd.Timedelta(seconds=1), root=root,405                                               source=source)406    except DataError:407        return {}, {}408    return data, quality409410411# ----------------------------------------------------------------------------------------------------- download412413def _merge(path: Path, new: pd.DataFrame, key: str) -> pd.DataFrame:414    if path.exists():415        old = pd.read_parquet(path)416        both = pd.concat([old, new], ignore_index=True)417        new = both[~both[key].duplicated(keep="last")].sort_values(key).reset_index(drop=True)418    path.parent.mkdir(parents=True, exist_ok=True)419    new.to_parquet(path, index=False)420    return new421422423def download(symbol: str, interval: str, start=None, end=None, *, out="data", funding_start=None,424             post: Callable = post_info, budget: Budget | None = None) -> dict:425    """Fetch candles and funding of one perpetual, keep closed bars, merge with the store. Returns a summary."""426    budget = budget or Budget()427    sym = symbol.upper() if symbol.upper() == symbol else symbol     # some listings are mixed case (kPEPE)428    raw = fetch_candles(sym, interval, start, end, post=post, budget=budget)429    if raw.empty:430        raise DataError(f"hyperliquid {sym}: no candle returned")431    candles, _ = normalise(raw, symbol=sym.upper(), interval=interval, source=SOURCE, start=start, end=end)432    cpath = Path(out) / SOURCE / f"{sym.upper()}_{interval}.parquet"433    merged = _merge(cpath, candles.reset_index(), "ts")434    _, cq = normalise(merged, symbol=sym.upper(), interval=interval, source=SOURCE)435    f_from = funding_start if funding_start is not None else (candles.index[0] if len(candles) else start)436    fraw = fetch_funding(sym, f_from, end, post=post, budget=budget)437    fmerged = _merge(funding_path(out, sym), fraw, "time_ms")438    _, fq = normalise_funding(fmerged, symbol=sym.upper())439    return {"symbol": sym.upper(), "candles_file": str(cpath), "funding_file": str(funding_path(out, sym)),440            "candles": cq.to_dict(), "funding": fq.to_dict(), "ok": cq.ok() and fq.ok()}441442443def measure_depth(symbol: str, intervals=("1h", "4h", "1d"), *, post: Callable = post_info,444                  budget: Budget | None = None) -> dict:445    """How far back the venue serves each series: first candle of each interval and first funding settlement."""446    budget = budget or Budget()447    now = _now_ms()448    out: dict = {"symbol": symbol, "measured_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),449                 "candles": {}}450    for iv in intervals:451        budget.take(20 + CANDLE_PAGE // 60)452        chunk = post({"type": "candleSnapshot", "req": {"coin": symbol, "interval": iv, "startTime": 0,453                                                         "endTime": now}})454        if chunk:455            first = min(int(c["t"]) for c in chunk)456            out["candles"][iv] = {"rows_served": len(chunk),457                                  "first": pd.Timestamp(first, unit="ms", tz="UTC").isoformat(),458                                  "days": round((now - first) / 86_400_000, 1)}459    budget.take(20 + FUNDING_PAGE // 20)460    f = post({"type": "fundingHistory", "coin": symbol, "startTime": 0})461    if f:462        first = int(f[0]["time"])463        out["funding"] = {"first": pd.Timestamp(first, unit="ms", tz="UTC").isoformat(),464                          "days": round((now - first) / 86_400_000, 1), "rows_per_page": len(f)}465    return out466467468def main(argv=None) -> int:469    p = argparse.ArgumentParser(prog="python -m spq.data.hyperliquid", description=__doc__.split("\n")[0])470    p.add_argument("--symbols", required=True, help="comma-separated perpetuals, e.g. BTC,ETH,SOL,HYPE")471    p.add_argument("--interval", default="1h")472    p.add_argument("--start", default=None, help="UTC, inclusive (default: as far back as the venue serves)")473    p.add_argument("--end", default=None, help="UTC, exclusive (default: now)")474    p.add_argument("--funding-start", default=None,475                   help="UTC; default: the first candle (funding older than the candles is not downloaded)")476    p.add_argument("--out", default="data")477    p.add_argument("--weight-per-minute", type=int, default=600)478    p.add_argument("--depth", action="store_true", help="also measure how far back each series goes")479    a = p.parse_args(argv)480    budget = Budget(a.weight_per_minute)481    status = 0482    symbols = [s.strip() for s in a.symbols.split(",") if s.strip()]483    try:484        meta = fetch_meta(budget=budget)485        path = meta_path(a.out)486        path.parent.mkdir(parents=True, exist_ok=True)487        path.write_text(json.dumps({"fetched_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),488                                    "universe": meta}, indent=1) + "\n")489    except DataError as e:490        print(f"meta: {e}", file=sys.stderr)491        status = 1492    for sym in symbols:493        try:494            print(json.dumps(download(sym, a.interval, a.start, a.end, out=a.out, funding_start=a.funding_start,495                                      budget=budget)))496            if a.depth:497                print(json.dumps(measure_depth(sym, budget=budget)))498        except DataError as e:499            print(f"{sym}: {e}", file=sys.stderr)500            status = 1501    return status502503504if __name__ == "__main__":505    sys.exit(main())506
Files main
Menu

Theme