/**
* Detrended Price Oscillator (DPO)
* Removes trend from price data to identify cycles and overbought/oversold conditions
*/
export interface DetrendedPriceData {
// DPO value
dpo: number
// Moving average used for detrending
ma: number
// Cycle position
cyclePosition: 'peak' | 'trough' | 'rising' | 'falling' | 'neutral'
// Overbought/oversold levels based on historical data
overbought: boolean
oversold: boolean
// Zero line cross signals
zeroCross: 'bullish' | 'bearish' | 'none'
// Trend strength removed from price
detrendedStrength: number
// Signal
signal: 'buy' | 'sell' | 'neutral'
// Cycle length estimation
estimatedCycleLength: number | null
}
/**
* Calculate Detrended Price Oscillator
* @param prices Array of closing prices
* @param period Period for moving average (default 20)
* @returns DetrendedPriceData object
*/
export function calculateDetrendedPrice(
prices: number[],
period: number = 20
): DetrendedPriceData | null {
// Minimum 10 data points required
if (prices.length < 10) {
return null
}
// Use adaptive period based on available data
const effectivePeriod = Math.min(period, Math.floor(prices.length * 0.4))
// Calculate the displaced moving average
// DPO uses SMA displaced by (period/2 + 1) periods back
const displacement = Math.floor(effectivePeriod / 2) + 1
// Get the price from (period + displacement) periods ago for the MA calculation
const maStartIndex = Math.max(0, prices.length - effectivePeriod - displacement)
const maEndIndex = Math.max(effectivePeriod, prices.length - displacement)
if (maEndIndex <= maStartIndex) {
// Fallback: use simple calculation
const avgPrice = prices.reduce((sum, p) => sum + p, 0) / prices.length
const currentPrice = prices[prices.length - 1]
return createSimpleDPOResult(currentPrice - avgPrice, avgPrice)
}
const maPrices = prices.slice(maStartIndex, maEndIndex)
const ma = maPrices.reduce((sum, price) => sum + price, 0) / maPrices.length
// Current price
const currentPrice = prices[prices.length - 1]
// Calculate DPO: Price - Displaced MA
const dpo = currentPrice - ma
// Determine cycle position (non-recursive)
let cyclePosition: 'peak' | 'trough' | 'rising' | 'falling' | 'neutral' = 'neutral'
if (prices.length >= effectivePeriod + displacement + 2) {
// Calculate previous DPO values directly without recursion
const prevMaStartIndex = Math.max(0, prices.length - 1 - effectivePeriod - displacement)
const prevMaEndIndex = Math.max(0, prices.length - 1 - displacement)
const prevPrevMaStartIndex = Math.max(0, prices.length - 2 - effectivePeriod - displacement)
const prevPrevMaEndIndex = Math.max(0, prices.length - 2 - displacement)
if (prevMaStartIndex >= 0 && prevPrevMaStartIndex >= 0) {
const prevMaPrices = prices.slice(prevMaStartIndex, prevMaEndIndex)
const prevMa = prevMaPrices.reduce((sum, price) => sum + price, 0) / prevMaPrices.length
const prevPrice = prices[prices.length - 2]
const prevDpo = prevPrice - prevMa
const prevPrevMaPrices = prices.slice(prevPrevMaStartIndex, prevPrevMaEndIndex)
const prevPrevMa = prevPrevMaPrices.reduce((sum, price) => sum + price, 0) / prevPrevMaPrices.length
const prevPrevPrice = prices[prices.length - 3]
const prevPrevDpo = prevPrevPrice - prevPrevMa
if (dpo > prevDpo && prevDpo > prevPrevDpo) {
cyclePosition = 'rising'
} else if (dpo < prevDpo && prevDpo < prevPrevDpo) {
cyclePosition = 'falling'
} else if (dpo < prevDpo && prevDpo > prevPrevDpo) {
cyclePosition = 'peak'
} else if (dpo > prevDpo && prevDpo < prevPrevDpo) {
cyclePosition = 'trough'
}
}
}
// Calculate overbought/oversold based on historical DPO values
const dpoHistory = calculateDPOHistory(prices, period)
let overbought = false
let oversold = false
if (dpoHistory.length >= 20) {
const sortedDpo = [...dpoHistory].sort((a, b) => a - b)
const lower80 = sortedDpo[Math.floor(sortedDpo.length * 0.2)]
const upper80 = sortedDpo[Math.floor(sortedDpo.length * 0.8)]
overbought = dpo > upper80
oversold = dpo < lower80
}
// Zero line cross detection
let zeroCross: 'bullish' | 'bearish' | 'none' = 'none'
if (dpoHistory.length >= 2) {
const prevDpo = dpoHistory[dpoHistory.length - 2]
if (dpo > 0 && prevDpo <= 0) {
zeroCross = 'bullish'
} else if (dpo < 0 && prevDpo >= 0) {
zeroCross = 'bearish'
}
}
// Detrended strength (absolute value of DPO)
const detrendedStrength = Math.abs(dpo)
// Generate signal
let signal: 'buy' | 'sell' | 'neutral' = 'neutral'
if (oversold && (zeroCross === 'bullish' || cyclePosition === 'trough')) {
signal = 'buy'
} else if (overbought && (zeroCross === 'bearish' || cyclePosition === 'peak')) {
signal = 'sell'
} else if (zeroCross === 'bullish') {
signal = 'buy'
} else if (zeroCross === 'bearish') {
signal = 'sell'
}
// Estimate cycle length based on recent peaks/troughs
const estimatedCycleLength = estimateCycleLength(dpoHistory)
return {
dpo,
ma,
cyclePosition,
overbought,
oversold,
zeroCross,
detrendedStrength,
signal,
estimatedCycleLength
}
}
/**
* Helper function to create simple DPO result for fallback
*/
function createSimpleDPOResult(dpo: number, ma: number): DetrendedPriceData {
return {
dpo,
ma,
cyclePosition: dpo > 0 ? 'rising' : dpo < 0 ? 'falling' : 'neutral',
overbought: dpo > ma * 0.02,
oversold: dpo < -ma * 0.02,
zeroCross: 'none',
detrendedStrength: Math.min(100, Math.abs(dpo / ma) * 500),
signal: dpo < -ma * 0.02 ? 'buy' : dpo > ma * 0.02 ? 'sell' : 'neutral',
estimatedCycleLength: null
}
}
/**
* Helper function to calculate DPO history (non-recursive)
*/
function calculateDPOHistory(prices: number[], period: number): number[] {
const dpoValues: number[] = []
const maxHistory = Math.min(50, prices.length - period * 2) // Limit history for performance
const displacement = Math.floor(period / 2) + 1
for (let i = period * 2; i <= prices.length; i++) {
const slice = prices.slice(0, i)
// Calculate DPO directly without recursion
const maStartIndex = slice.length - period - displacement
const maEndIndex = slice.length - displacement
if (maStartIndex >= 0) {
const maPrices = slice.slice(maStartIndex, maEndIndex)
const ma = maPrices.reduce((sum, price) => sum + price, 0) / maPrices.length
const currentPrice = slice[slice.length - 1]
const dpo = currentPrice - ma
dpoValues.push(dpo)
}
}
return dpoValues.slice(-maxHistory)
}
/**
* Helper function to estimate cycle length
*/
function estimateCycleLength(dpoHistory: number[]): number | null {
if (dpoHistory.length < 10) {
return null
}
// Find peaks and troughs
const peaks: number[] = []
const troughs: number[] = []
for (let i = 1; i < dpoHistory.length - 1; i++) {
if (dpoHistory[i] > dpoHistory[i - 1] && dpoHistory[i] > dpoHistory[i + 1]) {
peaks.push(i)
} else if (dpoHistory[i] < dpoHistory[i - 1] && dpoHistory[i] < dpoHistory[i + 1]) {
troughs.push(i)
}
}
// Calculate average distance between peaks and troughs
const allPoints = [...peaks, ...troughs].sort((a, b) => a - b)
if (allPoints.length < 3) {
return null
}
let totalDistance = 0
let count = 0
for (let i = 1; i < allPoints.length; i++) {
totalDistance += allPoints[i] - allPoints[i - 1]
count++
}
return count > 0 ? totalDistance / count : null
}
/**
* Get DPO cycle analysis
* @param dpo DetrendedPriceData object
* @returns Cycle analysis description
*/
export function getDPOCycleAnalysis(dpo: DetrendedPriceData): string {
const { cyclePosition, estimatedCycleLength, dpo: value } = dpo
let analysis = `DPO: ${value.toFixed(4)}`
switch (cyclePosition) {
case 'peak':
analysis += ' - Cycle peak detected'
break
case 'trough':
analysis += ' - Cycle trough detected'
break
case 'rising':
analysis += ' - Rising in cycle'
break
case 'falling':
analysis += ' - Falling in cycle'
break
default:
analysis += ' - Neutral cycle position'
}
if (estimatedCycleLength) {
analysis += ` - Estimated cycle: ${estimatedCycleLength.toFixed(1)} periods`
}
return analysis
}
/**
* Calculate DPO for multiple periods
* @param prices Array of closing prices
* @param periods Array of periods to calculate DPO for
* @returns Array of DetrendedPriceData objects
*/
export function calculateMultipleDetrendedPrice(
prices: number[],
periods: number[] = [10, 20, 30]
): DetrendedPriceData[] {
return periods
.map(period => calculateDetrendedPrice(prices, period))
.filter((dpo): dpo is DetrendedPriceData => dpo !== null)
}