Repository Public
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