Relative Vigor Index

The Relative Vigor Index (RVI) is a momentum oscillator that measures the conviction behind price movements by comparing the relationship between closing and opening prices to the trading range. It operates on the principle that in uptrends, prices tend to close higher than they open, while in downtrends, prices tend to close lower than they open. The indicator oscillates around a zero line, with positive values indicating bullish conviction and negative values suggesting bearish momentum.

What It Measures

The RVI quantifies market conviction by analyzing whether prices are closing near their highs or lows relative to their opening levels. It calculates the difference between close and open prices, normalizes it against the full trading range (high minus low), and then smooths the result using a moving average. This approach captures the directional energy and confidence behind price movements.

When to Use

The RVI is most effective in trending markets where strong directional conviction exists, helping traders identify momentum shifts before they become obvious in price action. It works well as a confirmation tool alongside trend-following strategies or as an early warning system for potential reversals. Traders often use RVI during periods of clear market sentiment when price conviction is a key factor in determining trade entries and exits.

Interpretation

RVI values above zero indicate bullish conviction (prices closing higher than opening), while values below zero suggest bearish sentiment (prices closing lower than opening). The primary trading signals occur when the RVI crosses above the zero line (buy signal) or below the zero line (sell signal). Divergences between RVI and price action can also indicate weakening trends—when price makes new highs but RVI fails to confirm, it may signal diminishing conviction and a potential reversal.

Default Usage

use rust_ti::strength_indicators::bulk::relative_vigor_index;
use rust_ti::ConstantModelType;

pub fn main() {
    // fetch the data in your preferred way
    // let close = vec![...];  // closing prices
    // let high = vec![...];   // high prices
    // let low = vec![...];    // low prices
    // let open = vec![...];  // open prices

    let rvi = relative_vigor_index(&open, &high, &low, &close, ConstantModelType::SimpleMovingAverage, 10);
    println!("{:?}", rvi);
}
import pytechnicalindicators as pti

# fetch the data in your preferred way
# close = [...]  # closing prices
# high = [...]   # high prices
# low = [...]    # low prices
# open = [...]  # open prices

rvi = pti.strength_indicators.bulk.relative_vigor_index(open, high, low, close, model="SimpleMovingAverage", period=10)
print(rvi)
// WASM import
import init, { strength_bulk_relativeVigorIndex, ConstantModelType} from 'https://cdn.jsdelivr.net/npm/ti-engine@latest/dist/web/ti_engine.js';

await init();

// fetch the data in your preferred way
// const close = [...];  // closing prices
// const high = [...];   // high prices
// const low = [...];    // low prices
// const open = [...];  // open prices

const rvi = strength_bulk_relativeVigorIndex(open, high, low, close, ConstantModelType["SimpleMovingAverage"], 10);
console.log(rvi);

Optimization

The best way to determine what the best parameters for your indicator are is to build a simple optimization loop that tests all possible parameter combinations between a defined min and max value, and rate the output.

Below is an example of how to do this in Rust.

use rust_ti::strength_indicators::bulk::relative_vigor_index;
use rust_ti::ConstantModelType;
use rust_ti::chart_trends::{peaks, valleys};

fn proximity_rating(fuzzed_location: &usize, price_location: &usize) -> f64 {
    1.0 / (*fuzzed_location as f64 - *price_location as f64).abs()
}


pub fn main() {

    // fetch the data in your preferred way
    let indicator_loop = Instant::now();

        // get buy and sell points, in an ideal world we would buy at the lowest point in the dip and sell at the highest point in the peak
        // In the course of a 20-day period (1 month of trading days), we want to find the highest peak and lowest valley within 5 days of each other
        let sell_points = peaks(&close, 20, 5)
            .into_iter()
            .map(|(_, i)| i)
            .collect::<Vec<usize>>();
        let buy_points = valleys(&close, 20, 5)
            .into_iter()
            .map(|(_, i)| i)
            .collect::<Vec<usize>>();

        // Define the ranges for optimization
        let max_period = 20;
        let min_period = 4; // RVI requires minimum 4 periods

        let fuzz_parameter = 5; // Allowable distance from buy/sell points

        // Store the best parameters found
        let mut best_rating = 0.0;
        let mut best_period = 0;
        let mut best_model = ConstantModelType::SimpleMovingAverage;
        let mut best_indicators = vec![];

        let models = vec![
            ConstantModelType::SimpleMovingAverage,
            ConstantModelType::ExponentialMovingAverage,
            ConstantModelType::SmoothedMovingAverage,
            ConstantModelType::SimpleMovingMedian,
            ConstantModelType::SimpleMovingMode,
        ];

        let total_iterations = (max_period - min_period + 1) * models.len();
        let mut iteration_count = 0;
        println!(
            "
Running optimization loop with {} total iterations...",
            total_iterations
        );

        for &ma_type in &models {
            for period in min_period..=max_period {
                iteration_count += 1;
                if iteration_count % (total_iterations / 20).max(1) == 0 {
                    let next_log_percent = (iteration_count * 100) / total_iterations;
                    println!("Optimization is {}% complete...", next_log_percent);
                }

                let indicators = relative_vigor_index(&open, &high, &low, &close, ma_type, period);
                let mut rating = vec![];
                let mut matched_sell = vec![];
                let mut matched_buy = vec![];

                for i in 1..indicators.len() {
                    let price_location = i + period; // Adjust for indicator lag
                    if price_location >= close.len() {
                        break;
                    }

                    let rvi_current = indicators[i];
                    let rvi_previous = indicators[i - 1];

                    // RVI signals: crossover of zero line
                    // Buy signal: RVI crosses above zero (positive momentum/conviction)
                    if rvi_current > 0.0 && rvi_previous <= 0.0 {
                        if buy_points.contains(&price_location) {
                            rating.push(1.0);
                            matched_buy.push(price_location);
                        } else if sell_points.contains(&price_location) {
                            rating.push(-1.0);
                        } else {
                            let mut found_buy = false;
                            for fuzzed_location in (price_location.saturating_sub(fuzz_parameter))
                                ..=(price_location + fuzz_parameter)
                            {
                                if buy_points.contains(&fuzzed_location) {
                                    rating.push(proximity_rating(&fuzzed_location, &price_location));
                                    matched_buy.push(fuzzed_location);
                                    found_buy = true;
                                }
                                if sell_points.contains(&fuzzed_location) {
                                    if !matched_buy.contains(&fuzzed_location) {
                                        rating.push(-proximity_rating(&fuzzed_location, &price_location));
                                    }
                                }
                            }
                            if !found_buy {
                                rating.push(0.0);
                            }
                        }
                    }
                    // Sell signal: RVI crosses below zero (negative momentum/conviction)
                    else if rvi_current < 0.0 && rvi_previous >= 0.0 {
                        if sell_points.contains(&price_location) {
                            rating.push(1.0);
                            matched_sell.push(price_location);
                        } else if buy_points.contains(&price_location) {
                            rating.push(-1.0);
                        } else {
                            let mut found_sell = false;
                            for fuzzed_location in (price_location.saturating_sub(fuzz_parameter))
                                ..=(price_location + fuzz_parameter)
                            {
                                if sell_points.contains(&fuzzed_location) {
                                    rating.push(proximity_rating(&fuzzed_location, &price_location));
                                    matched_sell.push(fuzzed_location);
                                    found_sell = true;
                                }
                                if buy_points.contains(&fuzzed_location) {
                                    if !matched_sell.contains(&fuzzed_location) {
                                        rating.push(-proximity_rating(&fuzzed_location, &price_location));
                                    }
                                }
                            }
                            if !found_sell {
                                rating.push(0.0);
                            }
                        }
                    }
                }

                // Look for any missed buy/sell points and penalize
                for missed_sell in sell_points.iter() {
                    if !matched_sell.contains(missed_sell) {
                        rating.push(-1.0);
                    }
                }
                for missed_buy in buy_points.iter() {
                    if !matched_buy.contains(missed_buy) {
                        rating.push(-1.0);
                    }
                }

                if !rating.is_empty() {
                    let total_rating: f64 = rating.iter().sum::<f64>() / (rating.len() as f64);
                    if total_rating > best_rating {
                        best_rating = total_rating;
                        best_period = period;
                        best_model = ma_type;
                        best_indicators = indicators.clone();
                    }
                }
            }
        }

        println!(
            "Indicators optimization loop took {} ms to run",
            indicator_loop.elapsed().as_millis()
        );

        println!("
Best Relative Vigor Index parameters found:");
        println!("period = {}", best_period);
        println!("model = {:?}", best_model);
        println!("Rating: {}", best_rating);
        println!("Best Indicator values: {:?}", best_indicators);

}

Optimization Output

Below is an example output from the optimization code above run on a year of S&P data.

Best Relative Vigor Index parameters found:
period = 4
model = SimpleMovingAverage
Rating: 0.026502732240437168
Best Indicator values: [-0.03682707097342103, 0.4282213600163352, 0.5730887325585908, ...]

Interactive Chart

To better illustrate how the indicator performs with different parameters, an interactive chart is provided below comparing default parameters (blue) with optimized parameters (green).

Analysis

The optimized RVI strategy (period=4) significantly outperformed the default configuration (period=10), achieving a total profit of $2.22 versus a loss of $-1.14. The shorter period allowed the optimized strategy to capture more trading opportunities with 47 trades compared to 23 trades for the default settings, resulting in more frequent but smaller position adjustments that better matched the market's momentum shifts. While both strategies ended with open short positions, the optimized parameters demonstrated superior timing and conviction detection, turning price momentum crossovers into profitable entries and exits.

Optimized trading simulation

Initial Investment
$1000.00
Final Capital
$1002.22
Total P&L
$2.22
Open Position
  • SideSHORT
  • Shares0.0361
  • Entry$5572.07
  • Value$5638.94

Default trading simulation

Initial Investment
$1000.00
Final Capital
$998.86
Total P&L
$-1.14
Open Position
  • SideSHORT
  • Shares0.0332
  • Entry$5956.06
  • Value$5638.94

Trading simulation code

For those you want to run their own simulation to compare results

use rust_ti::strength_indicators::bulk::relative_vigor_index;
use rust_ti::ConstantModelType;

fn simulate_trading(best_indicator: &[f64], best_period: usize, close: &[f64]) {
    println!("
--- Trading Simulation ---");

    let initial_capital = 1000.0;
    let mut capital = initial_capital;
    let investment_pct = 0.20;

    struct Position {
        entry_price: f64,
        shares: f64,
    }

    let mut open_long: Option<Position> = None;
    let mut open_short: Option<Position> = None;

    // Print table header
    println!(
        "{:<5} | {:<19} | {:<10} | {:<10} | {:<12} | {:<15} | {:<10}",
        "Day", "Event", "RVI", "Price", "Shares", "Capital", "P/L"
    );
    println!("{}", "-".repeat(95));

    for i in 1..best_indicator.len() {
        let price_index = i + best_period;
        if price_index >= close.len() {
            break;
        }

        let rvi_current = best_indicator[i];
        let rvi_previous = best_indicator[i - 1];
        let current_price = close[price_index];
        let day = price_index;

        // --- Handle Long Position ---
        if let Some(long_pos) = open_long.take() {
            // Sell signal: RVI crosses below zero
            if rvi_current < 0.0 && rvi_previous >= 0.0 {
                let sale_value = long_pos.shares * current_price;
                let profit = sale_value - (long_pos.shares * long_pos.entry_price);
                capital += sale_value;
                println!(
                    "{:<5} | {:<19} | {:<10.4} | ${:<9.2} | {:<12.4} | ${:<14.2} | ${:<9.2}",
                    day,
                    "Sell (Close Long)",
                    rvi_current,
                    current_price,
                    long_pos.shares,
                    capital,
                    profit
                );
            } else {
                open_long = Some(long_pos); // Put it back if not selling
            }
        } else if rvi_current > 0.0 && rvi_previous <= 0.0 && open_short.is_none() {
            // Buy signal: RVI crosses above zero
            let investment = capital * investment_pct;
            let shares_bought = investment / current_price;
            open_long = Some(Position {
                entry_price: current_price,
                shares: shares_bought,
            });
            capital -= investment;
            println!(
                "{:<5} | {:<19} | {:<10.4} | ${:<9.2} | {:<12.4} | ${:<14.2} | {}",
                day,
                "Buy (Open Long)",
                rvi_current,
                current_price,
                shares_bought,
                capital,
                "-"
            );
        }

        // --- Handle Short Position ---
        if let Some(short_pos) = open_short.take() {
            // Cover signal: RVI crosses above zero
            if rvi_current > 0.0 && rvi_previous <= 0.0 {
                let cost_to_cover = short_pos.shares * current_price;
                let profit = (short_pos.shares * short_pos.entry_price) - cost_to_cover;
                capital += profit;
                println!(
                    "{:<5} | {:<19} | {:<10.4} | ${:<9.2} | {:<12.4} | ${:<14.2} | ${:<9.2}",
                    day,
                    "Cover (Close Short)",
                    rvi_current,
                    current_price,
                    short_pos.shares,
                    capital,
                    profit
                );
            } else {
                open_short = Some(short_pos); // Put it back if not covering
            }
        } else if rvi_current < 0.0 && rvi_previous >= 0.0 && open_long.is_none() {
            // Short signal: RVI crosses below zero
            let short_value = capital * investment_pct;
            let shares_shorted = short_value / current_price;
            open_short = Some(Position {
                entry_price: current_price,
                shares: shares_shorted,
            });
            println!(
                "{:<5} | {:<19} | {:<10.4} | ${:<9.2} | {:<12.4} | ${:<14.2} | {}",
                day,
                "Short (Open Short)",
                rvi_current,
                current_price,
                shares_shorted,
                capital,
                "-"
            );
        }
    }

    println!("
--- Final Results ---");
    if let Some(pos) = open_long {
        println!("Simulation ended with an OPEN LONG position:");
        println!("  - Shares: {:.4}", pos.shares);
        println!("  - Entry Price: ${:.2}", pos.entry_price);
        let last_price = close.last().unwrap_or(&0.0);
        let current_value = pos.shares * last_price;
        capital += current_value;
        println!(
            "  - Position value at last price (${:.2}): ${:.2}",
            last_price, current_value
        );
    }
    if let Some(pos) = open_short {
        println!("Simulation ended with an OPEN SHORT position:");
        println!("  - Shares: {:.4}", pos.shares);
        println!("  - Entry Price: ${:.2}", pos.entry_price);
        let last_price = close.last().unwrap_or(&0.0);
        let cost_to_cover = pos.shares * last_price;
        let pnl = (pos.shares * pos.entry_price) - cost_to_cover;
        capital += pnl;
        println!(
            "  - Unrealized P/L at last price (${:.2}): ${:.2}",
            last_price, pnl
        );
    }

    let final_pnl = capital - initial_capital;
    println!("
Initial Capital: ${:.2}", initial_capital);
    println!("Final Capital:   ${:.2}", capital);
    println!("Total P/L:       ${:.2}", final_pnl);
}

fn main() {
    // Fetch data and perform optimization as shown in the optimization code above
    simulate_trading(&best_indicators, best_period, &close);

    // Compare with default parameters (typically period=10, SimpleMovingAverage)
    println!("

Default Indicator values for comparison:");
    let default_rvi = relative_vigor_index(
        &open,
        &high,
        &low,
        &close,
        ConstantModelType::SimpleMovingAverage,
        10,
    );
    println!("{:?}", default_rvi);
    simulate_trading(&default_rvi, 10, &close);
}