Percentage Price Oscillator (PPO)
The Percentage Price Oscillator (PPO) is a momentum indicator that shows the relationship between two exponential moving averages as a percentage. It is closely related to the MACD (Moving Average Convergence Divergence) but expresses the difference as a percentage rather than an absolute value, making it easier to compare across different price levels and securities.
The PPO is calculated by subtracting the longer-period EMA from the shorter-period EMA and then dividing the result by the longer-period EMA. This normalization makes the indicator more suitable for comparing momentum across different assets or time periods.
What It Measures
The PPO measures the momentum and trend strength of a security by analyzing the relationship between two moving averages. By expressing this relationship as a percentage, it provides a normalized view of price momentum that can be compared across different securities regardless of their absolute price levels.
When to Use
- Identify Trend Strength: The PPO helps identify the strength of a trend. When the PPO is positive and rising, it indicates strengthening upward momentum. When negative and falling, it signals strengthening downward momentum.
- Generate Trading Signals: Crossovers of the zero line can signal potential trend changes. A move above zero suggests bullish momentum, while a drop below zero indicates bearish momentum.
- Compare Multiple Securities: Unlike absolute price indicators, the PPO's percentage-based calculation makes it ideal for comparing momentum across different stocks or assets.
- Divergence Analysis: PPO divergences from price can signal potential reversals. If price makes new highs but the PPO doesn't, it may indicate weakening momentum and a potential reversal.
Interpretation
- Positive values: Indicate that the short-term average is above the long-term average, suggesting upward momentum
- Negative values: Indicate that the short-term average is below the long-term average, suggesting downward momentum
- Zero line crossovers: Moving from negative to positive can signal a bullish trend change, while moving from positive to negative can signal a bearish trend change
- Extreme values: Very high positive values may indicate overbought conditions, while very low negative values may indicate oversold conditions
- Divergences: When price and PPO move in opposite directions, it can signal potential trend reversals
Default Usage
use rust_ti::momentum_indicators::bulk::percentage_price_oscillator;
use rust_ti::ConstantModelType::ExponentialMovingAverage;
pub fn main() {
// fetch the data in your preferred way
// let close = vec![...]; // closing prices
let ppo = percentage_price_oscillator(&close, 9, 26, ExponentialMovingAverage);
println!("{:?}", ppo);
}
import pytechnicalindicators as pti
# fetch the data in your preferred way
# close = [...] # closing prices
ppo = pti.momentum_indicators.bulk.percentage_price_oscillator(close, short_period=9, long_period=26, model="ExponentialMovingAverage")
print(ppo)
// WASM import
import init, { momentum_bulk_percentagePriceOscillator, 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 ppo = momentum_bulk_percentagePriceOscillator(close, 9, 26, ConstantModelType["ExponentialMovingAverage"]);
console.log(ppo);
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::momentum_indicators::bulk::percentage_price_oscillator;
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_long_period = 40;
let min_long_period = 2;
let max_short_period = 40;
let min_short_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
let min_constant_multiplier = 0;
let max_constant_multiplier = 1000;
// Store the best parameters found
let mut best_rating = 0.0;
let mut best_short_period = 0;
let mut best_long_period = 0;
let mut best_model = ConstantModelType::SimpleMovingAverage;
let mut best_oversold = 0;
let mut best_overbought = 0;
let mut best_indicators = vec![];
let total_count = (max_long_period - min_long_period) * (max_short_period - min_short_period) * (max_oversold - min_oversold) * (max_overbought - min_overbought) * 3 * 7;
let mut iteration_count = 0;
let mut next_log_percent = 5;
println!("
Running optimization loop with {} total iterations...", total_count);
for oversold in min_oversold..=max_oversold {
let oversold = (oversold as f64 * -1.0) / 100.0;
for overbought in min_overbought..=max_overbought {
let overbought = overbought as f64 / 100.0;
for &ma_type in &[ConstantModelType::SimpleMovingAverage, ConstantModelType::ExponentialMovingAverage, ConstantModelType::SmoothedMovingAverage] {
for long_period in min_long_period..=max_long_period {
for short_period in min_short_period..=max_short_period {
if short_period >= long_period {
continue;
}
iteration_count += 1;
let percent_complete = (iteration_count * 100 / total_count) as u64;
if percent_complete >= next_log_percent {
println!("Optimization is {}% complete...", next_log_percent);
next_log_percent += 5;
}
let indicators = percentage_price_oscillator(&close, short_period, long_period, ma_type);
let mut rating = vec![];
let mut matched_sell = vec![];
let mut matched_buy = vec![];
for i in 0..indicators.len() {
let price_location = i + long_period;
if indicators[i] > overbought as f64 {
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 indicators[i] < oversold as f64 {
if buy_points.contains(&price_location) {
// If buy point == rsi, 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_long_period = long_period;
best_short_period = short_period;
// best_constant_multiplier = multiplier;
best_model = ma_type;
// best_deviation_model = deviation_model;
best_oversold = (oversold * -100.0) as usize;
best_overbought = (overbought * 100.0) as usize;
best_indicators = indicators.clone();
}
}
}
}
}
}
println!(
"Indicators optimization loop took {} ms to run",
indicator_loop.elapsed().as_millis()
);
println!("
Best Indicator parameters found:");
println!("long_period = {}", best_long_period);
println!("short_period = {}", best_short_period);
println!("model = {:?}", best_model);
println!("oversold = {}", best_oversold as f64 * -1.0);
println!("overbought = {}", best_overbought);
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:
long_period = 9
short_period = 5
model = SimpleMovingAverage
oversold = -14
overbought = 43
Rating: 0.3730701754385966
Best Indicator values: [0.646822610750545, 0.5278922991711282, 0.2831605394941842, ...]
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 PPO parameters demonstrate significantly improved trading performance compared to the default settings. A comprehensive trading simulation was conducted to evaluate both parameter sets. Both strategies started with an initial capital of $1000 and invested 20% of the remaining capital on each trade.
Long positions were opened when the PPO fell below the oversold level and closed when it rose above the overbought level. Short positions were opened when the PPO rose above the overbought level and closed when it fell below the oversold level.
The results are shown in the tables below. The optimized PPO strategy yielded a profit of $5.51, with a $194.94 open position. This substantially outperformed the default PPO strategy which resulted in a loss of $97.14 (with a $168.50 open position). The optimized parameters provided more reliable trading signals and better timing for entries and exits.
Optimized trading simulation
- SideLONG
- Shares0.0346
- Entry$5861.57
- Value$194.94
Default trading simulation
- SideLONG
- Shares0.0299
- Entry$6144.15
- Value$168.50
Trading simulation code
For those you want to run their own simulation to compare results
use rust_ti::momentum_indicators::bulk::percentage_price_oscillator;
use rust_ti::ConstantModelType;
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 / 100.0) * -1.0;
let best_overbought = best_overbought as f64 / 100.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", "Indicator", "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 ppo_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 ppo_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)", ppo_val, current_price, long_pos.shares, capital, profit);
} else {
open_long = Some(long_pos); // Put it back if not selling
}
} else if ppo_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)", ppo_val, current_price, shares_bought, capital, "-");
}
// --- Handle Short Position ---
if let Some(short_pos) = open_short.take() {
if ppo_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)", ppo_val, current_price, short_pos.shares, capital, profit);
} else {
open_short = Some(short_pos); // Put it back if not covering
}
} else if ppo_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)", ppo_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_long_period, &close, best_oversold, best_overbought);
println!("
Default Indicator values for comparison:");
let default_wr = percentage_price_oscillator(&close, 9, 26, ConstantModelType::ExponentialMovingAverage);
println!("{:?}", default_wr);
simulate_trading(&default_wr, 20, &close, 10, 10);
}