sifting/io
Developer Tutorials
11 min readSiftingIO Team

RSI in Python: Wilder's Formula and Live Candle Updates

Calculate RSI in Python with Wilder's smoothing, check a worked example and update live candles without counting the same bar twice.

RSI in Python: Wilder's Formula and Live Candle Updates

Two charts can show the same market, the same interval and RSI(14), yet disagree by several points. Before changing the formula, check where each calculation begins and whether the last candle is still moving.

RSI in Python is straightforward once those choices are explicit. Calculate gains and losses from consecutive closes, initialise their averages, and apply Wilder's smoothing to each new completed bar. Keep updates to the forming candle separate from that permanent state.

This guide builds the calculation from a small worked example, then connects it to historical bars and real-time prices from SiftingIO. The example prices are invented so every number can be checked. They are not market observations or a trading result.

What RSI measures#

The Relative Strength Index compares the size of recent upward and downward price changes. It expresses that balance on a scale from 0 to 100. It does not compare one asset with another, despite the words "relative strength".

The usual input is the closing price. RSI(14) uses a smoothing period of 14 bars, which might mean 14 minutes, 14 hours or 14 daily observations depending on the chart. Volume, highs and lows are not inputs to the standard close-based calculation.

The familiar 30 and 70 levels are interpretation thresholds, not instructions to buy or sell. A persistent trend can keep RSI near an extreme. Fidelity's RSI guide describes the indicator and its conventional interpretation.

For a data application, the first job is simpler: produce a reproducible number and make its status clear.

Wilder's RSI formula#

For each pair of consecutive closes, split the price change into two non-negative values:

change = current close minus previous close
gain = max(change, 0)
loss = max(negative change, 0)

For a period of n, seed the calculation with the arithmetic mean of the first n gains and the first n losses. That requires n + 1 closes. Fourteen closes contain only thirteen changes, so they cannot produce this implementation's first RSI(14).

After the seed, update the two averages as follows:

new average gain = ((previous average gain × (n − 1)) + current gain) ÷ n
new average loss = ((previous average loss × (n − 1)) + current loss) ÷ n
RSI = 100 × average gain ÷ (average gain + average loss)

The last expression is algebraically equivalent to the usual relative-strength formula when the denominator is positive. It also handles an average loss of zero without dividing by that loss first.

Notice that the averages keep information from earlier bars. Wilder's calculation does not discard the oldest observation and recompute a simple average of just the latest 14 changes at every step. That rolling-average variant gives a different indicator.

A worked RSI calculation you can reproduce#

Use a five-period RSI to keep the arithmetic short. The method is identical for a period of 14.

BarCloseChangeGainLoss
1100Not availableNot availableNot available
2102+220
3101−101
4103+220
5102−101
6104+220

There are five changes. Total gains are 6 and total losses are 2, giving average gain 1.2 and average loss 0.4. The first RSI is 100 × 1.2 ÷ 1.6, or 75.00.

Now add a close of 103. The average gain becomes 0.96 and the average loss becomes 0.52. RSI falls to 64.8649. A subsequent close of 105 produces the next row below.

Latest barCloseSmoothed average gainSmoothed average lossRSI(5)
61041.20000.400075.0000
71030.96000.520064.8649
81051.16800.416073.7374

Keep full precision during the calculation. Round the displayed result, not the state carried into the next bar.

A small Python implementation#

The following class needs only Python's standard library. Call update once per completed bar, in chronological order. Call preview for a possible close of the current candle; it calculates from a copy and leaves the committed state intact.

"""Educational Wilder RSI implementation. Inputs must be ordered closed bars."""
from copy import copy
from math import isfinite


class WilderRSI:
    def __init__(self, period=14):
        if isinstance(period, bool) or not isinstance(period, int) or period < 1:
            raise ValueError("period must be a positive integer")
        self.period = period
        self.previous = None
        self.changes = 0
        self.gain = 0.0
        self.loss = 0.0

    def update(self, close):
        close = float(close)
        if not isfinite(close) or close <= 0:
            raise ValueError("close must be finite and positive")
        if self.previous is None:
            self.previous = close
            return None

        change = close - self.previous
        self.previous = close
        up = max(change, 0.0)
        down = max(-change, 0.0)
        self.changes += 1

        if self.changes <= self.period:
            self.gain += up
            self.loss += down
            if self.changes < self.period:
                return None
            self.gain /= self.period
            self.loss /= self.period
        else:
            n = self.period
            self.gain = (self.gain * (n - 1) + up) / n
            self.loss = (self.loss * (n - 1) + down) / n

        total = self.gain + self.loss
        return None if total == 0 else 100 * self.gain / total

    def preview(self, candidate_close):
        return copy(self).update(candidate_close)

An all-flat series makes both smoothed averages zero. This implementation returns None because that ratio is undefined. Some libraries choose a numeric convention instead. Match that convention deliberately when comparing implementations.

The input checks reject non-finite and non-positive closes. This example targets instruments with positive prices, such as a normal forex pair. It is not a universal validator for every financial contract.

Run the worked example:

prices = [100, 102, 101, 103, 102, 104, 103, 105]
state = WilderRSI(period=5)

for bar, close in enumerate(prices, start=1):
    value = state.update(close)
    print(bar, close, None if value is None else round(value, 4))

The first five results are None. The final three are 75.0, 64.8649 and 73.7374. A steadily rising sequence produces 100 after its seed; a steadily falling sequence produces 0.

Why a pandas RSI can disagree#

A common implementation smooths gains and losses with ewm(alpha=1/14, adjust=False). The decay factor is right for Wilder's recurrence, but the initial state still matters.

According to the pandas documentation, the recursive calculation starts from the first observation. min_periods=14 controls when a result becomes visible. It does not replace that initial observation with the arithmetic mean of the first 14 changes. Also, span=14 uses a different decay factor from alpha=1/14.

These settings explain many differences that look like bad market data. If matching another chart is the goal, compare its seed, smoothing method and history start before rounding the output to fewer decimals.

The explicit loop above makes the seed inspectable. The TA-Lib RSI implementation is another useful reference when investigating compatibility, including its own edge-case choices.

Load historical bars from SiftingIO#

SiftingIO supplies historical OHLCV data through REST and real-time market data through WebSocket. For this example, use hourly EURUSD closes from the forex historical bars endpoint.

Keep the API key in an environment variable. This request downloads one page from a fixed, already elapsed date range. It does not use the current unfinished candle.

curl --fail --silent --show-error --compressed \
  --get 'https://api.sifting.io/v1/hist/forex/EURUSD/bars' \
  --header "X-API-Key: $SIFTINGIO_API_KEY" \
  --data-urlencode 'start=2026-10-01T00:00:00Z' \
  --data-urlencode 'end=2026-10-30T23:00:00Z' \
  --data-urlencode 'interval=1h' \
  --data-urlencode 'order=asc' \
  --data-urlencode 'limit=2000' \
  --output eurusd.json

The t field is the bar-opening time in UTC epoch milliseconds; c is the close. The other price fields describe the candle, but RSI only needs the ordered closes. --compressed requests a compressed response and lets curl decode it.

For a larger download, follow meta.next_cursor until the range is complete. Keep the same interval and requested end. The cursor pagination guide explains the longer-history workflow.

This reader refuses an unfinished download and duplicate or out-of-order timestamps rather than quietly calculating from them:

import json
from datetime import datetime, timezone

with open("eurusd.json", encoding="utf-8") as handle:
    page = json.load(handle)

if page.get("meta", {}).get("next_cursor"):
    raise ValueError("Fetch the remaining pages before calculating RSI")

bars = page["data"]
if len(bars) < 15:
    raise ValueError("RSI(14) needs at least 15 closes")

times = [bar["t"] for bar in bars]
if any(isinstance(t, bool) or not isinstance(t, int) for t in times):
    raise ValueError("Expected integer timestamps in milliseconds")
if any(a >= b for a, b in zip(times, times[1:])):
    raise ValueError("Bars must have unique, increasing timestamps")

state = WilderRSI(14)
for bar in bars:
    value = state.update(bar["c"])
    timestamp = datetime.fromtimestamp(bar["t"] / 1000, timezone.utc)
    if value is not None:
        print(timestamp.isoformat(), round(value, 4))

Put the class and reader in the same script. This validates ordering, not market completeness. A weekend gap in forex is different from a missing bar during an active session. Investigate unexpected gaps before accepting the calculation; do not insert yesterday's price to make the row count look complete.

Request history before the portion of the chart you intend to display. The seed affects the next values, with its influence on each smoothed average shrinking by a factor of 13 ÷ 14 per update for RSI(14). There is no universal warm-up length that guarantees agreement across every price series. Test convergence for the precision your application needs.

Update a live RSI without counting a candle twice#

WebSocket messages arrive more often than candles close. Updating a 14-period RSI for every tick would make its period count ticks rather than bars.

For an hourly chart, keep the committed state at the last completed hour. Use the latest valid price as the candidate close for the forming hour and call preview. Each new candidate starts from that same committed state.

In the five-period example, stop after bar 6, whose close is 104 and RSI is 75. During the next candle, the displayed preview might change like this:

Candidate close in the same candlePreview RSI(5)Commit to history?
10364.8649No
10475.0000No
10578.3784No

Those are three alternatives for one bar. They are not three new observations. Once the final close is established, call update once for that bar.

With SiftingIO, subscribe to product: "fx" and the symbol EURUSD using the WebSocket protocol. Process the price and timestamp fields of the relevant tick frames. Use event timestamps to place updates in the right candle, and label the displayed indicator as forming until the candle is settled.

The subscription can begin with a cached snapshot. Do not assume its arrival time makes it a fresh trade in the current candle. After a disconnect, recover the missing completed bars through REST before resuming the committed RSI. Reconcile recently completed candles with the historical series; late data or a correction can require recalculation from the affected point.

For an initial implementation, it is often simpler to use REST bars as the committed record and WebSocket prices only for the provisional display. That makes discrepancies easier to inspect without promising that a locally built candle will always match the provider's historical candle exactly.

What to check when two RSI values differ#

CheckWhy it changes RSIPractical comparison
Price source and basisDifferent closes produce different changesCompare the actual close arrays first
Candle interval and sessionOne hourly boundary can differ from anotherCompare UTC opening timestamps
Seed and history startRecursive averages carry earlier informationUse the same starting bars
Smoothing methodWilder, standard EMA and simple rolling means differRecord the recurrence and seed
Forming candleIts latest price changes before the closeCompare completed bars only
Gaps and duplicate rowsThey change which observations enter the sequenceCheck timestamps before calculating
Stock adjustmentsSplits and other adjustments can change historical closesKeep one documented adjustment basis
Flat-series conventionZero gains and zero losses leave a zero denominatorDocument the chosen missing-value policy

Check these in order. A formula comparison is useful only after the two inputs describe the same series.

Start with one symbol and one interval#

Create a free SiftingIO account, retrieve a small historical range and reproduce the worked calculation before connecting a live stream. The forex data product and endpoint reference describe the available access and coverage. Use the current account limits when deciding how much history to request.

SiftingIO also exposes precomputed indicators through its technical signals API. That is useful when an application needs indicator readings without maintaining its own calculation. If you compare those values with this example, align the interval, source history, candle status and implementation conventions first.

A useful RSI display should answer three questions: which closes went into it, which calculation was used, and whether the current value is final or still forming. Getting those details right makes the number easier to trust and the integration much easier to debug.

All postsLast updated November 10, 2026
Keep reading

Related posts