Skip to content

Percentage Price Oscillator (PPO)

The Percentage Price Oscillator (PPO) measures the percentage difference between two exponential moving averages. Unlike the Absolute Price Oscillator (APO), which shows the absolute point difference, PPO expresses the difference as a percentage of the slower moving average. This percentage-based approach makes PPO values comparable across different securities and price levels, allowing for standardized analysis regardless of whether a stock trades at $10 or $1,000.

Usage

require 'sqa/tai'

prices = [44.34, 44.09, 44.15, 43.61, 44.33, 44.83,
          45.10, 45.42, 45.84, 46.08, 46.03, 46.41,
          46.22, 45.64, 46.21, 46.25, 46.08, 46.46,
          46.70, 47.00, 47.25, 47.50, 47.35, 47.75]

# Calculate PPO with default periods (12, 26)
ppo = SQA::TAI.ppo(prices, fast_period: 12, slow_period: 26)

puts "Current PPO: #{ppo.last.round(4)}%"

Parameters

Parameter Type Required Default Description
prices Array Yes - Array of price values
fast_period Integer No 12 Period for fast EMA
slow_period Integer No 26 Period for slow EMA
ma_type Integer No 0 Moving average type (0=SMA, 1=EMA, etc.)

Returns

Returns an array of PPO values expressed as percentages. The first slow_period - 1 values will be nil. Values typically range between -10% and +10%, though they can exceed these bounds during strong trends.

Formula

PPO = ((Fast EMA - Slow EMA) / Slow EMA) × 100

For example, if the 12-period EMA is 46.50 and the 26-period EMA is 45.00:

PPO = ((46.50 - 45.00) / 45.00) × 100 = 3.33%

Interpretation

PPO Value Interpretation
Positive Fast MA above slow MA - bullish momentum
Negative Fast MA below slow MA - bearish momentum
Zero Fast MA equals slow MA - neutral
Increasing Momentum strengthening
Decreasing Momentum weakening

Note: Array elements should be ordered from oldest to newest (chronological order)

Signal Strength

  • 0 to ±2%: Weak momentum or consolidation
  • ±2% to ±5%: Moderate momentum
  • ±5% to ±10%: Strong momentum
  • Beyond ±10%: Extreme momentum (potential reversal zone)

Why Percentage Matters

The percentage-based calculation provides several key advantages:

1. Cross-Security Comparison

PPO allows direct comparison between securities at different price levels:

# Stock A trading at $10
stock_a_prices = [9.50, 9.80, 10.00, 10.20, 10.50]
ppo_a = SQA::TAI.ppo(stock_a_prices)

# Stock B trading at $1000
stock_b_prices = [950, 980, 1000, 1020, 1050]
ppo_b = SQA::TAI.ppo(stock_b_prices)

# Both will show similar PPO values (~5%) despite 100x price difference
puts "Stock A PPO: #{ppo_a.last.round(2)}%"
puts "Stock B PPO: #{ppo_b.last.round(2)}%"

Both stocks show similar percentage momentum, making them directly comparable for relative strength analysis.

2. Historical Consistency

PPO remains consistent across time even as a security's price level changes dramatically:

# Early 2010: Stock at $50, APO shows +0.50 points
# Late 2024: Stock at $500, APO shows +5.00 points

# PPO would show similar +1% in both cases
# This allows for consistent historical backtesting

3. Standardized Thresholds

You can use the same PPO thresholds across all securities:

def analyze_momentum(prices)
  ppo = SQA::TAI.ppo(prices)
  current_ppo = ppo.last

  case current_ppo
  when 5..Float::INFINITY
    "Strong bullish momentum (#{current_ppo.round(2)}%)"
  when 2..5
    "Moderate bullish momentum (#{current_ppo.round(2)}%)"
  when -2..2
    "Neutral/Consolidation (#{current_ppo.round(2)}%)"
  when -5..-2
    "Moderate bearish momentum (#{current_ppo.round(2)}%)"
  when -Float::INFINITY..-5
    "Strong bearish momentum (#{current_ppo.round(2)}%)"
  end
end

# Same thresholds work for penny stocks and high-priced stocks
puts analyze_momentum(low_price_stock)
puts analyze_momentum(high_price_stock)

Example: Basic PPO Signals

prices = load_historical_prices('AAPL')

ppo = SQA::TAI.ppo(prices, fast_period: 12, slow_period: 26)
current_ppo = ppo.last
previous_ppo = ppo[-2]

# Zero line crossover
if previous_ppo < 0 && current_ppo > 0
  puts "PPO crossed above zero - Bullish signal"
  puts "Momentum shifted from negative to positive"
elsif previous_ppo > 0 && current_ppo < 0
  puts "PPO crossed below zero - Bearish signal"
  puts "Momentum shifted from positive to negative"
end

# Momentum strength
if current_ppo.abs > 5
  direction = current_ppo > 0 ? "bullish" : "bearish"
  puts "Strong #{direction} momentum: #{current_ppo.round(2)}%"
end

Example: PPO vs APO Comparison

# Demonstrate percentage advantage
prices_low = [10, 10.2, 10.5, 10.8, 11.0]  # Low-priced stock
prices_high = [1000, 1020, 1050, 1080, 1100]  # High-priced stock

apo_low = SQA::TAI.apo(prices_low)
apo_high = SQA::TAI.apo(prices_high)

ppo_low = SQA::TAI.ppo(prices_low)
ppo_high = SQA::TAI.ppo(prices_high)

puts <<~COMPARISON
  Low-Priced Stock ($10):
    APO: #{apo_low.last.round(4)} points
    PPO: #{ppo_low.last.round(4)}%

  High-Priced Stock ($1000):
    APO: #{apo_high.last.round(4)} points (100x larger!)
    PPO: #{ppo_high.last.round(4)}% (same as low-priced stock)

  Conclusion: PPO normalizes momentum across price levels
COMPARISON

Example: PPO Divergence

prices = load_historical_prices('TSLA')

ppo = SQA::TAI.ppo(prices)

# Find recent price and PPO peaks
price_peak_1 = prices[-20..-10].max
price_peak_2 = prices[-9..-1].max

ppo_peak_1 = ppo[-20..-10].compact.max
ppo_peak_2 = ppo[-9..-1].compact.max

# Bearish divergence: price makes higher high, PPO makes lower high
if price_peak_2 > price_peak_1 && ppo_peak_2 < ppo_peak_1
  puts <<~DIVERGENCE
    Bearish Divergence Detected!
    Price: $#{price_peak_1} -> $#{price_peak_2} (higher high)
    PPO: #{ppo_peak_1.round(2)}% -> #{ppo_peak_2.round(2)}% (lower high)

    Interpretation: Price is rising but momentum is weakening
    Warning: Potential trend reversal ahead
  DIVERGENCE
end

# Bullish divergence: price makes lower low, PPO makes higher low
price_low_1 = prices[-20..-10].min
price_low_2 = prices[-9..-1].min

ppo_low_1 = ppo[-20..-10].compact.min
ppo_low_2 = ppo[-9..-1].compact.min

if price_low_2 < price_low_1 && ppo_low_2 > ppo_low_1
  puts <<~DIVERGENCE
    Bullish Divergence Detected!
    Price: $#{price_low_1} -> $#{price_low_2} (lower low)
    PPO: #{ppo_low_1.round(2)}% -> #{ppo_low_2.round(2)}% (higher low)

    Interpretation: Price is falling but momentum is strengthening
    Opportunity: Potential trend reversal upward
  DIVERGENCE
end

Example: PPO with Signal Line

prices = load_historical_prices('MSFT')

ppo = SQA::TAI.ppo(prices)

# Create signal line (9-period EMA of PPO)
signal_line = SQA::TAI.ema(ppo.compact, period: 9)

# Pad signal line to match PPO array size
signal_line = [nil] * (ppo.size - signal_line.size) + signal_line

current_ppo = ppo.last
current_signal = signal_line.last
previous_ppo = ppo[-2]
previous_signal = signal_line[-2]

# PPO/Signal crossover (similar to MACD)
if previous_ppo < previous_signal && current_ppo > current_signal
  puts <<~SIGNAL
    PPO Bullish Crossover
    PPO: #{current_ppo.round(2)}%
    Signal: #{current_signal.round(2)}%
    Action: Consider buying
  SIGNAL
elsif previous_ppo > previous_signal && current_ppo < current_signal
  puts <<~SIGNAL
    PPO Bearish Crossover
    PPO: #{current_ppo.round(2)}%
    Signal: #{current_signal.round(2)}%
    Action: Consider selling
  SIGNAL
end

# Histogram (PPO - Signal)
histogram = current_ppo - current_signal
puts "PPO Histogram: #{histogram.round(2)}%"

Example: Multi-Security Momentum Ranking

# PPO excels at ranking momentum across different securities
stocks = ['AAPL', 'GOOGL', 'MSFT', 'TSLA', 'AMZN']

momentum_scores = stocks.map do |ticker|
  prices = load_historical_prices(ticker)
  ppo = SQA::TAI.ppo(prices)

  {
    ticker: ticker,
    price: prices.last,
    ppo: ppo.last.round(2)
  }
end

# Sort by PPO (percentage momentum)
ranked = momentum_scores.sort_by { |s| -s[:ppo] }

puts "Momentum Rankings (PPO):"
puts "=" * 40
ranked.each_with_index do |stock, index|
  puts "#{index + 1}. #{stock[:ticker]}: #{stock[:ppo]}% (Price: $#{stock[:price]})"
end

puts "\nTop momentum plays: #{ranked.first(2).map { |s| s[:ticker] }.join(', ')}"

Example: Overbought/Oversold with PPO

prices = load_historical_prices('SPY')

ppo = SQA::TAI.ppo(prices)

# Calculate PPO statistical bounds
recent_ppo = ppo.last(50).compact
ppo_mean = recent_ppo.sum / recent_ppo.size
ppo_std = Math.sqrt(recent_ppo.map { |x| (x - ppo_mean)**2 }.sum / recent_ppo.size)

upper_band = ppo_mean + (2 * ppo_std)
lower_band = ppo_mean - (2 * ppo_std)

current_ppo = ppo.last

puts <<~ANALYSIS
  PPO Statistical Analysis:
  Current PPO: #{current_ppo.round(2)}%
  Mean: #{ppo_mean.round(2)}%
  Std Dev: #{ppo_std.round(2)}%
  Upper Band (+2σ): #{upper_band.round(2)}%
  Lower Band (-2σ): #{lower_band.round(2)}%
ANALYSIS

if current_ppo > upper_band
  puts "PPO above upper band - Overbought condition"
  puts "Momentum may be overextended"
elsif current_ppo < lower_band
  puts "PPO below lower band - Oversold condition"
  puts "Potential reversal opportunity"
end

Trading Strategies

1. Zero-Line Strategy

  • Buy: PPO crosses above zero (bullish momentum begins)
  • Sell: PPO crosses below zero (bearish momentum begins)

2. Signal Line Strategy

  • Buy: PPO crosses above its signal line
  • Sell: PPO crosses below its signal line

3. Divergence Strategy

  • Buy: Bullish divergence (price makes lower low, PPO makes higher low)
  • Sell: Bearish divergence (price makes higher high, PPO makes lower high)

4. Extreme Readings

  • Take Profits: PPO exceeds ±10% (momentum overextension)
  • Avoid: PPO near zero (weak momentum, choppy conditions)

Advanced Techniques

1. PPO Trend Filter

Use PPO as a trend filter for other strategies:

ppo = SQA::TAI.ppo(prices)

if ppo.last > 0
  puts "Bullish trend - only take long positions"
elsif ppo.last < 0
  puts "Bearish trend - only take short positions"
end

2. PPO Rate of Change

Monitor how fast PPO is changing:

ppo = SQA::TAI.ppo(prices)
ppo_change = ppo.last - ppo[-5]

if ppo_change > 2
  puts "Rapidly accelerating momentum - strong trend"
elsif ppo_change < -2
  puts "Rapidly decelerating momentum - trend exhaustion"
end

3. Multi-Timeframe PPO

daily_prices = load_historical_prices('AAPL', timeframe: 'daily')
weekly_prices = load_historical_prices('AAPL', timeframe: 'weekly')

daily_ppo = SQA::TAI.ppo(daily_prices)
weekly_ppo = SQA::TAI.ppo(weekly_prices)

# Strongest signals when both timeframes align
if daily_ppo.last > 0 && weekly_ppo.last > 0
  puts "Multi-timeframe bullish - high conviction buy"
elsif daily_ppo.last < 0 && weekly_ppo.last < 0
  puts "Multi-timeframe bearish - high conviction sell"
end

Common Settings

Fast Slow Use Case
12 26 Standard (MACD equivalent)
9 18 More responsive for short-term trading
20 40 Smoother for swing trading
5 35 Aggressive crossover system

PPO vs APO: When to Use Each

Use PPO when: - Comparing momentum across different securities - Analyzing stocks at vastly different price levels - Building universal trading strategies - Conducting historical analysis across time periods - Ranking relative strength

Use APO when: - Analyzing a single security in isolation - Need actual point values for position sizing - Working with absolute price movements - Calculating precise entry/exit points

  • APO - Absolute price oscillator (point-based version)
  • MACD - Similar momentum oscillator with histogram
  • RSI - Bounded momentum oscillator
  • ROC - Rate of change (another percentage indicator)

See Also