/**
* Schaff Trend Cycle (STC) Indicator
* Combines MACD with Stochastic oscillator and double smoothing for early trend signals
*/
/**
* Calculate Schaff Trend Cycle
* @param highs Array of high prices
* @param lows Array of low prices
* @param closes Array of close prices
* @param cycleLength Cycle length for MACD (default 23)
* @param fastLength Fast EMA length (default 23)
* @param slowLength Slow EMA length (default 50)
* @param kPeriod Stochastic K period (default 10)
* @param dPeriod Stochastic D period (default 3)
* @returns SchaffTrendCycleData object
*/
export function calculateSchaffTrendCycle(highs, lows, closes, cycleLength = 23, fastLength = 23, slowLength = 50, kPeriod = 10, dPeriod = 3) {
// Minimum 15 data points required
if (closes.length < 15) {
return null;
}
// Use adaptive periods based on available data
const dataRatio = Math.min(1, closes.length / 63);
const effectiveSlowLength = Math.max(10, Math.floor(slowLength * dataRatio));
const effectiveFastLength = Math.max(5, Math.floor(fastLength * dataRatio));
const effectiveCycleLength = Math.max(5, Math.floor(cycleLength * dataRatio));
const effectiveKPeriod = Math.max(3, Math.floor(kPeriod * dataRatio));
const effectiveDPeriod = Math.max(2, Math.floor(dPeriod * dataRatio));
// Step 1: Calculate MACD using effective periods
const macdData = calculateMACD(closes, effectiveFastLength, effectiveSlowLength, effectiveCycleLength);
if (!macdData || macdData.length === 0) {
// Fallback: create simple MACD-like data from price changes
const simpleMACD = closes.slice(1).map((c, i) => ({
MACD: c - closes[i],
signal: 0,
histogram: c - closes[i]
}));
if (simpleMACD.length === 0)
return null;
// Use fallback data
const latestMACD = simpleMACD[simpleMACD.length - 1];
return createSTCResult(latestMACD, simpleMACD, effectiveKPeriod, effectiveDPeriod);
}
// Get the latest MACD values
const latestMACD = macdData[macdData.length - 1];
const macd = latestMACD.MACD;
const signal = latestMACD.signal;
const histogram = latestMACD.histogram;
// Step 2: Calculate Stochastic of MACD (cycle within a cycle)
// Find highest high and lowest low of MACD over effectiveKPeriod
const macdValues = macdData.map(m => m.MACD);
const useKPeriod = Math.min(effectiveKPeriod, macdValues.length);
const recentMACD = macdValues.slice(-useKPeriod);
const highestMACD = Math.max(...recentMACD);
const lowestMACD = Math.min(...recentMACD);
const macdRange = highestMACD - lowestMACD;
// Calculate %K: (MACD - Lowest MACD) / (Highest MACD - Lowest MACD) * 100
const k = macdRange > 0 ? ((macd - lowestMACD) / macdRange) * 100 : 50;
// Step 3: Smooth %K with simple moving average to get %D
const useDPeriod = Math.min(effectiveDPeriod, macdValues.length);
let d;
if (macdValues.length >= useKPeriod + useDPeriod - 1) {
const kValues = [];
// Calculate %K for the last useDPeriod values
for (let i = Math.max(0, macdValues.length - useKPeriod - useDPeriod + 1); i < macdValues.length; i++) {
const slice = macdValues.slice(Math.max(0, i - useKPeriod + 1), i + 1);
const highest = Math.max(...slice);
const lowest = Math.min(...slice);
const range = highest - lowest;
const kVal = range > 0 ? ((macdValues[i] - lowest) / range) * 100 : 50;
kValues.push(kVal);
}
// Calculate SMA of %K values for %D
const recentK = kValues.slice(-useDPeriod);
d = recentK.reduce((sum, val) => sum + val, 0) / recentK.length;
}
else {
d = k; // Fallback if not enough data
}
// Step 4: Apply double smoothing (cycle within a cycle)
// The STC uses double smoothing of the %D value
const stc = d; // In the simplified version, we use %D as STC
// Determine cycle position
let cyclePosition = 'middle';
if (stc < 25) {
cyclePosition = 'bottom';
}
else if (stc > 75) {
cyclePosition = 'top';
}
else if (stc > 50) {
cyclePosition = 'falling';
}
else if (stc < 50) {
cyclePosition = 'rising';
}
// Determine trend
let trend = 'neutral';
if (stc > 50) {
trend = 'bearish';
}
else if (stc < 50) {
trend = 'bullish';
}
// Check overbought/oversold
const overbought = stc > 75;
const oversold = stc < 25;
// Calculate signal strength
const strength = Math.min(100, Math.abs(stc - 50) * 2);
// Check for cycle signals
let bullishCycleSignal = false;
let bearishCycleSignal = false;
if (macdValues.length >= 2) {
const prevSTC = calculateSTCForPeriod(highs.slice(0, -1), lows.slice(0, -1), closes.slice(0, -1));
if (prevSTC && prevSTC.stc <= 25 && stc > 25) {
bullishCycleSignal = true;
}
else if (prevSTC && prevSTC.stc >= 75 && stc < 75) {
bearishCycleSignal = true;
}
}
// Generate trading signal
let signal_out = 'neutral';
if (bullishCycleSignal) {
signal_out = 'buy';
}
else if (bearishCycleSignal) {
signal_out = 'sell';
}
else if (oversold && trend === 'bullish') {
signal_out = 'buy';
}
else if (overbought && trend === 'bearish') {
signal_out = 'sell';
}
// Estimate cycle length (simplified)
const estimatedCycleLength = estimateCycleLength(macdValues);
return {
stc,
macd,
macdSignal: signal,
histogram,
cyclePosition,
trend,
overbought,
oversold,
strength,
bullishCycleSignal,
bearishCycleSignal,
tradingSignal: signal_out,
estimatedCycleLength
};
}
/**
* Helper function to create STC result from MACD data
*/
function createSTCResult(latestMACD, macdData, kPeriod, dPeriod) {
const macdValues = macdData.map(m => m.MACD);
const useKPeriod = Math.min(kPeriod, macdValues.length);
const recentMACD = macdValues.slice(-useKPeriod);
const highestMACD = Math.max(...recentMACD);
const lowestMACD = Math.min(...recentMACD);
const macdRange = highestMACD - lowestMACD;
const k = macdRange > 0 ? ((latestMACD.MACD - lowestMACD) / macdRange) * 100 : 50;
const stc = k; // Simplified
let cyclePosition = 'middle';
if (stc < 25)
cyclePosition = 'bottom';
else if (stc > 75)
cyclePosition = 'top';
else if (stc > 50)
cyclePosition = 'falling';
else if (stc < 50)
cyclePosition = 'rising';
let trend = 'neutral';
if (stc > 50)
trend = 'bearish';
else if (stc < 50)
trend = 'bullish';
return {
stc,
macd: latestMACD.MACD,
macdSignal: latestMACD.signal,
histogram: latestMACD.histogram,
cyclePosition,
trend,
overbought: stc > 75,
oversold: stc < 25,
strength: Math.min(100, Math.abs(stc - 50) * 2),
bullishCycleSignal: false,
bearishCycleSignal: false,
tradingSignal: stc < 25 ? 'buy' : stc > 75 ? 'sell' : 'neutral',
estimatedCycleLength: null
};
}
/**
* Helper function to calculate MACD
*/
function calculateMACD(closes, fastLength, slowLength, signalLength) {
// Use adaptive periods
const effectiveSlow = Math.min(slowLength, closes.length - 1);
const effectiveFast = Math.min(fastLength, effectiveSlow - 1);
const effectiveSignal = Math.min(signalLength, effectiveSlow - 1);
if (effectiveFast < 2 || effectiveSlow < 3) {
return null;
}
const fastEMA = calculateEMA(closes, effectiveFast);
const slowEMA = calculateEMA(closes, effectiveSlow);
if (!fastEMA || fastEMA.length === 0 || !slowEMA || slowEMA.length === 0) {
return null;
}
const macdLine = [];
for (let i = 0; i < fastEMA.length; i++) {
if (slowEMA[i] !== undefined) {
macdLine.push(fastEMA[i] - slowEMA[i]);
}
}
if (macdLine.length === 0) {
return null;
}
const signalLine = calculateEMA(macdLine, Math.max(2, effectiveSignal));
if (!signalLine || signalLine.length === 0) {
// Fallback: use macdLine as both
return macdLine.map(m => ({ MACD: m, signal: m, histogram: 0 }));
}
const result = [];
for (let i = 0; i < macdLine.length; i++) {
if (signalLine[i] !== undefined) {
result.push({
MACD: macdLine[i],
signal: signalLine[i],
histogram: macdLine[i] - signalLine[i]
});
}
}
return result;
}
/**
* Helper function to calculate EMA
*/
function calculateEMA(values, period) {
if (values.length < period) {
return [];
}
const ema = [];
const multiplier = 2 / (period + 1);
// First EMA value
ema.push(values[0]);
// Calculate subsequent values
for (let i = 1; i < values.length; i++) {
const currentEMA = (values[i] - ema[ema.length - 1]) * multiplier + ema[ema.length - 1];
ema.push(currentEMA);
}
return ema;
}
/**
* Helper function to calculate STC for a specific period
*/
function calculateSTCForPeriod(highs, lows, closes) {
return calculateSchaffTrendCycle(highs, lows, closes);
}
/**
* Helper function to estimate cycle length
*/
function estimateCycleLength(macdValues) {
if (macdValues.length < 20) {
return null;
}
// Find zero crossings to estimate cycle length
const zeroCrossings = [];
for (let i = 1; i < macdValues.length; i++) {
if ((macdValues[i - 1] <= 0 && macdValues[i] > 0) ||
(macdValues[i - 1] >= 0 && macdValues[i] < 0)) {
zeroCrossings.push(i);
}
}
if (zeroCrossings.length < 2) {
return null;
}
// Calculate average distance between zero crossings
let totalDistance = 0;
for (let i = 1; i < zeroCrossings.length; i++) {
totalDistance += zeroCrossings[i] - zeroCrossings[i - 1];
}
return totalDistance / (zeroCrossings.length - 1);
}
/**
* Get STC interpretation
* @param stc SchaffTrendCycleData object
* @returns Human-readable interpretation
*/
export function getSTCInterpretation(stc) {
const { stc: value, cyclePosition, bullishCycleSignal, bearishCycleSignal, trend } = stc;
let interpretation = `STC: ${value.toFixed(2)}`;
if (bullishCycleSignal) {
interpretation += ' - Bullish cycle signal';
}
else if (bearishCycleSignal) {
interpretation += ' - Bearish cycle signal';
}
else {
interpretation += ` - ${cyclePosition} of cycle, ${trend} trend`;
}
return interpretation;
}
/**
* Calculate STC cycle analysis
* @param stc SchaffTrendCycleData object
* @returns Cycle analysis
*/
export function analyzeSTCCycle(stc) {
const { stc: value, cyclePosition, estimatedCycleLength } = stc;
let cyclePhase = 'middle';
let trendReliability = 50;
let nextMove = 'sideways';
let timeToPeak = null;
if (value < 30) {
cyclePhase = 'early';
nextMove = 'up';
trendReliability = 70;
}
else if (value > 70) {
cyclePhase = 'late';
nextMove = 'down';
trendReliability = 70;
}
else if (value > 45 && value < 55) {
nextMove = 'sideways';
trendReliability = 30;
}
// Estimate time to peak/trough
if (estimatedCycleLength && cyclePhase === 'early') {
timeToPeak = Math.round(estimatedCycleLength * (50 - value) / 50);
}
else if (estimatedCycleLength && cyclePhase === 'late') {
timeToPeak = Math.round(estimatedCycleLength * (value - 50) / 50);
}
return { cyclePhase, trendReliability, nextMove, timeToPeak };
}