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?
- The smoothing rule. Wilder's original recursion and a plain simple average of gains and losses both answer to the name RSI(14).
- 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.
- 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.
- 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.
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 dateThe 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.
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 warmupThe 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.
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_hourThe 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.
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 dateThe 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:
- Period. 14 on both sides, counted the same way. Fourteen changes needs fifteen closes.
- Smoothing. Wilder, simple, or standard exponential, named out loud.
- Seed and warm-up. Where the recursion started, and how many bars ran before the first value you can see.
- 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.