chore: add AGENTS.md with build, lint, test commands and style guidelines
This commit is contained in:
367
js/ui/signals-calculator.js
Normal file
367
js/ui/signals-calculator.js
Normal file
@ -0,0 +1,367 @@
|
||||
// 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 (use cache if valid)
|
||||
let results = indicator.cachedResults;
|
||||
if (!results || !Array.isArray(results) || results.length !== candles.length) {
|
||||
const IndicatorClass = IndicatorRegistry[indicatorType];
|
||||
if (!IndicatorClass) return;
|
||||
const instance = new IndicatorClass(indicator);
|
||||
results = instance.calculate(candles);
|
||||
// Don't save back to cache here, let drawIndicatorsOnChart be the source of truth for cache
|
||||
}
|
||||
|
||||
if (!results || !Array.isArray(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 if (indicatorType === 'hurst') {
|
||||
// Hurst Bands: check price crossing bands
|
||||
const upper = result.upper;
|
||||
const lower = result.lower;
|
||||
const prevUpper = prevResult.upper;
|
||||
const prevLower = prevResult.lower;
|
||||
|
||||
if (upper === undefined || lower === undefined ||
|
||||
prevUpper === undefined || prevLower === undefined) continue;
|
||||
|
||||
// BUY: price crossed down below lower band
|
||||
if (prevCandle.close > prevLower && candle.close < lower) {
|
||||
lastCrossoverTimestamp = candle.time;
|
||||
lastSignalType = 'buy';
|
||||
break;
|
||||
}
|
||||
// SELL: price crossed down below upper band
|
||||
if (prevCandle.close > prevUpper && candle.close < upper) {
|
||||
lastCrossoverTimestamp = candle.time;
|
||||
lastSignalType = 'sell';
|
||||
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 if (indicatorType === 'hurst') {
|
||||
// Hurst Bands: use price vs bands
|
||||
const upper = lastResult?.upper;
|
||||
const lower = lastResult?.lower;
|
||||
const currentPrice = candles[candles.length - 1]?.close;
|
||||
if (upper !== undefined && lower !== undefined && currentPrice !== undefined) {
|
||||
if (currentPrice < lower) {
|
||||
indicator.lastSignalType = 'buy';
|
||||
} else if (currentPrice > upper) {
|
||||
indicator.lastSignalType = 'sell';
|
||||
} else {
|
||||
indicator.lastSignalType = 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 || !Array.isArray(results) || results.length !== candles.length) {
|
||||
const instance = new IndicatorClass(indicator);
|
||||
meta = instance.getMetadata();
|
||||
results = instance.calculate(candles);
|
||||
indicator.cachedResults = results;
|
||||
indicator.cachedMeta = meta;
|
||||
}
|
||||
|
||||
if (!results || !Array.isArray(results) || results.length === 0) {
|
||||
console.log('[Signals] No valid 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;
|
||||
}
|
||||
Reference in New Issue
Block a user