Skip to content
Rust & NativeRust y nativo

Building a High-Performance Financial Charting App: Rust + Kotlin on AndroidConstruir una app de gráficos financieros de alto rendimiento: Rust y Kotlin en Android

ByPor David Cruz Anaya, Senior Mobile & Applied AI Engineer (Android, Kotlin Multiplatform, product AI)Ingeniero sénior de móvil e IA aplicada (Android, Kotlin Multiplatform, IA de producto)

Updated 11 July 2026Actualizado el 11 de julio de 20266 min read6 min de lectura
AndroidMobile DevelopmentKotlinRustPerformance
Building a High-Performance Financial Charting App: Rust + Kotlin on Android
Share:Compartir:

Rust and Kotlin hybrid architecture for Android financial charting app

I built a real-time Android chart that streams cryptocurrency prices from Binance. Moving averages, RSI, and MACD have to run over thousands of points at 60 FPS without stalling the UI. Rust does the indicator math through JNI, and the app falls back to pure Kotlin if the native library does not load. This post is the implementation and the benchmark numbers from that app.

Why Wasn’t Kotlin Fast Enough?

The application streams live BTC/USDT price data from Binance WebSocket, processes candlestick data, and calculates multiple technical indicators in realtime. With 1,000+ historical candlesticks on screen and updates coming in continuously, the Kotlin implementation worked but showed limitations:

  • Full chart processing (SMA 20/50, EMA 12/26, RSI 14, MACD) taking 400–600µs per frame
  • Occasional GC pauses causing frame drops during intensive calculations
  • CPU usage spiking when users toggle multiple indicators simultaneously
  • Memory pressure from allocating temporary arrays for each calculation

The target was 60 FPS (16.6ms per frame) with 10,000 data points and every indicator on. While Kotlin could handle smaller datasets, scaling to larger historical views needed optimisation.

Rust has no garbage collector, and the compiler can auto-vectorise those loops with SIMD.

Architecture

The app has four layers:

┌─────────────────────────────────────────────────────────────┐
│                  Jetpack Compose UI Layer                   │
│  • CandlestickChart  • Volume Bars  • RSI Indicators        │
│  • Real-time Updates • Smooth Canvas Animations             │
└─────────────────────────────┬───────────────────────────────┘
                              │
┌─────────────────────────────▼───────────────────────────────┐
│            TechnicalIndicators.kt (Hybrid)                  │
│  • Loads Rust library via System.loadLibrary()              │
│  • Calls native methods for performance                     │
│  • Auto-falls back to Kotlin if library unavailable         │
└─────────────────────────────┬───────────────────────────────┘
                              │
                ┌─────────────┴─────────────┐
                │                           │
    ┌───────────▼──────────┐    ┌──────────▼────────────┐
    │  Rust Native Code    │    │  Kotlin Fallback      │
    │  (Primary Path)      │    │  (Backup Path)        │
    │  • lib.rs (JNI)      │    │  • calculateSMAKotlin │
    │  • indicators.rs     │    │  • calculateEMAKotlin │
    │  • 6-80x faster      │    │  • Always works       │
    └──────────────────────┘    └───────────────────────┘

If the Rust library fails to load (wrong architecture, corrupted file), the app uses pure Kotlin. Users see slower calculations instead of a crash.

The implementation

Layer 1: Pure Rust Algorithms (indicators.rs)

The calculations live in pure Rust, with no Android dependencies. The simple moving average:

/// Calculate Simple Moving Average (SMA)
///
/// # Arguments
/// * `data` - Input price data
/// * `period` - Number of periods for averaging
///
/// # Returns
/// Vector of SMA values with NaN for insufficient data points
pub fn calculate_sma(data: &[f32], period: usize) -> Vec<f32> {
    if data.len() < period {
        return vec![];
    }
    
    let mut result = vec![f32::NAN; data.len()];
    
    // Calculate SMA for values where we have enough data
    for i in (period - 1)..data.len() {
        let start = i.saturating_sub(period - 1);
        let sum: f32 = data[start..=i].iter().sum();
        result[i] = sum / period as f32;
    }

    result
}

The SMA function:

  • Memory efficiency: Pre-allocated result vector with NaN padding
  • Bounds safety: Using saturating_sub prevents underflow without runtime checks
  • Iterator optimization: The compiler can vectorize the sum() operation using SIMD
  • Zero allocations: No heap allocations in the hot loop

The exponential moving average keeps one running value:

pub fn calculate_ema(data: &[f32], period: usize) -> Vec<f32> {
    if data.len() < period {
        return vec![];
    }

    let mut result = vec![f32::NAN; data.len()];
    let multiplier = 2.0 / (period as f32 + 1.0);
    // Calculate initial SMA as first EMA value
    let sum: f32 = data[0..period].iter().sum();
    let mut ema = sum / period as f32;
    result[period - 1] = ema;
    // Calculate EMA for remaining values
    for i in period..data.len() {
        ema = (data[i] - ema) * multiplier + ema;
        result[i] = ema;
    }
    result
}

The EMA stores its state in one f32 and updates it on each price.

RSI tracks gains and losses separately, then smooths both averages:

pub fn calculate_rsi(data: &[f32], period: usize) -> Vec<f32> {
    if data.len() < period + 1 {
        return vec![];
    }

    let mut result = vec![f32::NAN; data.len()];

    // Calculate price changes and separate gains/losses
    let mut gains = vec![0.0f32; data.len() - 1];
    let mut losses = vec![0.0f32; data.len() - 1];
    for i in 1..data.len() {
        let change = data[i] - data[i - 1];
        gains[i - 1] = if change > 0.0 { change } else { 0.0 };
        losses[i - 1] = if change < 0.0 { -change } else { 0.0 };
    }

    // Calculate initial average gain and loss
    let sum_gain: f32 = gains[0..period].iter().sum();
    let sum_loss: f32 = losses[0..period].iter().sum();
    let mut avg_gain = sum_gain / period as f32;
    let mut avg_loss = sum_loss / period as f32;

    // Calculate first RSI
    let rs = if avg_loss == 0.0 { 100.0 } else { avg_gain / avg_loss };
    let rsi = 100.0 - (100.0 / (1.0 + rs));
    result[period] = rsi;

    // Calculate remaining RSI values using smoothed averages
    for i in period..gains.len() {
        avg_gain = (avg_gain * (period - 1) as f32 + gains[i]) / period as f32;
        avg_loss = (avg_loss * (period - 1) as f32 + losses[i]) / period as f32;
        let rs_value = if avg_loss == 0.0 { 100.0 } else { avg_gain / avg_loss };
        let rsi_value = 100.0 - (100.0 / (1.0 + rs_value));
        result[i + 1] = rsi_value;
    }

    result
}

Notes:

  • Division by zero is handled explicitly without exceptions
  • The smoothing calculation reuses previous averages (proper Wilder’s smoothing)
  • All arrays are stack-allocated where possible
  • No string allocations or debug overhead in release builds

Layer 2: JNI Bridge Layer (lib.rs)

The JNI bindings convert a Kotlin FloatArray to a Rust &[f32]. The SMA binding:

use jni::JNIEnv;
use jni::objects::{JClass, JFloatArray};
use jni::sys::jfloatArray;

mod indicators;

/// Calculate Simple Moving Average (SMA)
#[no_mangle]
pub extern "C" fn Java_com_davthecoder_financial_TechnicalIndicators_calculateSMANative(
    env: JNIEnv,
    _class: JClass,
    data: JFloatArray,
    period: i32,
) -> jfloatArray {
    let len = match env.get_array_length(&data) {
        Ok(l) => l as usize,
        Err(_) => return JFloatArray::default().into_raw(),
    };
    let mut data_vec = vec![0.0f32; len];
    if env.get_float_array_region(&data, 0, &mut data_vec).is_err() {
        return JFloatArray::default().into_raw();
    }
    let result = indicators::calculate_sma(&data_vec, period as usize);
    match env.new_float_array(result.len() as i32) {
        Ok(output) => {
            let _ = env.set_float_array_region(&output, 0, &result);
            output.into_raw()
        }
        Err(_) => JFloatArray::default().into_raw(),
    }
}

The function name follows JNI’s strict naming convention: Java_<package>_<class>_<method>. The #[no_mangle] attribute prevents Rust’s name mangling, ensuring the JVM can locate the symbol. Error handling uses match expressions, returning empty arrays on failure rather than panicking (which would crash the Android app).

get_float_array_region copies the Java array into Rust in one JNI call, instead of one element at a time. set_float_array_region copies the result back.

The RSI binding uses the same pattern and takes a period:

#[no_mangle]
pub extern "C" fn Java_com_davthecoder_financial_TechnicalIndicators_calculateRSINative(
    env: JNIEnv,
    _class: JClass,
    data: JFloatArray,
    period: i32,
) -> jfloatArray {
    let len = match env.get_array_length(&data) {
        Ok(l) => l as usize,
        Err(_) => return JFloatArray::default().into_raw(),
    };

    let mut data_vec = vec![0.0f32; len];
    if env.get_float_array_region(&data, 0, &mut data_vec).is_err() {
        return JFloatArray::default().into_raw();
    }

    let result = indicators::calculate_rsi(&data_vec, period as usize);
    match env.new_float_array(result.len() as i32) {
        Ok(output) => {
            let _ = env.set_float_array_region(&output, 0, &result);
            output.into_raw()
        }
        Err(_) => JFloatArray::default().into_raw(),
    }
}

The MACD binding is slightly more complex due to returning flattened arrays with three values per data point:

#[no_mangle]
pub extern "C" fn Java_com_davthecoder_financial_TechnicalIndicators_calculateMACDNative(
    env: JNIEnv,
    _class: JClass,
    data: JFloatArray,
    fast_period: i32,
    slow_period: i32,
    signal_period: i32,
) -> jfloatArray {
    let len = match env.get_array_length(&data) {
        Ok(l) => l as usize,
        Err(_) => return JFloatArray::default().into_raw(),
    };

    let mut data_vec = vec![0.0f32; len];

    if env.get_float_array_region(&data, 0, &mut data_vec).is_err() {
        return JFloatArray::default().into_raw();
    }

    let result = indicators::calculate_macd(
        &data_vec,
        fast_period as usize,
        slow_period as usize,
        signal_period as usize,
    );

    match env.new_float_array(result.len() as i32) {
        Ok(output) => {
            let _ = env.set_float_array_region(&output, 0, &result);
            output.into_raw()
        }
        Err(_) => JFloatArray::default().into_raw(),
    }
}

Layer 3: Kotlin Hybrid Wrapper (TechnicalIndicators.kt)

The Kotlin wrapper loads the native library and falls back when that fails:

package com.davthecoder.financial

object TechnicalIndicators {

    private var useNative = true

    init {
        try {
            System.loadLibrary("financial_indicators")
        } catch (e: UnsatisfiedLinkError) {
            useNative = false
            println("Failed to load native library, using Kotlin fallback: ${e.message}")
        }
    }

    // Native method declarations
    @JvmStatic
    private external fun calculateSMANative(
        data: FloatArray,
        period: Int,
    ): FloatArray

    /**
     * Calculate Simple Moving Average (SMA)
     * @param data Input price data
     * @param period Number of periods for averaging
     * @return List of SMA values with NaN for insufficient data points
     */
    fun calculateSMA(
        data: FloatArray,
        period: Int,
    ): FloatArray {
        return if (useNative) {
            try {
                calculateSMANative(data, period)
            } catch (e: Exception) {
                calculateSMAKotlin(data, period)
            }
        } else {
            calculateSMAKotlin(data, period)
        }
    }

    private fun calculateSMAKotlin(
        data: FloatArray,
        period: Int,
    ): FloatArray {
        if (data.size < period) {
            return FloatArray(0)
        }
        val result = FloatArray(data.size)
        // Fill initial values with NaN (not enough data for calculation)
        for (i in 0 until period - 1) {
            result[i] = Float.NaN
        }
        // Calculate SMA for remaining values
        for (i in period - 1 until data.size) {
            var sum = 0f
            for (j in i - period + 1..i) {
                sum += data[j]
            }
            result[i] = sum / period.toFloat()
        }
        return result
    }

}

The same wrapper covers the other indicators. The fallback has three steps:

  1. Try native first (calculateSMANative)
  2. Catch runtime exceptions and fall back to Kotlin if native call fails
  3. Boot-time detection sets useNative = false if library doesn’t load at all

The Kotlin versions use the same formulas. The EMA fallback:

private fun calculateEMAKotlin(
    data: FloatArray,
    period: Int,
): FloatArray {
    if (data.size < period) {
        return FloatArray(0)
    }

    val result = FloatArray(data.size)
    val multiplier = 2f / (period.toFloat() + 1f)

    // Fill initial values with NaN
    for (i in 0 until period - 1) {
        result[i] = Float.NaN
    }

    // Calculate initial SMA as first EMA value
    var sum = 0f
    for (i in 0 until period) {
        sum += data[i]
    }
    var ema = sum / period.toFloat()
    result[period - 1] = ema

    // Calculate EMA for remaining values
    for (i in period until data.size) {
        ema = (data[i] - ema) * multiplier + ema
        result[i] = ema
    }

    return result
}

The RSI and MACD fallbacks use the same formulas as the Rust versions.

Benchmark results

I ran instrumented tests on a Google Pixel 5 (ARM64, Android 14) with simulated BTC prices.

Benchmark Configuration

  • Device: Google Pixel 5 (arm64-v8a)
  • Data: Realistic BTC price movements (simulated with Gaussian distribution)
  • Iterations: 100 per test with 10 warmup iterations
  • Sizes: 100, 500, 1K, 5K, 10K data points

MACD: Up to 80x Speedup

MACD runs several EMA passes, and it is the largest gap in the table:

| Data Points   | Rust  | Kotlin  | Speedup |
|---------------|-------|---------|---------|
| 100           | 19µs  | 1556µ   | 80.89x  |
| 500           | 59µs  | 3297µs  | 55.75x  |
| 1000          | 104µs | 5839µs  | 55.83x  |
| 5000          | 404µs | 11463µs | 28.31x  |
| 10000         | 810µs | 5215µs  | 6.44x   |

From the actual log output:

Size: 100  | Current: 19µs  | Kotlin: 1556µs | Speedup: 80.89x
Size: 500  | Current: 59µs  | Kotlin: 3297µs | Speedup: 55.75x
Size: 1000 | Current: 104µs | Kotlin: 5839µs | Speedup: 55.83x

At 100 points, Rust is 80x faster. At 10,000 points, it is 6.4x faster.

RSI

RSI:

| Data Points   | Rust  | Kotlin  | Speedup |
|---------------|-------|---------|---------|
| 100           | 13µs  | 217µs   | 15.66x  |
| 500           | 35µs  | 546µs   | 15.47x  |
| 1000          | 68µs  | 68µs    | 1.00x   |
| 5000          | 250µs | 291µs   | 1.16x   |
| 10000         | 490µs | 617µs   | 1.26x   |

Below 1,000 points, Rust is about 15x faster. At 1,000 points and above, the times converge. The Kotlin RSI loop is simple enough that the JIT can catch up.

SMA

SMA:

| Data Points   | Rust  | Kotlin  | Speedup |
|---------------|-------|---------|---------|
| 100           | 12µs  | 223µs   | 17.80x  |
| 500           | 42µs  | 47µs    | 1.12x   |
| 1000          | 79µs  | 95µs    | 1.20x   |
| 5000          | 350µs | 485µs   | 1.38x   |
| 10000         | 685µs | 970µs   | 1.42x   |

Full chart

Full chart processing runs SMA 20/50, EMA 12/26, RSI 14, and MACD 12/26/9 together:

| Data Points   | Time    | Potential FPS | vs 60 FPS Target |
|---------------|---------|---------------|------------------|
| 100           | 79µs    | 12,579 FPS    | ✅ 210x headroom |
| 500           | 233µs   | 4,292 FPS     | ✅ 71x headroom  |
| 1000          | 420µs   | 2,380 FPS     | ✅ 40x headroom  |
| 5000          | 1,875µs | 533 FPS       | ✅ 9x headroom   |
| 10000         | 3,704µs | 270 FPS       | ✅ 4.5x headroom |

From the log output:

Size: 100  | Time: 79µs  | Potential FPS: 12579.1
Size: 500  | Time: 233µs | Potential FPS: 4291.8
Size: 1000 | Time: 420µs | Potential FPS: 2380.4

With 10,000 candlesticks and every indicator on, processing finishes in under 4 ms, inside the 16.6 ms budget for 60 FPS.

Where the speed comes from

The gap comes from a few things at once:

  1. Zero GC overhead: Rust has no garbage collector. Kotlin’s GC can pause for milliseconds during collection cycles.
  2. Memory layout: Rust’s contiguous arrays have better cache locality than Kotlin’s boxed arrays.
  3. SIMD auto-vectorisation: The compiler can process 4+ floats simultaneously using ARM NEON instructions.
  4. JNI overhead is minimal: Bulk array copies (1–5µs for 10K elements) are amortised across the calculation time.
  5. No runtime warmup: Rust code runs at full speed immediately; Kotlin needs JIT warmup iterations.

The 100-point rows are faster too. The tables show the largest gaps on the small sets.

Benchmark Log to Visualizations: https://gemini.google.com/share/940c19741096

How Do You Build Rust for Android?

build-rust.sh cross-compiles for the Android ABIs:

#!/bin/bash
set -e

echo "🦀 Building Rust libraries for Android..."
# Check if Rust is installed
if ! command -v cargo &> /dev/null; then
    echo "❌ Rust/Cargo not found. Please install Rust from https://rustup.rs"
    exit 1
fi
# Add Android targets if not already added
TARGETS=(
    "aarch64-linux-android"
    "armv7-linux-androideabi"
    "x86_64-linux-android"
    "i686-linux-android"
)
echo "📦 Checking Android targets..."
for target in "${TARGETS[@]}"; do
    if ! rustup target list | grep -q "$target (installed)"; then
        echo "Adding target: $target"
        rustup target add "$target"
    fi
done
# Set Android NDK path (adjust if needed)
if [ -z "$ANDROID_NDK_HOME" ]; then
    if [ -d "$HOME/Library/Android/sdk/ndk" ]; then
        export ANDROID_NDK_HOME="$HOME/Library/Android/sdk/ndk/$(ls -1 $HOME/Library/Android/sdk/ndk | tail -1)"
    else
        echo "⚠️  ANDROID_NDK_HOME not set. Please set it to your NDK installation path."
        exit 1
    fi
fi
# Build for each target
for target in "${TARGETS[@]}"; do
    echo "🔨 Building for $target..."
    cargo build --release --target "$target"
done
# Copy libraries to Android jniLibs
JNILIBS_DIR="../app/src/main/jniLibs"
mkdir -p "$JNILIBS_DIR"
# Map Rust targets to Android ABIs
for target in "${TARGETS[@]}"; do
    case "$target" in
        "aarch64-linux-android")
            abi="arm64-v8a"
            ;;
        "armv7-linux-androideabi")
            abi="armeabi-v7a"
            ;;
        "x86_64-linux-android")
            abi="x86_64"
            ;;
        "i686-linux-android")
            abi="x86"
            ;;
    esac
    src="target/$target/release/libfinancial_indicators.so"
    dst="$JNILIBS_DIR/$abi"
    if [ -f "$src" ]; then
        mkdir -p "$dst"
        cp "$src" "$dst/libfinancial_indicators.so"
        echo "✅ Copied $src -> $dst/libfinancial_indicators.so"
    fi
done
echo "🎉 Rust build complete!"

The script:

  1. Verifies Rust installation
  2. Installs Android targets if missing
  3. Locates Android NDK automatically
  4. Compiles for all four architectures
  5. Copies .so files to the correct jniLibs directories

Complete Build Workflow

# 1. Install Rust and Android targets (one-time setup)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup target add aarch64-linux-android armv7-linux-androideabi \
                  x86_64-linux-android i686-linux-android

# 2. Build Rust libraries
./build-rust.sh

# 3. Build and run Android app
./gradlew installDebug

# 4. Run benchmarks (optional)
./run-benchmarks.sh

The resulting APK structure:

app-debug.apk
├── lib/
│   ├── arm64-v8a/
│   │   └── libfinancial_indicators.so    # 64-bit ARM (most devices)
│   ├── armeabi-v7a/
│   │   └── libfinancial_indicators.so    # 32-bit ARM (older devices)
│   ├── x86_64/
│   │   └── libfinancial_indicators.so    # Emulator
│   └── x86/
│       └── libfinancial_indicators.so    # Older emulator
└── classes.dex                            # Kotlin/Java bytecode

Android automatically loads the correct .so file based on device architecture.

What I learned

On macOS, unit tests could not load the .so. The Kotlin fallback let me test the indicator logic without a device. A corrupted .so or an unsupported architecture takes the same path in production.

The Rust and Kotlin functions use the same formulas. Unit tests check that calculateSMA(data, 20) matches within floating-point precision on both paths.

get_float_array_region copies 10K elements in 1–5µs. Element-by-element access took 100µs or more.

MACD gained the most, up to 80x. EMA gained 1.5–2x. That gap set which calculations to work on next.

I wrote the SMA binding first and copied it for the other indicators. The JNI boilerplate is nearly the same for every array calculation.

Rust has to be compiled on macOS or Linux before the APK build. build-rust.sh does that, and CI runs the script so the .so files stay current.

The JNI bindings return empty arrays instead of panicking, so a failed call does not crash the app. For debugging I used println! in Rust and read the output in logcat.

When to use this pattern

Use the hybrid path when the loop processes 1,000 or more elements, the update cannot pause for garbage collection, the work is CPU-bound, and the function is pure so it can be tested without Android. Someone on the team needs to write the Rust, or be willing to learn it.

Skip it for database queries and network calls, for Jetpack Compose UI, for one operation on a small dataset, and during early prototyping. Add Rust after you have product-market fit.

The source, benchmarks, and build scripts are at github.com/davthecodercom/Financial-RustAndAndroid.


Performance measurements from Google Pixel 5 (ARM64, Android 14) running instrumented tests with 100 iterations per benchmark.


Arquitectura híbrida de Rust y Kotlin para una app de gráficos financieros en Android

Construí un gráfico de Android en tiempo real que recibe precios de criptomonedas de Binance. Las medias móviles, el RSI y el MACD tienen que correr sobre miles de puntos a 60 FPS sin frenar la interfaz. Rust hace las cuentas de los indicadores a través de JNI, y la app cae a Kotlin puro si la biblioteca nativa no carga. Este artículo es la implementación y los números de benchmark de esa app.

¿Por qué Kotlin no era lo bastante rápido?

La aplicación hace streaming de datos de precio BTC/USDT en vivo desde el WebSocket de Binance, procesa datos de velas y calcula varios indicadores técnicos en tiempo real. Con más de 1.000 velas históricas en pantalla y actualizaciones que llegan sin parar, la implementación en Kotlin funcionaba, pero mostraba límites:

  • Procesado completo del gráfico (SMA 20/50, EMA 12/26, RSI 14, MACD) que tardaba 400–600 µs por fotograma
  • Pausas ocasionales del recolector de basura que provocaban pérdidas de fotograma durante cálculos intensivos
  • Picos de uso de CPU cuando quien usa la app activa varios indicadores a la vez
  • Presión de memoria por asignar arrays temporales en cada cálculo

El objetivo era 60 FPS (16,6 ms por fotograma) con 10.000 puntos de datos y todos los indicadores activos. Kotlin podía con conjuntos más pequeños, pero escalar a vistas históricas más grandes pedía optimización.

Rust no tiene recolector de basura, y el compilador puede autovectorizar esos bucles con SIMD.

Arquitectura

La app tiene cuatro capas:

┌─────────────────────────────────────────────────────────────┐
│                  Jetpack Compose UI Layer                   │
│  • CandlestickChart  • Volume Bars  • RSI Indicators        │
│  • Real-time Updates • Smooth Canvas Animations             │
└─────────────────────────────┬───────────────────────────────┘
                              │
┌─────────────────────────────▼───────────────────────────────┐
│            TechnicalIndicators.kt (Hybrid)                  │
│  • Loads Rust library via System.loadLibrary()              │
│  • Calls native methods for performance                     │
│  • Auto-falls back to Kotlin if library unavailable         │
└─────────────────────────────┬───────────────────────────────┘
                              │
                ┌─────────────┴─────────────┐
                │                           │
    ┌───────────▼──────────┐    ┌──────────▼────────────┐
    │  Rust Native Code    │    │  Kotlin Fallback      │
    │  (Primary Path)      │    │  (Backup Path)        │
    │  • lib.rs (JNI)      │    │  • calculateSMAKotlin │
    │  • indicators.rs     │    │  • calculateEMAKotlin │
    │  • 6-80x faster      │    │  • Always works       │
    └──────────────────────┘    └───────────────────────┘

Si la biblioteca de Rust no llega a cargarse (arquitectura incorrecta, archivo corrupto), la app usa Kotlin puro. Quien la usa ve cálculos más lentos, no un crash.

La implementación

Capa 1: algoritmos en Rust puro (indicators.rs)

Los cálculos viven en Rust puro, sin dependencias de Android. La media móvil simple:

/// Calculate Simple Moving Average (SMA)
///
/// # Arguments
/// * `data` - Input price data
/// * `period` - Number of periods for averaging
///
/// # Returns
/// Vector of SMA values with NaN for insufficient data points
pub fn calculate_sma(data: &[f32], period: usize) -> Vec<f32> {
    if data.len() < period {
        return vec![];
    }
    
    let mut result = vec![f32::NAN; data.len()];
    
    // Calculate SMA for values where we have enough data
    for i in (period - 1)..data.len() {
        let start = i.saturating_sub(period - 1);
        let sum: f32 = data[start..=i].iter().sum();
        result[i] = sum / period as f32;
    }

    result
}

La función SMA:

  • Eficiencia de memoria: vector de resultado preasignado, con relleno de NaN
  • Seguridad de límites: usar saturating_sub evita el underflow sin comprobaciones en tiempo de ejecución
  • Optimización del iterador: el compilador puede vectorizar la operación sum() con SIMD
  • Cero asignaciones: sin asignaciones en el heap dentro del bucle caliente

La media móvil exponencial guarda un solo valor en curso:

pub fn calculate_ema(data: &[f32], period: usize) -> Vec<f32> {
    if data.len() < period {
        return vec![];
    }

    let mut result = vec![f32::NAN; data.len()];
    let multiplier = 2.0 / (period as f32 + 1.0);
    // Calculate initial SMA as first EMA value
    let sum: f32 = data[0..period].iter().sum();
    let mut ema = sum / period as f32;
    result[period - 1] = ema;
    // Calculate EMA for remaining values
    for i in period..data.len() {
        ema = (data[i] - ema) * multiplier + ema;
        result[i] = ema;
    }
    result
}

La EMA guarda su estado en un f32 y lo actualiza con cada precio.

El RSI sigue las ganancias y las pérdidas por separado, y luego suaviza las dos medias:

pub fn calculate_rsi(data: &[f32], period: usize) -> Vec<f32> {
    if data.len() < period + 1 {
        return vec![];
    }

    let mut result = vec![f32::NAN; data.len()];

    // Calculate price changes and separate gains/losses
    let mut gains = vec![0.0f32; data.len() - 1];
    let mut losses = vec![0.0f32; data.len() - 1];
    for i in 1..data.len() {
        let change = data[i] - data[i - 1];
        gains[i - 1] = if change > 0.0 { change } else { 0.0 };
        losses[i - 1] = if change < 0.0 { -change } else { 0.0 };
    }

    // Calculate initial average gain and loss
    let sum_gain: f32 = gains[0..period].iter().sum();
    let sum_loss: f32 = losses[0..period].iter().sum();
    let mut avg_gain = sum_gain / period as f32;
    let mut avg_loss = sum_loss / period as f32;

    // Calculate first RSI
    let rs = if avg_loss == 0.0 { 100.0 } else { avg_gain / avg_loss };
    let rsi = 100.0 - (100.0 / (1.0 + rs));
    result[period] = rsi;

    // Calculate remaining RSI values using smoothed averages
    for i in period..gains.len() {
        avg_gain = (avg_gain * (period - 1) as f32 + gains[i]) / period as f32;
        avg_loss = (avg_loss * (period - 1) as f32 + losses[i]) / period as f32;
        let rs_value = if avg_loss == 0.0 { 100.0 } else { avg_gain / avg_loss };
        let rsi_value = 100.0 - (100.0 / (1.0 + rs_value));
        result[i + 1] = rsi_value;
    }

    result
}

Notas:

  • La división por cero se trata de forma explícita, sin excepciones
  • El cálculo de suavizado reutiliza las medias anteriores (el suavizado de Wilder, como debe ser)
  • Todos los arrays se asignan en la pila cuando es posible
  • Sin asignaciones de cadenas ni sobrecoste de depuración en los builds de release

Capa 2: capa de puente JNI (lib.rs)

Los bindings JNI convierten un FloatArray de Kotlin en un &[f32] de Rust. El binding de la SMA:

use jni::JNIEnv;
use jni::objects::{JClass, JFloatArray};
use jni::sys::jfloatArray;

mod indicators;

/// Calculate Simple Moving Average (SMA)
#[no_mangle]
pub extern "C" fn Java_com_davthecoder_financial_TechnicalIndicators_calculateSMANative(
    env: JNIEnv,
    _class: JClass,
    data: JFloatArray,
    period: i32,
) -> jfloatArray {
    let len = match env.get_array_length(&data) {
        Ok(l) => l as usize,
        Err(_) => return JFloatArray::default().into_raw(),
    };
    let mut data_vec = vec![0.0f32; len];
    if env.get_float_array_region(&data, 0, &mut data_vec).is_err() {
        return JFloatArray::default().into_raw();
    }
    let result = indicators::calculate_sma(&data_vec, period as usize);
    match env.new_float_array(result.len() as i32) {
        Ok(output) => {
            let _ = env.set_float_array_region(&output, 0, &result);
            output.into_raw()
        }
        Err(_) => JFloatArray::default().into_raw(),
    }
}

El nombre de la función sigue la convención de nombres estricta de JNI: Java_<package>_<class>_<method>. El atributo #[no_mangle] impide el mangling de nombres de Rust, y así la JVM puede localizar el símbolo. La gestión de errores usa expresiones match y devuelve arrays vacíos ante un fallo, en lugar de hacer panic (lo que tiraría la app de Android).

get_float_array_region copia el array de Java a Rust en una sola llamada JNI, en lugar de ir elemento a elemento. set_float_array_region copia el resultado de vuelta.

El binding del RSI usa el mismo patrón y recibe un periodo:

#[no_mangle]
pub extern "C" fn Java_com_davthecoder_financial_TechnicalIndicators_calculateRSINative(
    env: JNIEnv,
    _class: JClass,
    data: JFloatArray,
    period: i32,
) -> jfloatArray {
    let len = match env.get_array_length(&data) {
        Ok(l) => l as usize,
        Err(_) => return JFloatArray::default().into_raw(),
    };

    let mut data_vec = vec![0.0f32; len];
    if env.get_float_array_region(&data, 0, &mut data_vec).is_err() {
        return JFloatArray::default().into_raw();
    }

    let result = indicators::calculate_rsi(&data_vec, period as usize);
    match env.new_float_array(result.len() as i32) {
        Ok(output) => {
            let _ = env.set_float_array_region(&output, 0, &result);
            output.into_raw()
        }
        Err(_) => JFloatArray::default().into_raw(),
    }
}

El binding del MACD es un poco más complejo porque devuelve arrays aplanados, con tres valores por punto de datos:

#[no_mangle]
pub extern "C" fn Java_com_davthecoder_financial_TechnicalIndicators_calculateMACDNative(
    env: JNIEnv,
    _class: JClass,
    data: JFloatArray,
    fast_period: i32,
    slow_period: i32,
    signal_period: i32,
) -> jfloatArray {
    let len = match env.get_array_length(&data) {
        Ok(l) => l as usize,
        Err(_) => return JFloatArray::default().into_raw(),
    };

    let mut data_vec = vec![0.0f32; len];

    if env.get_float_array_region(&data, 0, &mut data_vec).is_err() {
        return JFloatArray::default().into_raw();
    }

    let result = indicators::calculate_macd(
        &data_vec,
        fast_period as usize,
        slow_period as usize,
        signal_period as usize,
    );

    match env.new_float_array(result.len() as i32) {
        Ok(output) => {
            let _ = env.set_float_array_region(&output, 0, &result);
            output.into_raw()
        }
        Err(_) => JFloatArray::default().into_raw(),
    }
}

Capa 3: wrapper híbrido en Kotlin (TechnicalIndicators.kt)

El wrapper de Kotlin carga la biblioteca nativa y cae al respaldo cuando eso falla:

package com.davthecoder.financial

object TechnicalIndicators {

    private var useNative = true

    init {
        try {
            System.loadLibrary("financial_indicators")
        } catch (e: UnsatisfiedLinkError) {
            useNative = false
            println("Failed to load native library, using Kotlin fallback: ${e.message}")
        }
    }

    // Native method declarations
    @JvmStatic
    private external fun calculateSMANative(
        data: FloatArray,
        period: Int,
    ): FloatArray

    /**
     * Calculate Simple Moving Average (SMA)
     * @param data Input price data
     * @param period Number of periods for averaging
     * @return List of SMA values with NaN for insufficient data points
     */
    fun calculateSMA(
        data: FloatArray,
        period: Int,
    ): FloatArray {
        return if (useNative) {
            try {
                calculateSMANative(data, period)
            } catch (e: Exception) {
                calculateSMAKotlin(data, period)
            }
        } else {
            calculateSMAKotlin(data, period)
        }
    }

    private fun calculateSMAKotlin(
        data: FloatArray,
        period: Int,
    ): FloatArray {
        if (data.size < period) {
            return FloatArray(0)
        }
        val result = FloatArray(data.size)
        // Fill initial values with NaN (not enough data for calculation)
        for (i in 0 until period - 1) {
            result[i] = Float.NaN
        }
        // Calculate SMA for remaining values
        for (i in period - 1 until data.size) {
            var sum = 0f
            for (j in i - period + 1..i) {
                sum += data[j]
            }
            result[i] = sum / period.toFloat()
        }
        return result
    }

}

El mismo wrapper cubre el resto de indicadores. El respaldo tiene tres pasos:

  1. Probar primero lo nativo (calculateSMANative)
  2. Capturar las excepciones de tiempo de ejecución y caer a Kotlin si la llamada nativa falla
  3. Detección en el arranque, que pone useNative = false si la biblioteca no carga en absoluto

Las versiones en Kotlin usan las mismas fórmulas. El respaldo de la EMA:

private fun calculateEMAKotlin(
    data: FloatArray,
    period: Int,
): FloatArray {
    if (data.size < period) {
        return FloatArray(0)
    }

    val result = FloatArray(data.size)
    val multiplier = 2f / (period.toFloat() + 1f)

    // Fill initial values with NaN
    for (i in 0 until period - 1) {
        result[i] = Float.NaN
    }

    // Calculate initial SMA as first EMA value
    var sum = 0f
    for (i in 0 until period) {
        sum += data[i]
    }
    var ema = sum / period.toFloat()
    result[period - 1] = ema

    // Calculate EMA for remaining values
    for (i in period until data.size) {
        ema = (data[i] - ema) * multiplier + ema
        result[i] = ema
    }

    return result
}

Los respaldos del RSI y del MACD usan las mismas fórmulas que las versiones en Rust.

Resultados del benchmark

Ejecuté tests instrumentados en un Google Pixel 5 (ARM64, Android 14) con precios de BTC simulados.

Configuración del benchmark

  • Dispositivo: Google Pixel 5 (arm64-v8a)
  • Datos: movimientos realistas del precio de BTC (simulados con una distribución gaussiana)
  • Iteraciones: 100 por test, con 10 iteraciones de calentamiento
  • Tamaños: 100, 500, 1K, 5K y 10K puntos de datos

MACD: hasta 80 veces más rápido

El MACD hace varias pasadas de EMA, y es la diferencia más grande de la tabla:

| Data Points   | Rust  | Kotlin  | Speedup |
|---------------|-------|---------|---------|
| 100           | 19µs  | 1556µ   | 80.89x  |
| 500           | 59µs  | 3297µs  | 55.75x  |
| 1000          | 104µs | 5839µs  | 55.83x  |
| 5000          | 404µs | 11463µs | 28.31x  |
| 10000         | 810µs | 5215µs  | 6.44x   |

De la salida real del log:

Size: 100  | Current: 19µs  | Kotlin: 1556µs | Speedup: 80.89x
Size: 500  | Current: 59µs  | Kotlin: 3297µs | Speedup: 55.75x
Size: 1000 | Current: 104µs | Kotlin: 5839µs | Speedup: 55.83x

Con 100 puntos, Rust es 80 veces más rápido. Con 10.000 puntos, es 6,4 veces más rápido.

RSI

RSI:

| Data Points   | Rust  | Kotlin  | Speedup |
|---------------|-------|---------|---------|
| 100           | 13µs  | 217µs   | 15.66x  |
| 500           | 35µs  | 546µs   | 15.47x  |
| 1000          | 68µs  | 68µs    | 1.00x   |
| 5000          | 250µs | 291µs   | 1.16x   |
| 10000         | 490µs | 617µs   | 1.26x   |

Por debajo de 1.000 puntos, Rust es unas 15 veces más rápido. A partir de 1.000 puntos, los tiempos convergen. El bucle de RSI en Kotlin es lo bastante simple como para que el JIT lo alcance.

SMA

SMA:

| Data Points   | Rust  | Kotlin  | Speedup |
|---------------|-------|---------|---------|
| 100           | 12µs  | 223µs   | 17.80x  |
| 500           | 42µs  | 47µs    | 1.12x   |
| 1000          | 79µs  | 95µs    | 1.20x   |
| 5000          | 350µs | 485µs   | 1.38x   |
| 10000         | 685µs | 970µs   | 1.42x   |

Gráfico completo

El procesado completo del gráfico corre SMA 20/50, EMA 12/26, RSI 14 y MACD 12/26/9 a la vez:

| Data Points   | Time    | Potential FPS | vs 60 FPS Target |
|---------------|---------|---------------|------------------|
| 100           | 79µs    | 12,579 FPS    | ✅ 210x headroom |
| 500           | 233µs   | 4,292 FPS     | ✅ 71x headroom  |
| 1000          | 420µs   | 2,380 FPS     | ✅ 40x headroom  |
| 5000          | 1,875µs | 533 FPS       | ✅ 9x headroom   |
| 10000         | 3,704µs | 270 FPS       | ✅ 4.5x headroom |

De la salida del log:

Size: 100  | Time: 79µs  | Potential FPS: 12579.1
Size: 500  | Time: 233µs | Potential FPS: 4291.8
Size: 1000 | Time: 420µs | Potential FPS: 2380.4

Con 10.000 velas y todos los indicadores activos, el procesado termina en menos de 4 ms, dentro del presupuesto de 16,6 ms para 60 FPS.

De dónde sale la velocidad

La diferencia sale de varias cosas a la vez:

  1. Cero sobrecoste del recolector: Rust no tiene recolector de basura. El recolector de Kotlin puede pausar milisegundos durante los ciclos de recolección.
  2. Disposición de memoria: los arrays contiguos de Rust tienen mejor localidad de caché que los arrays en caja (boxed) de Kotlin.
  3. Autovectorización SIMD: el compilador puede procesar 4 o más floats a la vez con instrucciones ARM NEON.
  4. El sobrecoste de JNI es mínimo: las copias de arrays en bloque (1–5 µs para 10K elementos) se amortizan a lo largo del tiempo de cálculo.
  5. Sin calentamiento en tiempo de ejecución: el código Rust va a toda velocidad desde el primer momento; Kotlin necesita iteraciones de calentamiento del JIT.

Las filas de 100 puntos también son más rápidas. Las tablas muestran las diferencias más grandes en los conjuntos pequeños.

Del log del benchmark a las visualizaciones: https://gemini.google.com/share/940c19741096

¿Cómo compilas Rust para Android?

build-rust.sh compila en cruz para las ABI de Android:

#!/bin/bash
set -e

echo "🦀 Building Rust libraries for Android..."
# Check if Rust is installed
if ! command -v cargo &> /dev/null; then
    echo "❌ Rust/Cargo not found. Please install Rust from https://rustup.rs"
    exit 1
fi
# Add Android targets if not already added
TARGETS=(
    "aarch64-linux-android"
    "armv7-linux-androideabi"
    "x86_64-linux-android"
    "i686-linux-android"
)
echo "📦 Checking Android targets..."
for target in "${TARGETS[@]}"; do
    if ! rustup target list | grep -q "$target (installed)"; then
        echo "Adding target: $target"
        rustup target add "$target"
    fi
done
# Set Android NDK path (adjust if needed)
if [ -z "$ANDROID_NDK_HOME" ]; then
    if [ -d "$HOME/Library/Android/sdk/ndk" ]; then
        export ANDROID_NDK_HOME="$HOME/Library/Android/sdk/ndk/$(ls -1 $HOME/Library/Android/sdk/ndk | tail -1)"
    else
        echo "⚠️  ANDROID_NDK_HOME not set. Please set it to your NDK installation path."
        exit 1
    fi
fi
# Build for each target
for target in "${TARGETS[@]}"; do
    echo "🔨 Building for $target..."
    cargo build --release --target "$target"
done
# Copy libraries to Android jniLibs
JNILIBS_DIR="../app/src/main/jniLibs"
mkdir -p "$JNILIBS_DIR"
# Map Rust targets to Android ABIs
for target in "${TARGETS[@]}"; do
    case "$target" in
        "aarch64-linux-android")
            abi="arm64-v8a"
            ;;
        "armv7-linux-androideabi")
            abi="armeabi-v7a"
            ;;
        "x86_64-linux-android")
            abi="x86_64"
            ;;
        "i686-linux-android")
            abi="x86"
            ;;
    esac
    src="target/$target/release/libfinancial_indicators.so"
    dst="$JNILIBS_DIR/$abi"
    if [ -f "$src" ]; then
        mkdir -p "$dst"
        cp "$src" "$dst/libfinancial_indicators.so"
        echo "✅ Copied $src -> $dst/libfinancial_indicators.so"
    fi
done
echo "🎉 Rust build complete!"

El script:

  1. Comprueba que Rust está instalado
  2. Instala los targets de Android si faltan
  3. Localiza el NDK de Android automáticamente
  4. Compila para las cuatro arquitecturas
  5. Copia los archivos .so a los directorios jniLibs correctos

Flujo de build completo

# 1. Install Rust and Android targets (one-time setup)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup target add aarch64-linux-android armv7-linux-androideabi \
                  x86_64-linux-android i686-linux-android

# 2. Build Rust libraries
./build-rust.sh

# 3. Build and run Android app
./gradlew installDebug

# 4. Run benchmarks (optional)
./run-benchmarks.sh

La estructura del APK resultante:

app-debug.apk
├── lib/
│   ├── arm64-v8a/
│   │   └── libfinancial_indicators.so    # 64-bit ARM (most devices)
│   ├── armeabi-v7a/
│   │   └── libfinancial_indicators.so    # 32-bit ARM (older devices)
│   ├── x86_64/
│   │   └── libfinancial_indicators.so    # Emulator
│   └── x86/
│       └── libfinancial_indicators.so    # Older emulator
└── classes.dex                            # Kotlin/Java bytecode

Android carga automáticamente el archivo .so correcto según la arquitectura del dispositivo.

Qué aprendí

En macOS, los tests unitarios no podían cargar el .so. El respaldo en Kotlin me dejó probar la lógica de los indicadores sin un dispositivo. Un .so corrupto o una arquitectura no soportada sigue el mismo camino en producción.

Las funciones de Rust y de Kotlin usan las mismas fórmulas. Los tests unitarios comprueban que calculateSMA(data, 20) coincide dentro de la precisión de coma flotante en los dos caminos.

get_float_array_region copia 10K elementos en 1–5 µs. El acceso elemento a elemento tardaba 100 µs o más.

El MACD ganó más, hasta 80 veces. La EMA ganó 1,5–2x. Esa diferencia marcó qué cálculos tocar después.

Escribí primero el binding de la SMA y lo copié para el resto de indicadores. El boilerplate de JNI es casi el mismo en cada cálculo sobre arrays.

Rust tiene que compilarse en macOS o Linux antes del build del APK. build-rust.sh hace eso, y el CI ejecuta el script para que los .so sigan al día.

Los bindings JNI devuelven arrays vacíos en lugar de hacer panic, así que una llamada fallida no tumba la app. Para depurar usé println! en Rust y leí la salida en logcat.

Cuándo usar este patrón

Usa el camino híbrido cuando el bucle procesa 1.000 elementos o más, la actualización no puede pausar por la recolección de basura, el trabajo está ligado a la CPU y la función es pura, así que se puede probar sin Android. Alguien del equipo tiene que escribir el Rust, o estar dispuesto a aprenderlo.

No hace falta para consultas a base de datos y llamadas de red, para la interfaz de Jetpack Compose, para una sola operación sobre un conjunto pequeño, y durante el prototipado temprano. Añade Rust después de demostrar el encaje producto-mercado.

El código, los benchmarks y los scripts de build están en github.com/davthecodercom/Financial-RustAndAndroid.


Mediciones de rendimiento en un Google Pixel 5 (ARM64, Android 14), con tests instrumentados de 100 iteraciones por benchmark.

Share:Compartir:

CommentsComentarios

Loading comments…Cargando comentarios…