Bollinger Bands in Python take only a few lines of pandas. Getting a chart that agrees with those lines takes a little more care: the standard deviation convention, the order of the candles, and whether the latest candle has finished all change the result.
This guide builds the calculation from historical OHLCV data, checks it against a small worked example, and then connects a live price stream without confusing ticks with candles. To follow the API example, create a free SiftingIO account and get an API key. You can run the calculation example first without an account.
The example uses EURUSD from the SiftingIO Forex API. Historical bars arrive over REST; current price updates arrive over WebSocket. They have different jobs in the application.
What are Bollinger Bands?#
Bollinger Bands place an upper and lower line around a moving average. In the common 20-period, two-standard-deviation configuration, the middle line is the average of the latest 20 closes. The distance to each outer line is twice the standard deviation of those same closes.
The bands describe price dispersion within that window. They are not a forecast or a rule that price must return to the middle.
| Component | Calculation | Meaning on the chart |
|---|---|---|
| Middle band | Mean of the last N closes | The window's average close |
| Upper band | Middle + K × standard deviation | Upper boundary for that window |
| Lower band | Middle − K × standard deviation | Lower boundary for that window |
| BandWidth (%) | 100 × (upper − lower) / middle | Band width relative to the average price |
| %B | (close − lower) / (upper − lower) | Position of the close relative to the bands |
Here, N is the number of bars and K is the width multiplier. With hourly data, N = 20 means 20 observed hourly bars, not 20 calendar days. A weekend can make the elapsed time longer than 20 hours.
John Bollinger's rules for using the bands make two useful distinctions: 20 and 2 are defaults, and touching a band is not, by itself, a buy or sell signal. Nor should two standard deviations be read as a promise that 95% of future prices will remain inside the bands.
The pandas detail that changes the answer#
Specify ddof rather than accepting the library default. This tutorial uses population standard deviation, ddof=0, so the variance divides by the number of observations in the window.
Pandas' Rolling.std defaults to ddof=1. That divides by N − 1 and makes the bands wider for the same closes. Neither setting should be hidden when comparing implementations.
Consider these five invented closing prices: 100, 102, 101, 103, 104. Their mean is 102. The squared deviations sum to 10, so population variance is 10 / 5 = 2 and standard deviation is √2.
| Quantity | Population calculation, ddof=0 | Sample calculation, ddof=1 |
|---|---|---|
| Mean | 102.0000 | 102.0000 |
| Standard deviation | 1.4142 | 1.5811 |
| Lower band, K = 2 | 99.1716 | 98.8377 |
| Upper band, K = 2 | 104.8284 | 105.1623 |
These are teaching values, not historical market observations. Five bars keep the arithmetic easy to check; the API example below uses 20.
If another chart disagrees, first match its symbol, price source, timeframe, close field, moving-average type, window length and ddof convention. Changing the width multiplier until the lines look similar can hide the real difference.
Calculate Bollinger Bands with pandas#
Install the packages for the examples in a Python virtual environment:
python -m pip install pandas numpy requests matplotlib websockets
The function below accepts a numeric close series in chronological order. It deliberately requires a full window. The first 19 rows of a 20-bar calculation remain empty rather than pretending that three or four observations provide a 20-bar band.
import numpy as np
import pandas as pd
def bollinger_bands(close, window=20, multiplier=2.0):
if not isinstance(window, int) or window < 2:
raise ValueError("window must be an integer of at least 2")
if not np.isfinite(multiplier) or multiplier <= 0:
raise ValueError("multiplier must be finite and positive")
close = pd.to_numeric(close, errors="raise").astype(float)
if not np.isfinite(close).all() or (close <= 0).any():
raise ValueError("closes must be finite, positive numbers")
rolling = close.rolling(window, min_periods=window)
middle = rolling.mean()
deviation = rolling.std(ddof=0)
upper = middle + multiplier * deviation
lower = middle - multiplier * deviation
width = upper - lower
return pd.DataFrame({
"close": close,
"middle": middle,
"upper": upper,
"lower": lower,
"bandwidth_pct": 100 * width / middle,
"percent_b": (close - lower) / width.where(width != 0),
})
example = pd.Series([100, 102, 101, 103, 104])
print(bollinger_bands(example, window=5).iloc[-1].round(4))
The final row is:
close 104.0000
middle 102.0000
upper 104.8284
lower 99.1716
bandwidth_pct 5.5459
percent_b 0.8536
percent_b is a ratio, not a percentage. Zero is the lower band, one is the upper band, and values outside that range are possible. When every close in the window is identical, the band width is zero and %B is undefined. The code leaves it empty instead of dividing by zero or assigning an arbitrary position.
The function validates values, not the trading calendar. That distinction matters when loading real data.
Load hourly EURUSD bars from the REST API#
Keep your API key in the SIFTING_API_KEY environment variable. Run this code locally or on your backend, not in a public notebook with a saved key or in browser JavaScript delivered to visitors.
The forex historical endpoint returns t, o, h, l, c and v. Here we need the bar's open timestamp, t, and close, c. A Bollinger calculation based on closes does not need volume.
This request asks for the preceding 14 days of hourly bars. The upper bound is the open time of the last completed UTC hour. A 14-day hourly range fits below the requested 2,000-row page size; the code still checks for a pagination token rather than silently accepting an incomplete response.
Add this after the calculation function:
import os
import requests
API_KEY = os.environ["SIFTING_API_KEY"]
PAIR = "EURUSD"
cutoff = pd.Timestamp.now(tz="UTC").floor("h")
def utc_string(value):
return value.isoformat().replace("+00:00", "Z")
response = requests.get(
f"https://api.sifting.io/v1/hist/forex/{PAIR}/bars",
headers={"X-API-Key": API_KEY, "Accept-Encoding": "gzip"},
params={
"start": utc_string(cutoff - pd.Timedelta(days=14)),
"end": utc_string(cutoff - pd.Timedelta(hours=1)),
"interval": "1h",
"order": "asc",
"limit": 2000,
},
timeout=30,
)
response.raise_for_status()
payload = response.json()
if payload.get("meta", {}).get("next_cursor"):
raise ValueError("Incomplete range: paginate before calculating bands")
bars = pd.DataFrame(payload["data"])
if bars.empty:
raise ValueError("No bars returned for this range")
bars["time"] = pd.to_datetime(bars["t"], unit="ms", utc=True)
if bars["time"].isna().any() or bars["time"].duplicated().any():
raise ValueError("Missing or duplicate bar timestamps")
bars = bars.sort_values("time").set_index("time")
if not (bars.index == bars.index.floor("h")).all():
raise ValueError("Expected UTC-aligned hourly bar opens")
bars = bars.loc[bars.index + pd.Timedelta(hours=1) <= cutoff]
if len(bars) < 20:
raise ValueError("At least 20 completed bars are required")
result = bollinger_bands(bars["c"])
print(result.tail(3).round(6))
print("Latest band uses the bar opened at:", result.index[-1])
The HTTP client decodes the gzip response. Authentication errors, rate limits and other unsuccessful responses stop the example at raise_for_status(); they are not converted into empty prices.
The timestamp filter excludes the current hourly bucket. It does not guarantee that the immediately preceding bar is already available or will never be revised. Check the final timestamp before treating the result as current. If the latest expected bar has not arrived, keep the previous calculation labelled with its own time and refresh the historical range later.
For a longer window or a smaller interval, follow the returned cursor until the requested range is complete. The market-data pagination guide covers that separately.
Missing bars are not zero prices#
A scheduled market closure and a delivery gap can both appear as an absent timestamp. This example calculates over the observed bars; it does not manufacture weekend candles or claim that every open-session hour is present.
Check unexpected gaps against the market schedule before using the result in an alert or backtest. Forward-filling a missing price inserts repeated closes and can narrow the bands artificially. Dropping the timestamp without recording the gap hides a different problem: the “20-hour” calculation may span more time than intended. Use the missing-candles guide when you need calendar-aware validation.
Plot the bands and the closing price#
Keep the warm-up section out of the plot rather than filling it. This shows the last 100 calculated observations and shades the area between the outer bands.
import matplotlib.pyplot as plt
view = result.dropna(subset=["middle", "upper", "lower"]).tail(100)
fig, ax = plt.subplots(figsize=(11, 5))
ax.plot(view.index, view["close"], label=f"{PAIR} close", linewidth=1.3)
ax.plot(view.index, view["middle"], label="20-bar SMA", linewidth=1)
ax.plot(view.index, view["upper"], label="Upper band", linewidth=0.8)
ax.plot(view.index, view["lower"], label="Lower band", linewidth=0.8)
ax.fill_between(
view.index, view["lower"].to_numpy(), view["upper"].to_numpy(),
alpha=0.12,
)
ax.set(title=f"{PAIR}: hourly Bollinger Bands (20, 2, ddof=0)",
xlabel="Bar open time (UTC)", ylabel="USD per EUR")
ax.legend()
fig.tight_layout()
plt.show()
Read a narrow band as lower dispersion within this window, not as a promised breakout direction. A wide band records greater dispersion; it does not say whether the next move will continue or reverse. If the interface also needs candles, the Python candlestick-chart tutorial shows how to render OHLCV with Plotly.
Add WebSocket prices without turning ticks into candles#
A live quote and a completed hourly close are different observations. Appending every WebSocket price to the close series changes the indicator into a 20-update calculation. It is no longer a 20-hour-bar calculation, even if the chart still uses that label.
For a dashboard, keep the two paths separate:
| Data or action | Transport | What the application does |
|---|---|---|
| Initial historical candles | REST | Validate order and calculate the bands |
| Current price | WebSocket | Move a separate price marker and show its timestamp |
| Newly completed hourly candle | REST refresh | Recalculate the window once the bar is available |
| Reconnect after a gap | WebSocket plus REST | Restore the subscription and refresh candle history |
SiftingIO's WebSocket protocol uses fx for forex subscriptions. Price frames have f: "tick"; the symbol is s, the price is p, and t is an epoch-millisecond timestamp. Control frames are not prices.
The following is a 30-second observation example, not an unattended alerting service. It compares accepted price updates with the fixed bands from the last historical calculation. It does not recalculate an intrabar band, send orders, or promise a match with another venue's chart.
import asyncio
import json
from websockets.asyncio.client import connect
async def observe_prices(latest_band, band_open, api_key):
async with connect("wss://stream.sifting.io/ws/v1") as ws:
await ws.send(json.dumps({"op": "auth", "key": api_key}))
auth = json.loads(await asyncio.wait_for(ws.recv(), timeout=5))
if auth.get("f") != "ack" or auth.get("op") != "auth":
raise RuntimeError("WebSocket authentication was not acknowledged")
await ws.send(json.dumps({
"op": "subscribe", "product": "fx", "symbols": [PAIR]
}))
deadline = asyncio.get_running_loop().time() + 30
last_timestamp = -1
while True:
remaining = deadline - asyncio.get_running_loop().time()
if remaining <= 0:
break
try:
raw = await asyncio.wait_for(ws.recv(), timeout=remaining)
except asyncio.TimeoutError:
break
frame = json.loads(raw)
if frame.get("f") == "error":
raise RuntimeError(f"Stream error: {frame.get('code', 'unknown')}")
if frame.get("f") != "tick" or frame.get("s") != PAIR:
continue
timestamp = int(frame["t"])
price = float(frame["p"])
age_ms = pd.Timestamp.now(tz="UTC").timestamp() * 1000 - timestamp
if not np.isfinite(price) or price <= 0:
continue
if timestamp <= last_timestamp or not 0 <= age_ms <= 60_000:
continue
last_timestamp = timestamp
position = "inside"
if price > latest_band["upper"]:
position = "above"
elif price < latest_band["lower"]:
position = "below"
print(f"{PAIR} {price:.5f}: {position} fixed bands; "
f"band bar opened {band_open}; tick age {age_ms / 1000:.1f}s")
# Run in a .py file after the REST example.
# In a notebook, use await observe_prices(...) instead of asyncio.run(...).
asyncio.run(observe_prices(result.iloc[-1], result.index[-1], API_KEY))
The 60-second freshness threshold is an example application policy, not a feed guarantee. It assumes a correctly synchronized local clock. Old snapshots, duplicate timestamps and future-dated frames are skipped. During a closed session, the script may print nothing.
A subscription can deliver a cached snapshot before its subscription acknowledgement. That is why the loop handles frames by their f field rather than discarding everything until it sees an acknowledgement.
For a persistent service, add application-level ping messages, reconnect backoff, a visible disconnected/stale state and historical refresh after each completed bar. Rebuild the band state after a reconnect before enabling alerts. Do not silently keep comparing fresh ticks with yesterday's bands. See detecting stale market data for that part of the interface.
Why a live price can differ from a historical chart#
The symbol name alone does not specify the full price basis. A historical close, an aggregated reference price, a broker quote and a venue's last trade need not be identical. Use consistent sources where your application requires exact matching, and document what you are comparing when they differ.
SiftingIO supplies market data, not trade execution. Its data methodology explains the reference-price approach. The practical benefit here is access to historical requests and live subscriptions through the same platform; it is not a claim that a live tick must reproduce a historical candle or a broker's executable price.
This matters most at a band boundary. A small source difference can change “inside” to “above.” Displaying the source and timestamps makes that result understandable instead of presenting it as an unexplained trading instruction.
Questions that come up when implementing Bollinger Bands#
Why do my bands start with NaN?#
A 20-bar window needs 20 valid closes. The first 19 rows have no complete window. Keep them empty or omit them from the plotted band series. Do not backfill them with a future calculation.
Can I calculate the bands from a WebSocket alone?#
You can calculate an indicator from a sequence of updates, but that is a different time series. To reproduce hourly close-based bands, you need historical hourly closes and a consistent method of obtaining each subsequent completed bar. This guide uses REST for that history and WebSocket for the current price marker.
Should a current candle change the bands?#
Only if the application intentionally shows provisional, intrabar bands. Label them as provisional and replace the current bucket rather than appending each update as another bar. The example here uses completed bars and keeps its live comparison separate.
Can this be used in a backtest?#
The calculation can, but its timing still matters. A band based on a candle's final close is not known before that close. A simulated trade cannot assume it used that information earlier in the candle. Add execution assumptions, transaction costs and calendar-aware gap checks before interpreting a strategy result.
What should I test before adding alerts?#
Test a constant-price window, a short warm-up, duplicated timestamps, invalid closes and a missing expected bar. Then disconnect the stream and confirm that the interface stops presenting its last value as current. Numerical correctness and a truthful live state are separate checks.
Start with one pair and one interval#
Get EURUSD hourly bars, run the five-value calculation above, and compare your implementation using the same window settings. Once the historical result is correct, attach the live price marker. Expanding to a watchlist is easier when the first chart has a clear definition of a bar, a timestamp and a completed calculation.
Get your SiftingIO API key, then use the Forex API page and API quickstart for the data connection. The SDKs are available when you prefer a client library to raw HTTP.
This is an implementation tutorial, not investment advice. The numerical example uses invented prices; the API examples require your own key and return data for the requested period.



