A candlestick chart in Python needs five fields: a timestamp and the open, high, low and close prices. Add volume, and you can put trading activity beneath the price chart. This tutorial uses Plotly and the SiftingIO Crypto Data API to build that chart for Bitcoin, then save it as an interactive HTML file. Create a free SiftingIO account to get your API key if you want to follow along.
The result is a historical chart you can open in a browser, zoom into and inspect by hovering over individual candles. It does not need a web server or a Plotly account, and the finished HTML does not contain your API key.
What the chart will show#
This example requests one day of hourly BTCUSD bars. Each candle represents one hour, not one trade. Its body shows the open and close; its wick reaches the high and low. The lower panel shows the aggregated traded volume reported for that hour.
For this crypto endpoint, volume is measured in the base asset. BTCUSD volume is therefore BTC, not dollars. It is not a claim about total volume across the entire crypto market.
| SiftingIO field | Meaning | Where it goes in Plotly |
|---|---|---|
t | Candle opening time, Unix milliseconds in UTC | x |
o | Opening price | open |
h | Highest price | high |
l | Lowest price | low |
c | Closing price | close |
v | Base-asset volume for the interval | A separate bar trace |
SiftingIO supplies the historical OHLCV data; Plotly handles the visualization. Keeping those jobs separate means you can change the chart without rewriting the data request. If the fields are unfamiliar, the OHLCV data guide explains how a bar summarizes an interval.
1. Download Bitcoin OHLCV data#
Use Python 3.10 or later. Install Plotly in your project environment:
python -m pip install plotly
Set SIFTINGIO_API_KEY in your terminal environment. Keep the actual key out of source control and browser code. Then run:
curl --fail --silent --show-error --compressed \
--get 'https://api.sifting.io/v1/hist/crypto/BTCUSD/bars' \
--header "X-API-Key: ${SIFTINGIO_API_KEY:?Set SIFTINGIO_API_KEY first}" \
--data-urlencode 'start=2026-10-01T00:00:00Z' \
--data-urlencode 'end=2026-10-01T23:59:59Z' \
--data-urlencode 'interval=1h' \
--data-urlencode 'limit=100' \
--output btcusd.json
--compressed matters: the historical endpoint requires gzip support, and curl decompresses the response before saving it. --fail makes an HTTP error fail the command instead of treating it as a successful download.
The dates select a completed UTC day. A full day would contain 24 hourly candles, but the code below does not invent bars if fewer arrive. The response has a data array and a meta object, as documented in the crypto historical bars reference.
For a larger date range, check meta.next_cursor. A successful request can still be only the first page. This example stops if another page exists; use the OHLCV pagination guide before expanding it into a bulk download.
2. Build the candlestick chart in Python#
Save this as chart.py beside btcusd.json:
import json
import math
from datetime import datetime, timezone
from pathlib import Path
import plotly.graph_objects as go
from plotly.subplots import make_subplots
def build_chart(payload):
meta = payload["meta"]
if meta.get("symbol") != "BTCUSD" or meta.get("interval") != "1h":
raise ValueError("Expected BTCUSD hourly bars.")
if meta.get("next_cursor"):
raise ValueError("More pages exist. Fetch them before charting.")
rows = sorted(payload["data"], key=lambda bar: bar["t"])
if not rows:
raise ValueError("No bars returned for this date range.")
if len({bar["t"] for bar in rows}) != len(rows):
raise ValueError("Duplicate candle timestamps.")
times, opens, highs, lows, closes, volumes = [], [], [], [], [], []
for bar in rows:
o, h, l, c = (float(bar[key]) for key in ("o", "h", "l", "c"))
if not all(math.isfinite(p) and p > 0 for p in (o, h, l, c)):
raise ValueError("Prices must be positive, finite numbers.")
if not l <= min(o, c) <= max(o, c) <= h:
raise ValueError("Invalid OHLC price bounds.")
v = None if bar.get("v") is None else float(bar["v"])
if v is not None and (not math.isfinite(v) or v < 0):
raise ValueError("Invalid volume.")
times.append(datetime.fromtimestamp(bar["t"] / 1000, timezone.utc))
opens.append(o)
highs.append(h)
lows.append(l)
closes.append(c)
volumes.append(v)
fig = make_subplots(
rows=2, cols=1, shared_xaxes=True,
row_heights=[0.75, 0.25], vertical_spacing=0.04,
)
fig.add_trace(go.Candlestick(
x=times, open=opens, high=highs, low=lows, close=closes,
name="BTCUSD", increasing_line_color="#16856b",
decreasing_line_color="#c44747",
), row=1, col=1)
fig.add_trace(go.Bar(
x=times, y=volumes, name="Volume (BTC)",
marker_color=["#16856b" if c >= o else "#c44747"
for o, c in zip(opens, closes)],
), row=2, col=1)
fig.update_layout(
title="BTCUSD: hourly candles and volume",
template="plotly_white", height=680, showlegend=False,
xaxis_rangeslider_visible=False,
)
fig.update_xaxes(type="date")
fig.update_xaxes(title_text="Candle open time (UTC)", row=2, col=1)
fig.update_yaxes(title_text="Price (USD)", row=1, col=1)
fig.update_yaxes(title_text="Volume (BTC)", row=2, col=1)
return fig
if __name__ == "__main__":
payload = json.loads(Path("btcusd.json").read_text())
figure = build_chart(payload)
figure.write_html("bitcoin-candlestick.html", include_plotlyjs=True)
print("Open bitcoin-candlestick.html in your browser.")
Run it:
python chart.py
Open bitcoin-candlestick.html. Drag across the plot to zoom, hover over a candle to read its prices, and double-click to reset the view.
Plotly's candlestick trace receives the four price arrays directly. The volume trace sits underneath it with a shared time axis, so a volume bar lines up with the candle it belongs to. Green volume bars mean the corresponding candle closed at or above its open; they do not mean "buy volume."
Why these small details matter#
Milliseconds are not seconds. The API's t field uses milliseconds. Dividing by 1,000 before calling datetime.fromtimestamp gives the intended UTC date. Leaving out that division can produce an out-of-range date rather than a usable chart.
Missing volume is not zero volume. The code preserves a missing or null v as None, leaving no volume bar at that point. An explicit zero stays zero. Price candles can still be drawn when volume is unavailable.
A missing candle should stay missing. Sorting puts the received candles in time order. It does not resample or forward-fill them. The date axis retains the elapsed time between observations instead of squeezing them into equally spaced text labels. A visible gap deserves investigation, not a fabricated flat candle.
The exported HTML carries your data. Plotly's HTML export embeds the figure data and, with include_plotlyjs=True, the plotting library. The file can work offline. It contains no API key in this example, but it does contain the returned prices and volumes. Check your usage rights before distributing or publicly hosting it.
Can this become a live Bitcoin chart?#
Yes, but this example is a historical snapshot. Reopening the HTML file does not fetch fresh data. To update this version, download the bars again and rerun the script.
A streaming chart needs a different update path: receive price ticks, group them into time buckets, update the current candle, and reconcile missing periods after a disconnect. SiftingIO's WebSocket stream supplies ticks, not this historical OHLCV response. Do not point a candlestick trace at a tick stream and assume it will build the bars for you.
Where to go next#
Start with the one-day chart. Check a few candle timestamps and values against the downloaded JSON, then change the date range or interval. If you change the interval, update the script's metadata check and chart title too. If you switch symbols, also update the symbol check and volume unit; ETHUSD volume is ETH, not BTC.
The useful part is a repeatable path from a documented data response to a chart you can inspect. Get your SiftingIO API key, run the request, and you have that first chart without building an application around it.



