Blog · Updated
Forecasting crypto price ranges and volatility: what a forecasting model can and cannot tell you
Short answer
A forecasting model will not tell you where Bitcoin goes next; prices are close to a random walk, so the median forecast sits near the last price. What it can give you is a statistical range, a band that should contain the price a stated share of the time, and that range is only worth using if you check its coverage. This tutorial builds 24-hour ranges for BTCUSDT from public Binance hourly data and scores them against a simple volatility baseline. It is not trading advice.
Key facts
- Short-horizon crypto prices behave close to a random walk, so a forecast's median is usually close to the last observed price; direction is not what a forecasting model adds.
- The useful output is the width of the forecast band: the 0.05 to 0.95 quantiles bound a 90% prediction interval, and its width is a volatility estimate.
- Calibration means the interval contains the realised price as often as it claims: a 90% interval should contain it about 90% of the time over many forecasts.
- Forecast log prices and convert back with
exp; quantiles survive that conversion exactly, but quantiles of returns cannot be summed into quantiles of price. - One Ephemeris request can hold up to 64 series, so 30 backtest windows fit in one call; the API accepts up to 21 quantile levels strictly between 0 and 1.
- Binance publishes free historical klines at data.binance.vision under terms that allow non-commercial use (CC BY-NC-SA 4.0); spot timestamps are in microseconds from 1 January 2025 and milliseconds before.
- This post describes statistical ranges. It is not trading or investment advice.
Can a forecasting model predict crypto prices?
Not in the sense most people mean. Liquid crypto pairs trade continuously and any predictable move tends to be traded away, so over hours and days the next price change is close to unpredictable. A time-series model trained on general data has no special information about the next move.
What is predictable is the size of moves. Volatility clusters: calm hours follow calm hours, and violent hours follow violent hours. That is the basis of the GARCH family of models in finance, and it is also what a probabilistic forecast's band picks up.
So read a crypto forecast like this:
- The median is roughly "the price stays about here". Do not read a slightly higher median as a buy signal.
- The band is "the price will probably stay within this range over the horizon". That is useful for sizing positions, setting stop distances, pricing options or sizing collateral buffers.
- The calibration tells you whether the band can be trusted. Without it, the band is just a shape.
Should I forecast prices, log prices or returns?
Forecast log prices, and be consistent. A log price is log(close). Its changes are log returns, and a band on the log price converts to a band on the price with exp, exactly, because quantiles are preserved by any increasing transform.
Raw prices work too, but at a level of 100,000 the model sees large absolute swings and the band is less comparable across time. Returns are the natural input for volatility models, but there is a trap: the 0.95 quantile of a 24-hour return is not the sum of 24 hourly 0.95 quantiles. If you forecast hourly returns, you cannot add up their quantiles to get a price range.
Pick one representation, use it for input and output, and convert at the end.
Which data does this tutorial use?
It uses Binance public data: monthly zip files of hourly candles (klines) at https://data.binance.vision/data/spot/monthly/klines/<SYMBOL>/1h/<SYMBOL>-1h-<YYYY-MM>.zip.
Each CSV has no header and 12 columns: open time, open, high, low, close, volume, close time, quote asset volume, number of trades, taker buy base volume, taker buy quote volume and an ignored field. For spot data, timestamps are in microseconds from 1 January 2025 and in milliseconds before that.
The data is covered by Binance's terms and conditions, which license it under CC BY-NC-SA 4.0 for non-commercial use such as academic research, non-monetised education and purely personal backtesting, and exclude commercial trading products and signal distribution. If you need data for commercial use, get it under a licence that allows it.
How do I build the request?
Load six months of hourly closes, take logs, and send the most recent window as values. The code builds 30 consecutive daily windows at once. Their next 24 hours are already in the data, so they are a backtest; for a live forecast of the next 24 hours, send the latest 1,024 points (log_price.iloc[-CONTEXT:]) the same way.
import os
import numpy as np
import pandas as pd
import requests
API = "https://ephemeris.cascade.industries/api/v1/forecast"
KEY = os.environ["EPHEMERIS_API_KEY"]
COLS = ["open_time", "open", "high", "low", "close", "volume", "close_time",
"quote_volume", "trades", "taker_buy_base", "taker_buy_quote", "ignore"]
def load_month(symbol, month):
url = (f"https://data.binance.vision/data/spot/monthly/klines/"
f"{symbol}/1h/{symbol}-1h-{month}.zip")
k = pd.read_csv(url, header=None, names=COLS, compression="zip")
unit = "us" if k["open_time"].iloc[0] > 10**14 else "ms" # microseconds from 2025
k.index = pd.to_datetime(k["open_time"], unit=unit, utc=True)
return k["close"]
months = [str(m) for m in pd.period_range("2025-01", "2025-06", freq="M")]
close = pd.concat([load_month("BTCUSDT", m) for m in months]).sort_index()
close = close[~close.index.duplicated()].asfreq("h") # missing hours become NaN
if close.isna().any():
raise ValueError("gap in the hourly data: forecast from the latest complete stretch")
log_price = np.log(close)
HORIZON = 24 # hours
CONTEXT = 1024 # about 6 weeks of hours
WINDOWS = 30 # 30 daily forecast origins
QUANTILES = [0.05, 0.1, 0.25, 0.5, 0.75, 0.9, 0.95]
n = len(log_price)
origins = [n - HORIZON - 24 * j for j in range(WINDOWS)][::-1] # oldest first
series = [{"values": log_price.iloc[p - CONTEXT:p].tolist(), "freq": "H"} for p in origins]
r = requests.post(
API,
headers={
"Authorization": f"Bearer {KEY}",
"Content-Type": "application/json",
"Idempotency-Key": "btcusdt-1h-2025h1-backtest-30x24",
},
json={
"mode": "ensemble",
"series": series,
"horizon": HORIZON,
"quantiles": QUANTILES,
"context_len": CONTEXT,
},
timeout=300,
)
r.raise_for_status()
out = r.json()
print(out["meta"]["models_used"], out["meta"]["billing"]["settled_mc"], "mc")ensemble mode runs every compatible model and pools their distributions. It costs more than route because you pay for every model that ran, but its purpose is calibrated uncertainty, which is the whole point here. Try route as well and compare coverage.
How do I turn the forecast into a price range?
Exponentiate the quantiles of the log price. For the most recent backtest window, the 24-hour-ahead range is:
last = out["forecasts"][-1]["quantiles"]
last_price = float(np.exp(series[-1]["values"][-1]))
p05, p50, p95 = (float(np.exp(last[k][-1])) for k in ("0.05", "0.5", "0.95"))
print(f"last close {last_price:,.0f}")
print(f"24h median {p50:,.0f}, 90% range {p05:,.0f} to {p95:,.0f}")
print(f"range width {(p95 - p05) / last_price:.1%} of the last price")The width is the information. If you want a single volatility number, the log-space distance between the 0.05 and 0.95 quantiles, divided by 3.29 (twice 1.645), is roughly the 24-hour standard deviation of log returns, assuming the distribution is close to normal. Crypto returns have fat tails, so treat that as an approximation.
How do I check whether the ranges are calibrated?
Count how often the realised log price falls inside each band, across all windows and all 24 steps, and compare with what the band claims. Then do the same for a simple baseline, so you know whether the model adds anything.
The baseline here is a random walk with constant volatility: the band is the last price plus or minus z times the recent hourly volatility times the square root of the step. It is the simplest honest competitor.
Z = {0.80: 1.2816, 0.90: 1.6449} # two-sided normal multipliers
BANDS = {0.80: ("0.1", "0.9"), 0.90: ("0.05", "0.95")}
steps = np.arange(1, HORIZON + 1)
hits = {("model", c): [] for c in Z} | {("baseline", c): [] for c in Z}
widths = {key: [] for key in hits}
for p, fc in zip(origins, out["forecasts"]):
actual = log_price.iloc[p:p + HORIZON].to_numpy()
hist = log_price.iloc[p - CONTEXT:p]
sigma = hist.diff().std()
last_lp = hist.iloc[-1]
q = {k: np.array(v) for k, v in fc["quantiles"].items()}
for c, (lo_k, hi_k) in BANDS.items():
lo, hi = q[lo_k], q[hi_k]
hits[("model", c)].append((actual >= lo) & (actual <= hi))
widths[("model", c)].append(hi - lo)
half = Z[c] * sigma * np.sqrt(steps)
hits[("baseline", c)].append(np.abs(actual - last_lp) <= half)
widths[("baseline", c)].append(2 * half)
for (name, c) in hits:
coverage = np.concatenate(hits[(name, c)]).mean()
width = np.concatenate(widths[(name, c)]).mean()
print(f"{name:8s} {c:.0%} band: coverage {coverage:.1%}, mean log width {width:.4f}")How to read the result:
- Coverage first. A 90% band with 75% coverage is too narrow and will hurt you in exactly the moves that matter. A 90% band with 99% coverage is too wide to be useful.
- Then width. Between two bands with similar coverage, the narrower one is better. A band can always reach its target coverage by being very wide; the skill is being narrow and still right.
- Then look by regime. Split the windows into calm and volatile periods. Fat tails mean bands often hold in calm weeks and break in crashes, and the average hides that.
Thirty windows is a small sample, and consecutive days share a volatility regime, so the estimate is noisy. Use several months of windows (in batches of up to 64 series) before trusting a number. We have not run this backtest, so we do not quote any coverage or width for it.
What can a forecasting model not tell you?
It cannot tell you about events that are not in the price history: an exchange failure, a regulatory announcement, a large holder selling. It also cannot tell you whether a trade is a good idea. Ranges that are well calibrated over a backtest can still be wrong in the next shock, because the worst days are rare and a backtest contains few of them.
Treat the band as one input to risk sizing, with your own judgement on top, and keep checking its coverage on new data. Nothing in this post is trading or investment advice.
What are the alternatives?
For volatility specifically, finance has well-tested tools:
- GARCH models in the open-source Python arch package model volatility clustering directly and produce variance forecasts.
- Realised volatility models, such as forecasting the daily sum of squared intraday returns, are standard in research and simple to run.
- Options-implied volatility, where a liquid options market exists, reflects what traders are paying for protection and is forward-looking in a way no history-only model is.
- Pretrained forecasting models, self-hosted or through an API such as Ephemeris, give a full quantile band from raw history with no model fitting; their advantage is convenience across many assets, not information the market lacks.
In our view, run at least one of these next to any band you rely on. If a GARCH band or the constant-volatility baseline above is as well calibrated and as narrow, the simpler model wins.
FAQ
Can a time-series model predict the direction of Bitcoin?
Not reliably. Prices over hours and days are close to a random walk, so the median forecast stays near the last price. The useful output is the width of the band, which estimates volatility.
What is a calibrated prediction interval?
One that contains the actual outcome as often as it claims. Over many forecasts, a 90% interval should contain the realised price about 90% of the time; check it with a backtest before relying on it.
Should I send prices or returns to a forecasting model?
Send log prices and convert the quantiles back with exp. Quantiles of hourly returns cannot be added up to get a price range over several hours.
Is Binance historical data free to use?
The klines at data.binance.vision are free to download. Binance's terms license them under CC BY-NC-SA 4.0 for non-commercial use, such as research, education and personal backtesting, and exclude commercial trading products.
Is a forecast range trading advice?
No. It is a statistical estimate of how far the price may move, based only on past prices, and it says nothing about events outside the data. This post is not trading or investment advice.