Chande Momentum Oscillator
Captures price momentum by comparing recent gains to recent losses over a specific period.
About the Chande Momentum Oscillator
The Chande Momentum Oscillator (CMO) is a technical momentum indicator developed by Tushar Chande. It measures the difference between the sum of gains and losses over a specified period, providing a momentum reading that oscillates between -100 and +100.
Unlike other momentum oscillators like RSI, the CMO uses raw momentum data without smoothing, which can make it more responsive to short-term price movements. The indicator is particularly useful for identifying overbought and oversold conditions, as well as potential trend reversals.
What It Measures
The Chande Momentum Oscillator measures the net momentum of price movements over a specified period. It calculates the difference between the sum of upward price movements and downward price movements, divided by the total of all price movements. This gives a normalized value that ranges from -100 to +100:
- Positive values indicate upward momentum (more buying pressure)
- Negative values indicate downward momentum (more selling pressure)
- Extreme values (near +100 or -100) suggest strong momentum in that direction
When to Use
The CMO is most effective in the following scenarios:
- Overbought/Oversold Conditions: Values above +50 typically indicate overbought conditions, while values below -50 suggest oversold conditions. These thresholds can be adjusted based on the asset's volatility.
- Divergence Analysis: When price makes new highs but CMO fails to confirm (bearish divergence), or price makes new lows but CMO doesn't (bullish divergence), it may signal a potential reversal.
- Trend Confirmation: Strong positive CMO values confirm uptrends, while strong negative values confirm downtrends.
- Range-Bound Markets: The oscillator works particularly well in sideways markets where it can identify turning points.
Interpretation
- Overbought Zone: CMO values above +50 (default) or higher thresholds suggest the asset may be overbought and due for a pullback.
- Oversold Zone: CMO values below -50 (default) or lower thresholds suggest the asset may be oversold and due for a bounce.
- Zero Line: The CMO crossing above or below zero can signal changes in momentum direction.
- Extreme Readings: Values near +100 or -100 indicate very strong momentum, which may precede a reversal or continuation depending on context.
- Momentum Divergence: Watch for divergences between price and CMO as potential reversal signals.
The optimized parameters found through testing (period=9, oversold=-36, overbought=44) show that shorter periods with tighter thresholds can improve trading performance, though traders should adjust these based on their specific market conditions and risk tolerance.
Example Usage
use centaur_technical_indicators::momentum_indicators::bulk::chande_momentum_oscillator;
pub fn main() {
// fetch the data in your preferred way
// let close = vec![...]; // closing prices
let chande_momentum = chande_momentum_oscillator(&close, 20);
println!("{:?}", chande_momentum);
}
import centaur_technical_indicators as cti
# fetch the data in your preferred way
# close = [...] # closing prices
chande_momentum = cti.momentum_indicators.bulk.chande_momentum_oscillator(close, period=20)
print(chande_momentum)
// WASM import
import init, { momentum_bulk_chandeMomentumOscillator } from 'https://cdn.jsdelivr.net/npm/centaur-technical-indicators@latest/dist/web/centaur-technical-indicators.js';
await init();
// fetch the data in your preferred way
// const close = [...]; // closing prices
const chandeMomentumSeries = momentum_bulk_chandeMomentumOscillator(close, 20);
console.log(chandeMomentumSeries);
Optimization
Even if the defaults have stood the test of time, tuning them to your specific asset and timeframe gives you signals that fit your market. Below shows you how to achieve this.
Optimization Code
use centaur_technical_indicators::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 = 40;
let min_period = 2;
let min_oversold = 0;
let max_oversold = 100;
let min_overbought = 0;
let max_overbought = 100;
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_oversold = 0;
let mut best_overbought = 0;
let mut best_indicators = vec![];
for oversold in min_oversold..=max_oversold {
let oversold = oversold as f64 * -1.0;
for overbought in min_overbought..=max_overbought {
for period in min_period..=max_period {
let indicators = chande_momentum_oscillator(&close, 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;
if indicators[i] > overbought as f64 {
if sell_points.contains(&price_location) {
// If sell point == CMO, rate positively
rating.push(1.0);
matched_sell.push(price_location);
} else if buy_points.contains(&price_location) {
// If buy point == CMO, 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 indicators[i] < oversold as f64 {
if buy_points.contains(&price_location) {
// If buy point == CMO, 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_oversold = (oversold * -1.0) as usize;
best_overbought = overbought;
best_indicators = indicators.clone();
}
}
}
}
println!(
"Indicators optimization loop took {} ms to run",
indicator_loop.elapsed().as_millis()
);
println!("
Best Chande Momentum parameters found:");
println!("Period: {}", best_period);
println!("Oversold threshold: {}", best_oversold);
println!("Overbought threshold: {}", best_overbought);
println!("Rating: {}", best_rating);
println!("Best Chande Momentum values: {:?}", best_indicators);
Optimization Output
Example output from running the optimization code above on a year of S&P data.
Best Chande Momentum parameters found:
Period: 9
Oversold threshold: -36
Overbought threshold: 44
Rating: 0.4182336182336182
Best Chande Momentum values: [27.12228011032827, 63.386396526772835, 58.026876071448505, ...]
Trading Simulation
In order to determine whether the optimized parameters beat the defaults, a trading simulation was run, below are the results.
Optimized Trading Simulation
- SideLONG
- Shares0.0352
- Entry$5954.50
- Value$198.68
Default Trading Simulation
Analysis
The optimized Chande Momentum Oscillator has considerably more buy/sell signals compared to the default parameters. To determine whether these additional signals are beneficial, a trading simulation was conducted using both the optimized and default values.
Both strategies started with an initial capital of $1000 and invested 20% of the remaining capital on each trade. Each trade was executed when the CMO crossed the overbought or oversold thresholds, with long positions opened when the CMO dipped below the oversold level and closed when it rose above the overbought level, and short positions opened when the CMO rose above the overbought level and closed when it fell below the oversold level.
The results are shown in the tables below. The optimized CMO strategy yielded a profit of $37.89, with a $198.68 open position. This outperformed the default CMO strategy which resulted in a loss of $12.66.
Trading Simulation Code
For those who want to run their own simulation to compare results.
fn simulate_trading(best_indicator: &[f64], best_period: usize, close: &[f64], best_oversold: usize, best_overbought: usize) {
// --- NEW TRADING SIMULATION CODE ---
println!("
--- Trading Simulation ---");
let best_oversold = best_oversold as f64 * -1.0;
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", "CMO", "Price", "Shares", "Capital", "P/L");
println!("{}", "-".repeat(95));
for i in 0..best_indicator.len() {
let price_index = i + best_period;
if price_index >= close.len() { break; }
let cmo_val = best_indicator[i];
let current_price = close[price_index];
let day = price_index;
// --- Handle Long Position ---
if let Some(long_pos) = open_long.take() {
if cmo_val > best_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)", cmo_val, current_price, long_pos.shares, capital, profit);
} else {
open_long = Some(long_pos); // Put it back if not selling
}
} else if cmo_val < best_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)", cmo_val, current_price, shares_bought, capital, "-");
}
// --- Handle Short Position ---
if let Some(short_pos) = open_short.take() {
if cmo_val < best_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)", cmo_val, current_price, short_pos.shares, capital, profit);
} else {
open_short = Some(short_pos); // Put it back if not covering
}
} else if cmo_val > best_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)", cmo_val, 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, best_oversold, best_overbought);
println!("
Default Indicator values for comparison:");
let default_wr = chande_momentum_oscillator(&close, 20);
println!("{:?}", default_wr);
simulate_trading(&default_wr, 20, &close, 50, 50);
}