← Engineering notes

Why your RSI changes when you give it more history

A rolling mean over 14 bars forgets bar 15 and a Wilder average never does, which is why the same RSI on the same candles differs when the series behind it starts earlier — and why an indicator cache key has to carry the depth of history it was computed over.

· 9 min read

Schematic drawing: two long decaying exponential weight curves drawn over a row of bars, one starting much further left than the other, the two curves converging only gradually toward the right edge

Two runs over the same candles, the same rsi(close, 14), the same timestamp — and two different numbers. Usually nothing is broken. The RSI you get depends on how far back the series you fed it began, and it keeps depending on that forever, not just for a warm-up period you can wait out.

The reason is one line of arithmetic. It also decides something structural downstream: it is why an indicator cache key has to include the depth of history a result was computed over, and not just the pair, the timeframe and the parameters.

Two kinds of memory

Our indicator vocabulary has both kinds sitting next to each other in the same file.

rolling_mean (konis_core/strategy/indicators.py:167) is Series.rolling(window, min_periods).mean(). It has a finite window. At bar 15 the value of bar 1 is not merely small in the answer — it is absent from it. Two series that start at different points produce identical values from the fourteenth shared bar onward, and nothing about the earlier history survives to distinguish them.

rsi (indicators.py:151) is talib.RSI, which is Wilder smoothing, and Wilder smoothing discards nothing. Its average of gains is a recurrence:

avg_k = avg_{k-1} + (x_k - avg_{k-1}) / n

That is an exponential moving average with alpha = 1/n. Every bar ever fed into it still has weight in today's value. The weight is small. It is not zero, and it never becomes zero.

Worth confirming rather than assuming, since the claim rests on it. Twelve lines reproduce what talib returns (the divide-by-zero guard elided):

def wilder_rsi(close, n):
    d = np.diff(close)
    up, dn = np.clip(d, 0, None), np.clip(-d, 0, None)
    out = np.full(len(close), np.nan)
    au, ad = up[:n].mean(), dn[:n].mean()      # the seed: a plain mean of n changes
    out[n] = 100 - 100 / (1 + au / ad)
    for i in range(n, len(d)):
        au += (up[i] - au) / n                 # alpha = 1/n, applied forever
        ad += (dn[i] - ad) / n
        out[i + 1] = 100 - 100 / (1 + au / ad)
    return out

Against talib.RSI over 460 bars of BTC 4h closes, the maximum absolute difference is 2.8e-14 at n=7 and 3.6e-14 at n=14 — float noise. So talib's RSI is that recurrence, and the seed is a simple mean of the first n changes. Both halves of that matter: a different starting point means a different seed, and the seed never fully leaves.

How fast the seed stops mattering

A seed's influence after k further bars is multiplied by (1 - 1/n) each bar, so the residual weight is (1 - 1/n)^k.

k bars later n = 7 (0.857143^k) n = 14 (0.928571^k)
14 0.116 0.354
50 4.49e-4 2.46e-2
100 2.02e-7 6.05e-4
250 1.83e-17 8.99e-9

At n=14, a full 14 bars past the seed, 35% of the seed is still in the answer. At 50 bars it is still 2.5%.

Invert it for the number of bars needed to drive the residual under a tolerance: k = ln(tol) / ln(1 - 1/n).

tolerance on the residual weight n = 7 n = 14
1e-3 45 94
1e-6 90 187
1e-9 135 280

That is the weight on the seed, not the error in RSI points. The error is the weight times how far apart the two seeds were. Since two seeds can easily differ by ten or twenty RSI points, the bar counts above are a floor, not an estimate.

What the difference actually measures

The last 460 four-hour bars of BTC/USDT:USDT ending 2026-09-13, read from user_data/data/okx/futures. Compute RSI over all 460 and keep the last 250; compute RSI over only those last 250; compare bar by bar on identical timestamps. Differences in RSI points:

bars after the short series starts RSI(7) RSI(14)
first bar it defines (the maximum) 16.36 17.59
10 13.0 not yet defined
25 1.43 8.09
50 0.0287 1.15
100 3.9e-6 0.0149
200 1.8e-12 9.8e-6
249 0.0 4.7e-7

The bar from which the two agree and stay agreeing:

within n = 7 n = 14
0.5 RSI points bar 38 bar 64
0.01 bar 58 bar 103
1e-4 bar 88 bar 172

The decay rate measured between bar 50 and bar 200 is 0.8549 per bar at n=7 and 0.9252 at n=14, against the predicted 0.857143 and 0.928571. The model is right about the shape.

It is not exactly right about the level, and it is worth saying where it misses. From the residual alone, a 17-point seed disagreement should fall under 0.5 in about 48 bars; measured, RSI(14) first goes under 0.5 and stays there at bar 64. RSI is a ratio of two independently smoothed averages, so the error is not one clean exponential, and stays under is a stricter test than is under on this bar. Use the formula to size a warm-up, then add slack; do not use it to certify one.

For contrast, the same comparison for rolling(14).mean() over the same two frames: maximum absolute difference 2.9e-11 over 237 shared bars. That residue is floating-point accumulation in the rolling sum, not memory of the earlier history. There is no decay curve to plot because there is nothing decaying.

EMA gets this wrong in a different way

talib's EMA seeds with a simple mean of the first timeperiod values, so a short frame is short twice over — fewer converged bars and fewer bars at all. Over the same 460-vs-250 comparison:

  • EMA(50): the short frame defines 201 values, the largest disagreement is 30.79 price units, the disagreement on the final bar is 0.0103.
  • EMA(200): the short frame defines 51 values, and the disagreement on the final bar is 1182.69 price units on a close of 77248. A 250-bar frame is simply not enough history to compute an EMA(200) that means anything.

user_data/strategies_v2/konis-v3.yaml declares ema at 50 and 200, rsi at period 7, and startup_candles: 260 — which is the reason that number is 260 rather than 200.

Where this stops being a rounding question

The same document's short-side condition is rsi > overbought with overbought: 80. Over the 243 bars where both the deep and the shallow RSI(7) are defined, the two land on opposite sides of 80 on three of them.

That is one pair, one window, one condition out of several, and it says nothing about what any rule would then do. What it does establish is narrower and sufficient: identical code and identical candles, differing only in how far back the frame began, produce a different boolean on three bars out of 243. A backtest and a live worker that disagree about warm-up depth are not running the same strategy, and the gap is not in the rules.

Why the cache key carries the depth

konis_core/strategy/indicator_cache.py shares one indicator result across every stream in a pass that wants the same thing. The key is fixed when the cache is bound to a frame:

frame_id = (pair, timeframe, candle_type, len(candles),
            _stamp(candles.index[0]), _stamp(candles.index[-1]))

Frame length and first timestamp are in there for exactly the reason above. The module's own statement of the requirement is blunt: a strategy asking for 250 bars must not be served a series computed over 460. Nothing in (fn, params) distinguishes those two calls; everything about their output does.

This is not hypothetical in the deployed set. On 5m, the combined-binh-cluc documents declare startup_candles: 50 and ichi-v1 declares 96. Add WARMUP_SLACK = 100 (konis_core/workers/signal_worker.py:49) and they load 150 and 196 bars of the same pair on the same timeframe in the same pass. Keyed on parameters alone, their RSI calls look interchangeable. They are not, and with the depth in the key they simply never meet — while the documents that do share a warm-up, which is most of a builder's output since they inherit its defaults, still do.

The second half of the key is provenance rather than names (runtime.py:128, :145). A parameter whose value names something already in the namespace is replaced by the key of whatever produced it, so rsi_fast in one document and rsi14 in another meet if they are the same computation, and two genuinely different series that happen to share a label cannot be confused. Names are a document's business; identity is the cache's.

What a pass actually loads

  • signal_worker.py:185: bars = strategy.startup_candles + warmup_slack, with slack 100. For konis-v3 that is 360 bars per stream.
  • candle_cache.py: the candle cache holds DEFAULT_DEPTH = 1000 bars per series and refreshes by probing the newest DEFAULT_PROBE_BARS = 8.
  • Every path out of that cache ends in .tail(bars). The strategy is handed exactly the depth it asked for, never whatever happens to be held. If it were handed the held frame, the length would drift with the cache's state, the frame identity would change for no reason the strategy can see, and so would the RSI.
  • The forming bar is dropped before the frame is scoped (signal_worker.py:193-198), so the identity is computed over precisely the frame the strategy reads.

If you are chasing this in your own runs

Make two runs agree on depth before concluding anything about a rule. Size a warm-up from n and the tolerance you need — k = ln(tol) / ln(1 - 1/n), then add slack, which is what the 100 is. Treat (indicator, params) as an incomplete key for anything recursive. And if you want the answer measured rather than derived, konis/optimize/analysis/recursive.py recomputes a document's indicators at a range of startup depths and reports which ones still vary; that is the empirical version of this whole post, and it will tell you about indicators whose recursion you had forgotten about.

KONIS is quantitative trading infrastructure. Backtest declarative strategies against years of exchange data, run what survives on a managed runtime, and read the market through analytics while it runs.

Build and backtest free — no card

Technical software and market analytics, not financial, investment or trading advice. Nothing here is a recommendation to buy or sell anything. Backtested results are historical and do not indicate future results. Trading carries substantial risk, including total loss of capital.