Strasmore Research
Learn Matt ConnorBy Matt Connor

Why Your RSI Differs Between Platforms

Your RSI reads 68, the chart says 71, and both are right. Here is where two platforms split on the same closes, with the code and the data behind it.

Your RSI differs between platforms for a dull reason: RSI(14) names a period, not a formula. Feed two platforms the same closing prices, ask each for a 14-period relative strength index, and the answers can sit several points apart with both of them computed correctly. The gap has four sources, and the first one is arithmetic you can settle in thirty lines of Python.

Why does RSI differ between platforms?

  1. The smoothing rule. Wilder's original recursion and a plain simple average of gains and losses both answer to the name RSI(14).
  2. The seed. Wilder's version needs a starting value before it can recurse. The usual choice is a simple average of the first 14 changes, and it is still a choice.
  3. The warm-up. Wilder's average never fully forgets a bar, so the printed value depends on how much history sat in front of the window you are looking at.
  4. The bar definition. Where a session starts and stops, and whether a minute with no trades exists as a bar at all, change the close series the formula reads.

RSI(14) is two formulas wearing one name

RSI measures the average size of up moves against the average size of down moves over a lookback window, then maps that ratio onto a 0 to 100 scale. Relative strength (RS) is average gain divided by average loss, and RSI is 100 minus 100 divided by (1 + RS). Readings near 70 are conventionally described as overbought and near 30 as oversold. Those lines are conventions, not properties of the market.

The disagreement hides inside the word average. Wilder's 1978 method seeds the calculation with a simple average of the first 14 gains and the first 14 losses, then updates on every later bar with avg = (prev * 13 + current) / 14. That is an exponentially weighted average with a very long memory: every bar since the seed still carries some weight. The other common implementation takes a simple average of the last 14 gains and the last 14 losses and discards everything older.

Here is both, in standard-library Python, over one invented series of 30 closes. The series is deliberately artificial: every step is exactly one dollar up or one dollar down, which lets you audit the second number by counting. Ten of the last fourteen steps are up, so the simple version has to print 100 * 10 / 14.

CLOSES = [
    100.0, 101.0, 100.0, 101.0, 102.0, 101.0, 100.0, 101.0, 100.0, 101.0,
    100.0,  99.0, 100.0, 101.0, 100.0,  99.0,  98.0,  97.0,  98.0,  97.0,
     96.0,  97.0,  98.0,  99.0, 100.0, 101.0, 102.0, 103.0, 104.0, 105.0,
]

def changes(closes):
    return [b - a for a, b in zip(closes, closes[1:])]

def rsi_wilder(closes, period=14):
    ch = changes(closes)
    gain = [max(c, 0.0) for c in ch]
    loss = [max(-c, 0.0) for c in ch]
    avg_gain = sum(gain[:period]) / period    # the seed
    avg_loss = sum(loss[:period]) / period
    for i in range(period, len(ch)):          # Wilder's smoothing
        avg_gain = (avg_gain * (period - 1) + gain[i]) / period
        avg_loss = (avg_loss * (period - 1) + loss[i]) / period
    return 100.0 - 100.0 / (1.0 + avg_gain / avg_loss)

def rsi_simple(closes, period=14):
    ch = changes(closes)[-period:]            # last 14 changes, nothing else
    avg_gain = sum(max(c, 0.0) for c in ch) / period
    avg_loss = sum(max(-c, 0.0) for c in ch) / period
    return 100.0 - 100.0 / (1.0 + avg_gain / avg_loss)

wilder = rsi_wilder(CLOSES)
simple = rsi_simple(CLOSES)
print(round(wilder, 2), round(simple, 2))     # 68.29 71.43

assert round(wilder, 2) == 68.29
assert round(simple, 2) == 71.43
assert wilder < 70.0 < simple                 # they disagree at the line

Same closes, same period, both implementations correct, and they land on opposite sides of 70. One screen labels that reading overbought. The other does not. No package is imported anywhere above, so nothing here moves when a library updates.

The same closes, both rules, over a real year

Real prices behave the same way. The panel below runs both versions over Apple's daily closes on a fixed historical window, seeds each series in early 2025, and steps forward one session at a time. Only the last 60 sessions are plotted, and the calculation behind them carries the whole window.

QueryRSI(14) on identical closes: Wilder's smoothing against a simple average
The exact SQL behind every number
WITH
    daily AS
    (
        SELECT arraySort(groupArray((day, px))) AS pts
        FROM
        (
            SELECT
                date                  AS day,
                toFloat64(max(close)) AS px
            FROM global_markets.stocks_daily_aggs
            WHERE ticker = 'AAPL'
              AND date >= '2025-01-02'
              AND date <= '2026-06-30'
            GROUP BY day
        )
    ),
    steps AS
    (
        SELECT
            arrayMap(p -> p.1, pts)                                 AS days,
            arrayPopFront(arrayDifference(arrayMap(p -> p.2, pts))) AS chg
        FROM daily
    ),
    points AS
    (
        SELECT
            days,
            arrayMap(x -> greatest(x, 0.0),  chg) AS up,
            arrayMap(x -> greatest(-x, 0.0), chg) AS dn,
            arrayJoin(arrayMap(k -> toInt64(k), arraySlice(arrayEnumerate(chg), -60))) AS e
        FROM steps
    ),
    averages AS
    (
        SELECT
            days[e + 1] AS day,
            (arraySum(arraySlice(up, 1, 14)) / 14) * pow(13.0 / 14.0, e - 14)
                + arraySum(arrayMap((g, j) -> g * pow(13.0 / 14.0, toInt64(j) - 1),
                      arrayReverse(arraySlice(up, 15, e - 14)),
                      arrayEnumerate(arraySlice(up, 15, e - 14)))) / 14 AS up_wilder,
            (arraySum(arraySlice(dn, 1, 14)) / 14) * pow(13.0 / 14.0, e - 14)
                + arraySum(arrayMap((g, j) -> g * pow(13.0 / 14.0, toInt64(j) - 1),
                      arrayReverse(arraySlice(dn, 15, e - 14)),
                      arrayEnumerate(arraySlice(dn, 15, e - 14)))) / 14 AS dn_wilder,
            arraySum(arraySlice(up, e - 13, 14)) / 14 AS up_simple,
            arraySum(arraySlice(dn, e - 13, 14)) / 14 AS dn_simple
        FROM points
    )
SELECT
    toString(day)                                                         AS date,
    round(100 - 100 / (1 + up_wilder / greatest(dn_wilder, 0.000001)), 2) AS rsi_wilder,
    round(100 - 100 / (1 + up_simple / greatest(dn_simple, 0.000001)), 2) AS rsi_simple,
    round(abs(rsi_wilder - rsi_simple), 2)                                AS rsi_spread
FROM averages
ORDER BY date
Run this yourself

The first plotted session, 2026-04-06, prints 54.41 under Wilder's smoothing against 59.73 under the simple average. The last, 2026-06-30, prints 46.91 against 49.07, a distance of 2.16 points across 60 plotted sessions. The third series is that distance. It is a function of how far the last 14 sessions sit from the sessions before them: identical, and the two averages agree; different, and they separate.

How much history came before your chart

Each Wilder update multiplies every earlier contribution by 13/14. An old bar keeps roughly 2.5% of its original influence after 50 further updates and roughly 0.06% after 100, and it never reaches exactly zero. The practical version of that: two platforms running identical code report different numbers on the same day when they load different amounts of history. The panel below fixes the final session and varies nothing except the warm-up in front of it.

QueryOne session, one formula, different amounts of warm-up
The exact SQL behind every number
WITH
    daily AS
    (
        SELECT arraySort(groupArray((day, px))) AS pts
        FROM
        (
            SELECT
                date                  AS day,
                toFloat64(max(close)) AS px
            FROM global_markets.stocks_daily_aggs
            WHERE ticker = 'AAPL'
              AND date >= '2025-01-02'
              AND date <= '2026-06-30'
            GROUP BY day
        )
    ),
    steps AS
    (
        SELECT arrayPopFront(arrayDifference(arrayMap(p -> p.2, pts))) AS chg
        FROM daily
    ),
    grid AS
    (
        SELECT
            arrayMap(x -> greatest(x, 0.0),  chg) AS up,
            arrayMap(x -> greatest(-x, 0.0), chg) AS dn,
            toInt64(length(chg))                  AS e,
            toInt64(arrayJoin([0, 1, 2, 5, 10, 20, 40, 80, 160, 250])) AS warmup
        FROM steps
    ),
    seeded AS
    (
        SELECT
            warmup,
            (arraySum(arraySlice(up, e - warmup - 13, 14)) / 14) * pow(13.0 / 14.0, warmup)
                + arraySum(arrayMap((g, j) -> g * pow(13.0 / 14.0, toInt64(j) - 1),
                      arrayReverse(arraySlice(up, e - warmup + 1, warmup)),
                      arrayEnumerate(arraySlice(up, e - warmup + 1, warmup)))) / 14 AS up_wilder,
            (arraySum(arraySlice(dn, e - warmup - 13, 14)) / 14) * pow(13.0 / 14.0, warmup)
                + arraySum(arrayMap((g, j) -> g * pow(13.0 / 14.0, toInt64(j) - 1),
                      arrayReverse(arraySlice(dn, e - warmup + 1, warmup)),
                      arrayEnumerate(arraySlice(dn, e - warmup + 1, warmup)))) / 14 AS dn_wilder,
            arraySum(arraySlice(up, e - 13, 14)) / 14 AS up_simple,
            arraySum(arraySlice(dn, e - 13, 14)) / 14 AS dn_simple
        FROM grid
    )
SELECT
    toString(warmup)                                                      AS warmup_bars,
    round(100 - 100 / (1 + up_wilder / greatest(dn_wilder, 0.000001)), 2) AS rsi_wilder,
    round(100 - 100 / (1 + up_simple / greatest(dn_simple, 0.000001)), 2) AS rsi_simple
FROM seeded
ORDER BY warmup
Run this yourself

The first row is the shortest calculation the formula allows: 14 changes and nothing in front of them. Wilder's recursion has not run a single step there, so it prints its own seed, which is the simple average, and both columns agree at 49.07. Every row below hands the identical formula more prior bars, and the reading moves. The longest warm-up in the panel prints 46.91. The simple version cannot see past its own 14-bar window, so it holds at 49.07 on every row. A browser chart holding 200 bars and a script that pulled 5,000 are not running the same calculation.

What counts as one bar

Everything above assumes both platforms read the same close series. Often they do not. A daily bar built from the regular session, 9:30 a.m. to 4:00 p.m. ET, ends at a different price from one built over the full trading day with premarket and after-hours prints included. Our guide to how OHLCV bars are built walks through the assembly. The short version is that a bar is a set of decisions about which prints belong inside it.

Start with the clock. Minute bars cover far more of the day than the regular session does.

QueryWhere the minute bars actually are, by ET hour
The exact SQL behind every number
SELECT
    et_hour,
    bar_count,
    round(100 * hour_volume / sum(hour_volume) OVER (), 2) AS volume_share_pct
FROM
(
    SELECT
        formatDateTime(toTimeZone(window_start, 'America/New_York'), '%H') AS et_hour,
        count()                                                            AS bar_count,
        toFloat64(sum(volume))                                             AS hour_volume
    FROM global_markets.delayed_stocks_minute_aggs
    WHERE ticker = 'AAPL'
      AND window_start >= '2026-06-01 04:00:00'
      AND window_start <  '2026-07-01 04:00:00'
    GROUP BY et_hour
)
ORDER BY et_hour
Run this yourself

The earliest ET hour carrying bars in that month is 04:00, holding 965 bars worth 0.23% of the month's volume. An RSI on daily closes never sees those minutes. An RSI on 5-minute or hourly bars sees them immediately, since the first bar of the reader's day lands at a different clock time on each platform. Timestamps carry a matching trap, covered in market data timestamps.

Even a daily close has more than one definition. The panel below puts the last regular-session minute bar next to the daily bar for the same dates.

QueryTwo definitions of one daily close, side by side
The exact SQL behind every number
SELECT
    toString(m.day)                                                    AS date,
    round(m.session_close, 2)                                          AS session_close,
    round(a.agg_close, 2)                                              AS daily_bar_close,
    round(10000 * (a.agg_close - m.session_close) / m.session_close, 1) AS close_spread_bps
FROM
(
    SELECT
        toDate(toTimeZone(window_start, 'America/New_York')) AS day,
        toFloat64(argMax(close, window_start))               AS session_close
    FROM global_markets.delayed_stocks_minute_aggs
    WHERE ticker = 'AAPL'
      AND window_start >= '2026-06-01 04:00:00'
      AND window_start <  '2026-07-01 04:00:00'
      AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
           + toMinute(toTimeZone(window_start, 'America/New_York'))) >= 570
      AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
           + toMinute(toTimeZone(window_start, 'America/New_York'))) < 960
    GROUP BY day
) AS m
INNER JOIN
(
    SELECT
        date                  AS day,
        toFloat64(max(close)) AS agg_close
    FROM global_markets.stocks_daily_aggs
    WHERE ticker = 'AAPL'
      AND date >= '2026-06-01'
      AND date <= '2026-06-30'
    GROUP BY day
) AS a ON a.day = m.day
ORDER BY date
Run this yourself

The last column measures the distance between the two definitions in basis points, where one basis point is 0.01%. On the final session shown, 2026-06-30, the regular-session close printed 289.09 against 289.36 on the daily bar, across 21 sessions in the window. Wherever that column sits away from zero, the two feeds hand your indicator a different input for the day, and Wilder's recursion carries the difference forward into every reading after it.

Method notes
  • Both daily panels seed Wilder's average with the simple average of the first 14 changes in the loaded window, then apply the closed form of the recursion, which is identical arithmetic to the Python loop above.
  • The date ranges are fixed historical windows, so the numbers on this page stay put when the post is regenerated.

Pin the settings before comparing any indicator

The rule generalizes well past RSI. Before comparing an indicator value across two platforms or two vendor APIs, pin these four things, in this order:

  1. Period. 14 on both sides, counted the same way. Fourteen changes needs fifteen closes.
  2. Smoothing. Wilder, simple, or standard exponential, named out loud.
  3. Seed and warm-up. Where the recursion started, and how many bars ran before the first value you can see.
  4. Bar definition. The session window, plus how extended hours and empty periods are treated.

Two values that differ after all four match are a bug worth reporting. Two values that differ before all four match are the settings talking. The same discipline applies to every stateful measure: anchored VWAP turns entirely on where the anchor sits, and relative volume turns on the baseline window and which sessions it counts.

FAQ

Why is my RSI different from my charting platform?

Most often the smoothing rule. Wilder's recursion and a simple 14-period average of gains and losses are both published as RSI(14) and return different numbers on identical closes. After that, check how much history each side loaded before the first visible value, and whether both are reading the same bars.

Which RSI calculation is the correct one?

Wilder's smoothing is the original definition from his 1978 book, and it is what most platforms mean by RSI. A simple-average version computes exactly what it claims under a name it shares. For reproducibility, what matters is that both sides of a comparison declare which one they run.

How many bars does RSI need before it settles?

Wilder's average never drops a bar completely, so it converges rather than settles. An old bar keeps roughly 2.5% of its original weight after 50 further updates and roughly 0.06% after 100, which is why a few hundred bars of warm-up bring two implementations into close agreement.

Does including premarket data change RSI?

On daily closes, usually not. On intraday bars, yes. Extended-hours minutes exist in the tape, and a platform that includes them starts the day at a different bar and can end one at a different price, which changes the closes the formula reads.

Can two platforms with matching settings still disagree?

Yes. A last-trade close and a consolidated close are different numbers, and a split-adjusted history is different again. Displayed rounding hides small gaps at one decimal place while the underlying values differ.


Every panel here ships with its SQL underneath, and the Python above runs on a stock interpreter with nothing installed. To run the same comparison on a ticker and window of your choosing, ask for it in plain English on the Strasmore terminal.

#rsi#technical indicators#market data#reproducibility#ohlcv