Chad's Website

Mindful code and creativity

The TA-Lib Conversion: Part 1 - A New Foundation in Rust

Converting TA-Lib technical indicators from C to Rust

The TA-Lib Conversion: Part 1 - A New Foundation in Rust

In a previous post, we explored the practical profitability of a single technical indicator: the RSI. That exploration—grounded in data and code—is the essence of what I love to do. But to analyze more complex strategies, we need a robust toolkit. For decades, the undisputed champion in that arena has been TA-Lib.

TA-Lib is a titan. First released in 2001, its C/C++ engine is the gold standard for technical analysis, powering countless trading applications worldwide. It's stable, reliable, and battle-tested over 20 years of use. But as the official FAQ notes, its very stability and the difficulty of contributing new functions in C have led to a slowdown in innovation.

This is where our new adventure begins.

This blog series will document a massive undertaking: a complete, native conversion of TA-Lib to Rust. Why Rust? For the same reasons developers are flocking to it for high-performance applications: memory safety without a garbage collector, incredible speed, and a modern toolchain that makes development a joy. The official TA-Lib project has an ongoing initiative to create a Rust version, and we're picking up that torch to explore what it takes to bring this legendary library into the future.

The Strategy: Don't Rewrite, Generate.

Converting over 150 indicators by hand would be a recipe for madness. It would be tedious, error-prone, and nearly impossible to verify. Thankfully, the original author of TA-Lib, Mario Fortier, gave us a better way.

The unsung hero of the entire TA-Lib project is a C program tucked away in the tools directory: gen_code.

gen_code is a powerful meta-program. It doesn't calculate moving averages; it writes the code that calculates moving averages. It reads from a series of abstract table definitions (table_a.c, table_b.c, etc.) that define the name, parameters, inputs, and outputs for every function in the library. From these definitions, it automatically generates the boilerplate C/C++ code, the .NET and Java wrappers, and much more. It's the key to TA-Lib's cross-platform success.

Our strategy is to harness this existing generator. Instead of rewriting everything, we are teaching the old master a new trick. We're extending gen_code by adding a gen_rust.c module that generates proper Rust function signatures and integrates seamlessly with the existing multi-language framework.

The Technical Challenges: When C Meets Rust

The conversion revealed fascinating challenges at the intersection of two very different programming paradigms. Here are the key battles we fought and won:

Challenge 1: The For-Loop Syntax Chasm C's flexible for-loops with multiple increment operations simply don't exist in Rust:

// C syntax that Rust can't handle
for(i=startIdx, outIdx=0; i <= endIdx; i++, outIdx++)

Our solution? A sophisticated macro system that transforms syntax at the preprocessor level:

// Cross-language macro abstraction
#if defined(_RUST)
    #define FOR_EACH_OUTPUT(startVal, endVal, idxVar, outIdxVar) \
    outIdxVar = 0; \
    for idxVar in startVal..=endVal {
    #define FOR_EACH_OUTPUT_END(outIdxVar) outIdxVar += 1; }
#else
    #define FOR_EACH_OUTPUT(startVal, endVal, idxVar, outIdxVar) \
    for(idxVar=startVal, outIdxVar=0; idxVar <= endVal; idxVar++, outIdxVar++) {
    #define FOR_EACH_OUTPUT_END(outIdxVar) }
#endif

Since our function parameters are already usize, the Rust range syntax startVal..=endVal works perfectly without explicit casting. This macro system successfully converts ~65% of TA-Lib's loop patterns automatically.

Challenge 2: Variable Declaration Syntax C and Rust declare variables completely differently. Our macro solution:

#if defined(_RUST)
    #define DECLARE_INT_VAR(name) let mut name: i32;
    #define DECLARE_INDEX_VAR(name) let mut name: usize;
#else
    #define DECLARE_INT_VAR(name) int name;
    #define DECLARE_INDEX_VAR(name) int name;
#endif

Challenge 3: The f32 to f64 Type Conversion Problem Single precision functions take f32 inputs but must write to f64 output arrays. Direct assignment fails with type mismatches. We solved this with the OUTPUT_F64 macro:

#if defined(_RUST)
    #define OUTPUT_F64(val) ((val) as f64)
#else
    #define OUTPUT_F64(val) (val)  // C handles conversion implicitly
#endif

Challenge 4: Function Signature Generation Generating proper Rust function signatures required sophisticated logic in gen_rust.c:

  • Maintaining API compatibility with camelCase parameter names
  • Proper Rust reference types (&[f64], &mut [f64], &mut usize)
  • Handling both single and double precision variants
  • Adding pub keywords for external visibility
  • Using usize for index parameters to match Rust idioms

The result? Clean, API-compatible Rust signatures:

pub fn mult(
    startIdx: usize,
    endIdx: usize,
    inReal0: &[f64],
    inReal1: &[f64],
    outBegIdx: &mut usize,
    outNBElement: &mut usize,
    outReal: &mut [f64],
) -> RetCode {
    // Implementation generated from C source
}

Challenge 5: The usize Type Safety Advantage Our transition to usize for index parameters revealed an interesting optimization opportunity in the generated Rust code. The compiler warnings highlight Rust's type safety benefits:

warning: comparison is useless due to type limits
  --> src/ta_func/mult.rs:65:12
   |
65 |         if startIdx < 0 {
   |            ^^^^^^^^^^^^

Since usize is unsigned, these comparisons against 0 are unnecessary—the type system guarantees they can never be negative. Our tests confirmed the behavior:

  • Normal case: mult(0, 4, ...)Success
  • usize::MAX case: mult(usize::MAX, 4, ...)OutOfRangeEndIndex (caught by endIdx < startIdx)
  • Backwards range: mult(4, 0, ...)OutOfRangeEndIndex

The validation still works perfectly because endIdx < startIdx catches problematic cases, but the < 0 comparisons are dead code. This showcases a key advantage of Rust: the type system eliminates entire classes of validation checks that are necessary in C. We can optimize our generated code by removing these redundant checks, making the Rust version both safer and more efficient than the original C.

The Build Process Revolution

We didn't stop at code generation. We integrated Rust tooling directly into the build process:

  • Automatic cargo fix --lib -p ta-lib --allow-dirty for linting
  • Automatic cargo fmt for consistent formatting
  • Multi-path cargo detection for cross-platform compatibility
  • Comprehensive test suite generation and execution

The gen_code now produces not just working Rust code, but properly formatted, linted, and tested Rust code.

Testing and Validation: Trust but Verify

With great power comes great responsibility. A code generator that produces incorrect results is worse than no generator at all. Our testing strategy is comprehensive:

Comprehensive Test Coverage:

  • Double precision and single precision variants
  • Edge cases (negative indices, invalid ranges)
  • Partial array processing
  • Error condition validation
  • Lookback function testing

Example Test Structure:

#[test]
fn test_mult_double_precision() {
    let in_real0 = [1.0, 2.0, 3.0, 4.0, 5.0];
    let in_real1 = [2.0, 3.0, 4.0, 5.0, 6.0];
    let mut out_real = [0.0; 5];
    let mut out_beg_idx = 0usize;
    let mut out_nb_element = 0usize;
    
    let result = Core::mult(0, 4, &in_real0, &in_real1, 
                           &mut out_beg_idx, &mut out_nb_element, 
                           &mut out_real);
    
    assert_eq!(result, RetCode::Success);
    assert_eq!(out_nb_element, 5);
    // Verify each calculation: [2.0, 6.0, 12.0, 20.0, 30.0]
}

Automated Quality Assurance: Every generated function goes through:

  1. cargo check - Rust compiler validation
  2. cargo fmt - Consistent code formatting
  3. cargo fix - Automatic linting fixes
  4. cargo test - Comprehensive test execution

The Architecture: Single Source, Multiple Targets

The elegance of our solution lies in maintaining Mario Fortier's original architectural principle: single source of truth. The same ta_MULT.c file now generates correct code for four different languages:

/* Generated */ #if defined( _MANAGED )
/* Generated */ public: enum class Core::RetCode Core::MULT(...)
/* Generated */ #elif defined( _JAVA )
/* Generated */ public RetCode mult(...)
/* Generated */ #elif defined( _RUST )
/* Generated */ pub fn mult(...) -> RetCode
/* Generated */ #else
/* Generated */ TA_LIB_API TA_RetCode TA_MULT(...)
/* Generated */ #endif

This preserves the maintainability that has made TA-Lib successful for over two decades while extending it to modern languages.

What's Next

We've proven the concept with a simple mathematical function and built a robust foundation for cross-language code generation. The real test will come as we tackle the classic indicators that form the backbone of technical analysis.

Our roadmap includes some of the most fundamental indicators: Simple Moving Average (SMA), Exponential Moving Average (EMA), and the Relative Strength Index (RSI) - the same RSI we explored for profitability in our previous analysis. Each presents unique challenges: SMA with its windowed calculations, EMA with its recursive nature, and RSI with its complex multi-step algorithm.

The journey ahead is ambitious, and we'll share our progress as we go. Some indicators will convert smoothly with our existing macro system. Others may require new techniques and creative solutions. That's the nature of pushing boundaries - you discover the limits as you test them.

Stay tuned. We're building something significant, one function at a time.