Moving Constant Envelopes

Moving Constant Envelopes

Moving Constant Envelopes are a technical indicator that creates price bands around a moving average by adding and subtracting a constant value. Unlike percentage-based envelopes, this indicator uses an absolute constant difference to establish upper and lower boundaries, making it particularly useful for assets with stable price ranges or when analyzing specific price levels.

What It Measures

Moving Constant Envelopes measure price deviations from a central moving average by establishing fixed-width bands above and below it. The indicator consists of three lines: an upper envelope (moving average + difference), a middle line (the moving average itself), and a lower envelope (moving average - difference). These envelopes help identify when prices are trading at extremes relative to their recent average, potentially signaling overbought or oversold conditions.

When to Use

This indicator is most effective in range-bound markets or when analyzing assets with relatively stable price levels. It works well for identifying potential reversal points when prices reach the envelope boundaries, and can be used to generate buy signals when prices touch the lower envelope and sell signals when prices touch the upper envelope. The constant difference approach is particularly valuable when you want consistent band widths regardless of price percentage movements.

Interpretation

Traders interpret the indicator by monitoring price interactions with the envelope bands. When price touches or breaks below the lower envelope, it may indicate an oversold condition and a potential buying opportunity. Conversely, when price touches or breaks above the upper envelope, it may indicate an overbought condition and a potential selling opportunity. The width of the envelopes (controlled by the difference parameter) determines the sensitivity—larger differences create wider bands that generate fewer but potentially more reliable signals, while smaller differences create tighter bands that generate more frequent signals but may include more false positives.

Default Usage

use rust_ti::candle_indicators::bulk::moving_constant_envelopes;
use rust_ti::ConstantModelType::SimpleMovingAverage;

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

    let moving_constant_envelopes = moving_constant_envelopes(&close, SimpleMovingAverage, 2.0, 10);
    println!("{:?}", moving_constant_envelopes);
}
import pytechnicalindicators as pti

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

moving_constant_envelopes = pti.candle_indicators.bulk.moving_constant_envelopes(close, model="SimpleMovingAverage", difference=2.0, period=10)
print(moving_constant_envelopes)
// WASM import
import init, { candle_bulk_movingConstantEnvelopes, 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 moving_constant_envelopes = candle_bulk_movingConstantEnvelopes(close, ConstantModelType["SimpleMovingAverage"], 2.0, 10);
console.log(moving_constant_envelopes);

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::chart_trends::{peaks, valleys};
use rust_ti::candle_indicators::bulk::moving_constant_envelopes;
use rust_ti::ConstantModelType;
use std::time::Instant;

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 = 126;
        let min_period = 2;

        let min_difference = 0;
        let max_difference = 1000;

        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_difference = 0.0;
        let mut best_model = ConstantModelType::SimpleMovingAverage;
        let mut best_indicators = vec![];

        let total_count = (max_period - min_period) * (max_difference - min_difference) * 5;
        let mut iteration_count = 0;
        println!("
Running optimization loop with {} total iterations...", total_count);

        for &ma_type in &[ConstantModelType::SimpleMovingAverage, ConstantModelType::ExponentialMovingAverage, ConstantModelType::SmoothedMovingAverage, ConstantModelType::SimpleMovingMedian, ConstantModelType::SimpleMovingMode] {
            for difference in min_difference..=max_difference {
                let difference = difference as f64 / 10.0;
                for period in min_period..=max_period {
                    iteration_count += 1;
                    if iteration_count % (total_count / 20) == 0 {
                        let next_log_percent = (iteration_count * 100) / total_count;
                        println!("Optimization is {}% complete...", next_log_percent);
                    }
                    let indicators = moving_constant_envelopes(&close, ma_type, difference, period);
                    let mut rating = vec![];
                    let mut matched_sell = vec![];
                    let mut matched_buy = vec![];
                    for i in 0..indicators.len() {
                        let price_location = i + period + 1; // Adjust for indicator lag
                        if i >= price_location { break; }
                        if price_location >= close.len() { break; }
                        let oversold = indicators[i].0;
                        let overbought = indicators[i].2; // Upper envelope
                        if close[price_location] > overbought {
                            if sell_points.contains(&price_location) {
                                // If sell point == rsi, rate positively
                                rating.push(1.0);
                                matched_sell.push(price_location);
                            } else if buy_points.contains(&price_location) {
                                // If buy point == rsi, rate negatively
                                rating.push(-1.0);
                            } else {
                                let mut found_sell = false;
                                for fuzzed_location in (price_location - fuzz_parameter)..=(price_location + fuzz_parameter) {
                                    // It's ok if we count multiple times for fuzzed locations as we reduce the rating
                                    // based off of distance from the actual sell point which will impact the final rating
                                    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) {
                                        // Note the `-` here to penalize for selling instead of buying
                                        if !matched_sell.contains(&fuzzed_location) {
                                            rating.push(-proximity_rating(&fuzzed_location, &price_location));
                                        }
                                    }
                                }
                                if !found_sell {
                                    rating.push(0.0);
                                }
                            }
                        } else if close[price_location] < oversold {
                            if buy_points.contains(&price_location) {
                                // If buy point == envelope signal, rate positively
                                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 - fuzz_parameter)..=(price_location + fuzz_parameter) {
                                    // It's ok if we count multiple times for fuzzed locations as we reduce the rating
                                    // based off of distance from the actual sell point which will impact the final rating
                                    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) {
                                        // Note the `-` here to penalize for buying instead of selling
                                        if !matched_buy.contains(&fuzzed_location) {
                                            rating.push(-proximity_rating(&fuzzed_location, &price_location));
                                        }
                                    }
                                }
                                if !found_buy {
                                    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);
                        }
                    }
                    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_difference = difference;
                        best_indicators = indicators.clone();
                    }
                }
            }
        }

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

        println!("
Best Indicator parameters found:");
        println!("period = {}", best_period);
        println!("model = {:?}", best_model);
        println!("difference = {}", best_difference);
        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 Indicator parameters found:
best_period = 23
model = SimpleMovingMedian
difference = 2.3
Rating: 0.356842105263158
Best Indicator values: [(5083.89766, 5203.58, 5323.262339999999), (5083.89766, 5203.58, 5323.262339999999), (5083.89766, 5203.58, 5323.262339999999), ...]

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 Moving Constant Envelopes strategy (23-period Simple Moving Median with 2.3 difference) significantly outperformed the default configuration (10-period Simple Moving Average with 2.0 difference) in a backtested trading simulation. Starting with $1,000 and investing 20% per trade, the optimized parameters generated a profit of $20.70 (2.07% gain) with 15 trades executed, while the default parameters resulted in a loss of $26.03 (-2.60% loss) with only 3 trades. The optimized strategy's use of the median instead of mean reduces sensitivity to price outliers, and the wider envelope bands (2.3 vs 2.0) with a longer period (23 vs 10) provided more reliable signals that better captured meaningful trend reversals rather than minor price fluctuations.

Optimized trading simulation

Initial Investment
$1000.00
Final Capital
$1020.70
Total P&L
$20.70
Open Position
  • SideLONG
  • Shares0.03515072466512056
  • Entry$5849.72
  • Value$198.21

Default trading simulation

Initial Investment
$1000.00
Final Capital
$973.97
Total P&L
$-26.03
Open Position
  • SideLONG
  • Shares0.0335414626154579
  • Entry$5849.72
  • Value$189.14

Trading simulation code

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

use rust_ti::chart_trends::{peaks, valleys};
use rust_ti::candle_indicators::bulk::moving_constant_envelopes;
use rust_ti::ConstantModelType::SimpleMovingAverage;

fn chart_simulate_trading(best_indicator: &[(f64, f64, 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", "Indicator", "Price", "Shares", "Capital", "P/L");
    println!("{}", "-".repeat(95));

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

        let indicator_overbought = best_indicator[i].2;
        let indicator_oversold = best_indicator[i].0;
        let current_price = close[price_index];
        let day = price_index;

        // --- Handle Long Position ---
        if let Some(long_pos) = open_long.take() {
            if current_price > indicator_overbought as f64 {
                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.2} | ${:<9.2} | {:<12.4} | ${:<14.2} | ${:<9.2}",
                         day, "Sell (Close Long)", indicator_overbought, current_price, long_pos.shares, capital, profit);
            } else {
                open_long = Some(long_pos); // Put it back if not selling
            }
        } else if current_price < indicator_oversold as f64 && open_short.is_none() { // Don't buy if short is open
            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.2} | ${:<9.2} | {:<12.4} | ${:<14.2} | {}",
                     day, "Buy (Open Long)", indicator_oversold, current_price, shares_bought, capital, "-");
        }

        // --- Handle Short Position ---
        if let Some(short_pos) = open_short.take() {
            if current_price < indicator_oversold as f64 {
                let cost_to_cover = short_pos.shares * current_price;
                let profit = (short_pos.shares * short_pos.entry_price) - cost_to_cover;
                capital += profit; // Add profit to capital
                println!("{:<5} | {:<19} | {:<10.2} | ${:<9.2} | {:<12.4} | ${:<14.2} | ${:<9.2}",
                         day, "Cover (Close Short)", indicator_oversold, current_price, short_pos.shares, capital, profit);
            } else {
                open_short = Some(short_pos); // Put it back if not covering
            }
        } else if current_price > indicator_overbought as f64 && open_long.is_none() { // Don't short if long is open
            let short_value = capital * investment_pct;
            let shares_shorted = short_value / current_price;
            open_short = Some(Position { entry_price: current_price, shares: shares_shorted });
            // Capital doesn't change when opening a short, it's held as collateral
            println!("{:<5} | {:<19} | {:<10.2} | ${:<9.2} | {:<12.4} | ${:<14.2} | {}",
                     day, "Short (Open Short)", indicator_overbought, 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);
        println!("{{ position = "LONG", shares = {}, entry_price = "${:.2}", position_value_at_last_price = "${:.2}" }}", pos.shares, pos.entry_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);
        println!("{{ position = "SHORT", shares = {}, entry_price = "${:.2}", position_value_at_last_price = "${:.2}" }}", pos.shares, pos.entry_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

    chart_simulate_trading(&best_indicators, best_period, &close);

    println!("
Default Indicator values for comparison:");
    let default_dc = moving_constant_envelopes(&close, ConstantModelType::SimpleMovingAverage, 2.0, 10);
    println!("{:?}", default_dc);
    chart_simulate_trading(&default_dc, 10, &close);
}