327 lines
14 KiB
JavaScript
327 lines
14 KiB
JavaScript
// Signal Calculator - orchestrates signal calculation using indicator-specific functions
|
|
// Signal calculation logic is now in each indicator file
|
|
|
|
import { IndicatorRegistry, getSignalFunction } from '../indicators/index.js';
|
|
|
|
/**
|
|
* Calculate signal for an indicator
|
|
* @param {Object} indicator - Indicator configuration
|
|
* @param {Array} candles - Candle data array
|
|
* @param {Object} indicatorValues - Computed indicator values for last candle
|
|
* @param {Object} prevIndicatorValues - Computed indicator values for previous candle
|
|
* @returns {Object} Signal object with type, strength, value, reasoning
|
|
*/
|
|
function calculateIndicatorSignal(indicator, candles, indicatorValues, prevIndicatorValues) {
|
|
const signalFunction = getSignalFunction(indicator.type);
|
|
|
|
if (!signalFunction) {
|
|
console.warn('[Signals] No signal function for indicator type:', indicator.type);
|
|
return null;
|
|
}
|
|
|
|
const lastCandle = candles[candles.length - 1];
|
|
const prevCandle = candles[candles.length - 2];
|
|
|
|
return signalFunction(indicator, lastCandle, prevCandle, indicatorValues, prevIndicatorValues);
|
|
}
|
|
|
|
/**
|
|
* Calculate aggregate summary signal from all indicators
|
|
*/
|
|
export function calculateSummarySignal(signals) {
|
|
console.log('[calculateSummarySignal] Input signals:', signals?.length);
|
|
|
|
if (!signals || signals.length === 0) {
|
|
return {
|
|
signal: 'hold',
|
|
strength: 0,
|
|
reasoning: 'No active indicators',
|
|
buyCount: 0,
|
|
sellCount: 0,
|
|
holdCount: 0
|
|
};
|
|
}
|
|
|
|
const buySignals = signals.filter(s => s.signal === 'buy');
|
|
const sellSignals = signals.filter(s => s.signal === 'sell');
|
|
const holdSignals = signals.filter(s => s.signal === 'hold');
|
|
|
|
const buyCount = buySignals.length;
|
|
const sellCount = sellSignals.length;
|
|
const holdCount = holdSignals.length;
|
|
const total = signals.length;
|
|
|
|
console.log('[calculateSummarySignal] BUY:', buyCount, 'SELL:', sellCount, 'HOLD:', holdCount);
|
|
|
|
const buyWeight = buySignals.reduce((sum, s) => sum + (s.strength || 0), 0);
|
|
const sellWeight = sellSignals.reduce((sum, s) => sum + (s.strength || 0), 0);
|
|
|
|
let summarySignal, strength, reasoning;
|
|
|
|
if (buyCount > sellCount && buyCount > holdCount) {
|
|
summarySignal = 'buy';
|
|
const avgBuyStrength = buyWeight / buyCount;
|
|
strength = Math.round(avgBuyStrength * (buyCount / total));
|
|
reasoning = `${buyCount} buy signals, ${sellCount} sell, ${holdCount} hold`;
|
|
} else if (sellCount > buyCount && sellCount > holdCount) {
|
|
summarySignal = 'sell';
|
|
const avgSellStrength = sellWeight / sellCount;
|
|
strength = Math.round(avgSellStrength * (sellCount / total));
|
|
reasoning = `${sellCount} sell signals, ${buyCount} buy, ${holdCount} hold`;
|
|
} else {
|
|
summarySignal = 'hold';
|
|
strength = 30;
|
|
reasoning = `Mixed signals: ${buyCount} buy, ${sellCount} sell, ${holdCount} hold`;
|
|
}
|
|
|
|
const result = {
|
|
signal: summarySignal,
|
|
strength: Math.min(Math.max(strength, 0), 100),
|
|
reasoning,
|
|
buyCount,
|
|
sellCount,
|
|
holdCount,
|
|
color: summarySignal === 'buy' ? '#26a69a' : summarySignal === 'sell' ? '#ef5350' : '#787b86'
|
|
};
|
|
|
|
console.log('[calculateSummarySignal] Result:', result);
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Calculate historical crossovers for all indicators based on full candle history
|
|
* Finds the last time each indicator crossed from BUY to SELL or SELL to BUY
|
|
*/
|
|
function calculateHistoricalCrossovers(activeIndicators, candles) {
|
|
activeIndicators.forEach(indicator => {
|
|
const indicatorType = indicator.type || indicator.indicatorType;
|
|
|
|
// Recalculate indicator values for all candles
|
|
const IndicatorClass = IndicatorRegistry[indicatorType];
|
|
if (!IndicatorClass) return;
|
|
|
|
const instance = new IndicatorClass(indicator);
|
|
const results = instance.calculate(candles);
|
|
|
|
if (!results || results.length === 0) return;
|
|
|
|
// Find the most recent crossover by going backwards from the newest candle
|
|
// candles are sorted oldest first, newest last
|
|
let lastCrossoverTimestamp = null;
|
|
let lastSignalType = null;
|
|
|
|
// Get indicator-specific parameters
|
|
const overbought = indicator.params?.overbought || 70;
|
|
const oversold = indicator.params?.oversold || 30;
|
|
|
|
for (let i = candles.length - 1; i > 0; i--) {
|
|
const candle = candles[i]; // newer candle
|
|
const prevCandle = candles[i-1]; // older candle
|
|
|
|
const result = results[i];
|
|
const prevResult = results[i-1];
|
|
|
|
if (!result || !prevResult) continue;
|
|
|
|
// Handle different indicator types
|
|
if (indicatorType === 'rsi' || indicatorType === 'stoch') {
|
|
// RSI/Stochastic: check crossing overbought/oversold levels
|
|
const rsi = result.rsi !== undefined ? result.rsi : result;
|
|
const prevRsi = prevResult.rsi !== undefined ? prevResult.rsi : prevResult;
|
|
|
|
if (rsi === undefined || prevRsi === undefined) continue;
|
|
|
|
// SELL: crossed down through overbought (was above, now below)
|
|
if (prevRsi > overbought && rsi <= overbought) {
|
|
lastCrossoverTimestamp = candle.time;
|
|
lastSignalType = 'sell';
|
|
break;
|
|
}
|
|
// BUY: crossed up through oversold (was below, now above)
|
|
if (prevRsi < oversold && rsi >= oversold) {
|
|
lastCrossoverTimestamp = candle.time;
|
|
lastSignalType = 'buy';
|
|
break;
|
|
}
|
|
} else {
|
|
// MA-style: check price crossing MA
|
|
const ma = result.ma !== undefined ? result.ma : result;
|
|
const prevMa = prevResult.ma !== undefined ? prevResult.ma : prevResult;
|
|
|
|
if (ma === undefined || prevMa === undefined) continue;
|
|
|
|
// Check crossover: price was on one side of MA, now on the other side
|
|
const priceAbovePrev = prevCandle.close > prevMa;
|
|
const priceAboveNow = candle.close > ma;
|
|
|
|
// SELL signal: price crossed from above to below MA
|
|
if (priceAbovePrev && !priceAboveNow) {
|
|
lastCrossoverTimestamp = candle.time;
|
|
lastSignalType = 'sell';
|
|
break;
|
|
}
|
|
// BUY signal: price crossed from below to above MA
|
|
if (!priceAbovePrev && priceAboveNow) {
|
|
lastCrossoverTimestamp = candle.time;
|
|
lastSignalType = 'buy';
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Always update the timestamp based on current data
|
|
// If crossover found use that time, otherwise use last candle time
|
|
if (lastCrossoverTimestamp) {
|
|
console.log(`[HistoricalCross] ${indicatorType}: Found ${lastSignalType} crossover at ${new Date(lastCrossoverTimestamp * 1000).toLocaleString()}`);
|
|
indicator.lastSignalTimestamp = lastCrossoverTimestamp;
|
|
indicator.lastSignalType = lastSignalType;
|
|
} else {
|
|
// No crossover found - use last candle time
|
|
const lastCandleTime = candles[candles.length - 1]?.time;
|
|
if (lastCandleTime) {
|
|
const lastResult = results[results.length - 1];
|
|
|
|
if (indicatorType === 'rsi' || indicatorType === 'stoch') {
|
|
// RSI/Stochastic: use RSI level to determine signal
|
|
const rsi = lastResult?.rsi !== undefined ? lastResult.rsi : lastResult;
|
|
if (rsi !== undefined) {
|
|
indicator.lastSignalType = rsi > overbought ? 'sell' : (rsi < oversold ? 'buy' : null);
|
|
indicator.lastSignalTimestamp = lastCandleTime;
|
|
}
|
|
} else {
|
|
// MA-style: use price vs MA
|
|
const ma = lastResult?.ma !== undefined ? lastResult.ma : lastResult;
|
|
if (ma !== undefined) {
|
|
const isAbove = candles[candles.length - 1].close > ma;
|
|
indicator.lastSignalType = isAbove ? 'buy' : 'sell';
|
|
indicator.lastSignalTimestamp = lastCandleTime;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Calculate signals for all active indicators
|
|
* @returns {Array} Array of indicator signals
|
|
*/
|
|
export function calculateAllIndicatorSignals() {
|
|
const activeIndicators = window.getActiveIndicators?.() || [];
|
|
const candles = window.dashboard?.allData?.get(window.dashboard?.currentInterval);
|
|
|
|
//console.log('[Signals] ========== calculateAllIndicatorSignals START ==========');
|
|
console.log('[Signals] Active indicators:', activeIndicators.length, 'Candles:', candles?.length || 0);
|
|
|
|
if (!candles || candles.length < 2) {
|
|
//console.log('[Signals] Insufficient candles available:', candles?.length || 0);
|
|
return [];
|
|
}
|
|
|
|
if (!activeIndicators || activeIndicators.length === 0) {
|
|
//console.log('[Signals] No active indicators');
|
|
return [];
|
|
}
|
|
|
|
const signals = [];
|
|
|
|
// Calculate crossovers for all indicators based on historical data
|
|
calculateHistoricalCrossovers(activeIndicators, candles);
|
|
|
|
for (const indicator of activeIndicators) {
|
|
const IndicatorClass = IndicatorRegistry[indicator.type];
|
|
if (!IndicatorClass) {
|
|
console.log('[Signals] No class for indicator type:', indicator.type);
|
|
continue;
|
|
}
|
|
|
|
// Use cached results if available, otherwise calculate
|
|
let results = indicator.cachedResults;
|
|
let meta = indicator.cachedMeta;
|
|
|
|
if (!results || !meta || results.length !== candles.length) {
|
|
const instance = new IndicatorClass(indicator);
|
|
meta = instance.getMetadata();
|
|
results = instance.calculate(candles);
|
|
indicator.cachedResults = results;
|
|
indicator.cachedMeta = meta;
|
|
}
|
|
|
|
if (!results || results.length === 0) {
|
|
console.log('[Signals] No results for indicator:', indicator.type);
|
|
continue;
|
|
}
|
|
|
|
const lastResult = results[results.length - 1];
|
|
const prevResult = results[results.length - 2];
|
|
if (lastResult === null || lastResult === undefined) {
|
|
console.log('[Signals] No valid last result for indicator:', indicator.type);
|
|
continue;
|
|
}
|
|
|
|
let values;
|
|
let prevValues;
|
|
if (typeof lastResult === 'object' && lastResult !== null && !Array.isArray(lastResult)) {
|
|
values = lastResult;
|
|
prevValues = prevResult;
|
|
} else if (typeof lastResult === 'number') {
|
|
values = { ma: lastResult };
|
|
prevValues = prevResult ? { ma: prevResult } : undefined;
|
|
} else {
|
|
console.log('[Signals] Unexpected result type for', indicator.type, ':', typeof lastResult);
|
|
continue;
|
|
}
|
|
|
|
const signal = calculateIndicatorSignal(indicator, candles, values, prevValues);
|
|
|
|
let currentSignal = signal;
|
|
let lastSignalDate = indicator.lastSignalTimestamp || null;
|
|
let lastSignalType = indicator.lastSignalType || null;
|
|
|
|
if (!currentSignal || !currentSignal.type) {
|
|
console.log('[Signals] No valid signal for', indicator.type, '- Using last signal if available');
|
|
|
|
if (lastSignalType && lastSignalDate) {
|
|
currentSignal = {
|
|
type: lastSignalType,
|
|
strength: 50,
|
|
value: candles[candles.length - 1]?.close,
|
|
reasoning: `No crossover (price equals MA)`
|
|
};
|
|
} else {
|
|
console.log('[Signals] No previous signal available - Skipping');
|
|
continue;
|
|
}
|
|
} else {
|
|
const currentCandleTimestamp = candles[candles.length - 1].time;
|
|
|
|
if (currentSignal.type !== lastSignalType || !lastSignalType) {
|
|
console.log('[Signals] Signal changed for', indicator.type, ':', lastSignalType, '->', currentSignal.type);
|
|
lastSignalDate = indicator.lastSignalTimestamp || currentCandleTimestamp;
|
|
lastSignalType = currentSignal.type;
|
|
indicator.lastSignalTimestamp = lastSignalDate;
|
|
indicator.lastSignalType = lastSignalType;
|
|
}
|
|
}
|
|
|
|
signals.push({
|
|
id: indicator.id,
|
|
name: meta?.name || indicator.type,
|
|
label: indicator.type?.toUpperCase(),
|
|
params: meta?.inputs && meta.inputs.length > 0
|
|
? indicator.params[meta.inputs[0].name]
|
|
: null,
|
|
type: indicator.type,
|
|
signal: currentSignal.type,
|
|
strength: Math.round(currentSignal.strength),
|
|
value: currentSignal.value,
|
|
reasoning: currentSignal.reasoning,
|
|
color: currentSignal.type === 'buy' ? '#26a69a' : currentSignal.type === 'sell' ? '#ef5350' : '#787b86',
|
|
lastSignalDate: lastSignalDate
|
|
});
|
|
}
|
|
|
|
//console.log('[Signals] ========== calculateAllIndicatorSignals END ==========');
|
|
console.log('[Signals] Total signals calculated:', signals.length);
|
|
return signals;
|
|
} |