/**
* Detrended Price Oscillator (DPO)
* Removes trend from price data to identify cycles and overbought/oversold conditions
*/
/**
* 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, period = 20) {
// 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 = '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 = '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 = '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, ma) {
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, period) {
const dpoValues = [];
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) {
if (dpoHistory.length < 10) {
return null;
}
// Find peaks and troughs
const peaks = [];
const troughs = [];
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) {
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, periods = [10, 20, 30]) {
return periods
.map(period => calculateDetrendedPrice(prices, period))
.filter((dpo) => dpo !== null);
}