I used AI to refactor a Rust RSI indicator. Here is why its results changed
This is a December 2023 development account of changing the RSI calculation used by NexusTrade’s historical implementation. If you were affected by that change, identify RSI-based portfolios, rerun their original backtest periods, compare entry and exit triggers, and review active rules before relying on old results. This article does not identify the platform release or every affected portfolio.
Consider reading and reacting on Medium to spread my reach!
Trusting external libraries can be a double-edged sword. When you come across a library that has garnered 574 stars, 103 forks, and is well-documented and tested, it’s natural to have a high level of trust in its reliability. This is especially true when it’s widely used in the community. However, this trust can be misleading. Can a library, even one this popular and seemingly well-vetted, be completely free of major bugs?
During my refactor, the RSI outputs differed from the original library’s tests. I initially described that as a major bug. The historical code below shows changed initialization and averaging behavior, so the discrepancy needs that context before treating one definition as universally correct.
Go to the original ta-rs libraryThis article shows how a timestamp-window refactor exposed an RSI discrepancy and why the calculation details matter.
Uncovering the Unexpected: A Different Goal Leads to a Major Discovery
Initially, I had no intention of auditing the technical analysis library I was using, trusting in its functionality. My original purpose for delving into the code was entirely different.
In the original library, you define a fixed window length. This is useful for backtesting and live-trading assuming your data is ingested at equal time intervals.
use ta::indicators::ExponentialMovingAverage;
use ta::Next;
// it can return an error, when an invalid length is passed (e.g. 0)
let mut ema = ExponentialMovingAverage::new(3).unwrap();
assert_eq!(ema.next(2.0), 2.0);
assert_eq!(ema.next(5.0), 3.5);
assert_eq!(ema.next(1.0), 2.25);
assert_eq!(ema.next(6.25), 4.25);
The reality is that expecting evenly distributed data in an algorithmic trading platform is quite a stretch. While this assumption could be forced to fit stock trading scenarios, it became clear that it was not viable when combined with cryptocurrency trading. The cryptocurrency API I was integrating with allowed me to fetch minutely, hourly, and daily cryptocurrency data. And I needed the flexibility to work with dynamically-sized windows.
Thus, I decided to fork the repo and fixed the code to work for my particular use case. For example:
use ta::indicators::ExponentialMovingAverage;
use ta::Next;
let mut ema = ExponentialMovingAverage::new(Duration::seconds(3)).unwrap(); // window size of 3 seconds
let now = Utc::now();
assert_eq!(ema.next((now, 2.0)), 2.0);
assert_eq!(ema.next((now + Duration::seconds(1), 5.0)), 3.5);
assert_eq!(ema.next((now + Duration::seconds(2), 1.0)), 2.25);
assert_eq!(ema.next((now + Duration::seconds(3), 6.25)), 4.25);
This dated refactor lets configurations express duration-based windows, such as a three-day SMA or a 30-minute rate of change. It does not establish interval-invariant EMA accuracy: the pinned fork applies a fixed coefficient, 2 divided by (duration in seconds plus 1), once per observation. Removing old timestamps from the stored window does not reweight the current EMA by elapsed time.
Go to my updated ta-rs libraryUnearthing the Bug
To reduce the scope, I deleted all technical indicators that weren’t currently implemented in the NexusTrade app. To do the refactor, I used my next-generation AI Configuration Platform, NexusGenAI, to streamline the process.
NexusGenAI allows you to create customized AI workflows. The workflow I created was called “TA-Rust-Refactor” and it contained exactly one prompt.
The “Refactor Rust” prompt used in the Application
While I probably could’ve created a script using NexusGenAI in the backend, I thought it would be faster to simply copy paste the indicators and ask the AI to regenerate the files.
The issue became apparent when I reached the final technical indicator in the library, the Relative Strength Index (RSI). The RSI is a momentum indicator commonly used in technical analysis to assess the magnitude of recent price changes. It helps in determining overbought or oversold conditions in the price of a stock or other asset. Typically, it is presented as an oscillator ranging from zero to 100, indicating potential overbought conditions when above 70 and oversold conditions when below 30.
The original unit test expected the rounded sequence below. Compare it with the fork’s committed test for the same prices; these are source-code expectations, not a newly executed validation.
Inspect the original RSI implementation and the December 23, 2023 fork and its tests. The original starts gain and loss at 0.1 and smooths them with an EMA. The fork starts with zero movement, returns 50 for no movement and 100 for gains without losses, and uses the timestamp-aware EMA.
// Historical initialization difference; excerpts, not a complete replacement.
// Original first observation:
up = 0.1;
down = 0.1;
// Fork first observation:
(0.0, 0.0)
// Fork zero-loss handling:
if avg_down == 0.0 {
if avg_up == 0.0 { 50.0 } else { 100.0 }
}| Input sequence | Original committed rounded expectations | Fork committed rounded expectations |
|---|---|---|
| 10.0, 10.5, 10.0, 9.5 | 50, 86, 35, 16 | 50, 100, 50, 33 |
The averaging convention changed too: the original three-observation EMA uses 2/(3+1); the historical fork computes its coefficient from duration seconds. The hand calculation below uses arithmetic averages instead. These are different definitions and initializations, so their disagreement alone does not prove that every upstream RSI result is wrong.
fn test_next() {
let mut rsi = RelativeStrengthIndex::new(3).unwrap();
assert_eq!(rsi.next(10.0), 50.0);
assert_eq!(rsi.next(10.5).round(), 86.0); // I was getting 100
assert_eq!(rsi.next(10.0).round(), 35.0); // I was getting 50
assert_eq!(rsi.next(9.5).round(), 16.0); // I was getting 33
}
While the first test case’s discrepancy might be rationalized (since the RSI, being a ratio of average gain to average loss, becomes undefined in the absence of any loss), the results of the final test case were perplexing and inexplicable. I couldn’t discern the logic behind these values.
After banging my head against the wall for hours, asking open-source models, and looking up online calculators, I decided to just calculate the Relative Strength Index by hand.
1. Calculate the daily price changes:
- Day 2: 10.5 - 10.0 = 0.5
- Day 3: 10.0 - 10.5 = -0.5
- Day 4: 9.5 - 10.0 = -0.5
2. Calculate the average gain and average loss for the 3-day period:
- Average gain: (0.5 + 0) / 3 = 0.1667
- Average loss: (0.5 + 0.5) / 3 = 0.3333
3. Calculate the Relative Strength (RS):
- RS = Average gain / Average loss = 0.1667 / 0.3333 = 0.5
4. Calculate the 3-day RSI:
- RSI = 100 - (100 / (1 + RS)) = 100 - (100 / (1 + 0.5)) = 100 - (100 / 1.5) = 100 - 66.67 = 33.33
So, the 3-day RSI for the given values is approximately 33.33.
This is exactly what I was getting in my test case!
Confirming the Bug
Doubting my findings, I turned to the GitHub issues for the library. There, my doubts were dispelled when I discovered a reported issue mirroring my experience.
Someone discovering that RSI does not match expected values
Another user reported different expected RSI values. That supported investigating the discrepancy, but agreement between two reports does not decide the averaging and initialization contract. My fork changed that contract and its tests; the linked historical implementation makes the change inspectable.
Concluding Thoughts
This experience reminded me to check the calculation contract, initialization and fixed input examples before trusting an indicator change. Library popularity is not a substitute for that review. Open source makes the implementation and its tests available to inspect.
In light of this, I’ve chosen to keep my version of the technical indicator library open-source. This decision ensures that NexusTrade users can fully understand what’s happening with their portfolios. By making our library open-source, we not only further our goal of democratizing algorithmic trading but also provide our users with utmost transparency regarding the inner workings of their trading strategies.
Thank you for reading. If you have technical indicators that you want implemented in the NexusTrade app, feel free to post an issue or submit a pull request!
Subscribe to NexusGenAI Go to NexusTrade📚 Follow our thought pieces on Medium
🤝 Connect with me on LinkedIn
👨💻 Explore our projects on GitHub
📸 Catch us on Instagram
🎵 Dive into our TikTok
No comments yet.